import asyncio import logging import os from contextlib import asynccontextmanager from fastapi import FastAPI, UploadFile, File, Form, Depends, HTTPException 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.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() # Start task queue (handles both video and image generation) from app.services.video_queue import task_queue await task_queue.recover() queue_task = asyncio.create_task(task_queue.run()) # Background task: auto-expire pending payment orders and sync status async def _order_expiry_loop(): from app.services.payment import expire_all_pending_orders, sync_pending_orders from logging import getLogger bg_logger = getLogger("payment") while True: try: async with async_session() as db: # 同步待支付订单状态(检查支付宝实际支付状态 sync_count = await sync_pending_orders(db) if sync_count > 0: bg_logger.info(f"Synced {sync_count} pending payment order(s)") # 自动过期订单 n = await expire_all_pending_orders(db) if n > 0: bg_logger.info(f"Auto-expired {n} pending payment order(s)") except Exception as e: bg_logger.error(f"Order expiry loop error: {e}") await asyncio.sleep(60) # check every minute expiry_task = asyncio.create_task(_order_expiry_loop()) # 启动时立即同步一次未支付订单 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 task_queue.stop() await queue_task expiry_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 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"), credits=10000, 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"), credits=2680, is_admin=False, user_type="frontend", ) db.add(demo_user) # Seed system configs configs = [ ("site_name", "民众普康", "网站名称"), ("site_logo", "", "网站Logo URL"), ("seo_title", "民众普康 - AI视频生成平台", "SEO标题"), ("seo_description", "专业的AI视频生成服务", "SEO描述"), ("seo_keywords", "AI视频,视频生成,人工智能", "SEO关键词"), # Agreement configs ("user_agreement_url", "", "用户协议PDF"), ("privacy_policy_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", "", "支付宝私钥"), # Text credit config ("text_credits_per_1000_tokens", "1", "每1000 token消耗文本积分"), ] for key, value, desc in configs: existing = await db.execute( select(SystemConfig).where(SystemConfig.key == key).limit(1) ) if not existing.scalar_one_or_none(): db.add( SystemConfig( id=generate_id(), key=key, value=value, description=desc ) ) # 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, ) ) # 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) .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) .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 default_menus = [ ("/projects", "我的项目", "HomeOutlined", 0, "frontend"), ("/conversation", "AI创作", "StarOutlined", 1, "frontend"), ("/records", "项目记录", "PlayCircleOutlined", 2, "frontend"), ("/generated", "素材云", "StarOutlined", 3, "frontend"), ] for path, label, icon, order, target in default_menus: existing = await db.execute( select(MenuConfig).where(MenuConfig.path == path).limit(1) ) if not existing.scalar_one_or_none(): db.add( MenuConfig( id=generate_id(), path=path, label=label, icon=icon, sort_order=order, is_active=True, menu_type="page", menu_target=target, is_default=True, ) ) # Seed admin group menus first, then page menus with parent_id admin_groups = [ ("模型配置", "RobotOutlined", 6), ("系统设置", "SettingOutlined", 99), ] group_ids = {} for label, icon, order in admin_groups: existing = await db.execute( select(MenuConfig).where( MenuConfig.label == label, MenuConfig.menu_type == "group", MenuConfig.menu_target == "admin", ) .limit(1) ) group = existing.scalar_one_or_none() if not group: gid = generate_id() db.add( MenuConfig( id=gid, path="", label=label, icon=icon, sort_order=order, is_active=True, menu_type="group", menu_target="admin", ) ) group_ids[label] = gid else: group_ids[label] = group.id # (path, label, icon, sort_order, parent_group_label or None) admin_menus = [ ("/", "数据概览", "DashboardOutlined", 0, None), ("/users", "用户管理", "UserOutlined", 1, None), ("/credit-records", "交易流水", "WalletOutlined", 2, None), ("/generation-records", "生成记录", "VideoCameraOutlined", 3, None), ("/recharge-packages", "充值套餐", "GiftOutlined", 4, None), ("/notifications", "消息推送", "BellOutlined", 5, 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", "操作日志", "HistoryOutlined", 5, "系统设置"), ] for path, label, icon, order, parent_group in admin_menus: existing = await db.execute( select(MenuConfig).where( MenuConfig.path == path, MenuConfig.menu_target == "admin", ) .limit(1) ) if not existing.scalar_one_or_none(): 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=group_ids.get(parent_group), ) ) # Seed recharge packages from app.models.recharge_package import RechargePackage default_packages = [ ("体验包", 500, 49, 0, "首次体验推荐", "normal", 0), ("进阶包", 2000, 168, 200, "最受欢迎", "normal", 1), ("专业包", 5000, 388, 500, "高性价比", "normal", 2), ("企业包", 20000, 1280, 2000, "团队首选", "normal", 3), ] for name, credits, price, bonus, desc, ptype, order in default_packages: existing = await db.execute( select(RechargePackage).where(RechargePackage.name == name).limit(1) ) if not existing.scalar_one_or_none(): db.add( RechargePackage( id=generate_id(), name=name, credits=credits, price=price, bonus_credits=bonus, description=desc, package_type=ptype, is_gift=False, is_active=True, sort_order=order, ) ) # 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") # 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") @application.get("/internal/health") async def health(): return {"status": "ok"} @application.get("/internal/decrypt-data", response_class=HTMLResponse) async def decrypt_data_page(): html_content = """ 数据解密工具

数据解密工具

用于解密日志中的加密数据



""" return HTMLResponse(content=html_content) @application.post("/api/decrypt") async def api_decrypt(encrypted_data: dict): try: data = encrypted_data.get("encrypted_data", "") if not data: return {"success": False, "error": "缺少加密数据"} decrypted = decrypt_data(data) if decrypted == {} and data: return {"success": False, "error": "解密失败,数据格式不正确"} return {"success": True, "decrypted_data": decrypted} except Exception as e: return {"success": False, "error": str(e)} @application.post("/api/admin/upload-pdf") async def upload_pdf( file: UploadFile = File(...), config_key: str = Form(...), ): """Upload a PDF file and save URL to system config.""" from app.dependencies import get_db from app.models.system_config import SystemConfig from app.models.base import async_session from sqlalchemy import select if not file.filename or not file.filename.endswith('.pdf'): raise HTTPException(status_code=400, detail="仅支持PDF文件") # Save file safe_name = f"{config_key}.pdf" file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, safe_name) content = await file.read() with open(file_path, "wb") as f: f.write(content) url = f"/uploads/{safe_name}" # Update system config async with async_session() as db: result = await db.execute( select(SystemConfig).where(SystemConfig.key == config_key).limit(1) ) config = result.scalar_one_or_none() if config: config.value = url else: db.add(SystemConfig( id=f"cfg_{config_key}", key=config_key, value=url, description="用户协议" if "agreement" in config_key else "隐私政策", )) await db.commit() return {"url": url} @application.get("/internal/", response_class=HTMLResponse) async def index(): return """ 民众普康
Backend Running

民众普康.AI

后端 API 服务

版本 v""" + settings.APP_VERSION + """

API 文档

Swagger UI 交互式文档

ReDoc

ReDoc 格式文档

健康检查

服务状态

解密数据

数据解密

""" return application app = create_app()