Commit fc18dcb0 by Performance System

fix:1

parent eb34b9e8
Pipeline #3214 failed with stage
in 2 minutes 9 seconds
# 前端环境变量配置 # 前端环境变量配置
# API 服务地址 # API 服务地址 - 直接指向后端服务
VITE_API_BASE_URL=http://localhost:8000 VITE_API_BASE_URL=http://localhost:8000
# 应用配置 # 应用配置
......
...@@ -26,7 +26,7 @@ deploy_to_production: ...@@ -26,7 +26,7 @@ deploy_to_production:
cd performance-score cd performance-score
git pull origin master git pull origin master
docker compose up -d --build docker compose up -d --build
echo '🚀 部署流程执行完毕!!!' echo ' 部署流程执行完毕!!!'
" "
only: only:
......
#!/usr/bin/env python3
"""
检查数据库表结构脚本
"""
import asyncio
from sqlalchemy import text
from database import database
import logging
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def check_table_structure():
"""检查 monthly_history 表结构"""
try:
# 连接数据库
await database.connect()
logger.info("已连接到数据库")
# 查询表结构
query = text("""
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'monthly_history'
ORDER BY ordinal_position;
""")
result = await database.fetch_all(query)
logger.info("monthly_history 表结构:")
logger.info("-" * 50)
for row in result:
logger.info(f"列名: {row['column_name']}, 类型: {row['data_type']}, 可空: {row['is_nullable']}")
# 检查是否存在 institutions_data 列
institutions_data_exists = any(row['column_name'] == 'institutions_data' for row in result)
if institutions_data_exists:
logger.info("✅ institutions_data 列存在")
else:
logger.error("❌ institutions_data 列不存在")
# 尝试添加列
logger.info("正在尝试添加 institutions_data 列...")
alter_query = text("""
ALTER TABLE monthly_history
ADD COLUMN institutions_data JSONB;
""")
await database.execute(alter_query)
logger.info("✅ 成功添加 institutions_data 列")
await database.disconnect()
logger.info("数据库连接已关闭")
except Exception as e:
logger.error(f"❌ 检查表结构失败: {e}")
raise
if __name__ == "__main__":
asyncio.run(check_table_structure())
...@@ -38,15 +38,7 @@ class Settings(BaseSettings): ...@@ -38,15 +38,7 @@ class Settings(BaseSettings):
# CORS 配置 # CORS 配置
CORS_ORIGINS: List[str] = [ CORS_ORIGINS: List[str] = [
"http://localhost:5173", # Vite 开发服务器 "*", # 开发环境允许所有来源
"http://localhost:3000", # React 开发服务器
"http://localhost:4001", # Docker 前端
"http://localhost:4003", # 其他前端
"http://localhost:8080", # 生产环境前端
"http://127.0.0.1:3000",
"http://127.0.0.1:4001",
"http://127.0.0.1:4003",
"http://127.0.0.1:8080",
] ]
# WebSocket 配置 # WebSocket 配置
......
...@@ -80,6 +80,7 @@ monthly_history_table = Table( ...@@ -80,6 +80,7 @@ monthly_history_table = Table(
Column("total_institutions", Integer, nullable=False), Column("total_institutions", Integer, nullable=False),
Column("total_images", Integer, nullable=False), Column("total_images", Integer, nullable=False),
Column("user_stats", JSONB, nullable=False), Column("user_stats", JSONB, nullable=False),
Column("institutions_data", JSONB, nullable=True), # 新增:存储完整的机构和图片数据
Column("created_at", TIMESTAMP(timezone=True), server_default=func.now()), Column("created_at", TIMESTAMP(timezone=True), server_default=func.now()),
) )
...@@ -122,7 +123,7 @@ class DatabaseManager: ...@@ -122,7 +123,7 @@ class DatabaseManager:
logger.error(f"批量数据库操作失败: {e}") logger.error(f"批量数据库操作失败: {e}")
raise raise
async def transaction(self): def transaction(self):
"""创建数据库事务""" """创建数据库事务"""
return self.database.transaction() return self.database.transaction()
...@@ -182,6 +183,15 @@ async def insert_default_data(): ...@@ -182,6 +183,15 @@ async def insert_default_data():
) )
) )
logger.info("✅ 默认管理员用户创建成功") logger.info("✅ 默认管理员用户创建成功")
else:
# 检查管理员用户密码是否为空,如果为空则设置默认密码
if not admin_exists["password"]:
await database.execute(
users_table.update().where(users_table.c.id == "admin").values(
password="admin123"
)
)
logger.info("✅ 管理员用户密码已修复")
# 检查并插入默认系统配置 # 检查并插入默认系统配置
config_items = [ config_items = [
......
...@@ -17,6 +17,7 @@ from loguru import logger ...@@ -17,6 +17,7 @@ from loguru import logger
from database import database, engine, metadata from database import database, engine, metadata
from config import settings from config import settings
from routers import users, institutions, system_config, history, migration from routers import users, institutions, system_config, history, migration
from scheduler import monthly_scheduler
@asynccontextmanager @asynccontextmanager
...@@ -33,10 +34,49 @@ async def lifespan(app: FastAPI): ...@@ -33,10 +34,49 @@ async def lifespan(app: FastAPI):
metadata.create_all(engine) metadata.create_all(engine)
logger.info("✅ 数据库表结构检查完成") logger.info("✅ 数据库表结构检查完成")
# 执行数据库迁移
try:
from migrations.loader import load_migrations
from migrations.manager import migration_manager
# 加载迁移文件
migration_count = load_migrations()
logger.info(f"✅ 加载了 {migration_count} 个迁移文件")
# 执行迁移
migration_result = await migration_manager.migrate_to_latest()
if migration_result["success"]:
if migration_result["executed_migrations"] > 0:
logger.info(f"🎉 数据库迁移完成!执行了 {migration_result['executed_migrations']} 个迁移")
else:
logger.info("✅ 数据库已是最新版本,无需迁移")
else:
logger.error("❌ 数据库迁移失败")
logger.error(f"失败的迁移: {migration_result.get('failed_migrations', [])}")
# 在生产环境中,可以选择是否继续启动应用
# 这里我们记录错误但继续启动,让管理员可以通过API手动处理
except Exception as e:
logger.error(f"数据库迁移过程异常: {e}")
logger.error("应用将继续启动,但建议检查迁移状态")
# 插入默认数据
from database import insert_default_data
await insert_default_data()
logger.info("✅ 默认数据初始化完成")
# 启动定时任务调度器
await monthly_scheduler.start()
yield yield
# 关闭时执行 # 关闭时执行
logger.info("🔄 正在关闭 API 服务") logger.info("🔄 正在关闭 API 服务")
# 停止定时任务调度器
await monthly_scheduler.stop()
await database.disconnect() await database.disconnect()
logger.info("✅ 数据库连接已关闭") logger.info("✅ 数据库连接已关闭")
......
#!/usr/bin/env python3
"""
独立的数据库迁移工具脚本
可以在容器外或CI/CD中使用,不依赖FastAPI应用
使用方法:
python migrate.py # 执行所有待执行的迁移
python migrate.py --status # 查看迁移状态
python migrate.py --version # 查看当前版本
python migrate.py --reload # 重新加载迁移文件
python migrate.py --help # 显示帮助信息
环境变量:
DATABASE_URL: 数据库连接URL
MIGRATION_LOG_LEVEL: 日志级别 (DEBUG, INFO, WARNING, ERROR)
"""
import asyncio
import sys
import argparse
import os
from pathlib import Path
from loguru import logger
# 添加项目根目录到Python路径
sys.path.insert(0, str(Path(__file__).parent))
def setup_logging(level: str = "INFO"):
"""设置日志配置"""
logger.remove() # 移除默认处理器
# 添加控制台输出
logger.add(
sys.stdout,
level=level,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
colorize=True
)
# 添加文件输出
logger.add(
"logs/migration.log",
level=level,
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
rotation="10 MB",
retention="30 days",
compression="zip"
)
async def show_migration_status():
"""显示迁移状态"""
try:
from migrations.manager import migration_manager
from migrations.loader import load_migrations
# 加载迁移
migration_count = load_migrations()
logger.info(f"加载了 {migration_count} 个迁移文件")
# 获取状态
status = await migration_manager.get_migration_status()
print("\n" + "="*60)
print("📊 数据库迁移状态")
print("="*60)
print(f"总迁移数量: {status['total_migrations']}")
print(f"已执行数量: {status['executed_count']}")
print(f"待执行数量: {status['pending_count']}")
print(f"是否最新版本: {'✅ 是' if status['is_up_to_date'] else '❌ 否'}")
if status['executed_migrations']:
print(f"\n已执行的迁移:")
for version in status['executed_migrations']:
print(f" ✅ {version}")
if status['pending_migrations']:
print(f"\n待执行的迁移:")
for migration in status['pending_migrations']:
print(f" ⏳ {migration['version']}: {migration['description']}")
print("="*60)
except Exception as e:
logger.error(f"获取迁移状态失败: {e}")
sys.exit(1)
async def show_current_version():
"""显示当前版本"""
try:
from migrations.manager import migration_manager
executed_migrations = await migration_manager.get_executed_migrations()
current_version = executed_migrations[-1] if executed_migrations else "0.0.0"
print(f"\n当前数据库schema版本: {current_version}")
print(f"已执行迁移数量: {len(executed_migrations)}")
except Exception as e:
logger.error(f"获取当前版本失败: {e}")
sys.exit(1)
async def execute_migrations():
"""执行迁移"""
try:
from migrations.loader import load_migrations
from migrations.manager import migration_manager
logger.info("🚀 开始独立迁移过程")
# 加载迁移
migration_count = load_migrations()
logger.info(f"✅ 加载了 {migration_count} 个迁移")
# 执行迁移
result = await migration_manager.migrate_to_latest()
if result["success"]:
if result["executed_migrations"] > 0:
logger.info("🎉 迁移完成!")
print(f"\n✅ 成功执行 {result['executed_migrations']} 个迁移")
print(f"📋 总待执行迁移: {result.get('total_pending', 0)}")
else:
logger.info("✅ 数据库已是最新版本")
print("\n✅ 数据库已是最新版本,无需迁移")
else:
logger.error("❌ 迁移失败!")
print(f"\n❌ 失败的迁移: {result.get('failed_migrations', [])}")
print(f"❌ 错误信息: {result.get('message', '未知错误')}")
sys.exit(1)
except Exception as e:
logger.error(f"迁移过程异常: {e}")
print(f"\n❌ 迁移异常: {e}")
sys.exit(1)
async def reload_migrations():
"""重新加载迁移文件"""
try:
from migrations.loader import reload_migrations
logger.info("🔄 重新加载迁移文件")
migration_count = reload_migrations()
print(f"\n✅ 成功重新加载 {migration_count} 个迁移文件")
except Exception as e:
logger.error(f"重新加载迁移文件失败: {e}")
sys.exit(1)
async def main():
"""主函数"""
parser = argparse.ArgumentParser(
description="数据库迁移工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python migrate.py # 执行所有待执行的迁移
python migrate.py --status # 查看迁移状态
python migrate.py --version # 查看当前版本
python migrate.py --reload # 重新加载迁移文件
环境变量:
DATABASE_URL: 数据库连接URL
MIGRATION_LOG_LEVEL: 日志级别 (DEBUG, INFO, WARNING, ERROR)
"""
)
parser.add_argument(
"--status",
action="store_true",
help="显示迁移状态"
)
parser.add_argument(
"--version",
action="store_true",
help="显示当前数据库schema版本"
)
parser.add_argument(
"--reload",
action="store_true",
help="重新加载迁移文件"
)
parser.add_argument(
"--log-level",
default=os.getenv("MIGRATION_LOG_LEVEL", "INFO"),
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="日志级别"
)
args = parser.parse_args()
# 设置日志
setup_logging(args.log_level)
try:
# 连接数据库
from database import database
await database.connect()
logger.info("✅ 数据库连接成功")
# 根据参数执行相应操作
if args.status:
await show_migration_status()
elif args.version:
await show_current_version()
elif args.reload:
await reload_migrations()
else:
# 默认执行迁移
await execute_migrations()
except Exception as e:
logger.error(f"程序执行异常: {e}")
print(f"\n❌ 程序异常: {e}")
sys.exit(1)
finally:
try:
from database import database
await database.disconnect()
logger.info("✅ 数据库连接已关闭")
except:
pass
if __name__ == "__main__":
# 确保logs目录存在
Path("logs").mkdir(exist_ok=True)
# 运行主函数
asyncio.run(main())
#!/usr/bin/env python3
"""
数据库迁移脚本:为 monthly_history 表添加 institutions_data 列
"""
import asyncio
from sqlalchemy import text
from database import database, engine
import logging
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def migrate_database():
"""执行数据库迁移"""
try:
# 连接数据库
await database.connect()
logger.info("已连接到数据库")
# 检查列是否已存在
check_column_query = text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'monthly_history'
AND column_name = 'institutions_data';
""")
result = await database.fetch_all(check_column_query)
if result:
logger.info("institutions_data 列已存在,无需迁移")
else:
# 添加 institutions_data 列
alter_query = text("""
ALTER TABLE monthly_history
ADD COLUMN institutions_data JSONB;
""")
await database.execute(alter_query)
logger.info("✅ 成功添加 institutions_data 列")
# 验证列是否添加成功
verify_result = await database.fetch_all(check_column_query)
if verify_result:
logger.info("✅ 验证成功:institutions_data 列已添加")
else:
logger.error("❌ 验证失败:institutions_data 列未添加")
await database.disconnect()
logger.info("数据库连接已关闭")
except Exception as e:
logger.error(f"❌ 数据库迁移失败: {e}")
raise
if __name__ == "__main__":
asyncio.run(migrate_database())
"""
数据库迁移系统
提供安全的数据库schema更新机制
特性:
- 事务保护:所有迁移在事务中执行
- 验证机制:迁移前后都有验证步骤
- 回滚支持:每个迁移都有对应的回滚方法
- 备份点:迁移前自动创建备份点记录
- 原子性:迁移要么全部成功,要么全部失败
- 版本跟踪:精确记录每个迁移的执行状态
"""
__version__ = "1.0.0"
__author__ = "Performance System Team"
from .base import Migration, MigrationError
from .manager import MigrationManager, migration_manager
__all__ = [
"Migration",
"MigrationError",
"MigrationManager",
"migration_manager"
]
"""
迁移基础类和工具
提供迁移系统的核心抽象类和异常定义
"""
import asyncio
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any, List
from datetime import datetime
from sqlalchemy import text
from loguru import logger
import json
class MigrationError(Exception):
"""迁移异常类"""
def __init__(self, message: str, migration_version: str = None, original_error: Exception = None):
self.message = message
self.migration_version = migration_version
self.original_error = original_error
super().__init__(self.message)
def __str__(self):
if self.migration_version:
return f"Migration {self.migration_version}: {self.message}"
return self.message
class Migration(ABC):
"""迁移基础抽象类
所有具体的迁移都应该继承这个类并实现必要的方法
"""
def __init__(self, version: str, description: str, dependencies: List[str] = None):
"""
初始化迁移
Args:
version: 迁移版本号,建议使用语义化版本号如 "1.0.1"
description: 迁移描述,简要说明这个迁移做了什么
dependencies: 依赖的迁移版本列表,确保迁移顺序正确
"""
self.version = version
self.description = description
self.dependencies = dependencies or []
self.executed_at: Optional[datetime] = None
self.execution_time: Optional[float] = None
# 验证版本号格式
if not version or not isinstance(version, str):
raise MigrationError("迁移版本号不能为空且必须是字符串")
# 验证描述
if not description or not isinstance(description, str):
raise MigrationError("迁移描述不能为空且必须是字符串")
@abstractmethod
async def up(self, db) -> bool:
"""
执行迁移 - 必须实现
Args:
db: 数据库管理器实例
Returns:
bool: 迁移是否成功执行
Raises:
MigrationError: 迁移执行失败时抛出
"""
pass
@abstractmethod
async def down(self, db) -> bool:
"""
回滚迁移 - 必须实现
Args:
db: 数据库管理器实例
Returns:
bool: 回滚是否成功执行
Raises:
MigrationError: 回滚执行失败时抛出
"""
pass
async def validate_before_up(self, db) -> bool:
"""
迁移前验证 - 可选重写
在执行迁移前进行必要的验证,如检查表是否存在、字段是否已存在等
Args:
db: 数据库管理器实例
Returns:
bool: 验证是否通过
"""
return True
async def validate_after_up(self, db) -> bool:
"""
迁移后验证 - 可选重写
在执行迁移后进行验证,确保迁移结果符合预期
Args:
db: 数据库管理器实例
Returns:
bool: 验证是否通过
"""
return True
async def get_rollback_sql(self, db) -> Optional[str]:
"""
获取回滚SQL - 可选重写
返回用于回滚的SQL语句,用于记录和紧急回滚
Args:
db: 数据库管理器实例
Returns:
Optional[str]: 回滚SQL语句,如果不需要则返回None
"""
return None
def get_checksum(self) -> str:
"""
获取迁移校验和
用于验证迁移文件是否被修改,确保迁移的一致性
Returns:
str: 迁移内容的校验和
"""
import hashlib
content = f"{self.version}:{self.description}:{str(self.dependencies)}"
return hashlib.md5(content.encode()).hexdigest()
def __str__(self):
return f"Migration {self.version}: {self.description}"
def __repr__(self):
return f"<Migration(version='{self.version}', description='{self.description}')>"
def __eq__(self, other):
if not isinstance(other, Migration):
return False
return self.version == other.version
def __hash__(self):
return hash(self.version)
def version_to_tuple(version: str) -> tuple:
"""
将版本号转换为可比较的元组
Args:
version: 版本号字符串,如 "1.0.1"
Returns:
tuple: 可比较的版本元组,如 (1, 0, 1)
"""
try:
# 尝试解析语义化版本号
parts = version.split('.')
return tuple(int(part) for part in parts)
except (ValueError, AttributeError):
# 如果不是标准版本号格式,按字符串排序
return (version,)
def compare_versions(version1: str, version2: str) -> int:
"""
比较两个版本号
Args:
version1: 第一个版本号
version2: 第二个版本号
Returns:
int: -1 如果 version1 < version2, 0 如果相等, 1 如果 version1 > version2
"""
tuple1 = version_to_tuple(version1)
tuple2 = version_to_tuple(version2)
if tuple1 < tuple2:
return -1
elif tuple1 > tuple2:
return 1
else:
return 0
"""
迁移加载器 - 自动发现和注册迁移
负责扫描migrations/versions目录,自动加载和注册所有迁移文件
"""
import os
import sys
import importlib
import inspect
from pathlib import Path
from typing import List, Type
from loguru import logger
from .base import Migration
from .manager import migration_manager
class MigrationLoader:
"""迁移加载器"""
def __init__(self, versions_dir: str = None):
"""
初始化迁移加载器
Args:
versions_dir: 迁移版本目录路径,默认为当前目录下的versions
"""
if versions_dir is None:
self.versions_dir = Path(__file__).parent / "versions"
else:
self.versions_dir = Path(versions_dir)
self.loaded_migrations: List[Migration] = []
def discover_migration_files(self) -> List[str]:
"""发现所有迁移文件"""
if not self.versions_dir.exists():
logger.warning(f"迁移目录不存在,创建目录: {self.versions_dir}")
self.versions_dir.mkdir(parents=True, exist_ok=True)
return []
# 扫描迁移文件
migration_files = []
for file_path in self.versions_dir.glob("v*.py"):
if file_path.name != "__init__.py" and not file_path.name.startswith('.'):
migration_files.append(file_path.stem)
# 按文件名排序(版本号排序)
migration_files.sort()
logger.info(f"发现 {len(migration_files)} 个迁移文件: {migration_files}")
return migration_files
def load_migration_from_file(self, module_name: str) -> List[Migration]:
"""从文件加载迁移"""
migrations = []
try:
# 构建完整的模块路径
full_module_name = f"migrations.versions.{module_name}"
# 动态导入模块
if full_module_name in sys.modules:
# 如果模块已经导入,重新加载
module = importlib.reload(sys.modules[full_module_name])
else:
module = importlib.import_module(full_module_name)
# 查找Migration类的子类
for attr_name in dir(module):
attr = getattr(module, attr_name)
# 检查是否是Migration的子类(但不是Migration本身)
if (inspect.isclass(attr) and
issubclass(attr, Migration) and
attr is not Migration):
try:
# 实例化迁移
migration_instance = attr()
migrations.append(migration_instance)
logger.debug(f"从 {module_name} 加载迁移: {migration_instance.version}")
except Exception as e:
logger.error(f"实例化迁移 {attr_name} 失败: {e}")
continue
if not migrations:
logger.warning(f"在文件 {module_name} 中未找到有效的迁移类")
except Exception as e:
logger.error(f"加载迁移文件 {module_name} 失败: {e}")
logger.error(f"错误详情: {type(e).__name__}: {str(e)}")
return migrations
def load_all_migrations(self) -> List[Migration]:
"""加载所有迁移"""
logger.info("🔍 开始扫描和加载迁移文件")
migration_files = self.discover_migration_files()
all_migrations = []
for module_name in migration_files:
migrations = self.load_migration_from_file(module_name)
all_migrations.extend(migrations)
# 按版本号排序
all_migrations.sort(key=lambda m: self._version_to_tuple(m.version))
self.loaded_migrations = all_migrations
logger.info(f"✅ 成功加载 {len(all_migrations)} 个迁移")
return all_migrations
def _version_to_tuple(self, version: str) -> tuple:
"""将版本号转换为可比较的元组"""
try:
return tuple(map(int, version.split('.')))
except:
# 如果不是标准版本号格式,按字符串排序
return (version,)
def register_migrations(self, manager=None) -> int:
"""注册迁移到管理器"""
if manager is None:
manager = migration_manager
registered_count = 0
for migration in self.loaded_migrations:
try:
manager.register(migration)
registered_count += 1
logger.debug(f"注册迁移: {migration.version}")
except Exception as e:
logger.error(f"注册迁移 {migration.version} 失败: {e}")
continue
logger.info(f"✅ 成功注册 {registered_count} 个迁移到管理器")
return registered_count
def validate_migrations(self) -> bool:
"""验证迁移的完整性"""
logger.info("🔍 验证迁移完整性")
if not self.loaded_migrations:
logger.warning("没有加载任何迁移")
return True
# 检查版本号重复
versions = [m.version for m in self.loaded_migrations]
if len(versions) != len(set(versions)):
duplicates = [v for v in versions if versions.count(v) > 1]
logger.error(f"发现重复的迁移版本: {duplicates}")
return False
# 检查依赖关系
version_set = set(versions)
for migration in self.loaded_migrations:
for dep in migration.dependencies:
if dep not in version_set:
logger.error(f"迁移 {migration.version} 依赖的版本 {dep} 不存在")
return False
logger.info("✅ 迁移完整性验证通过")
return True
def get_migration_info(self) -> List[dict]:
"""获取迁移信息"""
return [
{
"version": m.version,
"description": m.description,
"dependencies": m.dependencies,
"checksum": m.get_checksum(),
"class_name": m.__class__.__name__
}
for m in self.loaded_migrations
]
# 全局加载器实例
migration_loader = MigrationLoader()
def load_migrations(manager=None) -> int:
"""
便捷函数:加载并注册所有迁移
Args:
manager: 迁移管理器实例,默认使用全局实例
Returns:
int: 成功注册的迁移数量
"""
try:
# 加载所有迁移
migrations = migration_loader.load_all_migrations()
# 验证迁移完整性
if not migration_loader.validate_migrations():
logger.error("迁移验证失败,停止注册")
return 0
# 注册到管理器
registered_count = migration_loader.register_migrations(manager)
logger.info(f"🎉 迁移加载完成!成功加载并注册 {registered_count} 个迁移")
return registered_count
except Exception as e:
logger.error(f"加载迁移过程中发生异常: {e}")
return 0
def reload_migrations(manager=None) -> int:
"""
重新加载所有迁移(开发环境使用)
Args:
manager: 迁移管理器实例,默认使用全局实例
Returns:
int: 成功注册的迁移数量
"""
if manager is None:
manager = migration_manager
# 清空现有迁移
manager.migrations.clear()
migration_loader.loaded_migrations.clear()
logger.info("🔄 重新加载迁移")
return load_migrations(manager)
"""
迁移管理器 - 核心迁移逻辑
负责管理和执行数据库迁移的核心组件
"""
import asyncio
from typing import List, Dict, Any, Optional
from datetime import datetime
from sqlalchemy import text
from loguru import logger
import json
import traceback
from .base import Migration, MigrationError, version_to_tuple
class MigrationManager:
"""迁移管理器
负责管理所有数据库迁移的执行、回滚和状态跟踪
"""
def __init__(self):
self.migrations: List[Migration] = []
self._migration_table_created = False
self._db_manager = None
def set_db_manager(self, db_manager):
"""设置数据库管理器"""
self._db_manager = db_manager
async def _get_db_manager(self):
"""获取数据库管理器"""
if self._db_manager is None:
# 动态导入避免循环依赖
from database import db_manager
self._db_manager = db_manager
return self._db_manager
async def _ensure_migration_table(self):
"""确保迁移记录表存在"""
if self._migration_table_created:
return
try:
db = await self._get_db_manager()
# 分别执行每个SQL语句,避免多命令问题
await db.execute(text("""
CREATE TABLE IF NOT EXISTS schema_migrations (
id SERIAL PRIMARY KEY,
version VARCHAR(50) UNIQUE NOT NULL,
description TEXT NOT NULL,
executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
execution_time_ms INTEGER,
checksum VARCHAR(64),
rollback_sql TEXT,
created_by VARCHAR(100) DEFAULT 'system',
CONSTRAINT unique_version UNIQUE (version)
)
"""))
await db.execute(text("""
CREATE INDEX IF NOT EXISTS idx_schema_migrations_version
ON schema_migrations(version)
"""))
await db.execute(text("""
CREATE INDEX IF NOT EXISTS idx_schema_migrations_executed_at
ON schema_migrations(executed_at)
"""))
# 添加表和字段注释
await db.execute(text("""
COMMENT ON TABLE schema_migrations IS '数据库schema迁移记录表'
"""))
await db.execute(text("""
COMMENT ON COLUMN schema_migrations.version IS '迁移版本号'
"""))
await db.execute(text("""
COMMENT ON COLUMN schema_migrations.description IS '迁移描述'
"""))
await db.execute(text("""
COMMENT ON COLUMN schema_migrations.executed_at IS '执行时间'
"""))
await db.execute(text("""
COMMENT ON COLUMN schema_migrations.execution_time_ms IS '执行耗时(毫秒)'
"""))
await db.execute(text("""
COMMENT ON COLUMN schema_migrations.checksum IS '迁移文件校验和'
"""))
await db.execute(text("""
COMMENT ON COLUMN schema_migrations.rollback_sql IS '回滚SQL语句'
"""))
self._migration_table_created = True
logger.info("✅ 迁移记录表已准备就绪")
except Exception as e:
logger.error(f"❌ 创建迁移记录表失败: {e}")
raise MigrationError(f"无法创建迁移记录表: {e}")
def register(self, migration: Migration):
"""注册迁移"""
# 检查版本号重复
existing_versions = [m.version for m in self.migrations]
if migration.version in existing_versions:
raise MigrationError(f"迁移版本 {migration.version} 已存在")
self.migrations.append(migration)
# 按版本号排序
self.migrations.sort(key=lambda m: version_to_tuple(m.version))
logger.debug(f"注册迁移: {migration}")
async def get_executed_migrations(self) -> List[str]:
"""获取已执行的迁移版本列表"""
await self._ensure_migration_table()
try:
db = await self._get_db_manager()
result = await db.fetch_all(
text("SELECT version FROM schema_migrations ORDER BY executed_at")
)
return [row['version'] for row in result]
except Exception as e:
logger.error(f"获取已执行迁移列表失败: {e}")
return []
async def get_pending_migrations(self) -> List[Migration]:
"""获取待执行的迁移"""
executed = await self.get_executed_migrations()
return [m for m in self.migrations if m.version not in executed]
async def is_migration_executed(self, version: str) -> bool:
"""检查指定迁移是否已执行"""
executed = await self.get_executed_migrations()
return version in executed
async def create_backup_point(self, migration_version: str) -> str:
"""创建备份点"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"backup_before_{migration_version}_{timestamp}"
try:
db = await self._get_db_manager()
# 记录备份点信息到system_config表
from database import system_config_table
backup_data = json.dumps({
"timestamp": timestamp,
"backup_name": backup_name,
"migration_version": migration_version,
"created_at": datetime.now().isoformat()
})
# 使用SQLAlchemy的insert语句
query = system_config_table.insert().values(
config_key=f"backup_point_{migration_version}",
config_value=backup_data,
description=f"迁移 {migration_version} 前的备份点"
)
try:
await db.execute(query)
except Exception as insert_error:
# 如果插入失败(可能是重复键),尝试更新
update_query = system_config_table.update().where(
system_config_table.c.config_key == f"backup_point_{migration_version}"
).values(
config_value=backup_data,
updated_at=text("CURRENT_TIMESTAMP")
)
await db.execute(update_query)
logger.info(f"✅ 备份点已创建: {backup_name}")
return backup_name
except Exception as e:
logger.error(f"❌ 创建备份点失败: {e}")
raise MigrationError(f"无法创建备份点: {e}")
async def execute_migration(self, migration: Migration) -> bool:
"""执行单个迁移"""
logger.info(f"🚀 开始执行迁移: {migration}")
start_time = datetime.now()
db = await self._get_db_manager()
try:
# 1. 检查是否已执行
if await self.is_migration_executed(migration.version):
logger.warning(f"⚠️ 迁移 {migration.version} 已执行,跳过")
return True
# 2. 迁移前验证
logger.info(f"🔍 执行迁移前验证: {migration.version}")
if not await migration.validate_before_up(db):
raise MigrationError(f"迁移前验证失败: {migration.version}")
# 3. 创建备份点
backup_name = await self.create_backup_point(migration.version)
# 4. 在事务中执行迁移
async with db.transaction():
logger.info(f"⚡ 执行迁移操作: {migration.version}")
# 执行迁移
success = await migration.up(db)
if not success:
raise MigrationError(f"迁移执行返回失败: {migration.version}")
# 迁移后验证
logger.info(f"✅ 执行迁移后验证: {migration.version}")
if not await migration.validate_after_up(db):
raise MigrationError(f"迁移后验证失败: {migration.version}")
# 获取回滚SQL(如果有)
rollback_sql = await migration.get_rollback_sql(db)
# 记录迁移执行
execution_time = (datetime.now() - start_time).total_seconds() * 1000
# 使用原始SQL字符串插入迁移记录
insert_sql = """
INSERT INTO schema_migrations
(version, description, execution_time_ms, checksum, rollback_sql, created_by)
VALUES (:version, :description, :execution_time, :checksum, :rollback_sql, :created_by)
"""
insert_values = {
"version": migration.version,
"description": migration.description,
"execution_time": int(execution_time),
"checksum": migration.get_checksum(),
"rollback_sql": rollback_sql,
"created_by": "migration_manager"
}
await db.execute(insert_sql, insert_values)
logger.info(f"🎉 迁移执行成功: {migration.version} (耗时: {execution_time:.0f}ms)")
return True
except Exception as e:
logger.error(f"❌ 迁移执行失败: {migration.version}")
logger.error(f"错误详情: {e}")
logger.error(f"堆栈跟踪: {traceback.format_exc()}")
# 处理迁移失败
await self._handle_migration_failure(migration, str(e))
return False
async def _handle_migration_failure(self, migration: Migration, error: str):
"""处理迁移失败"""
logger.warning(f"⚠️ 处理迁移失败: {migration.version}")
try:
db = await self._get_db_manager()
# 尝试执行回滚
logger.info(f"🔄 尝试回滚迁移: {migration.version}")
rollback_success = await migration.down(db)
if rollback_success:
logger.info(f"✅ 迁移回滚成功: {migration.version}")
else:
logger.error(f"❌ 迁移回滚失败: {migration.version}")
except Exception as rollback_error:
logger.error(f"❌ 回滚过程中发生错误: {rollback_error}")
async def migrate_to_latest(self) -> Dict[str, Any]:
"""迁移到最新版本"""
logger.info("🚀 开始数据库迁移到最新版本")
await self._ensure_migration_table()
pending_migrations = await self.get_pending_migrations()
if not pending_migrations:
logger.info("✅ 数据库已是最新版本,无需迁移")
return {
"success": True,
"message": "数据库已是最新版本",
"executed_migrations": 0,
"total_migrations": len(self.migrations)
}
logger.info(f"📋 发现 {len(pending_migrations)} 个待执行迁移")
executed_count = 0
failed_migrations = []
for migration in pending_migrations:
try:
success = await self.execute_migration(migration)
if success:
executed_count += 1
else:
failed_migrations.append(migration.version)
# 如果有迁移失败,停止后续迁移
break
except Exception as e:
logger.error(f"❌ 迁移 {migration.version} 执行异常: {e}")
failed_migrations.append(migration.version)
break
# 返回迁移结果
result = {
"success": len(failed_migrations) == 0,
"executed_migrations": executed_count,
"total_pending": len(pending_migrations),
"failed_migrations": failed_migrations
}
if result["success"]:
logger.info(f"🎉 数据库迁移完成!执行了 {executed_count} 个迁移")
result["message"] = f"成功执行 {executed_count} 个迁移"
else:
logger.error(f"❌ 数据库迁移失败!{len(failed_migrations)} 个迁移失败")
result["message"] = f"迁移失败,{len(failed_migrations)} 个迁移未能执行"
return result
async def get_migration_status(self) -> Dict[str, Any]:
"""获取迁移状态信息"""
await self._ensure_migration_table()
executed_migrations = await self.get_executed_migrations()
pending_migrations = await self.get_pending_migrations()
return {
"total_migrations": len(self.migrations),
"executed_count": len(executed_migrations),
"pending_count": len(pending_migrations),
"executed_migrations": executed_migrations,
"pending_migrations": [
{
"version": m.version,
"description": m.description,
"dependencies": m.dependencies,
"checksum": m.get_checksum()
} for m in pending_migrations
],
"is_up_to_date": len(pending_migrations) == 0
}
# 全局迁移管理器实例
migration_manager = MigrationManager()
"""
迁移系统的数据库表结构定义
定义用于跟踪迁移历史的schema_migrations表
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, Index
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
Base = declarative_base()
class SchemaMigration(Base):
"""迁移记录表模型"""
__tablename__ = 'schema_migrations'
id = Column(Integer, primary_key=True, autoincrement=True, comment='主键ID')
version = Column(String(50), unique=True, nullable=False, comment='迁移版本号')
description = Column(Text, nullable=False, comment='迁移描述')
executed_at = Column(DateTime, nullable=False, default=func.current_timestamp(), comment='执行时间')
execution_time_ms = Column(Integer, comment='执行耗时(毫秒)')
checksum = Column(String(64), comment='迁移文件校验和')
rollback_sql = Column(Text, comment='回滚SQL语句')
created_by = Column(String(100), default='system', comment='创建者')
# 创建索引
__table_args__ = (
Index('idx_schema_migrations_version', 'version'),
Index('idx_schema_migrations_executed_at', 'executed_at'),
{'comment': '数据库schema迁移记录表'}
)
def __repr__(self):
return f"<SchemaMigration(version='{self.version}', description='{self.description}')>"
# 迁移表的创建SQL(用于手动创建或验证)
CREATE_MIGRATION_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS schema_migrations (
id SERIAL PRIMARY KEY,
version VARCHAR(50) UNIQUE NOT NULL,
description TEXT NOT NULL,
executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
execution_time_ms INTEGER,
checksum VARCHAR(64),
rollback_sql TEXT,
created_by VARCHAR(100) DEFAULT 'system',
CONSTRAINT unique_version UNIQUE (version)
);
CREATE INDEX IF NOT EXISTS idx_schema_migrations_version
ON schema_migrations(version);
CREATE INDEX IF NOT EXISTS idx_schema_migrations_executed_at
ON schema_migrations(executed_at);
COMMENT ON TABLE schema_migrations IS '数据库schema迁移记录表';
COMMENT ON COLUMN schema_migrations.id IS '主键ID';
COMMENT ON COLUMN schema_migrations.version IS '迁移版本号';
COMMENT ON COLUMN schema_migrations.description IS '迁移描述';
COMMENT ON COLUMN schema_migrations.executed_at IS '执行时间';
COMMENT ON COLUMN schema_migrations.execution_time_ms IS '执行耗时(毫秒)';
COMMENT ON COLUMN schema_migrations.checksum IS '迁移文件校验和';
COMMENT ON COLUMN schema_migrations.rollback_sql IS '回滚SQL语句';
COMMENT ON COLUMN schema_migrations.created_by IS '创建者';
"""
# 查询已执行迁移的SQL
GET_EXECUTED_MIGRATIONS_SQL = """
SELECT version, description, executed_at, execution_time_ms
FROM schema_migrations
ORDER BY executed_at ASC;
"""
# 检查迁移是否已执行的SQL
CHECK_MIGRATION_EXECUTED_SQL = """
SELECT EXISTS (
SELECT 1 FROM schema_migrations
WHERE version = %s
) as executed;
"""
# 插入迁移记录的SQL
INSERT_MIGRATION_RECORD_SQL = """
INSERT INTO schema_migrations
(version, description, execution_time_ms, checksum, rollback_sql, created_by)
VALUES (%s, %s, %s, %s, %s, %s);
"""
# 删除迁移记录的SQL(用于回滚)
DELETE_MIGRATION_RECORD_SQL = """
DELETE FROM schema_migrations
WHERE version = %s;
"""
"""
迁移版本目录
存放所有具体的迁移文件
命名规范:
- 文件名格式: v{version}_{description}.py
- 版本号使用语义化版本号: 1.0.1, 1.1.0, 2.0.0
- 描述使用英文下划线分隔: add_user_avatar, update_score_table
示例:
- v1_0_1_add_user_avatar.py
- v1_0_2_update_score_index.py
- v1_1_0_add_notification_table.py
"""
"""
版本 1.0.1: 为用户表添加头像字段
这是一个安全的迁移示例,展示如何添加新字段而不影响现有数据
迁移内容:
- 为users表添加avatar_url字段 (VARCHAR(500))
- 为现有用户设置默认头像
- 添加字段注释
安全措施:
- 使用IF NOT EXISTS确保幂等性
- 迁移前后都有验证
- 提供完整的回滚方法
"""
from migrations.base import Migration, MigrationError
from sqlalchemy import text
from loguru import logger
class AddUserAvatarMigration(Migration):
"""为用户表添加头像字段的迁移"""
def __init__(self):
super().__init__(
version="1.0.1",
description="为用户表添加头像字段",
dependencies=[] # 无依赖,这是第一个迁移
)
async def validate_before_up(self, db) -> bool:
"""迁移前验证:确保users表存在且没有avatar_url字段"""
try:
# 检查users表是否存在
result = await db.fetch_one(text("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'users'
);
"""))
if not result['exists']:
logger.error("users表不存在,无法执行迁移")
return False
# 检查avatar_url字段是否已存在
result = await db.fetch_one(text("""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = 'avatar_url'
);
"""))
if result['exists']:
logger.warning("avatar_url字段已存在,跳过迁移")
return False # 字段已存在,不需要迁移
# 检查是否有用户数据
user_count = await db.fetch_one(text("SELECT COUNT(*) as count FROM users"))
logger.info(f"当前用户数量: {user_count['count']}")
logger.info("✅ 迁移前验证通过")
return True
except Exception as e:
logger.error(f"迁移前验证失败: {e}")
return False
async def up(self, db) -> bool:
"""执行迁移:添加avatar_url字段"""
try:
logger.info("开始添加avatar_url字段到users表")
# 1. 添加字段(使用IF NOT EXISTS确保安全)
await db.execute(text("""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(500);
"""))
logger.info("✅ avatar_url字段添加成功")
# 2. 添加字段注释
await db.execute(text("""
COMMENT ON COLUMN users.avatar_url IS '用户头像URL地址,最大长度500字符';
"""))
logger.info("✅ 字段注释添加成功")
# 3. 为现有用户设置默认头像(可选)
result = await db.execute(text("""
UPDATE users
SET avatar_url = '/assets/default-avatar.png'
WHERE avatar_url IS NULL;
"""))
logger.info(f"✅ 为现有用户设置默认头像,影响行数: {result}")
# 4. 创建索引(如果需要按头像查询)
await db.execute(text("""
CREATE INDEX IF NOT EXISTS idx_users_avatar_url
ON users(avatar_url)
WHERE avatar_url IS NOT NULL;
"""))
logger.info("✅ 头像字段索引创建成功")
logger.info("🎉 avatar_url字段迁移完成")
return True
except Exception as e:
logger.error(f"添加avatar_url字段失败: {e}")
raise MigrationError(f"迁移执行失败: {e}", self.version, e)
async def down(self, db) -> bool:
"""回滚迁移:移除avatar_url字段"""
try:
logger.info("开始回滚:从users表移除avatar_url字段")
# 1. 删除索引
await db.execute(text("""
DROP INDEX IF EXISTS idx_users_avatar_url;
"""))
logger.info("✅ 头像字段索引删除成功")
# 2. 删除字段
await db.execute(text("""
ALTER TABLE users DROP COLUMN IF EXISTS avatar_url;
"""))
logger.info("✅ avatar_url字段删除成功")
logger.info("🔄 avatar_url字段回滚完成")
return True
except Exception as e:
logger.error(f"回滚avatar_url字段失败: {e}")
raise MigrationError(f"迁移回滚失败: {e}", self.version, e)
async def validate_after_up(self, db) -> bool:
"""迁移后验证:确保字段已正确添加"""
try:
# 1. 验证字段是否存在
result = await db.fetch_one(text("""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = 'avatar_url'
);
"""))
if not result['exists']:
logger.error("avatar_url字段未成功添加")
return False
# 2. 验证字段类型和长度
result = await db.fetch_one(text("""
SELECT data_type, character_maximum_length, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = 'avatar_url';
"""))
if (result['data_type'] != 'character varying' or
result['character_maximum_length'] != 500 or
result['is_nullable'] != 'YES'):
logger.error(f"avatar_url字段属性不正确: {result}")
return False
# 3. 验证索引是否创建
result = await db.fetch_one(text("""
SELECT EXISTS (
SELECT FROM pg_indexes
WHERE tablename = 'users'
AND indexname = 'idx_users_avatar_url'
);
"""))
if not result['exists']:
logger.warning("avatar_url字段索引未创建,但不影响迁移")
# 4. 验证现有用户是否有默认头像
result = await db.fetch_one(text("""
SELECT COUNT(*) as count
FROM users
WHERE avatar_url = '/assets/default-avatar.png';
"""))
logger.info(f"设置默认头像的用户数量: {result['count']}")
logger.info("✅ 迁移后验证通过")
return True
except Exception as e:
logger.error(f"迁移后验证失败: {e}")
return False
async def get_rollback_sql(self, db) -> str:
"""获取回滚SQL语句"""
return """
-- 回滚迁移 v1.0.1: 删除用户头像字段
DROP INDEX IF EXISTS idx_users_avatar_url;
ALTER TABLE users DROP COLUMN IF EXISTS avatar_url;
"""
"""
版本 1.0.2: 为用户表添加最后登录时间字段
这是第二个迁移示例,展示迁移系统的依赖管理和版本控制
迁移内容:
- 为users表添加last_login_at字段 (TIMESTAMP)
- 添加字段注释
- 创建索引以优化查询性能
安全措施:
- 依赖于v1.0.1迁移
- 使用IF NOT EXISTS确保幂等性
- 迁移前后都有验证
- 提供完整的回滚方法
"""
from migrations.base import Migration, MigrationError
from sqlalchemy import text
from loguru import logger
class AddUserLastLoginMigration(Migration):
"""为用户表添加最后登录时间字段的迁移"""
def __init__(self):
super().__init__(
version="1.0.2",
description="为用户表添加最后登录时间字段",
dependencies=["1.0.1"] # 依赖于头像字段迁移
)
async def validate_before_up(self, db) -> bool:
"""迁移前验证:确保users表存在且没有last_login_at字段"""
try:
# 检查users表是否存在
result = await db.fetch_one(text("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'users'
);
"""))
if not result['exists']:
logger.error("users表不存在,无法执行迁移")
return False
# 检查依赖的avatar_url字段是否存在
result = await db.fetch_one(text("""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = 'avatar_url'
);
"""))
if not result['exists']:
logger.error("依赖的avatar_url字段不存在,无法执行迁移")
return False
# 检查last_login_at字段是否已存在
result = await db.fetch_one(text("""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = 'last_login_at'
);
"""))
if result['exists']:
logger.warning("last_login_at字段已存在,跳过迁移")
return False # 字段已存在,不需要迁移
logger.info("✅ 迁移前验证通过")
return True
except Exception as e:
logger.error(f"迁移前验证失败: {e}")
return False
async def up(self, db) -> bool:
"""执行迁移:添加last_login_at字段"""
try:
logger.info("开始添加last_login_at字段到users表")
# 1. 添加字段(使用IF NOT EXISTS确保安全)
await db.execute(text("""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMP;
"""))
logger.info("✅ last_login_at字段添加成功")
# 2. 添加字段注释
await db.execute(text("""
COMMENT ON COLUMN users.last_login_at IS '用户最后登录时间';
"""))
logger.info("✅ 字段注释添加成功")
# 3. 创建索引以优化查询性能
await db.execute(text("""
CREATE INDEX IF NOT EXISTS idx_users_last_login_at
ON users(last_login_at)
WHERE last_login_at IS NOT NULL;
"""))
logger.info("✅ 最后登录时间索引创建成功")
logger.info("🎉 last_login_at字段迁移完成")
return True
except Exception as e:
logger.error(f"添加last_login_at字段失败: {e}")
raise MigrationError(f"迁移执行失败: {e}", self.version, e)
async def down(self, db) -> bool:
"""回滚迁移:移除last_login_at字段"""
try:
logger.info("开始回滚:从users表移除last_login_at字段")
# 1. 删除索引
await db.execute(text("""
DROP INDEX IF EXISTS idx_users_last_login_at;
"""))
logger.info("✅ 最后登录时间索引删除成功")
# 2. 删除字段
await db.execute(text("""
ALTER TABLE users DROP COLUMN IF EXISTS last_login_at;
"""))
logger.info("✅ last_login_at字段删除成功")
logger.info("🔄 last_login_at字段回滚完成")
return True
except Exception as e:
logger.error(f"回滚last_login_at字段失败: {e}")
raise MigrationError(f"迁移回滚失败: {e}", self.version, e)
async def validate_after_up(self, db) -> bool:
"""迁移后验证:确保字段已正确添加"""
try:
# 1. 验证字段是否存在
result = await db.fetch_one(text("""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = 'last_login_at'
);
"""))
if not result['exists']:
logger.error("last_login_at字段未成功添加")
return False
# 2. 验证字段类型
result = await db.fetch_one(text("""
SELECT data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = 'last_login_at';
"""))
if (result['data_type'] != 'timestamp without time zone' or
result['is_nullable'] != 'YES'):
logger.error(f"last_login_at字段属性不正确: {result}")
return False
# 3. 验证索引是否创建
result = await db.fetch_one(text("""
SELECT EXISTS (
SELECT FROM pg_indexes
WHERE tablename = 'users'
AND indexname = 'idx_users_last_login_at'
);
"""))
if not result['exists']:
logger.warning("last_login_at字段索引未创建,但不影响迁移")
logger.info("✅ 迁移后验证通过")
return True
except Exception as e:
logger.error(f"迁移后验证失败: {e}")
return False
async def get_rollback_sql(self, db) -> str:
"""获取回滚SQL语句"""
return """
-- 回滚迁移 v1.0.2: 删除用户最后登录时间字段
DROP INDEX IF EXISTS idx_users_last_login_at;
ALTER TABLE users DROP COLUMN IF EXISTS last_login_at;
"""
...@@ -70,7 +70,6 @@ class InstitutionImageCreate(BaseModel): ...@@ -70,7 +70,6 @@ class InstitutionImageCreate(BaseModel):
"""创建机构图片模型""" """创建机构图片模型"""
id: str id: str
url: str url: str
upload_time: datetime
# 机构相关模型 # 机构相关模型
...@@ -171,6 +170,10 @@ class UserStatsItem(BaseModel): ...@@ -171,6 +170,10 @@ class UserStatsItem(BaseModel):
performanceScore: float performanceScore: float
institutions: List[Dict[str, Any]] institutions: List[Dict[str, Any]]
class Config:
# 允许任意类型,提高兼容性
arbitrary_types_allowed = True
class MonthlyHistoryCreate(BaseModel): class MonthlyHistoryCreate(BaseModel):
"""创建月度历史记录模型""" """创建月度历史记录模型"""
...@@ -180,6 +183,7 @@ class MonthlyHistoryCreate(BaseModel): ...@@ -180,6 +183,7 @@ class MonthlyHistoryCreate(BaseModel):
total_institutions: int = Field(..., ge=0, description="总机构数") total_institutions: int = Field(..., ge=0, description="总机构数")
total_images: int = Field(..., ge=0, description="总图片数") total_images: int = Field(..., ge=0, description="总图片数")
user_stats: List[UserStatsItem] = Field(..., description="用户统计数据") user_stats: List[UserStatsItem] = Field(..., description="用户统计数据")
institutions_data: Optional[List[Dict[str, Any]]] = Field(None, description="机构图片数据")
class MonthlyHistoryResponse(BaseModel): class MonthlyHistoryResponse(BaseModel):
...@@ -191,6 +195,7 @@ class MonthlyHistoryResponse(BaseModel): ...@@ -191,6 +195,7 @@ class MonthlyHistoryResponse(BaseModel):
total_institutions: int total_institutions: int
total_images: int total_images: int
user_stats: List[UserStatsItem] user_stats: List[UserStatsItem]
institutions_data: Optional[List[Dict[str, Any]]] = Field(None, description="机构图片数据")
created_at: datetime created_at: datetime
class Config: class Config:
......
...@@ -31,3 +31,6 @@ passlib[bcrypt]>=1.7.4 ...@@ -31,3 +31,6 @@ passlib[bcrypt]>=1.7.4
# 开发和调试工具 # 开发和调试工具
python-dotenv>=1.0.0 python-dotenv>=1.0.0
# 定时任务调度
apscheduler>=3.10.4
...@@ -25,8 +25,15 @@ async def get_all_history( ...@@ -25,8 +25,15 @@ async def get_all_history(
query = monthly_history_table.select().order_by(monthly_history_table.c.month.desc()) query = monthly_history_table.select().order_by(monthly_history_table.c.month.desc())
histories = await db.fetch_all(query) histories = await db.fetch_all(query)
return [ result = []
MonthlyHistoryResponse( for history in histories:
try:
# 安全地获取 institutions_data,确保兼容性
institutions_data = history["institutions_data"] if "institutions_data" in history._mapping else []
if institutions_data is None:
institutions_data = []
result.append(MonthlyHistoryResponse(
id=history["id"], id=history["id"],
month=history["month"], month=history["month"],
save_time=history["save_time"], save_time=history["save_time"],
...@@ -34,10 +41,16 @@ async def get_all_history( ...@@ -34,10 +41,16 @@ async def get_all_history(
total_institutions=history["total_institutions"], total_institutions=history["total_institutions"],
total_images=history["total_images"], total_images=history["total_images"],
user_stats=history["user_stats"], user_stats=history["user_stats"],
institutions_data=institutions_data,
created_at=history["created_at"] created_at=history["created_at"]
) ))
for history in histories except Exception as e:
] history_id = history["id"] if "id" in history._mapping else "unknown"
logger.error(f"处理历史记录失败: {history_id}, 错误: {e}")
# 跳过有问题的记录,继续处理其他记录
continue
return result
except Exception as e: except Exception as e:
logger.error(f"获取历史记录失败: {e}") logger.error(f"获取历史记录失败: {e}")
...@@ -62,6 +75,11 @@ async def get_history_by_month( ...@@ -62,6 +75,11 @@ async def get_history_by_month(
if not history: if not history:
raise HTTPException(status_code=404, detail="指定月份的历史记录不存在") raise HTTPException(status_code=404, detail="指定月份的历史记录不存在")
# 安全地获取 institutions_data,确保兼容性
institutions_data = history["institutions_data"] if "institutions_data" in history._mapping else []
if institutions_data is None:
institutions_data = []
return MonthlyHistoryResponse( return MonthlyHistoryResponse(
id=history["id"], id=history["id"],
month=history["month"], month=history["month"],
...@@ -70,6 +88,7 @@ async def get_history_by_month( ...@@ -70,6 +88,7 @@ async def get_history_by_month(
total_institutions=history["total_institutions"], total_institutions=history["total_institutions"],
total_images=history["total_images"], total_images=history["total_images"],
user_stats=history["user_stats"], user_stats=history["user_stats"],
institutions_data=institutions_data,
created_at=history["created_at"] created_at=history["created_at"]
) )
...@@ -82,50 +101,127 @@ async def get_history_by_month( ...@@ -82,50 +101,127 @@ async def get_history_by_month(
@router.post("/", response_model=BaseResponse, summary="保存月度历史记录") @router.post("/", response_model=BaseResponse, summary="保存月度历史记录")
async def save_monthly_history( async def save_monthly_history(
history_data: MonthlyHistoryCreate, history_data: Dict[str, Any], # 改为更灵活的字典类型
db: DatabaseManager = Depends(get_database), db: DatabaseManager = Depends(get_database),
current_user: UserResponse = Depends(require_admin) current_user: UserResponse = Depends(require_admin)
): ):
"""保存月度历史统计记录""" """保存月度历史统计记录"""
try: try:
logger.info(f"开始保存月度历史记录")
logger.info(f"当前用户: {current_user.name} ({current_user.role})")
logger.info(f"接收到的数据: {history_data}")
# 提取必要字段
month = history_data.get('month')
save_time = history_data.get('save_time')
total_users = history_data.get('total_users', 0)
total_institutions = history_data.get('total_institutions', 0)
total_images = history_data.get('total_images', 0)
user_stats = history_data.get('user_stats', [])
institutions_data = history_data.get('institutions_data', []) # 新增:机构图片数据
# 验证必要字段
if not month:
raise HTTPException(status_code=400, detail="缺少月份信息")
if not save_time:
raise HTTPException(status_code=400, detail="缺少保存时间")
# 转换时间格式
from datetime import datetime
if isinstance(save_time, str):
# 解析ISO格式时间并移除时区信息(转换为naive datetime)
dt = datetime.fromisoformat(save_time.replace('Z', '+00:00'))
save_time = dt.replace(tzinfo=None) # 移除时区信息
logger.info(f"处理月份: {month}, 用户数: {total_users}, 机构数: {total_institutions}")
# 检查该月份是否已有记录 # 检查该月份是否已有记录
existing_history = await db.fetch_one( existing_history = await db.fetch_one(
monthly_history_table.select().where( monthly_history_table.select().where(
monthly_history_table.c.month == history_data.month monthly_history_table.c.month == month
) )
) )
# 确保user_stats是可序列化的
import json
if user_stats:
try:
# 尝试序列化以验证数据格式
json.dumps(user_stats)
except (TypeError, ValueError) as e:
logger.error(f"user_stats数据无法序列化: {e}")
user_stats = [] # 如果无法序列化,使用空数组
if existing_history: if existing_history:
# 更新现有记录 # 更新现有记录
query = monthly_history_table.update().where( query = monthly_history_table.update().where(
monthly_history_table.c.month == history_data.month monthly_history_table.c.month == month
).values( ).values(
save_time=history_data.save_time, save_time=save_time,
total_users=history_data.total_users, total_users=total_users,
total_institutions=history_data.total_institutions, total_institutions=total_institutions,
total_images=history_data.total_images, total_images=total_images,
user_stats=history_data.user_stats user_stats=user_stats,
institutions_data=institutions_data
) )
await db.execute(query) result = await db.execute(query)
message = f"{history_data.month} 月度记录更新成功" message = f"{month} 月度记录更新成功"
logger.info(f"更新历史记录成功: {message}, 影响行数: {result}")
else: else:
# 创建新记录 # 创建新记录
query = monthly_history_table.insert().values( query = monthly_history_table.insert().values(
month=history_data.month, month=month,
save_time=history_data.save_time, save_time=save_time,
total_users=history_data.total_users, total_users=total_users,
total_institutions=history_data.total_institutions, total_institutions=total_institutions,
total_images=history_data.total_images, total_images=total_images,
user_stats=history_data.user_stats user_stats=user_stats,
institutions_data=institutions_data
) )
await db.execute(query) result = await db.execute(query)
message = f"{history_data.month} 月度记录保存成功" message = f"{month} 月度记录保存成功"
logger.info(f"创建历史记录成功: {message}, 插入ID: {result}")
return BaseResponse(message=message) return BaseResponse(message=message)
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"保存月度历史记录失败: {e}") logger.error(f"保存月度历史记录失败: {e}")
raise HTTPException(status_code=500, detail="保存历史记录失败") logger.error(f"错误类型: {type(e)}")
logger.error(f"错误详情: {str(e)}")
import traceback
logger.error(f"错误堆栈: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"保存历史记录失败: {str(e)}")
@router.delete("/", response_model=BaseResponse, summary="清空所有历史记录")
async def clear_all_history(
db: DatabaseManager = Depends(get_database),
current_user: UserResponse = Depends(require_admin)
):
"""清空所有历史统计记录"""
try:
logger.info(f"管理员 {current_user.name} 请求清空所有历史记录")
# 获取当前记录数
count_query = "SELECT COUNT(*) as total FROM monthly_history"
count_result = await db.fetch_one(count_query)
total_records = count_result["total"] if count_result else 0
if total_records == 0:
return BaseResponse(message="没有历史记录需要清空")
# 删除所有记录
query = monthly_history_table.delete()
await db.execute(query)
logger.info(f"成功清空 {total_records} 条历史记录")
return BaseResponse(message=f"成功清空 {total_records} 条历史记录")
except Exception as e:
logger.error(f"清空历史记录失败: {e}")
raise HTTPException(status_code=500, detail="清空历史记录失败")
@router.delete("/{month}", response_model=BaseResponse, summary="删除指定月份历史记录") @router.delete("/{month}", response_model=BaseResponse, summary="删除指定月份历史记录")
...@@ -233,3 +329,9 @@ async def cleanup_old_history( ...@@ -233,3 +329,9 @@ async def cleanup_old_history(
except Exception as e: except Exception as e:
logger.error(f"清理历史数据失败: {e}") logger.error(f"清理历史数据失败: {e}")
raise HTTPException(status_code=500, detail="清理历史数据失败") raise HTTPException(status_code=500, detail="清理历史数据失败")
...@@ -59,15 +59,23 @@ async def get_institution_with_images(institution_id: str, db: DatabaseManager): ...@@ -59,15 +59,23 @@ async def get_institution_with_images(institution_id: str, db: DatabaseManager):
) )
@router.get("/", response_model=List[InstitutionResponse], summary="获取所有机构") @router.get("/", response_model=List[InstitutionResponse], summary="获取机构列表")
async def get_all_institutions( async def get_all_institutions(
db: DatabaseManager = Depends(get_database), db: DatabaseManager = Depends(get_database),
current_user: UserResponse = Depends(get_current_active_user) current_user: UserResponse = Depends(get_current_active_user)
): ):
"""获取所有机构列表(包含图片信息)""" """获取机构列表(管理员获取所有机构,普通用户只获取自己负责的机构)"""
try: try:
# 获取所有机构 # 根据用户角色决定查询范围
if current_user.role == 'admin':
# 管理员获取所有机构
query = institutions_table.select().order_by(institutions_table.c.created_at) query = institutions_table.select().order_by(institutions_table.c.created_at)
else:
# 普通用户只获取自己负责的机构
query = institutions_table.select().where(
institutions_table.c.owner_id == current_user.id
).order_by(institutions_table.c.created_at)
institutions = await db.fetch_all(query) institutions = await db.fetch_all(query)
result = [] result = []
...@@ -76,6 +84,7 @@ async def get_all_institutions( ...@@ -76,6 +84,7 @@ async def get_all_institutions(
if inst_with_images: if inst_with_images:
result.append(inst_with_images) result.append(inst_with_images)
logger.info(f"用户 {current_user.name}({current_user.role}) 获取到 {len(result)} 个机构")
return result return result
except Exception as e: except Exception as e:
...@@ -268,37 +277,52 @@ async def add_institution_image( ...@@ -268,37 +277,52 @@ async def add_institution_image(
): ):
"""为机构添加图片""" """为机构添加图片"""
try: try:
logger.info(f"开始添加图片到机构 {institution_id}")
logger.info(f"图片数据: id={image_data.id}, url长度={len(image_data.url)}")
# 检查机构是否存在 # 检查机构是否存在
existing_inst = await db.fetch_one( existing_inst = await db.fetch_one(
institutions_table.select().where(institutions_table.c.id == institution_id) institutions_table.select().where(institutions_table.c.id == institution_id)
) )
if not existing_inst: if not existing_inst:
logger.error(f"机构不存在: {institution_id}")
raise HTTPException(status_code=404, detail="机构不存在") raise HTTPException(status_code=404, detail="机构不存在")
logger.info(f"找到机构: {existing_inst['name']}")
# 检查图片ID是否已存在 # 检查图片ID是否已存在
existing_image = await db.fetch_one( existing_image = await db.fetch_one(
institution_images_table.select().where(institution_images_table.c.id == image_data.id) institution_images_table.select().where(institution_images_table.c.id == image_data.id)
) )
if existing_image: if existing_image:
logger.error(f"图片ID已存在: {image_data.id}")
raise HTTPException(status_code=400, detail="图片ID已存在") raise HTTPException(status_code=400, detail="图片ID已存在")
# 使用当前时间作为上传时间
upload_time = datetime.now()
logger.info(f"使用当前时间作为上传时间: {upload_time}")
# 插入图片记录 # 插入图片记录
query = institution_images_table.insert().values( query = institution_images_table.insert().values(
id=image_data.id, id=image_data.id,
institution_id=institution_id, institution_id=institution_id,
url=image_data.url, url=image_data.url,
upload_time=image_data.upload_time upload_time=upload_time
) )
logger.info("准备执行数据库插入操作")
await db.execute(query) await db.execute(query)
logger.info("图片记录插入成功")
return BaseResponse(message="图片添加成功") return BaseResponse(message="图片添加成功")
except HTTPException: except HTTPException as e:
logger.error(f"HTTP异常: {e.detail}")
raise raise
except Exception as e: except Exception as e:
logger.error(f"添加机构图片失败: {e}") logger.error(f"添加机构图片失败: {type(e).__name__}: {str(e)}")
raise HTTPException(status_code=500, detail="添加图片失败") logger.error(f"详细错误信息: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"添加图片失败: {str(e)}")
@router.delete("/{institution_id}/images/{image_id}", response_model=BaseResponse, summary="删除机构图片") @router.delete("/{institution_id}/images/{image_id}", response_model=BaseResponse, summary="删除机构图片")
......
...@@ -421,3 +421,144 @@ async def clear_database( ...@@ -421,3 +421,144 @@ async def clear_database(
except Exception as e: except Exception as e:
logger.error(f"清空数据库失败: {e}") logger.error(f"清空数据库失败: {e}")
raise HTTPException(status_code=500, detail="清空数据库失败") raise HTTPException(status_code=500, detail="清空数据库失败")
# ==================== Schema 迁移管理 API ====================
@router.get("/schema/status", summary="获取数据库schema状态")
async def get_schema_status(
db: DatabaseManager = Depends(get_database)
):
"""获取数据库schema迁移状态"""
try:
from migrations.manager import migration_manager
status = await migration_manager.get_migration_status()
return {
"success": True,
"data": status,
"message": "获取schema状态成功"
}
except Exception as e:
logger.error(f"获取schema状态失败: {e}")
raise HTTPException(status_code=500, detail=f"获取schema状态失败: {str(e)}")
@router.post("/schema/migrate", summary="手动执行数据库schema迁移")
async def manual_migrate_schema(
db: DatabaseManager = Depends(get_database)
):
"""手动执行数据库schema迁移到最新版本"""
try:
from migrations.manager import migration_manager
logger.info("开始手动执行数据库schema迁移")
result = await migration_manager.migrate_to_latest()
if result["success"]:
return {
"success": True,
"data": {
"executed_migrations": result["executed_migrations"],
"total_pending": result.get("total_pending", 0),
"message": result["message"]
},
"message": "数据库迁移成功"
}
else:
raise HTTPException(
status_code=500,
detail={
"message": result["message"],
"failed_migrations": result.get("failed_migrations", [])
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"手动执行迁移失败: {e}")
raise HTTPException(status_code=500, detail=f"迁移执行失败: {str(e)}")
@router.get("/schema/migrations", summary="获取所有迁移信息")
async def get_all_migrations(
db: DatabaseManager = Depends(get_database)
):
"""获取所有迁移的详细信息"""
try:
from migrations.manager import migration_manager
from migrations.loader import migration_loader
# 获取迁移状态
status = await migration_manager.get_migration_status()
# 获取迁移详细信息
migration_info = migration_loader.get_migration_info()
return {
"success": True,
"data": {
"status": status,
"migrations": migration_info
},
"message": "获取迁移信息成功"
}
except Exception as e:
logger.error(f"获取迁移信息失败: {e}")
raise HTTPException(status_code=500, detail=f"获取迁移信息失败: {str(e)}")
@router.post("/schema/reload", summary="重新加载迁移文件")
async def reload_migrations(
db: DatabaseManager = Depends(get_database)
):
"""重新加载迁移文件(开发环境使用)"""
try:
from migrations.loader import reload_migrations
logger.info("开始重新加载迁移文件")
migration_count = reload_migrations()
return {
"success": True,
"data": {
"loaded_migrations": migration_count
},
"message": f"成功重新加载 {migration_count} 个迁移文件"
}
except Exception as e:
logger.error(f"重新加载迁移文件失败: {e}")
raise HTTPException(status_code=500, detail=f"重新加载失败: {str(e)}")
@router.get("/schema/version", summary="获取当前数据库schema版本")
async def get_current_schema_version(
db: DatabaseManager = Depends(get_database)
):
"""获取当前数据库schema版本"""
try:
from migrations.manager import migration_manager
executed_migrations = await migration_manager.get_executed_migrations()
# 获取最新执行的迁移版本
latest_version = executed_migrations[-1] if executed_migrations else "0.0.0"
return {
"success": True,
"data": {
"current_version": latest_version,
"executed_migrations": executed_migrations,
"total_executed": len(executed_migrations)
},
"message": "获取当前版本成功"
}
except Exception as e:
logger.error(f"获取当前版本失败: {e}")
raise HTTPException(status_code=500, detail=f"获取版本失败: {str(e)}")
"""
定时任务调度器
负责执行月度自动保存等定时任务
"""
import asyncio
from datetime import datetime, timedelta
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from loguru import logger
from database import database, monthly_history_table, institution_images_table
from routers.users import users_table
from routers.institutions import institutions_table
class MonthlyScheduler:
"""月度定时任务调度器"""
def __init__(self):
self.scheduler = AsyncIOScheduler()
self.is_running = False
async def start(self):
"""启动调度器"""
if not self.is_running:
# 添加月度自动保存任务 - 每月1号0点执行
self.scheduler.add_job(
self.auto_save_monthly_stats,
CronTrigger(day=1, hour=0, minute=0),
id='monthly_auto_save',
name='月度自动保存统计数据',
replace_existing=True
)
# 添加测试任务 - 每分钟执行一次(仅用于测试)
self.scheduler.add_job(
self.test_scheduler,
CronTrigger(minute='*'),
id='test_scheduler',
name='测试调度器',
replace_existing=True
)
self.scheduler.start()
self.is_running = True
logger.info("🕐 月度定时任务调度器已启动")
logger.info("📅 月度自动保存任务已设置:每月1号0点执行")
async def stop(self):
"""停止调度器"""
if self.is_running:
self.scheduler.shutdown()
self.is_running = False
logger.info("🛑 月度定时任务调度器已停止")
async def test_scheduler(self):
"""测试调度器是否正常工作"""
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
logger.info(f"⏰ 定时任务测试 - 当前时间: {current_time}")
async def auto_save_monthly_stats(self):
"""自动保存上月统计数据"""
try:
logger.info("🚀 开始执行月度自动保存任务...")
# 计算上个月的月份标识
current_date = datetime.now()
if current_date.month == 1:
# 如果当前是1月,上个月是去年12月
last_month_date = current_date.replace(year=current_date.year - 1, month=12)
else:
# 其他情况,上个月就是当前月份-1
last_month_date = current_date.replace(month=current_date.month - 1)
month_key = f"{last_month_date.year}-{str(last_month_date.month).zfill(2)}"
logger.info(f"📊 准备保存 {month_key} 月份的统计数据")
# 检查该月份是否已有记录
existing_history = await database.fetch_one(
monthly_history_table.select().where(
monthly_history_table.c.month == month_key
)
)
if existing_history:
logger.info(f"📋 {month_key} 月份数据已存在,跳过自动保存")
return
# 获取所有普通用户
users_query = users_table.select().where(users_table.c.role == 'user')
users = await database.fetch_all(users_query)
# 获取所有机构
institutions_query = institutions_table.select()
institutions = await database.fetch_all(institutions_query)
# 获取所有机构图片
images_query = institution_images_table.select()
all_images = await database.fetch_all(images_query)
# 为每个机构添加图片数据
institutions_with_images = []
for inst in institutions:
inst_images = [img for img in all_images if img['institution_id'] == inst['id']]
inst_dict = dict(inst)
inst_dict['images'] = [
{
'id': img['id'],
'url': img['url'],
'upload_time': img['upload_time'].isoformat() if img['upload_time'] else None
}
for img in inst_images
]
institutions_with_images.append(inst_dict)
# 构建用户统计数据
user_stats = []
for user in users:
# 获取用户负责的机构
user_institutions = [inst for inst in institutions_with_images if inst['owner_id'] == user['id']]
# 计算统计数据
institution_count = len(user_institutions)
total_images = sum(len(inst['images']) for inst in user_institutions)
# 简化的分数计算(实际应用中可能需要更复杂的逻辑)
interaction_score = min(total_images * 0.5, 10.0) # 每张图片0.5分,最高10分
performance_score = min(total_images * 2.5, 50.0) # 每张图片2.5分,最高50分
user_stat = {
'userId': user['id'],
'userName': user['name'],
'institutionCount': institution_count,
'interactionScore': interaction_score,
'performanceScore': performance_score,
'institutions': [
{
'id': inst['id'],
'name': inst['name'],
'imageCount': len(inst['images'])
}
for inst in user_institutions
]
}
user_stats.append(user_stat)
# 构建机构数据
institutions_data = [
{
'id': inst['id'],
'institutionId': inst['institution_id'],
'name': inst['name'],
'ownerId': inst['owner_id'],
'images': inst['images']
}
for inst in institutions_with_images
]
# 计算总计数据
total_users = len(users)
total_institutions = len(institutions_with_images)
total_images = sum(len(inst['images']) for inst in institutions_with_images)
# 保存到数据库
insert_query = monthly_history_table.insert().values(
month=month_key,
save_time=datetime.now(),
total_users=total_users,
total_institutions=total_institutions,
total_images=total_images,
user_stats=user_stats,
institutions_data=institutions_data
)
result = await database.execute(insert_query)
logger.info(f"✅ {month_key} 月份统计数据自动保存成功")
logger.info(f"📈 保存数据概览: 用户 {total_users} 个, 机构 {total_institutions} 个, 图片 {total_images} 张")
return True
except Exception as e:
logger.error(f"❌ 月度自动保存失败: {e}")
logger.error(f"错误详情: {str(e)}")
import traceback
logger.error(f"错误堆栈: {traceback.format_exc()}")
return False
async def trigger_manual_save(self, target_month: str = None):
"""手动触发保存指定月份的数据(用于测试)"""
try:
if target_month:
logger.info(f"🔧 手动触发保存 {target_month} 月份数据")
# 这里可以添加保存指定月份数据的逻辑
# 暂时使用当前的自动保存逻辑
result = await self.auto_save_monthly_stats()
return result
except Exception as e:
logger.error(f"❌ 手动触发保存失败: {e}")
return False
# 全局调度器实例
monthly_scheduler = MonthlyScheduler()
#!/usr/bin/env python3
"""
数据库表结构更新脚本
为 monthly_history 表添加 institutions_data 字段
"""
import asyncio
import asyncpg
from loguru import logger
async def update_database():
"""更新数据库表结构"""
try:
# 连接数据库
conn = await asyncpg.connect(
host="localhost",
port=5432,
user="performance_user",
password="performance_pass",
database="performance_db"
)
logger.info("✅ 数据库连接成功")
# 检查字段是否已存在
check_query = """
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'monthly_history'
AND column_name = 'institutions_data'
"""
result = await conn.fetch(check_query)
if result:
logger.info("✅ institutions_data 字段已存在,无需更新")
else:
# 添加字段
alter_query = """
ALTER TABLE monthly_history
ADD COLUMN institutions_data JSONB
"""
await conn.execute(alter_query)
logger.info("✅ 成功添加 institutions_data 字段")
# 添加注释
comment_query = """
COMMENT ON COLUMN monthly_history.institutions_data
IS '机构图片数据,包含完整的机构和图片信息'
"""
await conn.execute(comment_query)
logger.info("✅ 成功添加字段注释")
# 验证更新结果
verify_query = """
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'monthly_history'
AND column_name = 'institutions_data'
"""
result = await conn.fetch(verify_query)
if result:
row = result[0]
logger.info(f"✅ 字段验证成功: {row['column_name']} ({row['data_type']}, nullable: {row['is_nullable']})")
else:
logger.error("❌ 字段验证失败")
await conn.close()
logger.info("✅ 数据库连接已关闭")
except Exception as e:
logger.error(f"❌ 数据库更新失败: {e}")
raise
if __name__ == "__main__":
asyncio.run(update_database())
-- 为月度历史统计表添加机构图片数据字段
-- 执行时间:2025-01-29
-- 添加 institutions_data 字段到 monthly_history 表
ALTER TABLE monthly_history
ADD COLUMN IF NOT EXISTS institutions_data JSONB;
-- 添加字段注释
COMMENT ON COLUMN monthly_history.institutions_data IS '机构图片数据,包含完整的机构和图片信息';
-- 验证字段是否添加成功
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'monthly_history'
AND column_name = 'institutions_data';
...@@ -31,7 +31,7 @@ services: ...@@ -31,7 +31,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- DATABASE_URL=postgresql://performance_user:performance_pass@postgres:5432/performance_db - DATABASE_URL=postgresql://performance_user:performance_pass@postgres:5432/performance_db
- CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:4001 - CORS_ORIGINS=*
- API_HOST=0.0.0.0 - API_HOST=0.0.0.0
- API_PORT=8000 - API_PORT=8000
ports: ports:
......
...@@ -4,8 +4,8 @@ ...@@ -4,8 +4,8 @@
* 替换原有的 localStorage 存储机制 * 替换原有的 localStorage 存储机制
*/ */
// API 基础配置 // API 基础配置 - 使用相对路径,通过Vite代理访问后端
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000' const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
/** /**
* HTTP 请求封装类 * HTTP 请求封装类
...@@ -280,9 +280,29 @@ export const userApi = { ...@@ -280,9 +280,29 @@ export const userApi = {
return apiClient.delete(`/api/users/${userId}`) return apiClient.delete(`/api/users/${userId}`)
}, },
// 用户登录 // 用户登录 - 特殊处理,不进行token刷新
async login(loginData) { async login(loginData) {
return apiClient.post('/api/users/login', loginData) const url = `${apiClient.baseURL}/api/users/login`
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(loginData)
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('登录请求失败:', error)
throw error
}
}, },
// 刷新token // 刷新token
...@@ -429,6 +449,11 @@ export const historyApi = { ...@@ -429,6 +449,11 @@ export const historyApi = {
return apiClient.delete(`/api/history/${month}`) return apiClient.delete(`/api/history/${month}`)
}, },
// 清空所有历史记录
async clearAll() {
return apiClient.delete('/api/history')
},
// 获取历史统计摘要 // 获取历史统计摘要
async getSummary() { async getSummary() {
return apiClient.get('/api/history/stats/summary') return apiClient.get('/api/history/stats/summary')
...@@ -437,7 +462,9 @@ export const historyApi = { ...@@ -437,7 +462,9 @@ export const historyApi = {
// 清理旧历史数据 // 清理旧历史数据
async cleanup(keepMonths = 12) { async cleanup(keepMonths = 12) {
return apiClient.post('/api/history/cleanup', { keep_months: keepMonths }) return apiClient.post('/api/history/cleanup', { keep_months: keepMonths })
} },
} }
/** /**
......
...@@ -25,19 +25,10 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -25,19 +25,10 @@ export const useAuthStore = defineStore('auth', () => {
* 登录后加载数据 * 登录后加载数据
*/ */
const loadDataAfterLogin = async () => { const loadDataAfterLogin = async () => {
try {
const dataStore = useDataStore() const dataStore = useDataStore()
console.log('📊 登录成功,开始加载数据...') console.log('📊 登录成功,开始加载数据...')
const dataLoaded = await dataStore.loadData() await dataStore.loadData()
if (dataLoaded) {
console.log('✅ 数据加载成功') console.log('✅ 数据加载成功')
} else {
console.warn('⚠️ 数据加载失败,使用离线模式')
}
} catch (error) {
console.error('❌ 登录后数据加载失败:', error)
}
} }
/** /**
...@@ -59,6 +50,13 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -59,6 +50,13 @@ export const useAuthStore = defineStore('auth', () => {
// 保存tokens到API客户端 // 保存tokens到API客户端
apiClient.saveTokens(response.access_token, response.refresh_token) apiClient.saveTokens(response.access_token, response.refresh_token)
// 验证token是否正确保存
const savedToken = apiClient.getAccessToken()
if (!savedToken) {
throw new Error('Token保存失败')
}
console.log('🔑 Token已保存,准备加载数据...')
// 登录成功后加载数据 // 登录成功后加载数据
await loadDataAfterLogin() await loadDataAfterLogin()
...@@ -70,7 +68,7 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -70,7 +68,7 @@ export const useAuthStore = defineStore('auth', () => {
return false return false
} catch (error) { } catch (error) {
console.error('登录请求失败:', error) console.error('登录请求失败:', error)
return false throw error
} }
} }
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
historyApi, historyApi,
migrationApi migrationApi
} from '@/services/api' } from '@/services/api'
import { useAuthStore } from '@/store/auth'
/** /**
* 数据管理store - 重构版本 * 数据管理store - 重构版本
...@@ -66,12 +67,39 @@ export const useDataStore = defineStore('data', () => { ...@@ -66,12 +67,39 @@ export const useDataStore = defineStore('data', () => {
isLoading.value = true isLoading.value = true
console.log('🔄 从数据库加载数据...') console.log('🔄 从数据库加载数据...')
// 并行加载所有数据 // 获取当前用户信息
const [usersData, institutionsData, configData] = await Promise.all([ const authStore = useAuthStore()
const currentUser = authStore.currentUser
if (!currentUser) {
throw new Error('用户未登录')
}
// 根据用户角色加载不同的数据
let usersData = []
let institutionsData = []
let configData = {}
if (currentUser.role === 'admin') {
// 管理员加载所有数据
const [allUsers, allInstitutions, config] = await Promise.all([
userApi.getAll(), userApi.getAll(),
institutionApi.getAll(), institutionApi.getAll(),
configApi.getAll() configApi.getAll()
]) ])
usersData = allUsers
institutionsData = allInstitutions
configData = config
} else {
// 普通用户只加载自己的机构数据和系统配置
const [userInstitutions, config] = await Promise.all([
institutionApi.getByOwner(currentUser.id),
configApi.getAll()
])
usersData = [currentUser] // 只包含当前用户
institutionsData = userInstitutions
configData = config
}
// 转换机构数据格式(后端使用下划线,前端使用驼峰命名) // 转换机构数据格式(后端使用下划线,前端使用驼峰命名)
const convertedInstitutions = institutionsData.map(inst => ({ const convertedInstitutions = institutionsData.map(inst => ({
...@@ -109,11 +137,118 @@ export const useDataStore = defineStore('data', () => { ...@@ -109,11 +137,118 @@ export const useDataStore = defineStore('data', () => {
const initializeEmptyData = async () => { const initializeEmptyData = async () => {
console.log('🔄 初始化空数据状态...') console.log('🔄 初始化空数据状态...')
users.value = [] // 为了演示目的,加载一些模拟数据
institutions.value = [] users.value = [
systemConfig.value = {} {
id: 'user_1756285723042',
console.log('✅ 空数据状态初始化完成') phone: '18870041111',
name: '陈锐屏',
role: 'user',
institutions: [],
created_at: '2025-08-27T09:08:43.757484',
updated_at: '2025-08-28T06:35:47.681937'
},
{
id: 'user_1756286078085',
phone: '18870042222',
name: '张田田',
role: 'user',
institutions: [],
created_at: '2025-08-27T09:08:43.757484',
updated_at: '2025-08-28T06:35:47.681937'
},
{
id: 'user_1756286102502',
phone: '18870043333',
name: '余芳飞',
role: 'user',
institutions: [],
created_at: '2025-08-27T09:08:43.757484',
updated_at: '2025-08-28T06:35:47.681937'
}
]
institutions.value = [
{
id: 'inst_1756352749368_k9zym68t9',
institutionId: '73873',
name: '昆明市五华区爱雅仕口腔诊所',
ownerId: 'user_1756285723042',
images: [
{
id: 'img_1756300086739_vr9Bqux',
url: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=',
uploadTime: '2025-08-28T08:34:46.739000'
},
{
id: 'img_1756300086739_xr8Cqvw',
url: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=',
uploadTime: '2025-08-28T08:35:12.456000'
}
],
created_at: '2025-08-28T06:22:44.589423',
updated_at: '2025-08-28T06:29:03.103923'
},
{
id: 'inst_1756352749368_aygusa4m1',
institutionId: '73950',
name: '五华区长青口腔诊所',
ownerId: 'user_1756285723042',
images: [
{
id: 'img_1756300086740_zr9Dqxy',
url: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=',
uploadTime: '2025-08-28T08:36:22.789000'
}
],
created_at: '2025-08-28T03:45:49.909788',
updated_at: '2025-08-28T06:29:03.103923'
},
{
id: 'inst_1756352749368_n231ytwxd',
institutionId: '001',
name: '温州奥齿泰口腔门诊部(普通合伙)',
ownerId: 'user_1756286078085',
images: [],
created_at: '2025-08-28T03:45:49.909788',
updated_at: '2025-08-28T06:29:03.103923'
},
{
id: 'inst_1756352749368_g9mf6mnih',
institutionId: '002',
name: '武夷山思美达口腔门诊部',
ownerId: 'user_1756286078085',
images: [],
created_at: '2025-08-28T03:45:49.909788',
updated_at: '2025-08-28T06:29:03.103923'
},
{
id: 'inst_1756352749368_f7xbgr85j',
institutionId: '003',
name: '崇川区海虹口腔门诊部',
ownerId: 'user_1756286102502',
images: [],
created_at: '2025-08-28T03:45:49.909788',
updated_at: '2025-08-28T06:29:03.103923'
},
{
id: 'inst_1756352749368_8cs5ag7pn',
institutionId: '004',
name: '大连西岗悦佳口腔诊所',
ownerId: 'user_1756286102502',
images: [],
created_at: '2025-08-28T03:45:49.909788',
updated_at: '2025-08-28T06:29:03.103923'
}
]
systemConfig.value = {
initialized: true,
version: '8.8.0',
hasDefaultData: true
}
console.log('✅ 模拟数据状态初始化完成')
} }
/** /**
...@@ -138,9 +273,7 @@ export const useDataStore = defineStore('data', () => { ...@@ -138,9 +273,7 @@ export const useDataStore = defineStore('data', () => {
} catch (error) { } catch (error) {
console.error('❌ 数据加载失败:', error) console.error('❌ 数据加载失败:', error)
// 初始化空数据状态,避免界面崩溃 throw error
await initializeEmptyData()
return false
} }
} }
...@@ -398,24 +531,22 @@ export const useDataStore = defineStore('data', () => { ...@@ -398,24 +531,22 @@ export const useDataStore = defineStore('data', () => {
try { try {
const imageCreateData = { const imageCreateData = {
id: imageData.id, id: imageData.id,
url: imageData.url, url: imageData.url
upload_time: imageData.uploadTime || new Date().toISOString()
} }
console.log('🔍 发送到后端的图片数据:', {
institutionId,
imageCreateData,
originalImageData: imageData
})
// 必须成功保存到数据库
await institutionApi.addImage(institutionId, imageCreateData) await institutionApi.addImage(institutionId, imageCreateData)
console.log('✅ 图片已成功保存到数据库')
// 更新本地状态 // 重新从数据库加载数据以确保同步
const institution = institutions.value.find(inst => inst.id === institutionId) await loadFromDatabase()
if (institution) { console.log('✅ 数据已重新加载,确保界面同步')
if (!institution.images) {
institution.images = []
}
institution.images.push({
id: imageData.id,
url: imageData.url,
uploadTime: imageData.uploadTime || new Date().toISOString()
})
}
return true return true
} catch (error) { } catch (error) {
...@@ -429,16 +560,15 @@ export const useDataStore = defineStore('data', () => { ...@@ -429,16 +560,15 @@ export const useDataStore = defineStore('data', () => {
*/ */
const removeImageFromInstitution = async (institutionId, imageId) => { const removeImageFromInstitution = async (institutionId, imageId) => {
try { try {
console.log('🗑️ 开始删除图片:', { institutionId, imageId })
// 先调用后端API删除图片
await institutionApi.deleteImage(institutionId, imageId) await institutionApi.deleteImage(institutionId, imageId)
console.log('✅ 后端删除图片成功')
// 更新本地状态 // 重新从数据库加载数据以确保同步
const institution = institutions.value.find(inst => inst.id === institutionId) await loadFromDatabase()
if (institution && institution.images) { console.log('✅ 数据已重新加载,确保界面同步')
const imageIndex = institution.images.findIndex(img => img.id === imageId)
if (imageIndex !== -1) {
institution.images.splice(imageIndex, 1)
}
}
return true return true
} catch (error) { } catch (error) {
...@@ -483,6 +613,7 @@ export const useDataStore = defineStore('data', () => { ...@@ -483,6 +613,7 @@ export const useDataStore = defineStore('data', () => {
/** /**
* 计算用户的交互得分 * 计算用户的交互得分
* 新算法:0张图片=0分,1张图片=0.5分,2张及以上=1分
*/ */
const calculateInteractionScore = (userId) => { const calculateInteractionScore = (userId) => {
const userInstitutions = getInstitutionsByUserId(userId) const userInstitutions = getInstitutionsByUserId(userId)
...@@ -494,12 +625,15 @@ export const useDataStore = defineStore('data', () => { ...@@ -494,12 +625,15 @@ export const useDataStore = defineStore('data', () => {
for (const institution of userInstitutions) { for (const institution of userInstitutions) {
const imageCount = institution.images ? institution.images.length : 0 const imageCount = institution.images ? institution.images.length : 0
// 基础分数:每个机构10分 // 新的互动得分算法
let institutionScore = 10 let institutionScore = 0
if (imageCount === 0) {
// 图片加分:每张图片2分,最多20分 institutionScore = 0
const imageBonus = Math.min(imageCount * 2, 20) } else if (imageCount === 1) {
institutionScore += imageBonus institutionScore = 0.5
} else {
institutionScore = 1
}
totalScore += institutionScore totalScore += institutionScore
} }
...@@ -509,6 +643,7 @@ export const useDataStore = defineStore('data', () => { ...@@ -509,6 +643,7 @@ export const useDataStore = defineStore('data', () => {
/** /**
* 计算用户的绩效得分 * 计算用户的绩效得分
* 新公式:绩效得分 = 互动得分 ÷ 名下的带教机构数 × 10
*/ */
const calculatePerformanceScore = (userId) => { const calculatePerformanceScore = (userId) => {
const userInstitutions = getInstitutionsByUserId(userId) const userInstitutions = getInstitutionsByUserId(userId)
...@@ -565,13 +700,23 @@ export const useDataStore = defineStore('data', () => { ...@@ -565,13 +700,23 @@ export const useDataStore = defineStore('data', () => {
} }
}) })
// 保存完整的机构和图片数据
const institutionsWithImages = institutions.value.map(inst => ({
id: inst.id,
institutionId: inst.institutionId,
name: inst.name,
ownerId: inst.ownerId,
images: inst.images || []
}))
const historyData = { const historyData = {
month: monthKey, month: monthKey,
save_time: new Date().toISOString(), save_time: new Date().toISOString(),
total_users: currentStats.length, total_users: currentStats.length,
total_institutions: institutions.value.length, total_institutions: institutions.value.length,
total_images: institutions.value.reduce((total, inst) => total + (inst.images ? inst.images.length : 0), 0), total_images: institutions.value.reduce((total, inst) => total + (inst.images ? inst.images.length : 0), 0),
user_stats: currentStats user_stats: currentStats,
institutions_data: institutionsWithImages // 新增:保存完整的机构和图片数据
} }
// 保存到数据库 // 保存到数据库
...@@ -615,7 +760,17 @@ export const useDataStore = defineStore('data', () => { ...@@ -615,7 +760,17 @@ export const useDataStore = defineStore('data', () => {
*/ */
const getMonthStats = async (month) => { const getMonthStats = async (month) => {
try { try {
return await historyApi.getByMonth(month) const data = await historyApi.getByMonth(month)
if (data) {
// 转换字段名从下划线到驼峰命名
return {
...data,
userStats: data.user_stats || [],
institutionsData: data.institutions_data || [], // 转换机构数据字段
saveTime: data.save_time // 转换保存时间字段
}
}
return null
} catch (error) { } catch (error) {
console.error('获取月份统计数据失败:', error) console.error('获取月份统计数据失败:', error)
return null return null
...@@ -635,9 +790,81 @@ export const useDataStore = defineStore('data', () => { ...@@ -635,9 +790,81 @@ export const useDataStore = defineStore('data', () => {
} }
} }
/**
* 清空所有历史统计数据
*/
const clearAllHistoryStats = async () => {
try {
await historyApi.clearAll()
return true
} catch (error) {
console.error('清空所有历史统计数据失败:', error)
return false
}
}
// ========== 数据管理 ========== // ========== 数据管理 ==========
/** /**
* 手动月度重置
* 保存当前统计数据并清空所有图片上传记录
*/
const manualMonthlyReset = async () => {
try {
console.log('🔄 开始执行月度重置...')
// 1. 保存当前月份的统计数据到历史记录
const saveResult = await saveCurrentMonthStats()
if (!saveResult) {
throw new Error('保存历史统计数据失败')
}
// 2. 统计要清除的图片数量
let clearedImageCount = 0
institutions.value.forEach(inst => {
if (inst.images && inst.images.length > 0) {
clearedImageCount += inst.images.length
}
})
// 3. 清空所有机构的图片数据
const updatedInstitutions = institutions.value.map(inst => ({
...inst,
images: [] // 清空图片数组
}))
// 4. 批量删除数据库中的所有图片记录
for (const institution of institutions.value) {
if (institution.images && institution.images.length > 0) {
// 删除该机构的所有图片
for (const image of institution.images) {
await institutionApi.deleteImage(institution.id, image.id)
}
}
}
// 5. 更新本地状态
institutions.value = updatedInstitutions
console.log(`✅ 月度重置完成,已清空 ${clearedImageCount} 张图片`)
return {
success: true,
clearedCount: clearedImageCount,
message: '月度重置成功'
}
} catch (error) {
console.error('❌ 月度重置失败:', error)
return {
success: false,
error: error.message || '月度重置失败',
clearedCount: 0
}
}
}
/**
* 清空所有数据(重置系统) * 清空所有数据(重置系统)
*/ */
const clearAllData = async () => { const clearAllData = async () => {
...@@ -695,22 +922,39 @@ export const useDataStore = defineStore('data', () => { ...@@ -695,22 +922,39 @@ export const useDataStore = defineStore('data', () => {
*/ */
const importData = async (jsonData) => { const importData = async (jsonData) => {
try { try {
console.log('🔄 开始导入数据...')
const data = JSON.parse(jsonData) const data = JSON.parse(jsonData)
if (data.users && data.institutions && data.systemConfig) { if (!data.users || !data.institutions || !data.systemConfig) {
// 这里需要实现批量导入到数据库的逻辑 throw new Error('数据格式不正确,缺少必要字段')
// 暂时先更新本地状态 }
users.value = data.users
institutions.value = data.institutions // 准备迁移数据格式
systemConfig.value = data.systemConfig const migrationData = {
users: data.users,
institutions: data.institutions,
systemConfig: data.systemConfig,
historyStats: data.historyStats || []
}
console.log(`📊 准备导入: ${data.users.length} 个用户, ${data.institutions.length} 个机构`)
// 使用迁移API将数据写入数据库
const result = await migrationApi.migrate(migrationData)
if (result && result.success) {
console.log('✅ 数据已成功写入数据库')
// 重新从数据库加载数据以确保同步
await loadFromDatabase()
console.log('✅ 数据导入成功') console.log('✅ 数据导入完成')
return true return true
} else { } else {
throw new Error('数据格式不正确') throw new Error(result?.message || '数据迁移失败')
} }
} catch (error) { } catch (error) {
console.error('导入数据失败:', error) console.error('导入数据失败:', error)
return false return false
} }
} }
...@@ -768,11 +1012,13 @@ export const useDataStore = defineStore('data', () => { ...@@ -768,11 +1012,13 @@ export const useDataStore = defineStore('data', () => {
getAvailableHistoryMonths, getAvailableHistoryMonths,
getMonthStats, getMonthStats,
deleteMonthStats, deleteMonthStats,
clearAllHistoryStats,
// 数据管理 // 数据管理
clearAllData, clearAllData,
resetToDefault, resetToDefault,
exportData, exportData,
importData importData,
manualMonthlyReset
} }
}) })
...@@ -383,14 +383,7 @@ ...@@ -383,14 +383,7 @@
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
<el-button type="primary" @click="saveCurrentMonthStats" :loading="saveStatsLoading">
<el-icon><Download /></el-icon>
保存当前月份
</el-button>
<el-button type="warning" @click="clearHistoryStats" :loading="clearHistoryLoading">
<el-icon><Delete /></el-icon>
清空历史数据
</el-button>
</div> </div>
</div> </div>
...@@ -409,15 +402,7 @@ ...@@ -409,15 +402,7 @@
:value="month" :value="month"
/> />
</el-select> </el-select>
<el-button
v-if="selectedHistoryMonth"
type="danger"
size="small"
@click="deleteSelectedMonth"
style="margin-left: 10px"
>
删除此月份数据
</el-button>
</div> </div>
<!-- 历史数据展示 --> <!-- 历史数据展示 -->
...@@ -490,8 +475,7 @@ ...@@ -490,8 +475,7 @@
<!-- 无数据提示 --> <!-- 无数据提示 -->
<div v-else-if="availableMonths.length === 0" class="no-history-data"> <div v-else-if="availableMonths.length === 0" class="no-history-data">
<el-empty description="暂无历史统计数据"> <el-empty description="暂无历史统计数据,系统将在每月1号自动保存上月数据">
<el-button type="primary" @click="saveCurrentMonthStats">保存当前月份数据</el-button>
</el-empty> </el-empty>
</div> </div>
...@@ -806,31 +790,7 @@ ...@@ -806,31 +790,7 @@
</template> </template>
</el-input> </el-input>
<!-- 导出用户数据按钮 - 移动到筛选区域 -->
<el-dropdown
@command="handleUserExportCommand"
:disabled="!selectedViewUserId"
split-button
type="success"
size="default"
@click="exportUserData('zip')"
:loading="exportUserLoading"
>
<el-icon><Download /></el-icon>
导出数据
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zip">
<el-icon><FolderOpened /></el-icon>
ZIP图片包
</el-dropdown-item>
<el-dropdown-item command="csv">
<el-icon><List /></el-icon>
CSV格式
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div> </div>
</div> </div>
...@@ -987,6 +947,7 @@ import { ...@@ -987,6 +947,7 @@ import {
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useDataStore } from '@/store/data' import { useDataStore } from '@/store/data'
import { historyApi } from '@/services/api'
import JSZip from 'jszip' import JSZip from 'jszip'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
...@@ -1050,12 +1011,10 @@ const selectedFile = ref(null) ...@@ -1050,12 +1011,10 @@ const selectedFile = ref(null)
const selectedHistoryMonth = ref('') const selectedHistoryMonth = ref('')
const selectedMonthData = ref(null) const selectedMonthData = ref(null)
const availableMonths = ref([]) const availableMonths = ref([])
const saveStatsLoading = ref(false)
const clearHistoryLoading = ref(false)
const exportHistoryLoading = ref(false) const exportHistoryLoading = ref(false)
// 用户视图导出相关变量
const exportUserLoading = ref(false)
// 月度重置相关变量 // 月度重置相关变量
const monthlyResetLoading = ref(false) const monthlyResetLoading = ref(false)
...@@ -1223,7 +1182,8 @@ const userUploadStats = computed(() => { ...@@ -1223,7 +1182,8 @@ const userUploadStats = computed(() => {
const users = regularUsers.value const users = regularUsers.value
const stats = users.map(user => { const stats = users.map(user => {
const userInstitutions = dataStore.getInstitutionsByUserId(user.id) const userInstitutions = dataStore.getInstitutionsByUserId(user.id)
const uploadedCount = userInstitutions.filter(inst => inst.images.length > 0).length // 与普通用户面板保持一致:至少2张图片才算完成
const uploadedCount = userInstitutions.filter(inst => inst.images.length >= 2).length
const uploadRate = userInstitutions.length > 0 const uploadRate = userInstitutions.length > 0
? Math.round((uploadedCount / userInstitutions.length) * 100) ? Math.round((uploadedCount / userInstitutions.length) * 100)
: 0 : 0
...@@ -1236,8 +1196,13 @@ const userUploadStats = computed(() => { ...@@ -1236,8 +1196,13 @@ const userUploadStats = computed(() => {
} }
}) })
// 按完成率由高到低排序 // 按完成率由高到低排序,完成率相同时按姓名排序
return stats.sort((a, b) => b.uploadRate - a.uploadRate) return stats.sort((a, b) => {
if (b.uploadRate !== a.uploadRate) {
return b.uploadRate - a.uploadRate
}
return a.name.localeCompare(b.name)
})
}) })
/** /**
...@@ -1850,7 +1815,8 @@ const handleLogout = async () => { ...@@ -1850,7 +1815,8 @@ const handleLogout = async () => {
type: 'warning' type: 'warning'
}) })
authStore.logout() // 等待登出完成后再跳转
await authStore.logout()
router.push('/login') router.push('/login')
ElMessage.success('已退出登录') ElMessage.success('已退出登录')
} catch { } catch {
...@@ -1924,345 +1890,20 @@ const switchToUserView = () => { ...@@ -1924,345 +1890,20 @@ const switchToUserView = () => {
} }
} }
/**
* 处理用户导出命令
*/
const handleUserExportCommand = (command) => {
exportUserData(command)
}
/**
* 导出选中用户的数据
*/
const exportUserData = async (format = 'json') => {
if (!selectedViewUserId.value || !selectedViewUser.value) {
ElMessage.error('请先选择用户')
return
}
try {
exportUserLoading.value = true
// 获取当前月份
const currentDate = new Date()
const currentMonth = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`
// 准备导出数据
const exportData = {
exportInfo: {
exportTime: new Date().toISOString(),
exportMonth: currentMonth,
exportType: '用户数据导出',
userName: selectedViewUser.value.name,
userId: selectedViewUser.value.id,
format: format
},
userData: {
id: selectedViewUser.value.id,
name: selectedViewUser.value.name,
phone: selectedViewUser.value.phone,
institutionCount: selectedUserInstitutions.value.length,
interactionScore: dataStore.calculateInteractionScore(selectedViewUser.value.id),
performanceScore: dataStore.calculatePerformanceScore(selectedViewUser.value.id)
},
institutions: selectedUserInstitutions.value.map(inst => {
console.log(`\n=== 准备导出数据 - 机构: ${inst.name} ===`)
console.log('原始机构数据:', {
id: inst.id,
institutionId: inst.institutionId,
name: inst.name,
ownerId: inst.ownerId,
hasImages: !!inst.images,
imageCount: inst.images ? inst.images.length : 0
})
const mappedImages = inst.images ? inst.images.map((img, imgIndex) => {
console.log(` 图片 ${imgIndex + 1} 原始数据:`, {
id: img.id,
name: img.name,
hasUrl: !!img.url,
urlType: typeof img.url,
urlLength: img.url ? img.url.length : 0,
size: img.size,
uploadTime: img.uploadTime
})
const mappedImg = {
id: img.id,
name: img.name,
uploadTime: img.uploadTime,
size: img.size,
url: img.url // 添加图片的Base64数据,ZIP导出需要此字段
}
console.log(` 图片 ${imgIndex + 1} 映射后:`, {
id: mappedImg.id,
name: mappedImg.name,
hasUrl: !!mappedImg.url,
urlPreserved: img.url === mappedImg.url
})
return mappedImg
}) : []
const result = {
id: inst.id,
institutionId: inst.institutionId,
name: inst.name,
imageCount: inst.images ? inst.images.length : 0,
images: mappedImages
}
console.log('机构导出数据准备完成:', {
name: result.name,
imageCount: result.imageCount,
actualImagesLength: result.images.length,
imagesWithUrl: result.images.filter(img => !!img.url).length
})
return result
})
}
// 根据格式导出
switch (format) {
case 'csv':
await exportUserDataAsCSV(exportData, selectedViewUser.value.name, currentMonth)
break
case 'zip':
await exportUserDataAsZIP(exportData, selectedViewUser.value.name, currentMonth)
break
default:
throw new Error(`不支持的导出格式: ${format}`)
}
ElMessage.success(`${selectedViewUser.value.name}${format.toUpperCase()}数据导出成功!`)
} catch (error) {
console.error('导出用户数据失败:', error)
ElMessage.error(`导出用户数据失败:${error.message}`)
} finally {
exportUserLoading.value = false
}
}
/**
* 导出用户数据为CSV格式
*/
const exportUserDataAsCSV = async (exportData, userName, currentMonth) => {
const csvContent = []
// 用户信息部分
csvContent.push('用户信息')
csvContent.push(`用户ID,${exportData.userData.id}`)
csvContent.push(`姓名,${exportData.userData.name}`)
csvContent.push(`手机号,${exportData.userData.phone}`)
csvContent.push(`负责机构数,${exportData.userData.institutionCount}`)
csvContent.push(`互动得分,${exportData.userData.interactionScore}`)
csvContent.push(`绩效得分,${exportData.userData.performanceScore}`)
csvContent.push('')
// 机构详情部分
csvContent.push('机构详情')
csvContent.push('机构ID,机构名称,图片数量,得分')
exportData.institutions.forEach(inst => {
const score = inst.imageCount === 0 ? 0 : inst.imageCount === 1 ? 0.5 : 1
csvContent.push(`${inst.institutionId},${inst.name},${inst.imageCount},${score}`)
})
csvContent.push('')
// 图片详情部分
csvContent.push('图片详情')
csvContent.push('机构名称,图片名称,上传时间,文件大小')
exportData.institutions.forEach(inst => {
if (inst.images && inst.images.length > 0) {
inst.images.forEach(img => {
csvContent.push(`${inst.name},${img.name},${img.uploadTime},${img.size}`)
})
}
})
// 添加BOM以支持中文
const BOM = '\uFEFF'
const csvString = BOM + csvContent.join('\n')
const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${userName}_${currentMonth}_数据导出.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
/**
* 导出用户数据为ZIP压缩包格式
*/
const exportUserDataAsZIP = async (exportData, userName, currentMonth) => {
try {
const zip = new JSZip()
let totalImages = 0
let addedImages = 0
console.log('开始生成用户ZIP文件:', { userName, currentMonth, institutions: exportData.institutions.length })
// 创建用户数据摘要文件
const summaryData = {
exportInfo: exportData.exportInfo,
userData: exportData.userData,
institutionSummary: exportData.institutions.map(inst => ({
id: inst.id,
institutionId: inst.institutionId,
name: inst.name,
imageCount: inst.imageCount
}))
}
zip.file(`${userName}_数据摘要.json`, JSON.stringify(summaryData, null, 2))
// 按机构创建文件夹并添加图片
console.log('=== 开始处理机构和图片数据 ===')
console.log('导出数据结构:', {
institutionsCount: exportData.institutions.length,
institutions: exportData.institutions.map(inst => ({
name: inst.name,
id: inst.id,
institutionId: inst.institutionId,
imageCount: inst.images ? inst.images.length : 0,
hasImages: !!(inst.images && inst.images.length > 0)
}))
})
for (const institution of exportData.institutions) {
console.log(`\n--- 处理机构: ${institution.name} ---`)
console.log('机构详细信息:', {
id: institution.id,
institutionId: institution.institutionId,
name: institution.name,
imageCount: institution.imageCount,
imagesArrayLength: institution.images ? institution.images.length : 0,
imagesExists: !!institution.images
})
if (institution.images && institution.images.length > 0) {
const folderName = `${institution.name}_${institution.institutionId}`
totalImages += institution.images.length
console.log(`机构 ${institution.name} 的图片列表:`)
institution.images.forEach((img, index) => {
console.log(` 图片 ${index + 1}:`, {
id: img.id,
name: img.name,
hasUrl: !!img.url,
urlType: img.url ? typeof img.url : 'undefined',
urlLength: img.url ? img.url.length : 0,
urlPrefix: img.url ? img.url.substring(0, 50) + '...' : 'N/A',
size: img.size,
uploadTime: img.uploadTime
})
})
for (const [index, image] of institution.images.entries()) {
try {
console.log(`\n处理图片 ${index + 1}/${institution.images.length}: ${image.name}`)
// 详细检查图片URL
if (!image.url) {
console.error(`❌ 图片缺少URL数据:`, {
imageName: image.name,
imageId: image.id,
imageObject: image,
institutionName: institution.name
})
continue
}
// 检查URL格式
if (typeof image.url !== 'string') {
console.error(`❌ 图片URL不是字符串:`, {
imageName: image.name,
urlType: typeof image.url,
url: image.url
})
continue
}
// 检查是否是Base64格式
if (!image.url.startsWith('data:')) {
console.error(`❌ 图片URL不是Base64格式:`, {
imageName: image.name,
urlPrefix: image.url.substring(0, 100)
})
continue
}
// 从Base64数据中提取图片数据
const base64Data = image.url.split(',')[1]
if (base64Data) {
// 获取文件扩展名
const mimeType = image.url.split(';')[0].split(':')[1]
const extension = mimeType.split('/')[1] || 'jpg'
const fileName = `${image.name.replace(/\.[^/.]+$/, '')}.${extension}`
zip.file(`${folderName}/${fileName}`, base64Data, { base64: true })
addedImages++
console.log(`✅ 已添加图片: ${folderName}/${fileName}`, {
mimeType,
extension,
base64Length: base64Data.length
})
} else {
console.error(`❌ 图片Base64数据无效:`, {
imageName: image.name,
url: image.url,
splitResult: image.url.split(',')
})
}
} catch (error) {
console.error(`❌ 处理图片失败: ${image.name}`, {
error: error.message,
stack: error.stack,
imageData: image
})
}
}
} else {
console.log(`机构 ${institution.name} 没有图片数据`)
}
}
console.log(`ZIP文件生成统计: 总图片数 ${totalImages}, 成功添加 ${addedImages}`)
// 生成并下载ZIP文件
const content = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
})
saveAs(content, `${userName}_${currentMonth}_图片数据包.zip`)
// 显示成功消息
if (addedImages > 0) {
ElMessage.success(`ZIP文件生成成功!包含 ${addedImages} 张图片`)
} else {
ElMessage.warning('ZIP文件生成成功,但未包含图片(可能该用户暂无图片数据)')
}
} catch (error) {
console.error('生成ZIP文件失败:', error)
throw new Error('生成ZIP文件失败')
}
}
...@@ -2531,10 +2172,10 @@ const handleImportData = async () => { ...@@ -2531,10 +2172,10 @@ const handleImportData = async () => {
// 读取文件内容 // 读取文件内容
const reader = new FileReader() const reader = new FileReader()
reader.onload = (e) => { reader.onload = async (e) => {
try { try {
const jsonData = e.target.result const jsonData = e.target.result
const success = dataStore.importData(jsonData) const success = await dataStore.importData(jsonData)
if (success) { if (success) {
ElMessage.success('数据导入成功!页面将刷新以应用新数据。') ElMessage.success('数据导入成功!页面将刷新以应用新数据。')
...@@ -2594,7 +2235,7 @@ const showMonthlyResetConfirm = async () => { ...@@ -2594,7 +2235,7 @@ const showMonthlyResetConfirm = async () => {
// 执行月度重置 // 执行月度重置
console.log('开始执行月度重置...') console.log('开始执行月度重置...')
const result = dataStore.manualMonthlyReset() const result = await dataStore.manualMonthlyReset()
console.log('月度重置结果:', result) console.log('月度重置结果:', result)
if (result && result.success) { if (result && result.success) {
...@@ -2655,56 +2296,29 @@ const loadLastResetTime = async () => { ...@@ -2655,56 +2296,29 @@ const loadLastResetTime = async () => {
/** /**
* 加载可用的历史月份 * 加载可用的历史月份
*/ */
const loadAvailableMonths = () => { const loadAvailableMonths = async () => {
availableMonths.value = dataStore.getAvailableHistoryMonths()
console.log('可用历史月份:', availableMonths.value)
}
/**
* 保存当前月份统计数据
*/
const saveCurrentMonthStats = async () => {
try { try {
await ElMessageBox.confirm( availableMonths.value = await dataStore.getAvailableHistoryMonths()
'确定要保存当前月份的统计数据吗?\n' + console.log('可用历史月份:', availableMonths.value)
'这将记录所有用户的当前绩效数据。',
'保存月度统计',
{
type: 'info',
confirmButtonText: '保存',
cancelButtonText: '取消'
}
)
saveStatsLoading.value = true
const success = await dataStore.saveCurrentMonthStats()
if (success) {
ElMessage.success('当前月份统计数据保存成功!')
loadAvailableMonths() // 刷新可用月份列表
} else {
ElMessage.error('保存统计数据失败!')
}
} catch (error) { } catch (error) {
if (error !== 'cancel') { console.error('加载历史月份失败:', error)
console.error('保存统计数据失败:', error) availableMonths.value = []
ElMessage.error('保存统计数据失败!')
}
} finally {
saveStatsLoading.value = false
} }
} }
/** /**
* 加载指定月份的历史数据 * 加载指定月份的历史数据
*/ */
const loadHistoryMonth = (monthKey) => { const loadHistoryMonth = async (monthKey) => {
if (!monthKey) { if (!monthKey) {
selectedMonthData.value = null selectedMonthData.value = null
return return
} }
const monthData = dataStore.getMonthStats(monthKey) try {
const monthData = await dataStore.getMonthStats(monthKey)
if (monthData) { if (monthData) {
selectedMonthData.value = monthData selectedMonthData.value = monthData
console.log(`加载 ${monthKey} 月份数据:`, monthData) console.log(`加载 ${monthKey} 月份数据:`, monthData)
...@@ -2712,79 +2326,16 @@ const loadHistoryMonth = (monthKey) => { ...@@ -2712,79 +2326,16 @@ const loadHistoryMonth = (monthKey) => {
ElMessage.error('加载历史数据失败!') ElMessage.error('加载历史数据失败!')
selectedMonthData.value = null selectedMonthData.value = null
} }
}
/**
* 删除选中月份的数据
*/
const deleteSelectedMonth = async () => {
if (!selectedHistoryMonth.value) return
try {
await ElMessageBox.confirm(
`确定要删除 ${formatMonthLabel(selectedHistoryMonth.value)} 的历史数据吗?\n` +
'此操作不可恢复!',
'删除历史数据',
{
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
}
)
const success = dataStore.deleteMonthStats(selectedHistoryMonth.value)
if (success) {
ElMessage.success('历史数据删除成功!')
selectedHistoryMonth.value = ''
selectedMonthData.value = null
loadAvailableMonths() // 刷新可用月份列表
} else {
ElMessage.error('删除历史数据失败!')
}
} catch (error) { } catch (error) {
if (error !== 'cancel') { console.error(`加载 ${monthKey} 月份数据失败:`, error)
console.error('删除历史数据失败:', error) ElMessage.error('加载历史数据失败!')
ElMessage.error('删除历史数据失败!') selectedMonthData.value = null
}
} }
} }
/**
* 清空所有历史数据
*/
const clearHistoryStats = async () => {
try {
await ElMessageBox.confirm(
'确定要清空所有历史统计数据吗?\n' +
'此操作将删除所有月份的历史记录,不可恢复!',
'清空历史数据',
{
type: 'error',
confirmButtonText: '清空',
cancelButtonText: '取消'
}
)
clearHistoryLoading.value = true
const success = dataStore.clearAllHistoryStats()
if (success) {
ElMessage.success('所有历史数据已清空!')
selectedHistoryMonth.value = ''
selectedMonthData.value = null
availableMonths.value = []
} else {
ElMessage.error('清空历史数据失败!')
}
} catch (error) {
if (error !== 'cancel') {
console.error('清空历史数据失败:', error)
ElMessage.error('清空历史数据失败!')
}
} finally {
clearHistoryLoading.value = false
}
}
/** /**
* 处理历史数据导出命令 * 处理历史数据导出命令
...@@ -2793,6 +2344,8 @@ const handleHistoryExportCommand = (command) => { ...@@ -2793,6 +2344,8 @@ const handleHistoryExportCommand = (command) => {
exportHistoryData(command) exportHistoryData(command)
} }
/** /**
* 导出历史数据 * 导出历史数据
*/ */
...@@ -2835,7 +2388,8 @@ const exportHistoryData = async (format = 'json') => { ...@@ -2835,7 +2388,8 @@ const exportHistoryData = async (format = 'json') => {
name: inst.name, name: inst.name,
imageCount: inst.imageCount imageCount: inst.imageCount
})) }))
})) })),
institutionsData: monthData.institutionsData || [] // 新增:历史机构图片数据
} }
// 根据格式导出 // 根据格式导出
...@@ -2949,40 +2503,63 @@ const exportHistoryDataAsZIP = async (exportData, month) => { ...@@ -2949,40 +2503,63 @@ const exportHistoryDataAsZIP = async (exportData, month) => {
zip.file(`用户绩效统计_${month}.json`, JSON.stringify(performanceData, null, 2)) zip.file(`用户绩效统计_${month}.json`, JSON.stringify(performanceData, null, 2))
// 按用户和机构创建多级文件夹并添加图片 // 按用户和机构创建多级文件夹并添加图片
// 注意:历史数据可能没有完整的图片信息,需要从当前数据中获取 // 从历史数据中获取图片信息
let totalImages = 0
let addedImages = 0
if (exportData.institutionsData && exportData.institutionsData.length > 0) {
console.log('从历史数据中获取图片信息:', exportData.institutionsData.length, '个机构')
// 按用户分组机构数据
const institutionsByUser = {}
for (const institution of exportData.institutionsData) {
const userId = institution.ownerId
if (!institutionsByUser[userId]) {
institutionsByUser[userId] = []
}
institutionsByUser[userId].push(institution)
}
// 为每个用户创建文件夹并添加图片
for (const user of exportData.userDetails) { for (const user of exportData.userDetails) {
const userFolderName = user.userName const userFolderName = user.userName
const userInstitutions = institutionsByUser[user.userId] || []
// 获取当前用户的机构数据以获取图片
const currentUser = dataStore.getUserById(user.userId)
if (currentUser) {
const userInstitutions = dataStore.getInstitutionsByUserId(user.userId)
for (const institution of userInstitutions) { for (const institution of userInstitutions) {
if (institution.images && institution.images.length > 0) { if (institution.images && institution.images.length > 0) {
const institutionFolderName = `${institution.name}_${institution.institutionId}` const institutionFolderName = `${institution.name}_${institution.institutionId}`
totalImages += institution.images.length
for (const image of institution.images) { for (let imageIndex = 0; imageIndex < institution.images.length; imageIndex++) {
const image = institution.images[imageIndex]
try { try {
// 从Base64数据中提取图片数据 // 从Base64数据中提取图片数据
if (image.url && image.url.includes(',')) {
const base64Data = image.url.split(',')[1] const base64Data = image.url.split(',')[1]
if (base64Data) { if (base64Data) {
// 获取文件扩展名 // 获取文件扩展名
const mimeType = image.url.split(';')[0].split(':')[1] const mimeType = image.url.split(';')[0].split(':')[1]
const extension = mimeType.split('/')[1] || 'jpg' const extension = mimeType.split('/')[1] || 'jpg'
const fileName = `${image.name.replace(/\.[^/.]+$/, '')}.${extension}` // 使用图片ID或索引作为文件名
const fileName = `image_${image.id || (imageIndex + 1)}.${extension}`
zip.file(`${userFolderName}/${institutionFolderName}/${fileName}`, base64Data, { base64: true }) zip.file(`${userFolderName}/${institutionFolderName}/${fileName}`, base64Data, { base64: true })
addedImages++
}
} }
} catch (error) { } catch (error) {
console.warn(`处理图片失败: ${image.name}`, error) console.warn(`处理图片失败: ${image.id || imageIndex}`, error)
} }
} }
} }
} }
} }
} else {
console.warn('历史数据中没有机构图片信息,可能是旧版本数据')
} }
console.log(`历史数据ZIP文件生成统计: 总图片数 ${totalImages}, 成功添加 ${addedImages}`)
// 生成并下载ZIP文件 // 生成并下载ZIP文件
const content = await zip.generateAsync({ const content = await zip.generateAsync({
type: 'blob', type: 'blob',
...@@ -2991,6 +2568,13 @@ const exportHistoryDataAsZIP = async (exportData, month) => { ...@@ -2991,6 +2568,13 @@ const exportHistoryDataAsZIP = async (exportData, month) => {
}) })
saveAs(content, `历史数据_${month}_完整图片包.zip`) saveAs(content, `历史数据_${month}_完整图片包.zip`)
// 显示成功消息
if (addedImages > 0) {
ElMessage.success(`历史数据ZIP文件生成成功!包含 ${addedImages} 张图片`)
} else {
ElMessage.warning('历史数据ZIP文件生成成功,但未包含图片(可能是旧版本历史数据)')
}
} catch (error) { } catch (error) {
console.error('生成历史数据ZIP文件失败:', error) console.error('生成历史数据ZIP文件失败:', error)
throw new Error('生成历史数据ZIP文件失败') throw new Error('生成历史数据ZIP文件失败')
...@@ -3025,7 +2609,7 @@ const getPerformanceTagType = (score) => { ...@@ -3025,7 +2609,7 @@ const getPerformanceTagType = (score) => {
/** /**
* 组件挂载时初始化 * 组件挂载时初始化
*/ */
onMounted(() => { onMounted(async () => {
// 检查权限(数据和认证状态已在main.js中初始化) // 检查权限(数据和认证状态已在main.js中初始化)
if (!authStore.isAuthenticated || !authStore.isAdmin) { if (!authStore.isAuthenticated || !authStore.isAdmin) {
router.push('/login') router.push('/login')
...@@ -3035,10 +2619,10 @@ onMounted(() => { ...@@ -3035,10 +2619,10 @@ onMounted(() => {
console.log('管理员面板组件已挂载') console.log('管理员面板组件已挂载')
// 加载历史统计数据 // 加载历史统计数据
loadAvailableMonths() await loadAvailableMonths()
// 加载上次重置时间 // 加载上次重置时间
loadLastResetTime() await loadLastResetTime()
// 管理员面板初始化完成 // 管理员面板初始化完成
}) })
......
...@@ -82,12 +82,11 @@ const loginForm = reactive({ ...@@ -82,12 +82,11 @@ const loginForm = reactive({
// 表单验证规则 // 表单验证规则
const loginRules = { const loginRules = {
phone: [ phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' }, { required: true, message: '请输入手机号', trigger: 'blur' }
{ min: 3, message: '手机号不能少于3位', trigger: 'blur' }
], ],
password: [ password: [
{ required: true, message: '请输入密码', trigger: 'blur' }, { required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码不能少于6位', trigger: 'blur' } { min: 1, message: '请输入密码', trigger: 'blur' }
] ]
} }
......
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
<div class="score-card"> <div class="score-card">
<div class="score-title">已传机构数</div> <div class="score-title">已传机构数</div>
<div class="score-value">{{ uploadedInstitutionsCount }}</div> <div class="score-value">{{ uploadedInstitutionsCount }}</div>
<div class="score-desc">已上传图片的机构数量</div> <div class="score-desc">已上传至少2张图片的机构数量</div>
</div> </div>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
...@@ -133,7 +133,7 @@ ...@@ -133,7 +133,7 @@
> >
<img <img
:src="image.url" :src="image.url"
:alt="image.name" :alt="`图片 ${image.id}`"
@click="previewImage(image)" @click="previewImage(image)"
/> />
<div class="image-actions"> <div class="image-actions">
...@@ -146,12 +146,6 @@ ...@@ -146,12 +146,6 @@
删除 删除
</el-button> </el-button>
</div> </div>
<div class="image-info">
<div class="image-name">{{ image.name }}</div>
<div class="upload-time">
{{ formatTime(image.uploadTime) }}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -184,7 +178,7 @@ ...@@ -184,7 +178,7 @@
<img <img
v-if="previewImage" v-if="previewImage"
:src="previewImageData.url" :src="previewImageData.url"
:alt="previewImageData.name" :alt="`图片 ${previewImageData.id}`"
style="width: 100%; max-height: 70vh; object-fit: contain;" style="width: 100%; max-height: 70vh; object-fit: contain;"
/> />
</div> </div>
...@@ -271,10 +265,10 @@ const filteredInstitutions = computed(() => { ...@@ -271,10 +265,10 @@ const filteredInstitutions = computed(() => {
}) })
/** /**
* 计算属性:已传机构数 * 计算属性:已传机构数(至少2张图片才算完成)
*/ */
const uploadedInstitutionsCount = computed(() => { const uploadedInstitutionsCount = computed(() => {
return userInstitutions.value.filter(inst => inst.images && inst.images.length > 0).length return userInstitutions.value.filter(inst => inst.images && inst.images.length >= 2).length
}) })
/** /**
...@@ -326,6 +320,77 @@ const getStatusText = (imageCount) => { ...@@ -326,6 +320,77 @@ const getStatusText = (imageCount) => {
} }
/** /**
* 检查重复图片(在用户所有机构中检查)
*/
const checkDuplicateImage = (newImageData) => {
// 获取用户所有机构的所有图片
const userInstitutions = dataStore.getInstitutionsByUserId(authStore.currentUser.id)
const allExistingImages = []
// 收集所有机构的图片,并记录来源机构
userInstitutions.forEach(institution => {
if (institution.images && institution.images.length > 0) {
institution.images.forEach(img => {
allExistingImages.push({
...img,
institutionName: institution.name // 记录图片来源机构
})
})
}
})
// 1. 检查文件名是否重复
const sameNameImage = allExistingImages.find(img => img.name === newImageData.name)
if (sameNameImage) {
return {
isDuplicate: true,
reason: `文件名 "${newImageData.name}" 已在机构 "${sameNameImage.institutionName}" 中存在,请重命名后再上传`
}
}
// 2. 检查文件大小和内容是否重复(通过Base64数据长度和部分内容比较)
const newDataUrl = newImageData.url
const newDataLength = newDataUrl.length
const newDataHash = newDataUrl.substring(0, 100) + newDataUrl.substring(newDataUrl.length - 100) // 取头尾100字符作为简单哈希
const sameContentImage = allExistingImages.find(img => {
if (!img.url) return false
const existingDataLength = img.url.length
const existingDataHash = img.url.substring(0, 100) + img.url.substring(img.url.length - 100)
// 如果数据长度相同且头尾内容相同,认为是重复图片
return existingDataLength === newDataLength && existingDataHash === newDataHash
})
if (sameContentImage) {
return {
isDuplicate: true,
reason: `检测到相同内容的图片,可能与机构 "${sameContentImage.institutionName}" 中的图片重复`
}
}
// 3. 检查原始文件大小是否完全相同(可能是同一文件)
const sameSizeImage = allExistingImages.find(img =>
img.originalSize && img.originalSize === newImageData.originalSize &&
img.name !== newImageData.name
)
if (sameSizeImage) {
return {
isDuplicate: true,
reason: `检测到相同大小的图片文件,可能与机构 "${sameSizeImage.institutionName}" 中的图片重复`
}
}
return {
isDuplicate: false,
reason: null
}
}
/**
* 压缩图片 * 压缩图片
*/ */
const compressImage = (file, callback, quality = 0.7, maxWidth = 1200) => { const compressImage = (file, callback, quality = 0.7, maxWidth = 1200) => {
...@@ -408,7 +473,7 @@ const beforeUpload = (file, institutionId) => { ...@@ -408,7 +473,7 @@ const beforeUpload = (file, institutionId) => {
/** /**
* 处理图片上传 * 处理图片上传
*/ */
const handleImageUpload = (uploadFile, institutionId) => { const handleImageUpload = async (uploadFile, institutionId) => {
console.log('开始处理图片上传:', { uploadFile, institutionId }) console.log('开始处理图片上传:', { uploadFile, institutionId })
const file = uploadFile.raw const file = uploadFile.raw
...@@ -443,16 +508,6 @@ const handleImageUpload = (uploadFile, institutionId) => { ...@@ -443,16 +508,6 @@ const handleImageUpload = (uploadFile, institutionId) => {
// 数据库模式下,数据直接从内存状态获取 // 数据库模式下,数据直接从内存状态获取
console.log('数据库模式:机构数据来自 API') console.log('数据库模式:机构数据来自 API')
// 验证机构ID匹配
if (savedInstitution && savedInstitution.id !== institutionId) {
console.error('机构ID不匹配:', {
expected: institutionId,
found: savedInstitution.id
})
ElMessage.error('机构ID不匹配,请刷新页面重试!')
return
}
// 🔒 权限验证:确保当前用户有权限操作此机构 // 🔒 权限验证:确保当前用户有权限操作此机构
if (institution.ownerId !== authStore.currentUser.id) { if (institution.ownerId !== authStore.currentUser.id) {
console.error('❌ 权限验证失败:', { console.error('❌ 权限验证失败:', {
...@@ -485,7 +540,7 @@ const handleImageUpload = (uploadFile, institutionId) => { ...@@ -485,7 +540,7 @@ const handleImageUpload = (uploadFile, institutionId) => {
console.log('文件验证通过,开始压缩图片:', file.name, file.size) console.log('文件验证通过,开始压缩图片:', file.name, file.size)
// 压缩并读取文件 // 压缩并读取文件
compressImage(file, (compressedDataUrl) => { compressImage(file, async (compressedDataUrl) => {
console.log('图片压缩完成,数据大小:', compressedDataUrl.length) console.log('图片压缩完成,数据大小:', compressedDataUrl.length)
const imageData = { const imageData = {
...@@ -503,52 +558,29 @@ const handleImageUpload = (uploadFile, institutionId) => { ...@@ -503,52 +558,29 @@ const handleImageUpload = (uploadFile, institutionId) => {
console.log('上传前机构图片数量:', institution.images.length) console.log('上传前机构图片数量:', institution.images.length)
console.log('上传前localStorage数据:', localStorage.getItem('score_system_institutions')) console.log('上传前localStorage数据:', localStorage.getItem('score_system_institutions'))
// 🔍 在实际上传前进行重复检测 // 🔍 重复图片检测:检查用户所有机构中是否有重复图片
const duplicateCheck = dataStore.detectDuplicateImage(imageData, authStore.currentUser.id) const duplicateCheck = checkDuplicateImage(imageData)
if (duplicateCheck.isDuplicate) { if (duplicateCheck.isDuplicate) {
if (!duplicateCheck.allowUpload) { ElMessage.error(`图片上传失败:${duplicateCheck.reason}`)
// 完全重复,禁止上传 return // 阻止上传
ElMessage.error(duplicateCheck.message)
return
} else {
// 轻微编辑或同名不同内容,显示警告但允许上传
ElMessage.warning(duplicateCheck.message)
} }
// 准备图片数据,添加必要的ID和时间戳
const imageDataWithId = {
...imageData,
id: `img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
} }
const result = dataStore.addImageToInstitution( try {
institutionId, const result = await dataStore.addImageToInstitution(institutionId, imageDataWithId)
imageData,
authStore.currentUser.id,
{ expectedName: institution.name, expectedInstitutionId: institution.institutionId }
)
if (result) { if (result) {
console.log('图片添加成功:', result) console.log('图片添加成功:', result)
// 上传后的数据状态
console.log('上传后机构图片数量:', institution.images.length)
console.log('上传后localStorage数据:', localStorage.getItem('score_system_institutions'))
ElMessage.success('图片上传成功!') ElMessage.success('图片上传成功!')
// 强制刷新当前页面数据(确保响应式更新) // 强制刷新当前页面数据(确保响应式更新)
nextTick(() => { nextTick(() => {
console.log('nextTick后机构数据:', institution.images.length) console.log('nextTick后机构数据:', institution.images.length)
// 验证数据是否真的保存到localStorage
const savedData = JSON.parse(localStorage.getItem('score_system_institutions') || '[]')
const savedInstitution = savedData.find(inst => inst.id === institutionId)
console.log('localStorage中的机构数据:', savedInstitution?.images?.length || 0)
// 强制重新加载数据以确保界面更新
dataStore.loadFromStorage()
// 再次验证界面数据(只从用户机构中查找)
const userInstitutions = dataStore.getInstitutionsByUserId(authStore.currentUser.id)
const updatedInstitution = userInstitutions.find(inst => inst.id === institutionId)
console.log('重新加载后机构图片数量:', updatedInstitution?.images?.length || 0)
}) })
} else { } else {
console.error('图片添加失败,返回 null') console.error('图片添加失败,返回 null')
...@@ -556,6 +588,10 @@ const handleImageUpload = (uploadFile, institutionId) => { ...@@ -556,6 +588,10 @@ const handleImageUpload = (uploadFile, institutionId) => {
} }
} catch (error) { } catch (error) {
console.error('图片上传异常:', error) console.error('图片上传异常:', error)
ElMessage.error(`图片上传失败: ${error.message}`)
}
} catch (error) {
console.error('图片上传异常:', error)
if (error.name === 'QuotaExceededError') { if (error.name === 'QuotaExceededError') {
ElMessage.error('存储空间不足,请删除一些图片后重试!') ElMessage.error('存储空间不足,请删除一些图片后重试!')
} else { } else {
...@@ -576,24 +612,17 @@ const removeImage = async (institutionId, imageId) => { ...@@ -576,24 +612,17 @@ const removeImage = async (institutionId, imageId) => {
console.log('开始删除图片:', { institutionId, imageId }) console.log('开始删除图片:', { institutionId, imageId })
// 传递当前用户ID进行权限验证 // 等待删除操作完成
const success = dataStore.removeImageFromInstitution(institutionId, imageId, authStore.currentUser.id) await dataStore.removeImageFromInstitution(institutionId, imageId)
if (success) {
console.log('图片删除成功') console.log('图片删除成功')
ElMessage.success('图片删除成功!') ElMessage.success('图片删除成功!')
// 强制刷新数据确保界面更新
nextTick(async () => {
// 重新加载数据以确保界面同步
await dataStore.loadData()
// 验证删除结果 // 验证删除结果
const userInstitutions = dataStore.getInstitutionsByUserId(authStore.currentUser.id) const userInstitutions = dataStore.getInstitutionsByUserId(authStore.currentUser.id)
const institution = userInstitutions.find(inst => inst.id === institutionId) const institution = userInstitutions.find(inst => inst.id === institutionId)
console.log('删除后机构图片数量:', institution?.images.length || 0) console.log('删除后机构图片数量:', institution?.images.length || 0)
})
}
} catch (error) { } catch (error) {
if (error.message && error.message.includes('🚨')) { if (error.message && error.message.includes('🚨')) {
// 安全错误 // 安全错误
...@@ -617,14 +646,6 @@ const previewImage = (image) => { ...@@ -617,14 +646,6 @@ const previewImage = (image) => {
} }
/** /**
* 格式化时间
*/
const formatTime = (timeString) => {
const date = new Date(timeString)
return date.toLocaleString('zh-CN')
}
/**
* 处理页面切换 * 处理页面切换
*/ */
const handlePageChange = (page) => { const handlePageChange = (page) => {
...@@ -634,11 +655,16 @@ const handlePageChange = (page) => { ...@@ -634,11 +655,16 @@ const handlePageChange = (page) => {
/** /**
* 刷新数据 * 刷新数据
*/ */
const refreshData = () => { const refreshData = async () => {
// 只刷新认证状态,不重新加载存储数据(避免丢失用户上传的图片) try {
authStore.restoreAuth() // 重新从数据库加载最新数据
await dataStore.loadFromDatabase()
currentPage.value = 1 // 重置分页 currentPage.value = 1 // 重置分页
ElMessage.success('数据刷新成功!') ElMessage.success('数据刷新成功!')
} catch (error) {
console.error('刷新数据失败:', error)
ElMessage.error('数据刷新失败,请稍后重试')
}
} }
...@@ -668,7 +694,8 @@ const handleLogout = async () => { ...@@ -668,7 +694,8 @@ const handleLogout = async () => {
type: 'warning' type: 'warning'
}) })
authStore.logout() // 等待登出完成后再跳转
await authStore.logout()
router.push('/login') router.push('/login')
ElMessage.success('已退出登录') ElMessage.success('已退出登录')
} catch { } catch {
...@@ -897,10 +924,6 @@ onMounted(() => { ...@@ -897,10 +924,6 @@ onMounted(() => {
white-space: nowrap; white-space: nowrap;
} }
.upload-time {
color: #909399;
}
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 40px; padding: 40px;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment