790 lines
36 KiB
Python
790 lines
36 KiB
Python
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())
|
||
|
||
# 启动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())
|
||
|
||
# 启动时立即同步一次未支付订单
|
||
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
|
||
upload_queue.stop()
|
||
await upload_queue_task
|
||
material_consumption_queue.stop()
|
||
await consumption_queue_task
|
||
consumption_schedule_task.cancel()
|
||
pre_test_poll_task.cancel()
|
||
expiry_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"),
|
||
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"),
|
||
("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", "", "支付宝私钥"),
|
||
# Text credit config
|
||
("text_credits_per_1000_tokens", "1", "每1000 token消耗文本积分"),
|
||
# User credits config
|
||
("user_register_credits", "100", "用户注册赠送积分"),
|
||
("user_login_credits", "0", "用户每日登录赠送积分"),
|
||
("user_login_credits_enabled", "false", "启用每日登录赠送积分"),
|
||
]
|
||
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
|
||
|
||
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),
|
||
("/recharge-packages", "充值套餐", "GiftOutlined", 4, None),
|
||
("/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 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/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'<div class="error-message">错误: {celery_error}</div>' if celery_error else ''
|
||
redis_error_html = f'<div class="error-message">错误: {redis_error}</div>' if redis_error else ''
|
||
|
||
html_content = f"""<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>服务状态监控</title>
|
||
<style>
|
||
body {{ font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; background: #f5f6fa; }}
|
||
h1 {{ color: #1a1a2e; font-size: 28px; margin-bottom: 8px; }}
|
||
h1 span {{ color: #6366f1; }}
|
||
.status-card {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; margin-bottom: 16px; }}
|
||
.status-card h3 {{ margin: 0 0 12px; font-size: 16px; color: #1e293b; }}
|
||
.status-indicator {{ display: inline-flex; align-items: center; gap: 8px; }}
|
||
.status-dot {{ width: 10px; height: 10px; border-radius: 50%; }}
|
||
.status-dot.running {{ background: #22c55e; box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); }}
|
||
.status-dot.connected {{ background: #22c55e; box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); }}
|
||
.status-dot.error {{ background: #dc2626; box-shadow: 0 0 8px rgba(220, 38, 38, 0.5); }}
|
||
.status-dot.disabled {{ background: #94a3b8; }}
|
||
.status-dot.unknown {{ background: #f59e0b; }}
|
||
.status-dot.no_workers {{ background: #f59e0b; }}
|
||
.status-dot.disconnected {{ background: #dc2626; }}
|
||
.status-text {{ font-weight: 600; }}
|
||
.status-text.running, .status-text.connected {{ color: #16a34a; }}
|
||
.status-text.error, .status-text.disconnected {{ color: #dc2626; }}
|
||
.status-text.disabled {{ color: #64748b; }}
|
||
.status-text.unknown, .status-text.no_workers {{ color: #d97706; }}
|
||
.error-message {{ margin-top: 8px; padding: 8px 12px; background: #fef2f2; border-radius: 6px; font-size: 13px; color: #dc2626; word-break: break-all; }}
|
||
.info-box {{ margin-top: 12px; padding: 12px; background: #f1f5f9; border-radius: 8px; font-size: 13px; color: #64748b; }}
|
||
.back-link {{ display: inline-block; margin-top: 20px; color: #6366f1; text-decoration: none; font-weight: 600; }}
|
||
.back-link:hover {{ text-decoration: underline; }}
|
||
.version {{ font-size: 13px; color: #94a3b8; margin-top: 4px; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>服务状态<span>.</span></h1>
|
||
<p class="version">版本 v{settings.APP_VERSION}</p>
|
||
|
||
<div class="status-card">
|
||
<h3>Celery 任务队列</h3>
|
||
<div class="status-indicator">
|
||
<div class="status-dot {celery_status}"></div>
|
||
<span class="status-text {celery_status}">{celery_text}</span>
|
||
</div>
|
||
{celery_error_html}
|
||
<div class="info-box">
|
||
<strong>说明:</strong> Celery 用于异步处理 AI 生成任务、轮询任务和下载任务。如果显示"无可用 Worker",请启动 Celery worker。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="status-card">
|
||
<h3>Redis 缓存</h3>
|
||
<div class="status-indicator">
|
||
<div class="status-dot {redis_status}"></div>
|
||
<span class="status-text {redis_status}">{redis_text}</span>
|
||
</div>
|
||
{redis_error_html}
|
||
<div class="info-box">
|
||
<strong>说明:</strong> Redis 用于 Celery 消息队列、任务状态存储和缓存。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="status-card">
|
||
<h3>数据库连接</h3>
|
||
<div class="status-indicator">
|
||
<div class="status-dot connected"></div>
|
||
<span class="status-text connected">已连接</span>
|
||
</div>
|
||
<div class="info-box">
|
||
<strong>说明:</strong> PostgreSQL 数据库已连接。
|
||
</div>
|
||
</div>
|
||
|
||
<a href="/internal/" class="back-link">← 返回首页</a>
|
||
</body>
|
||
</html>"""
|
||
return HTMLResponse(content=html_content)
|
||
|
||
@application.get("/internal/decrypt-data", response_class=HTMLResponse)
|
||
async def decrypt_data_page():
|
||
html_content = """
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>数据解密工具</title>
|
||
<style>
|
||
body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }
|
||
h1 { color: #1a1a2e; }
|
||
textarea { width: 100%; height: 150px; padding: 12px; border: 1px solid #e2e8f0; border-radius: 8px; font-family: monospace; font-size: 14px; }
|
||
button { background: #6366f1; color: white; border: none; padding: 12px 24px; border-radius: 8px; cursor: pointer; font-size: 16px; }
|
||
button:hover { background: #4f46e5; }
|
||
.result { margin-top: 20px; padding: 16px; background: #f8fafc; border-radius: 8px; }
|
||
.result pre { white-space: pre-wrap; word-break: break-all; font-size: 14px; color: #334155; }
|
||
.error { color: #dc2626; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>数据解密工具</h1>
|
||
<p>用于解密日志中的加密数据</p>
|
||
<textarea id="encryptedInput" placeholder="请输入加密的数据..."></textarea>
|
||
<br><br>
|
||
<button onclick="decrypt()">解密</button>
|
||
<div class="result" id="result"></div>
|
||
<script>
|
||
async function decrypt() {
|
||
const input = document.getElementById('encryptedInput').value.trim();
|
||
const resultDiv = document.getElementById('result');
|
||
|
||
if (!input) {
|
||
resultDiv.innerHTML = '<p class="error">请输入加密数据</p>';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/decrypt', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ encrypted_data: input })
|
||
});
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
resultDiv.innerHTML = '<pre>' + JSON.stringify(data.decrypted_data, null, 2) + '</pre>';
|
||
} else {
|
||
resultDiv.innerHTML = '<p class="error">解密失败: ' + data.error + '</p>';
|
||
}
|
||
} catch (e) {
|
||
resultDiv.innerHTML = '<p class="error">请求失败: ' + e.message + '</p>';
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
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.get("/internal/", response_class=HTMLResponse)
|
||
async def index():
|
||
return """<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head><meta charset="utf-8"><title>民众普康</title>
|
||
<style>
|
||
body{font-family:system-ui,-apple-system,sans-serif;max-width:600px;margin:80px auto;text-align:center;color:#1a1a2e;background:#f5f6fa}
|
||
h1{font-size:28px;margin-bottom:8px}
|
||
h1 span{color:#6366f1}
|
||
p{color:#64748b;margin:4px 0}
|
||
a{color:#6366f1;text-decoration:none;font-weight:600}
|
||
a:hover{text-decoration:underline}
|
||
.cards{display:flex;gap:16px;justify-content:center;margin-top:32px}
|
||
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:20px 28px;text-align:center}
|
||
.card h3{margin:0 0 4px;font-size:16px}
|
||
.card p{font-size:13px;margin:0}
|
||
.badge{display:inline-block;background:#6366f1;color:#fff;font-size:11px;padding:2px 8px;border-radius:6px;margin-bottom:12px}
|
||
</style></head>
|
||
<body>
|
||
<div class="badge">Backend Running</div>
|
||
<h1>民众普康<span>.AI</span></h1>
|
||
<p>后端 API 服务</p>
|
||
<p style="font-size:13px;margin-top:12px">版本 v""" + settings.APP_VERSION + """</p>
|
||
<div class="cards">
|
||
<div class="card"><h3><a href="/internal/api-docs">API 文档</a></h3><p>Swagger UI 交互式文档</p></div>
|
||
<div class="card"><h3><a href="/internal/api-redoc">ReDoc</a></h3><p>ReDoc 格式文档</p></div>
|
||
<div class="card"><h3><a href="/internal/status">服务状态</a></h3><p>Celery/Redis 监控</p></div>
|
||
<div class="card"><h3><a href="/internal/decrypt-data">解密数据</a></h3><p>数据解密</p></div>
|
||
</div>
|
||
</body></html>"""
|
||
|
||
return application
|
||
|
||
|
||
app = create_app()
|