Files
video-gen/video-gen-api/app/main.py
T
2026-05-25 18:29:32 +08:00

591 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
app.state.db_session_factory = async_session
yield
task_queue.stop()
await queue_task
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"))
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)
)
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 - use first model config if available
model_result = await db.execute(select(ModelConfig).limit(1))
model = model_result.scalar_one_or_none()
if model:
existing_ratio = await db.execute(
select(CreditRatio).where(CreditRatio.model_config_id == model.id).limit(1)
)
if not existing_ratio.scalars().first():
for gen_type, resolution, ratio_val, base, per_sec in [
("video", "480p", 1.0, 60, 2),
("video", "720p", 1.0, 80, 2),
("video", "1080p", 1.5, 120, 3),
("image", "2K", 1.0, 4, 0),
("image", "4K", 1.0, 6, 0),
]:
db.add(
CreditRatio(
id=generate_id(),
model_config_id=model.id,
gen_type=gen_type,
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"),
("/records", "生成记录", "PlayCircleOutlined", 1, "frontend"),
("/credits", "积分中心", "WalletOutlined", 2, "frontend"),
("/conversation", "ai对话", "StarOutlined", 3, "frontend"),
]
for path, label, icon, order, target in default_menus:
existing = await db.execute(
select(MenuConfig).where(MenuConfig.path == path)
)
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",
)
)
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",
)
)
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)
)
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)
)
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")
# Static files for generated videos
storage_dir = os.path.abspath(settings.STORAGE_LOCAL_PATH)
os.makedirs(storage_dir, exist_ok=True)
application.mount("/videos", StaticFiles(directory=storage_dir), name="videos")
# Static files for generated images
storage_image_dir = os.path.abspath(settings.STORAGE_IMAGE_LOCAL_PATH)
os.makedirs(storage_image_dir, exist_ok=True)
application.mount("/images", StaticFiles(directory=storage_image_dir), name="images")
@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 = """
<!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>数据解密工具111</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.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)
)
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 """<!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/health">健康检查</a></h3><p>服务状态</p></div>
<div class="card"><h3><a href="/internal/decrypt-data">解密数据</a></h3><p>数据解密</p></div>
</div>
</body></html>"""
return application
app = create_app()