- 日报/周报/月报改为结构化卡片推送(column_set布局) - 新增 report:daily/weekly/monthly 权限到角色管理 - 产出统计只算中期制作阶段动画秒数 - 效率之星改为跨项目加权通过率 - AI点评补充风险数据源 - 禁用多余admin账号,股东角色加报告权限 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
328 lines
14 KiB
Python
328 lines
14 KiB
Python
"""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') NOT NULL
|
||
"""))
|
||
conn.commit()
|
||
print("[MIGRATE] expanded content_type 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()
|