import asyncio import logging import os from contextlib import asynccontextmanager from fastapi import FastAPI, UploadFile, File, Form, Depends, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from app.config import settings from app.models import init_database, close_database from app.utils.redis import init_redis, close_redis from app.api.v1 import api_router from app.api.v2 import api_router_v2 from app.middleware.logging import RequestLoggingMiddleware from app.middleware.anti_crawler import AntiCrawlerMiddleware from app.middleware.rate_limit import RateLimitMiddleware from app.middleware.request_encrypt import RequestEncryptMiddleware from app.services.log_config import decrypt_data logging.basicConfig(level=logging.INFO if settings.DEBUG else logging.WARNING) @asynccontextmanager async def lifespan(app: FastAPI): from app.models import async_session # Ensure upload directory exists os.makedirs(settings.UPLOAD_LOCAL_PATH, exist_ok=True) await init_database() await init_redis() # await _seed_data() # 启动token刷新定时任务(每5分钟检查一次,小于800秒有效期的token进行刷新) from app.tasks.token_refresh_task import token_refresh_scheduler token_refresh_task = asyncio.create_task(token_refresh_scheduler()) # 启动上传任务队列 from app.services.upload_queue import upload_queue await upload_queue.recover() upload_queue_task = asyncio.create_task(upload_queue.run()) # 启动素材消耗队列 from app.services.material_consumption_queue import material_consumption_queue consumption_queue_task = asyncio.create_task(material_consumption_queue.run()) # 启动素材消耗计划任务(每天9点自动同步) from app.tasks.material_consumption_task import schedule_daily_sync consumption_schedule_task = asyncio.create_task(schedule_daily_sync()) # 启动前测结果轮询任务(每分钟检查一次) from app.tasks.pre_test_result_task import poll_pre_test_results pre_test_poll_task = asyncio.create_task(poll_pre_test_results()) # 启动前测任务队列 from app.services.pre_test_queue import pre_test_queue pre_test_queue_task = asyncio.create_task(pre_test_queue.run()) # 启动时立即同步一次未支付订单 asyncio.create_task(asyncio.sleep(5)) # 等待5秒后再同步,让系统完全启动 async def startup_sync(): await asyncio.sleep(5) from app.services.payment import sync_pending_orders from logging import getLogger bg_logger = getLogger("payment") try: async with async_session() as db: sync_count = await sync_pending_orders(db) if sync_count > 0: bg_logger.info(f"Startup: Synced {sync_count} pending payment order(s)") except Exception as e: bg_logger.error(f"Startup sync error: {e}") asyncio.create_task(startup_sync()) app.state.db_session_factory = async_session yield upload_queue.stop() await upload_queue_task pre_test_queue.stop() await pre_test_queue_task material_consumption_queue.stop() await consumption_queue_task consumption_schedule_task.cancel() pre_test_poll_task.cancel() token_refresh_task.cancel() await close_database() await close_redis() async def _seed_data(): """Insert initial data on first run.""" from app.models import async_session from app.models.user import User from app.models.system_config import SystemConfig from app.models.video_engine import VideoEngine from app.models.image_engine import ImageEngine from app.models.credit_ratio import CreditRatio from app.models.model_config import ModelConfig from app.services.auth import hash_password from app.utils.id_gen import generate_id from sqlalchemy import select, func async with async_session() as db: # Check if admin exists result = await db.execute(select(User).where(User.is_admin == True).limit(1)) admin = result.scalar_one_or_none() if not admin: admin_user = User( id=generate_id(), username="admin", email="admin@videogen.ai", phone="13800000000", hashed_password=hash_password("123456"), is_admin=True, user_type="admin", ) db.add(admin_user) # Seed demo user result = await db.execute(select(User).where(User.username == "demo").limit(1)) demo = result.scalar_one_or_none() if not demo: demo_user = User( id=generate_id(), username="demo", email="demo@videogen.ai", phone="13888888888", hashed_password=hash_password("123456"), is_admin=False, user_type="frontend", ) db.add(demo_user) # Seed system configs configs = [ ("site_name", "民众智创", "网站名称"), ("site_logo", "", "网站Logo URL"), ("site_copyright", "© 2024 民众智创 版权所有", "网站底部版权信息"), ("seo_title", "民众智创 - AI视频生成平台", "SEO标题"), ("seo_description", "专业的AI视频生成服务", "SEO描述"), ("seo_keywords", "AI视频,视频生成,人工智能", "SEO关键词"), # Agreement config ("user_agreement_privacy_url", "", "用户协议及隐私政策PDF"), # Payment configs ("payment_wechat_enabled", "false", "微信支付启用"), ("payment_wechat_mch_id", "", "微信商户号"), ("payment_wechat_api_key", "", "微信API密钥"), ("payment_alipay_enabled", "false", "支付宝启用"), ("payment_alipay_app_id", "", "支付宝AppID"), ("payment_alipay_private_key", "", "支付宝私钥"), # User credits config ("user_register_credits", "100", "用户注册赠送积分"), ("user_login_credits", "0", "用户每日登录赠送积分"), ("user_login_credits_enabled", "false", "启用每日登录赠送积分"), # Operation manual ("operation_manual", "", "操作手册链接"), ] for key, value, desc in configs: existing = await db.execute( select(SystemConfig).where(SystemConfig.key == key).limit(1) ) existing_config = existing.scalar_one_or_none() if not existing_config: db.add( SystemConfig( id=generate_id(), key=key, value=value, description=desc ) ) elif not existing_config.description and desc: # Update description if not set existing_config.description = desc db.add(existing_config) # Seed video engine existing_engine = await db.execute( select(VideoEngine).where(VideoEngine.provider == "ark") ) if not existing_engine.scalars().first(): db.add( VideoEngine( id=generate_id(), name="Seedance 2.0", provider="ark", api_base="https://ark.cn-beijing.volces.com/api/v3", api_key="", model_name="doubao-seedance-2-0-260128", supported_ratios='["16:9","4:3","1:1","3:4","9:16","21:9"]', supported_resolutions='["480p","720p","1080p"]', supported_durations='[4,5,6,7,8,9,10,11,12,13,14,15]', max_duration=15, is_active=True, priority=10, ) ) existing_fast_engine = await db.execute( select(VideoEngine).where(VideoEngine.model_name == "doubao-seedance-2-0-fast-260128") ) if not existing_fast_engine.scalars().first(): db.add( VideoEngine( id=generate_id(), name="Seedance 2.0 fast", provider="ark", api_base="https://ark.cn-beijing.volces.com/api/v3", api_key="", model_name="doubao-seedance-2-0-fast-260128", supported_ratios='["16:9","4:3","1:1","3:4","9:16","21:9"]', supported_resolutions='["480p","720p","1080p"]', supported_durations='[4,5,6,7,8,9,10,11,12,13,14,15]', max_duration=15, is_active=True, priority=10, ) ) # Seed image engine existing_img_engine = await db.execute( select(ImageEngine).where(ImageEngine.provider == "ark").limit(1) ) if not existing_img_engine.scalar_one_or_none(): db.add( ImageEngine( id=generate_id(), name="豆包文生图", provider="ark", api_base="https://ark.cn-beijing.volces.com/api/v3", api_key="", model_name="doubao-seedream-5-0-260128", supported_models='["doubao-seedream-5-0-260128"]', supported_sizes='{"2K":{"1:1":"2048×2048","4:3":"2304×1728","3:4":"1728×2304","16:9":"2560×1440","9:16":"1600×2848","3:2":"2496×1664","2:3":"1664×2496","21:9":"3024×1296"},"4K":{"1:1":"4096×4096","4:3":"4608×3456","3:4":"3520×4704","16:9":"5404×3040","9:16":"3040×5504","3:2":"4992×3328","2:3":"3328×4992","21:9":"6197×2656"}}', default_size="2K", generate_url="https://ark.cn-beijing.volces.com/api/v3/images/generations", is_active=True, priority=10, ) ) # Seed default Ark model config existing_sdk_model = await db.execute( select(ModelConfig).where(ModelConfig.provider == "sdk").limit(1) ) if not existing_sdk_model.scalar_one_or_none(): db.add( ModelConfig( id=generate_id(), name="火山引擎 Ark", provider="sdk", api_base="https://ark.cn-beijing.volces.com/api/v3", api_key="", model_name="doubao-seed-2-0-lite-260215", weight=1, max_tokens=4096, temperature=0.7, is_active=True, priority=10, ) ) # 文字模型媒体使用 base64 开关 existing_media_format = await db.execute( select(SystemConfig).where(SystemConfig.key == "llm_media_as_base64").limit(1) ) if not existing_media_format.scalar_one_or_none(): db.add( SystemConfig( id=generate_id(), key="llm_media_as_base64", value="true", description="文字模型请求时图片/视频使用 base64 编码(而非 URL 链接)", ) ) # Seed credit ratios - model_config_id is kept as a compatible field name, # but now stores the actual engine id: # - gen_type=video -> video_engines.id # - gen_type=image -> image_engines.id await db.flush() default_video_engine_result = await db.execute( select(VideoEngine) .where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None)) .order_by(VideoEngine.priority.desc(), VideoEngine.id.desc()) .limit(1) ) default_video_engine = default_video_engine_result.scalar_one_or_none() if default_video_engine: for resolution, ratio_val, base, per_sec in [ ("480p", 1.0, 60, 2), ("720p", 1.0, 80, 2), ("1080p", 1.5, 120, 3), ]: existing_ratio = await db.execute( select(CreditRatio) .where(CreditRatio.model_config_id == default_video_engine.id) .where(CreditRatio.gen_type == "video") .where(CreditRatio.resolution == resolution) .limit(1) ) if not existing_ratio.scalar_one_or_none(): db.add( CreditRatio( id=generate_id(), model_config_id=default_video_engine.id, gen_type="video", resolution=resolution, ratio=ratio_val, base_credits=base, per_second_credits=per_sec, ) ) default_image_engine_result = await db.execute( select(ImageEngine) .where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None)) .order_by(ImageEngine.priority.desc(), ImageEngine.id.desc()) .limit(1) ) default_image_engine = default_image_engine_result.scalar_one_or_none() if default_image_engine: for resolution, ratio_val, base, per_sec in [ ("2K", 1.0, 4, 0), ("4K", 1.0, 6, 0), ]: existing_ratio = await db.execute( select(CreditRatio) .where(CreditRatio.model_config_id == default_image_engine.id) .where(CreditRatio.gen_type == "image") .where(CreditRatio.resolution == resolution) .limit(1) ) if not existing_ratio.scalar_one_or_none(): db.add( CreditRatio( id=generate_id(), model_config_id=default_image_engine.id, gen_type="image", resolution=resolution, ratio=ratio_val, base_credits=base, per_second_credits=per_sec, ) ) # Seed menu configs from app.models.menu_config import MenuConfig menu_count = await db.execute(select(func.count(MenuConfig.id))) menu_count_result = menu_count.scalar_one() logging.info(f"Menu config count: {menu_count_result}") if menu_count_result == 0: logging.info("Inserting default menu configs...") frontend_menus = [ {"id": "0019eca2549ba477069", "label": "制作素材", "path": "", "icon": "PlayCircleOutlined", "sort_order": 1, "is_active": True, "parent_id": None, "menu_type": "group", "menu_target": "frontend", "is_default": True}, {"id": "0019f01914a66279ec0", "label": "灵感参考", "path": "", "icon": "HomeOutlined", "sort_order": 2, "is_active": True, "parent_id": None, "menu_type": "group", "menu_target": "frontend", "is_default": True}, {"id": "0019eca2735b9f3d944", "label": "我的资产", "path": "", "icon": "HomeOutlined", "sort_order": 3, "is_active": True, "parent_id": None, "menu_type": "group", "menu_target": "frontend", "is_default": True}, {"id": "0019eca27ec59922048", "label": "广告素材管理", "path": "", "icon": "HomeOutlined", "sort_order": 4, "is_active": True, "parent_id": None, "menu_type": "group", "menu_target": "frontend", "is_default": True}, {"id": "0019f01b5717017792f", "label": "首页", "path": "/home", "icon": "HomeOutlined", "sort_order": 0, "is_active": True, "parent_id": None, "menu_type": "page", "menu_target": "frontend", "is_default": True}, {"id": "0019f01b445f51dee00", "label": "我的项目", "path": "/projects", "icon": "AppstoreOutlined", "sort_order": 0, "is_active": True, "parent_id": "0019eca2549ba477069", "menu_type": "page", "menu_target": "frontend", "is_default": True}, {"id": "0019f01b445f854a14a", "label": "AI创作", "path": "/conversation", "icon": "StarOutlined", "sort_order": 1, "is_active": True, "parent_id": "0019eca2549ba477069", "menu_type": "page", "menu_target": "frontend", "is_default": True}, {"id": "0019e49af896982b070", "label": "爆款开头复刻", "path": "/initial", "icon": "CodeOutlined", "sort_order": 2, "is_active": True, "parent_id": "0019eca2549ba477069", "menu_type": "page", "menu_target": "frontend", "is_default": True}, {"id": "0019e4f26a8c4c0de5a", "label": "拆镜复刻", "path": "/removelens", "icon": "CameraOutlined", "sort_order": 3, "is_active": True, "parent_id": "0019eca2549ba477069", "menu_type": "page", "menu_target": "frontend", "is_default": True}, {"id": "0019f01b7bb6e147445", "label": "爆款榜单", "path": "/popular", "icon": "FireOutlined", "sort_order": 0, "is_active": True, "parent_id": "0019f01914a66279ec0", "menu_type": "page", "menu_target": "frontend", "is_default": True}, {"id": "0019f019267a4de06a0", "label": "创意广场", "path": "/creativeplaza", "icon": "BulbOutlined", "sort_order": 1, "is_active": True, "parent_id": "0019f01914a66279ec0", "menu_type": "page", "menu_target": "frontend", "is_default": True}, {"id": "0019e80aff6d0ea5843", "label": "素材云", "path": "/generated", "icon": "CloudOutlined", "sort_order": 0, "is_active": True, "parent_id": "0019eca2735b9f3d944", "menu_type": "page", "menu_target": "frontend", "is_default": True}, {"id": "0019f01b44606cc4c81", "label": "投放平台授权", "path": "/authorization", "icon": "UserOutlined", "sort_order": 0, "is_active": True, "parent_id": "0019eca27ec59922048", "menu_type": "page", "menu_target": "frontend", "is_default": True}, {"id": "0019ef924a3521924ab", "label": "素材ID列表", "path": "/materials", "icon": "FileTextOutlined", "sort_order": 1, "is_active": True, "parent_id": "0019eca27ec59922048", "menu_type": "page", "menu_target": "frontend", "is_default": True}, {"id": "0019eb61d6fc2c14ebd", "label": "消耗列表", "path": "/consume", "icon": "FileTextOutlined", "sort_order": 1, "is_active": True, "parent_id": "0019eca27ec59922048", "menu_type": "page", "menu_target": "frontend", "is_default": True}, {"id": "0019ed36864f343d347", "label": "素材前测", "path": "/pretest", "icon": "DatabaseOutlined", "sort_order": 2, "is_active": True, "parent_id": "0019eca27ec59922048", "menu_type": "page", "menu_target": "frontend", "is_default": True}, ] for menu in frontend_menus: db.add(MenuConfig(**menu)) admin_groups = [ ("模型设置", "RobotOutlined", 98), ("模型配置", "RobotOutlined", 6), ("系统设置", "SettingOutlined", 99), ] admin_group_ids: dict[str, str] = {} for label, icon, order in admin_groups: gid = generate_id() admin_group_ids[label] = gid db.add( MenuConfig( id=gid, path="", label=label, icon=icon, sort_order=order, is_active=True, menu_type="group", menu_target="admin", ) ) admin_pages = [ ("/", "数据概览", "DashboardOutlined", 0, None), ("/users", "用户管理", "UserOutlined", 1, None), ("/credit-records", "交易流水", "WalletOutlined", 2, None), ("/generation-ai", "创作记录", "BulbOutlined", 3, None), ("/generation-records", "项目记录", "VideoCameraOutlined", 3, None), ("/credit-products", "积分产品", "GiftOutlined", 4, None), ("/llm-billing-policies", "LLM积分配置", "RobotOutlined", 5, "模型设置"), ("/llm-billing-executions", "LLM调用审计", "DatabaseOutlined", 6, "模型设置"), ("/notifications", "消息推送", "BellOutlined", 5, None), ("/payment-stats", "支付统计", "LineChartOutlined", 6, None), ("/video-engines", "视频引擎", "PlayCircleOutlined", 0, "模型设置"), ("/models", "模型配置", "RobotOutlined", 1, "模型设置"), ("/image-engines", "图片模型", "PictureOutlined", 2, "模型设置"), ("/credit-ratios", "积分比例", "CalculatorOutlined", 3, "模型设置"), ("/payment", "支付配置", "DollarOutlined", 1, "系统设置"), ("/industries", "行业配置", "AppstoreOutlined", 2, "系统设置"), ("/menu-configs", "菜单配置", "SettingOutlined", 3, "系统设置"), ("/settings", "系统设置", "SettingOutlined", 4, "系统设置"), ("/operation-logs", "操作日志", "DatabaseOutlined", 5, "系统设置"), ("/oauthapp-list", "授权应用", "MenuOutlined", 28, "系统设置"), ("/contact-requests", "联系请求", "MessageCircleOutlined", 29, "系统设置"), ] for path, label, icon, order, parent_group in admin_pages: db.add( MenuConfig( id=generate_id(), path=path, label=label, icon=icon, sort_order=order, is_active=True, menu_type="page", menu_target="admin", parent_id=admin_group_ids.get(parent_group), ) ) logging.info("Default menu configs inserted successfully") # 积分订阅套餐和增值包只允许管理后台人工录入;未配置时客户端不展示购买入口。 # Seed industry configs from app.models.industry_config import IndustryConfig default_industries = [ ("ecommerce", "电商", "ShoppingCartOutlined", "电商行业的视频生成模板", 0), ("social", "社交", "TeamOutlined", "社交行业的视频生成模板", 1), ] for key, label, icon, desc, order in default_industries: existing = await db.execute( select(IndustryConfig).where(IndustryConfig.key == key).limit(1) ) if not existing.scalar_one_or_none(): db.add( IndustryConfig( id=generate_id(), key=key, label=label, icon=icon, description=desc, skills="[]", is_active=True, sort_order=order, ) ) await db.commit() def create_app() -> FastAPI: application = FastAPI( title=settings.APP_NAME, version=settings.APP_VERSION, lifespan=lifespan, docs_url="/internal/api-docs", redoc_url="/internal/api-redoc", ) # Middleware (outermost first) application.add_middleware(RequestLoggingMiddleware) application.add_middleware(AntiCrawlerMiddleware) application.add_middleware(RateLimitMiddleware) application.add_middleware(RequestEncryptMiddleware) application.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Encrypted"], ) # Routes application.include_router(api_router, prefix="/api") application.include_router(api_router_v2, prefix="/api/v2") from app.api.v3 import api_router_v3 application.include_router(api_router_v3, prefix="/api/v3") # === API v3 请求日志中间件 === import json as _json import time as _time from app.services.api_v3.logging_service import log_request, log_response, log_request_error @application.middleware("http") async def v3_request_logger(request: Request, call_next): """记录所有 /api/v3/ 请求和响应。""" if not str(request.url.path).startswith("/api/v3"): return await call_next(request) start_time = _time.perf_counter() # 提取 API Key ID auth_header = request.headers.get("Authorization", "") api_key_id = "unknown" if auth_header.startswith("Bearer "): api_key_id = auth_header[7:15] + "..." # 读取请求体 body = None if request.method in ("POST", "PUT", "PATCH"): try: body = await request.json() except Exception: pass log_request( method=request.method, path=str(request.url.path), api_key_id=api_key_id, body=body, ) try: response = await call_next(request) except Exception as exc: duration_ms = int((_time.perf_counter() - start_time) * 1000) log_request_error( method=request.method, path=str(request.url.path), api_key_id=api_key_id, error=str(exc), ) return JSONResponse( content={"code": 50000, "data": None, "message": f"服务器内部错误: {str(exc)[:200]}"}, status_code=200, ) duration_ms = int((_time.perf_counter() - start_time) * 1000) # 读取响应体 response_body = None try: response_body = _json.loads(response.body) except Exception: pass log_response( method=request.method, path=str(request.url.path), api_key_id=api_key_id, status_code=response.status_code, body=response_body, duration_ms=duration_ms, ) return response # === API v3 统一异常处理 === from fastapi.exceptions import HTTPException from fastapi.responses import JSONResponse from app.services.api_v3.pricing_service import PricingNotConfiguredError @application.exception_handler(HTTPException) async def v3_http_exception_handler(request: Request, exc: HTTPException): """仅对 /api/v3/ 路径返回统一格式,HTTP 状态码固定 200。""" if not str(request.url.path).startswith("/api/v3"): # 非 v3 路径返回标准 HTTPException 响应,保持原始状态码 return JSONResponse( status_code=exc.status_code, content={"detail": exc.detail}, ) detail = exc.detail message = detail.get("message", str(detail)) if isinstance(detail, dict) else str(detail) code_map = {400: 40000, 401: 40100, 403: 40300, 404: 40400, 429: 42900, 422: 42200, 500: 50000, 504: 50400} code = code_map.get(exc.status_code, exc.status_code * 100) return JSONResponse(content={"code": code, "data": None, "message": message}, status_code=200) @application.exception_handler(PricingNotConfiguredError) async def v3_pricing_not_configured_handler(request: Request, exc: PricingNotConfiguredError): if not str(request.url.path).startswith("/api/v3"): raise exc return JSONResponse(content={"code": 40001, "data": None, "message": str(exc)}, status_code=200) @application.exception_handler(Exception) async def v3_general_exception_handler(request: Request, exc: Exception): if not str(request.url.path).startswith("/api/v3"): raise exc return JSONResponse(content={"code": 50000, "data": None, "message": f"服务器内部错误: {str(exc)[:200]}"}, status_code=200) # Static files for uploads upload_dir = os.path.abspath(settings.UPLOAD_LOCAL_PATH) os.makedirs(upload_dir, exist_ok=True) application.mount("/uploads", StaticFiles(directory=upload_dir), name="uploads") # 挂载 API v3 生成文件静态目录 generate_dir = os.path.join(os.path.dirname(upload_dir), "generate") os.makedirs(generate_dir, exist_ok=True) application.mount("/generate", StaticFiles(directory=generate_dir), name="generate") @application.get("/internal/health") async def health(): return {"status": "ok"} @application.get("/internal/status", response_class=HTMLResponse) async def status_page(): from app.tasks.celery_app import celery_app from app.config import settings celery_status = "unknown" celery_error = "" redis_status = "unknown" redis_error = "" try: if celery_app: inspect = celery_app.control.inspect() try: workers = inspect.stats() if workers: celery_status = "running" else: celery_status = "no_workers" except Exception as e: celery_status = "error" celery_error = str(e) else: celery_status = "disabled" except Exception as e: celery_status = "error" celery_error = str(e) try: if settings.REDIS_URL: import redis r = redis.from_url(settings.REDIS_URL) r.ping() redis_status = "connected" else: redis_status = "disabled" except Exception as e: redis_status = "disconnected" redis_error = str(e) def get_celery_text(status): if status == 'running': return '运行中' elif status == 'no_workers': return '无可用 Worker' elif status == 'disabled': return '已禁用' elif status == 'error': return '连接失败' else: return '未知' def get_redis_text(status): if status == 'connected': return '已连接' elif status == 'disabled': return '已禁用' elif status == 'disconnected': return '未连接' else: return '未知' celery_text = get_celery_text(celery_status) redis_text = get_redis_text(redis_status) celery_error_html = f'
' if celery_error else '' redis_error_html = f'' if redis_error else '' html_content = f"""版本 v{settings.APP_VERSION}
用于解密日志中的加密数据
后端 API 服务
版本 v""" + settings.APP_VERSION + """
""" return application app = create_app()