"""AirLabs Project —— 主入口""" from dotenv import load_dotenv load_dotenv() from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from database import engine, Base from models import ( User, Role, PhaseGroup, BUILTIN_ROLES, COST_PERM_MIGRATION, Project, ProjectMilestone, DEFAULT_MILESTONES, Submission ) from auth import hash_password from sqlalchemy.orm.attributes import flag_modified from sqlalchemy import text, inspect import os import logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # 创建所有表 Base.metadata.create_all(bind=engine) # ── 手动迁移:为旧 SQLite 数据库补充 role_id 列 ── from config import DATABASE_URL as _db_url if _db_url.startswith("sqlite"): import sqlalchemy with engine.connect() as conn: cols = [r[1] for r in conn.execute(sqlalchemy.text("PRAGMA table_info('users')"))] if "role_id" not in cols: conn.execute(sqlalchemy.text("ALTER TABLE users ADD COLUMN role_id INTEGER")) conn.commit() logger.info("[MIGRATE] added column users.role_id") role_cols = [r[1] for r in conn.execute(sqlalchemy.text("PRAGMA table_info('roles')"))] if "exempt_submission" not in role_cols: conn.execute(sqlalchemy.text("ALTER TABLE roles ADD COLUMN exempt_submission INTEGER NOT NULL DEFAULT 0")) conn.commit() logger.info("[MIGRATE] added column roles.exempt_submission") app = FastAPI(title="AirLabs Project", version="1.0.0") # CORS from config import CORS_ORIGINS app.add_middleware( CORSMiddleware, allow_origins=CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # 注册路由 from routers.auth import router as auth_router from routers.users import router as users_router from routers.projects import router as projects_router from routers.submissions import router as submissions_router from routers.costs import router as costs_router from routers.dashboard import router as dashboard_router from routers.roles import router as roles_router try: from routers.reports import router as reports_router except ImportError as e: reports_router = None logging.warning(f"[路由] reports 模块加载失败: {e}") app.include_router(auth_router) app.include_router(users_router) app.include_router(projects_router) app.include_router(submissions_router) app.include_router(costs_router) app.include_router(dashboard_router) app.include_router(roles_router) if reports_router: app.include_router(reports_router) # 前端静态文件 frontend_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "dist") if os.path.exists(frontend_dir): app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dir, "assets")), name="assets") @app.get("/{full_path:path}") async def serve_frontend(full_path: str): # 不拦截 API 路径,让 FastAPI 路由正常处理(含 redirect_slashes) if full_path.startswith("api/") or full_path == "api": raise HTTPException(status_code=404, detail="Not Found") file_path = os.path.join(frontend_dir, full_path) if os.path.isfile(file_path): return FileResponse(file_path) return FileResponse(os.path.join(frontend_dir, "index.html")) @app.on_event("startup") async def start_scheduler(): """启动定时任务调度器""" try: from services.scheduler_service import setup_scheduler setup_scheduler() except Exception as e: logger.warning(f"[定时任务] 启动失败(不影响核心功能): {e}") @app.on_event("shutdown") async def stop_scheduler(): """关闭定时任务调度器""" try: from services.scheduler_service import scheduler scheduler.shutdown(wait=False) logger.info("[定时任务] 已关闭") except Exception: pass @app.on_event("startup") def init_roles_and_admin(): """首次启动时创建内置角色和默认管理员""" from database import SessionLocal db = SessionLocal() try: # ── 数据库结构迁移(幂等,必须在 ORM 查询之前) ── try: inspector = inspect(engine) ms_cols = [c['name'] for c in inspector.get_columns('project_milestones')] sub_cols = [c['name'] for c in inspector.get_columns('submissions')] role_cols = [c['name'] for c in inspector.get_columns('roles')] with engine.connect() as conn: # ProjectMilestone 新字段 if 'estimated_days' not in ms_cols: conn.execute(text("ALTER TABLE project_milestones ADD COLUMN estimated_days INT NULL")) conn.execute(text("ALTER TABLE project_milestones ADD COLUMN start_date DATE NULL")) conn.commit() print("[MIGRATE] added estimated_days, start_date to project_milestones") # Submission 新字段 if 'milestone_id' not in sub_cols: conn.execute(text("ALTER TABLE submissions ADD COLUMN milestone_id INT NULL")) conn.execute(text("ALTER TABLE submissions ADD COLUMN delay_reason TEXT NULL")) conn.commit() print("[MIGRATE] added milestone_id, delay_reason to submissions") if 'episode_number' not in sub_cols: conn.execute(text("ALTER TABLE submissions ADD COLUMN episode_number INT NULL")) conn.commit() print("[MIGRATE] added episode_number to submissions") # Role: exempt_submission 字段 if 'exempt_submission' not in role_cols: conn.execute(text("ALTER TABLE roles ADD COLUMN exempt_submission INT NOT NULL DEFAULT 0")) conn.commit() print("[MIGRATE] added exempt_submission to roles") # MySQL: 扩展 content_type 枚举(使用 Python enum 名称)+ 旧值迁移 from config import DATABASE_URL if not DATABASE_URL.startswith("sqlite"): try: conn.execute(text(""" ALTER TABLE submissions MODIFY COLUMN content_type ENUM('PLANNING','SYNOPSIS','CONCEPT_DESIGN','TEST_FOOTAGE', 'SCRIPT','STORYBOARD','CHARACTER_DESIGN','SCENE_DESIGN', 'PROP_DESIGN','ANIMATION','DUBBING','AI_DUBBING','SOUND_EFFECTS', 'SHOT_REPAIR','EDITING','MUSIC','SUBTITLE','OTHER', 'DESIGN','TRAINING','RECRUITMENT','INTERNAL_OTHER') NOT NULL """)) conn.commit() print("[MIGRATE] expanded content_type enum") except Exception: pass # 已经扩展过 # MySQL: 扩展 project_phase 枚举(加入 INTERNAL/内部事务) try: conn.execute(text(""" ALTER TABLE submissions MODIFY COLUMN project_phase ENUM('PRE','PRODUCTION','POST','INTERNAL') NOT NULL """)) conn.commit() print("[MIGRATE] expanded project_phase enum") except Exception: pass # 已经扩展过 # MySQL: 扩展 work_type 枚举(加入 REVISION/修改) try: conn.execute(text(""" ALTER TABLE submissions MODIFY COLUMN work_type ENUM('PRODUCTION','TEST','PLAN','REVISION','QC') NOT NULL """)) conn.commit() print("[MIGRATE] expanded work_type enum") except Exception: pass # 已经扩展过 # 旧值迁移:DESIGN → PLANNING r1 = conn.execute(text("UPDATE submissions SET content_type='ANIMATION' WHERE content_type='DESIGN'")) conn.commit() if (r1.rowcount or 0) > 0: print(f"[MIGRATE] remapped {r1.rowcount} old DESIGN → ANIMATION") except Exception as e: print(f"[MIGRATE] schema migration error (non-fatal): {e}") # 一次性迁移:阶段"制作"→"中期" try: phase_tables = [ ("submissions", "project_phase"), ("projects", "current_phase"), ("users", "phase_group"), ("project_milestones", "phase"), ] total_migrated = 0 for table, col in phase_tables: r = conn.execute(text(f"UPDATE {table} SET {col} = '中期' WHERE {col} = '制作'")) total_migrated += r.rowcount or 0 conn.commit() if total_migrated > 0: print(f"[MIGRATE] renamed phase '制作'→'中期' in {total_migrated} rows") except Exception as e: print(f"[MIGRATE] phase rename error (non-fatal): {e}") # 初始化 / 同步内置角色权限 for role_name, role_def in BUILTIN_ROLES.items(): existing = db.query(Role).filter(Role.name == role_name).first() if not existing: role = Role( name=role_name, description=role_def["description"], permissions=role_def["permissions"], is_system=1, ) db.add(role) print(f"[OK] created role: {role_name}") elif existing.is_system: # 同步内置角色:完全对齐代码定义(增+减) current = set(existing.permissions or []) defined = set(role_def["permissions"]) added = defined - current removed = current - defined if added or removed: existing.permissions = list(defined) flag_modified(existing, "permissions") parts = [] if added: parts.append(f"added {added}") if removed: parts.append(f"removed {removed}") print(f"[SYNC] {role_name}: {', '.join(parts)}") db.commit() # 迁移旧成本权限 → 细分权限 old_cost_perms = set(COST_PERM_MIGRATION.keys()) for role in db.query(Role).all(): perms = list(role.permissions or []) changed = False for old_perm, new_perms in COST_PERM_MIGRATION.items(): if old_perm in perms: perms.remove(old_perm) for np in new_perms: if np not in perms: perms.append(np) changed = True if changed: role.permissions = perms print(f"[MIGRATE] upgraded cost permissions for role: {role.name}") db.commit() # 为已有项目补充默认里程碑 for proj in db.query(Project).all(): has_ms = db.query(ProjectMilestone).filter( ProjectMilestone.project_id == proj.id ).first() if not has_ms: for ms in DEFAULT_MILESTONES: db.add(ProjectMilestone( project_id=proj.id, name=ms["name"], phase=PhaseGroup(ms["phase"]), sort_order=ms.get("sort_order", 0), )) print(f"[MIGRATE] added default milestones for project: {proj.name}") db.commit() # 为已有项目补充"剪辑"里程碑(如缺失) for proj in db.query(Project).all(): has_edit = db.query(ProjectMilestone).filter( ProjectMilestone.project_id == proj.id, ProjectMilestone.name == "剪辑", ).first() if not has_edit: db.add(ProjectMilestone( project_id=proj.id, name="剪辑", phase=PhaseGroup("后期"), sort_order=4, )) db.commit() # 迁移:为旧用户补充默认 role_id(成员角色) member_role = db.query(Role).filter(Role.name == "成员").first() if member_role: orphans = db.query(User).filter(User.role_id.is_(None)).all() for u in orphans: u.role_id = member_role.id print(f"[MIGRATE] assigned default role '成员' to user: {u.username}") if orphans: db.commit() # 迁移:从 description 提取 EP 号填充 episode_number try: import re as _re orphan_subs = db.query(Submission).filter( Submission.episode_number.is_(None), Submission.description.isnot(None), ).all() migrated_ep = 0 for s in orphan_subs: m = _re.match(r'EP(\d+)', s.description or '', _re.IGNORECASE) if m: s.episode_number = int(m.group(1)) migrated_ep += 1 if migrated_ep: db.commit() print(f"[MIGRATE] extracted episode_number for {migrated_ep} submissions") except Exception as e: print(f"[MIGRATE] episode extraction error (non-fatal): {e}") # 创建默认管理员(关联超级管理员角色) admin_role = db.query(Role).filter(Role.name == "超级管理员").first() if admin_role and not db.query(User).filter(User.username == "admin").first(): owner = User( username="admin", password_hash=hash_password("admin123"), name="管理员", phase_group=PhaseGroup.PRODUCTION, role_id=admin_role.id, monthly_salary=0, ) db.add(owner) db.commit() print("[OK] default admin created: admin / admin123") finally: db.close()