This commit is contained in:
2026-05-25 17:08:18 +08:00
parent df501f6151
commit f1259b71b5
9178 changed files with 1626125 additions and 0 deletions
View File
View File
+32
View File
@@ -0,0 +1,32 @@
from fastapi import APIRouter
from app.api.v1.auth import router as auth_router
from app.api.v1.projects import router as projects_router
from app.api.v1.generation import router as generation_router
from app.api.v1.credits import router as credits_router
from app.api.v1.payments import router as payments_router
from app.api.v1.notifications import router as notifications_router
from app.api.v1.captcha import router as captcha_router
from app.api.v1.admin import router as admin_router
from app.api.v1.sms import router as sms_router
from app.api.v1.industries import router as industries_router
from app.api.v1.menu_configs import router as menu_configs_router
from app.api.v1.recharge_packages import router as recharge_packages_router
from app.api.v1.video_engines import router as video_engines_router
from app.api.v1.image_engines import router as image_engines_router
api_router = APIRouter()
api_router.include_router(auth_router)
api_router.include_router(projects_router)
api_router.include_router(generation_router)
api_router.include_router(credits_router)
api_router.include_router(payments_router)
api_router.include_router(notifications_router)
api_router.include_router(captcha_router)
api_router.include_router(admin_router)
api_router.include_router(sms_router)
api_router.include_router(industries_router)
api_router.include_router(menu_configs_router)
api_router.include_router(recharge_packages_router)
api_router.include_router(video_engines_router)
api_router.include_router(image_engines_router)
File diff suppressed because it is too large Load Diff
+204
View File
@@ -0,0 +1,204 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.dependencies import get_db, get_current_user
from app.models.system_config import SystemConfig
from app.models.user import User
from app.schemas.auth import LoginRequest, ChangePasswordRequest, RegisterRequest
from app.schemas.user import UserOut
from app.services.auth import (
authenticate_user,
create_access_token,
decode_access_token,
hash_password,
verify_password,
)
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login")
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
# Validate captcha in production (when SMS_MOCK is false)
if not settings.SMS_MOCK:
if not req.captcha_token:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="需要验证码",
)
token_sub = decode_access_token(req.captcha_token)
if not token_sub or not token_sub.startswith("captcha:"):
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码无效或已过期",
)
user = await authenticate_user(db, req.username, req.password)
if not user:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
# Only allow frontend users to login via this endpoint
if user.user_type != "frontend":
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="该账号不允许在此登录",
)
user.last_login_at = datetime.now()
user.credits = round(user.credits, 2)
await db.flush()
token = create_access_token(user.id, req.remember_me)
return {"access_token": token, "token_type": "bearer", "user": UserOut.model_validate(user)}
@router.post("/register")
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user with phone + SMS code + password."""
# Verify SMS code
from app.services.sms import verify_sms_code
ok = await verify_sms_code(req.phone, req.code)
if not ok:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码错误或已过期",
)
# Check if phone already registered
existing = await db.execute(select(User).where(User.phone == req.phone))
if existing.scalar_one_or_none():
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该手机号已注册",
)
# Create user with default username: 用户 + last 4 digits of phone
import random
username = f"用户{req.phone[-4:]}"
existing_name = await db.execute(select(User).where(User.username == username))
if existing_name.scalar_one_or_none():
username = f"用户{req.phone[-4:]}{random.randint(10, 99)}"
user = User(
id=generate_id(),
username=username,
phone=req.phone,
hashed_password=hash_password(req.password),
credits=100,
is_admin=False,
user_type="frontend",
)
db.add(user)
await db.flush()
# Assign default menus to new user
from app.models.menu_config import MenuConfig
result = await db.execute(
select(MenuConfig).where(
MenuConfig.is_default == True,
MenuConfig.is_active == True,
MenuConfig.menu_target.in_(["frontend", "both"]),
MenuConfig.menu_type == "page",
)
)
default_menus = result.scalars().all()
if default_menus:
user.allowed_menus = [m.path for m in default_menus if m.path]
user.credits = round(user.credits, 2)
token = create_access_token(user.id)
return {"access_token": token, "token_type": "bearer", "user": UserOut.model_validate(user)}
@router.post("/logout")
async def logout(current_user: User = Depends(get_current_user)):
return {"message": "ok"}
@router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user)):
current_user.credits = round(current_user.credits, 2)
return current_user
@router.post("/change-password")
async def change_password(
req: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if not verify_password(req.old_password, current_user.hashed_password):
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="原密码错误",
)
if len(req.new_password) < 6:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="密码至少6位",
)
current_user.hashed_password = hash_password(req.new_password)
await db.flush()
return {"message": "密码修改成功"}
@router.get("/site-info")
async def get_site_info(db: AsyncSession = Depends(get_db)):
"""Public endpoint returning site name and logo."""
result = await db.execute(
select(SystemConfig).where(SystemConfig.key.in_([
"site_name", "site_logo", "user_agreement_url", "privacy_policy_url"
]))
)
configs = result.scalars().all()
info = {c.key: c.value for c in configs}
return {
"site_name": info.get("site_name", "VideoGen.AI"),
"site_logo": info.get("site_logo", ""),
"user_agreement_url": info.get("user_agreement_url", ""),
"privacy_policy_url": info.get("privacy_policy_url", ""),
}
@router.post("/admin-login")
async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Admin-only login endpoint."""
user = await authenticate_user(db, req.username, req.password)
if not user:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
if user.user_type != "admin":
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="该账号不是管理员账号",
)
user.last_login_at = datetime.now()
user.credits = round(user.credits, 2)
await db.flush()
token = create_access_token(user.id, req.remember_me)
return {"access_token": token, "token_type": "bearer", "user": UserOut.model_validate(user)}
+20
View File
@@ -0,0 +1,20 @@
from fastapi import APIRouter, HTTPException
from app.schemas.captcha import CaptchaResponse, CaptchaVerifyRequest, CaptchaTokenResponse
from app.services.captcha import generate_slider_captcha, verify_slider_captcha
router = APIRouter(prefix="/captcha", tags=["captcha"])
@router.get("/slider", response_model=CaptchaResponse)
async def get_slider_captcha():
data = await generate_slider_captcha()
return data
@router.post("/verify", response_model=CaptchaTokenResponse)
async def verify_captcha(req: CaptchaVerifyRequest):
token = await verify_slider_captcha(req.captcha_id, req.x_offset)
if not token:
raise HTTPException(status_code=400, detail="验证失败,请重试")
return CaptchaTokenResponse(token=token)
+41
View File
@@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.credit_ratio import CreditRatio
from app.schemas.credit import CreditBalanceOut, CreditRecordOut
from app.schemas.credit_ratio import CreditRatioOut
from app.services.credits import get_records
from sqlalchemy import select
router = APIRouter(prefix="/credits", tags=["credits"])
@router.get("", response_model=CreditBalanceOut)
async def get_credits(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
records = await get_records(db, current_user.id)
return CreditBalanceOut(
credits=round(current_user.credits, 2),
records=[CreditRecordOut.model_validate(r) for r in records],
)
@router.get("/ratios", response_model=dict)
async def get_credit_ratios(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(CreditRatio))
ratios = result.scalars().all()
grouped = {}
for ratio in ratios:
if ratio.gen_type not in grouped:
grouped[ratio.gen_type] = []
grouped[ratio.gen_type].append(CreditRatioOut.model_validate(ratio))
return grouped
+602
View File
@@ -0,0 +1,602 @@
import json
import logging
import os
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.project import Project
from app.models.generation_record import GenerationRecord
from app.schemas.generation import (
OptimizeParams,
GenerateParams,
GenerationRecordOut,
OptimizeResult,
UpdatePromptRequest,
GenerationType,
DURATIONS,
ASPECT_RATIOS,
RESOLUTIONS,
IMAGE_SIZES,
)
from app.services.credits import deduct_credits, calc_text_credits, calc_video_credits, calc_image_credits
from app.services.llm import optimize_prompt
from app.services.video_url import generate_temp_url, validate_and_get_record_id, get_video_stream_url
from app.utils.id_gen import generate_id
from app.utils.exceptions import InsufficientCreditsError, RecordNotFoundError, InvalidStatusError
router = APIRouter(prefix="/generation-records", tags=["generation"])
logger = logging.getLogger("videogen")
def _record_to_out(record: GenerationRecord, project_name: str) -> GenerationRecordOut:
refs = None
if record.media_references:
try:
refs = json.loads(record.media_references)
except (json.JSONDecodeError, TypeError):
refs = None
error_message = record.error_message
if error_message:
from app.services.error_codes import ARK_ERRORS
import re
match = re.search(r"code='([^']+)'", error_message)
if match:
code = match.group(1)
if code in ARK_ERRORS:
error_message = ARK_ERRORS[code]
else:
parts = error_message.split(":")
if len(parts) >= 2 and parts[1].strip() in ARK_ERRORS:
error_message = ARK_ERRORS[parts[1].strip()]
return GenerationRecordOut(
id=record.id,
project_id=record.project_id,
project_name=project_name,
original_prompt=record.original_prompt,
optimized_prompt=record.optimized_prompt,
gen_type=record.gen_type,
duration=record.duration,
aspect_ratio=record.aspect_ratio,
resolution=record.resolution,
image_size=record.image_size,
image_proportion=record.image_proportion,
image_px=record.image_px,
status=record.status,
video_url=record.video_url,
image_url=record.image_url,
references=refs,
text_credits_cost=round(record.text_credits_cost or 0.00, 2),
# text_tokens_used=record.text_tokens_used or 0,
credits_cost=round(record.credits_cost or 0.00, 2),
# video_tokens_used=record.video_tokens_used or 0,
# image_tokens_used=record.image_tokens_used or 0,
error_message=error_message,
created_at=record.created_at,
generated_at=record.generated_at,
)
@router.get("", response_model=list[GenerationRecordOut])
async def list_records(
project_id: str | None = Query(None, alias="project_id"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = (
select(GenerationRecord, Project.name)
.join(Project, GenerationRecord.project_id == Project.id)
.where(GenerationRecord.user_id == current_user.id)
.order_by(GenerationRecord.created_at.desc())
)
if project_id:
query = query.where(GenerationRecord.project_id == project_id)
result = await db.execute(query)
rows = result.all()
return [
_record_to_out(record, project_name)
for record, project_name in rows
]
@router.post("/optimize", response_model=OptimizeResult)
async def optimize(
req: OptimizeParams,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# Validate parameters based on generation type
if req.gen_type == GenerationType.video:
if req.duration not in DURATIONS:
raise HTTPException(status_code=400, detail=f"视频时长必须为{DURATIONS}秒之一")
if not req.duration:
raise HTTPException(status_code=400, detail="视频生成需要指定时长")
elif req.gen_type == GenerationType.image:
if req.image_size not in IMAGE_SIZES:
raise HTTPException(status_code=400, detail=f"图片分辨率必须为{IMAGE_SIZES}之一")
if not req.image_size:
raise HTTPException(status_code=400, detail="图片生成需要指定画面分辨率")
# Idempotency check: if key provided, return existing record if found
if req.idempotency_key:
existing = await db.execute(
select(GenerationRecord, Project.name)
.join(Project, GenerationRecord.project_id == Project.id)
.where(
GenerationRecord.user_id == current_user.id,
GenerationRecord.idempotency_key == req.idempotency_key,
GenerationRecord.gen_type == req.gen_type,
GenerationRecord.status == "prompt_optimized",
)
.order_by(GenerationRecord.created_at.desc())
.limit(1)
)
row = existing.first()
if row:
record, project_name = row
return OptimizeResult(
optimized_prompt=record.optimized_prompt or "",
text_credits_cost=record.text_credits_cost or 0.00,
text_tokens_used=record.text_tokens_used or 0,
record=_record_to_out(record, project_name),
)
# Check project exists and belongs to user
proj_result = await db.execute(
select(Project).where(
Project.id == req.project_id,
Project.user_id == current_user.id,
)
)
project = proj_result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# Optimize prompt via LLM with type-specific context
try:
optimized, token_usage = await optimize_prompt(
db, req.prompt,
user_id=current_user.id,
industry_key=project.industry,
duration=req.duration if req.gen_type == GenerationType.video else None,
image_size=req.image_size if req.gen_type == GenerationType.image else None,
image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None,
image_px=req.image_px if req.gen_type == GenerationType.image else None,
references=req.references,
gen_type=req.gen_type,
)
# Create record BEFORE LLM call so it's visible if user refreshes
record = GenerationRecord(
id=generate_id(),
user_id=current_user.id,
project_id=req.project_id,
original_prompt=req.prompt,
gen_type=req.gen_type,
duration=req.duration,
image_size=req.image_size,
image_proportion=req.image_proportion,
image_px=req.image_px,
status="optimizing",
credits_cost=0,
text_credits_cost=0,
text_tokens_used=0,
media_references=json.dumps(req.references) if req.references else None,
idempotency_key=req.idempotency_key,
)
db.add(record)
await db.flush()
await db.commit()
except Exception as e:
from app.services.error_codes import extract_error_message
# record.status = "failed"
# record.error_message = extract_error_message(e, "提示词")
# await db.flush()
# await db.commit()
raise HTTPException(status_code=502, detail=f"AI模型调用失败: {extract_error_message(e, "提示词")}")
text_credits = await calc_text_credits(
db, token_usage["input_tokens"], token_usage["output_tokens"],
)
await deduct_credits(
db, current_user.id, text_credits,
f"提示词优化 - {project.name}",
)
record.optimized_prompt = optimized
record.status = "prompt_optimized"
record.text_credits_cost = round(text_credits, 2)
record.text_tokens_used = token_usage["total_tokens"]
await db.flush()
return OptimizeResult(
optimized_prompt=optimized,
text_credits_cost=round(text_credits, 2),
# text_tokens_used=token_usage["total_tokens"],
record=_record_to_out(record, project.name),
)
@router.post("/{record_id}/generate")
async def generate(
record_id: str,
req: GenerateParams,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(GenerationRecord, Project.name)
.join(Project, GenerationRecord.project_id == Project.id)
.where(
GenerationRecord.id == record_id,
GenerationRecord.user_id == current_user.id,
)
)
row = result.first()
if not row:
raise RecordNotFoundError()
record, project_name = row
if record.status not in ("prompt_optimized", "failed"):
raise InvalidStatusError("当前状态不允许生成")
if record.gen_type == GenerationType.video:
# Video generation
if req.aspect_ratio not in ASPECT_RATIOS:
raise HTTPException(status_code=400, detail="不支持的画面比例")
if req.resolution not in RESOLUTIONS:
raise HTTPException(status_code=400, detail="不支持的分辨率")
duration = record.duration or 5
video_credits = await calc_video_credits(db, duration, req.resolution)
await deduct_credits(
db, current_user.id, video_credits,
f"视频生成 - {project_name}",
related_id=record_id,
)
record.aspect_ratio = req.aspect_ratio
record.resolution = req.resolution
record.credits_cost = round(video_credits, 2)
record.status = "generating"
record.error_message = None
await db.flush()
try:
from app.services.video_gen import get_active_engine, submit_video_task
from app.services.error_codes import extract_error_message
from app.services.video_queue import task_queue
engine = await get_active_engine(db)
task_id = await submit_video_task(db, engine, record)
record.seedance_task_id = task_id
await db.flush()
await task_queue.enqueue(record_id)
except Exception as e:
record.status = "failed"
record.error_message = extract_error_message(e, "视频")
await db.flush()
elif record.gen_type == GenerationType.image:
# Image generation
image_credits = await calc_image_credits(db, req.image_size or record.image_size or "2K")
await deduct_credits(
db, current_user.id, image_credits,
f"图片生成 - {project_name}",
related_id=record_id,
)
record.image_size = req.image_size or record.image_size or "2K"
record.credits_cost = round(image_credits, 2)
record.status = "generating"
record.error_message = None
await db.commit()
from app.services.video_queue import task_queue
await task_queue.enqueue(record_id)
return _record_to_out(record, project_name)
@router.post("/{record_id}/retry")
async def retry_generation(
record_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(GenerationRecord, Project.name)
.join(Project, GenerationRecord.project_id == Project.id)
.where(
GenerationRecord.id == record_id,
GenerationRecord.user_id == current_user.id,
)
)
row = result.first()
if not row:
raise RecordNotFoundError()
record, project_name = row
if record.status != "failed":
raise InvalidStatusError("只有失败的记录可以重试")
# Re-deduct video credits for retry
if record.duration and record.resolution:
video_credits = await calc_video_credits(db, record.duration, record.resolution)
await deduct_credits(
db, current_user.id, video_credits,
f"视频重试 - {project_name}",
related_id=record_id,
)
record.credits_cost = round((record.credits_cost or 0) + video_credits, 2)
record.status = "generating"
record.error_message = None
await db.flush()
try:
from app.services.video_gen import get_active_engine, submit_video_task, extract_error_message
from app.services.video_queue import task_queue
engine = await get_active_engine(db)
task_id = await submit_video_task(db, engine, record)
record.seedance_task_id = task_id
await db.flush()
await task_queue.enqueue(record_id)
except Exception as e:
record.status = "failed"
record.error_message = extract_error_message(e)
await db.flush()
return _record_to_out(record, project_name)
@router.put("/{record_id}/prompt")
async def update_prompt(
record_id: str,
req: UpdatePromptRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(GenerationRecord).where(
GenerationRecord.id == record_id,
GenerationRecord.user_id == current_user.id,
)
)
record = result.scalar_one_or_none()
if not record:
raise RecordNotFoundError()
if record.status != "prompt_optimized":
raise InvalidStatusError("只有待生成状态可以修改提示词")
record.optimized_prompt = req.optimized_prompt
await db.flush()
return {"message": "ok"}
@router.get("/{record_id}/video")
async def get_video(
record_id: str,
token: str = Query(...),
db: AsyncSession = Depends(get_db),
):
"""Validate temp token and redirect to video URL."""
validated_id = await validate_and_get_record_id(token)
if validated_id != record_id:
raise HTTPException(status_code=403, detail="无效的视频链接")
video_url = await get_video_stream_url(db, record_id)
if not video_url:
raise HTTPException(status_code=404, detail="视频不存在")
return RedirectResponse(url=video_url)
@router.get("/{record_id}/queue-status")
async def get_queue_status(
record_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get queue position, estimated wait time, and current status for a generation record."""
from sqlalchemy import func
result = await db.execute(
select(GenerationRecord).where(
GenerationRecord.id == record_id,
GenerationRecord.user_id == current_user.id,
)
)
record = result.scalar_one_or_none()
if not record:
raise RecordNotFoundError()
queue_position = None
estimated_wait_seconds = None
if record.status == "generating":
ahead_result = await db.execute(
select(func.count(GenerationRecord.id)).where(
GenerationRecord.status == "generating",
GenerationRecord.created_at < record.created_at,
)
)
ahead = ahead_result.scalar() or 0
queue_position = ahead + 1
estimated_wait_seconds = ahead * 60
return {
"record_id": record.id,
"status": record.status,
"queue_position": queue_position,
"estimated_wait_seconds": estimated_wait_seconds,
}
@router.post("/callbacks/seedance")
async def seedance_callback(request: Request, db: AsyncSession = Depends(get_db)):
"""Receive async callback from Seedance API."""
data = await request.json()
task_id = data.get("id")
task_status = data.get("status")
if not task_id:
return {"message": "ignored"}
result = await db.execute(
select(GenerationRecord).where(GenerationRecord.seedance_task_id == task_id)
)
record = result.scalar_one_or_none()
if not record:
return {"message": "record not found"}
if task_status == "succeeded":
remote_url = data.get("content", {}).get("video_url", "")
record.status = "completed"
# Download video to local storage
if settings.STORAGE_TYPE == "local" and remote_url:
try:
from app.services.video_gen import download_video
dest = os.path.join(settings.STORAGE_LOCAL_PATH, f"{record.id}.mp4")
await download_video(remote_url, dest)
record.video_url = f"/videos/{record.id}.mp4"
except Exception as e:
logger.warning(f"Callback download failed, using remote URL: {e}")
record.video_url = remote_url
else:
record.video_url = remote_url
record.generated_at = datetime.now()
# Extract video token usage from callback
usage = data.get("usage", {})
if usage:
record.video_tokens_used = usage.get("total_tokens", 0)
# Log callback response
from app.services.video_gen import _log_video_response
_log_video_response(record.id, data)
# Notify user
from app.services.notification import create_notification
from app.api.v1.notifications import push_notification_to_user
notif = await create_notification(
db, record.user_id, "视频生成完成",
"您的视频已生成完成,可以查看了。", "video", record.id,
)
await push_notification_to_user(record.user_id, notif)
elif task_status == "failed":
record.status = "failed"
record.error_message = data.get("error", "视频生成失败")
# Log callback response
from app.services.video_gen import _log_video_response
_log_video_response(record.id, data, error=record.error_message)
# Notify user
from app.services.notification import create_notification
from app.api.v1.notifications import push_notification_to_user
notif = await create_notification(
db, record.user_id, "视频生成失败",
f"视频生成失败:{record.error_message}", "video", record.id,
)
await push_notification_to_user(record.user_id, notif)
await db.flush()
return {"message": "ok"}
@router.post("/upload-image")
async def upload_image(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
gen_type: str = Query("video", description="生成类型:video-视频,image-图片"),
):
"""Upload an image for generation reference."""
import os
import uuid
from app.config import settings
from datetime import datetime
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="仅支持图片文件")
ext = os.path.splitext(file.filename or ".png")[1] or ".png"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = f"{gen_type}_img_{current_user.id}_{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
date_dir = datetime.now().strftime("%Y/%m/%d")
dir_path = os.path.join(settings.UPLOAD_LOCAL_PATH, "images", date_dir)
os.makedirs(dir_path, exist_ok=True)
file_path = os.path.join(dir_path, safe_name)
content = await file.read()
if len(content) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="图片大小不能超过10MB")
with open(file_path, "wb") as f:
f.write(content)
url = f"/uploads/images/{date_dir}/{safe_name}"
return {"url": url, "filename": file.filename or safe_name, "type": "image", "gen_type": gen_type}
@router.post("/upload-video")
async def upload_video(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
):
"""Upload a video for generation reference."""
import os
import uuid
from app.config import settings
from datetime import datetime
if not file.content_type or not file.content_type.startswith("video/"):
raise HTTPException(status_code=400, detail="仅支持视频文件")
ext = os.path.splitext(file.filename or ".mp4")[1] or ".mp4"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = f"video_ref_{current_user.id}_{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
date_dir = datetime.now().strftime("%Y/%m/%d")
dir_path = os.path.join(settings.UPLOAD_LOCAL_PATH, "videos", date_dir)
os.makedirs(dir_path, exist_ok=True)
file_path = os.path.join(dir_path, safe_name)
content = await file.read()
if len(content) > 100 * 1024 * 1024:
raise HTTPException(status_code=400, detail="视频大小不能超过100MB")
with open(file_path, "wb") as f:
f.write(content)
url = f"/uploads/videos/{date_dir}/{safe_name}"
return {"url": url, "filename": file.filename or safe_name, "type": "video"}
@router.post("/delete-file")
async def delete_upload(
url: str = Query(..., description="文件URL,如 /uploads/images/2024/01/01/video_img_xxx.png"),
current_user: User = Depends(get_current_user),
):
"""Delete an uploaded file by URL."""
import os
from app.config import settings
if not url.startswith("/uploads/"):
raise HTTPException(status_code=400, detail="无效的文件路径")
if current_user.id not in url:
raise HTTPException(status_code=403, detail="无权删除此文件")
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, url.replace("/uploads/", ""))
if os.path.exists(file_path):
os.remove(file_path)
return {"message": "ok"}
+48
View File
@@ -0,0 +1,48 @@
import json
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.image_engine import ImageEngine
from app.schemas.image_engine import ImageEngineListResponse
router = APIRouter(prefix="/image-engines", tags=["image-engines"])
@router.get("", response_model=ImageEngineListResponse)
async def list_active_engines(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Public endpoint returning active image engine capabilities."""
result = await db.execute(
select(ImageEngine)
.where(ImageEngine.is_active == True)
.order_by(ImageEngine.priority.desc())
)
engines = result.scalars().all()
items = []
for e in engines:
models = []
sizes = {}
try:
models = json.loads(e.supported_models) if e.supported_models else []
except Exception:
pass
try:
sizes = json.loads(e.supported_sizes) if e.supported_sizes else {}
except Exception:
pass
items.append({
"id": e.id,
"name": e.name,
"provider": e.provider,
"supported_models": models,
"supported_sizes": sizes,
"default_size": e.default_size,
})
return {"items": items}
+46
View File
@@ -0,0 +1,46 @@
import json
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.industry_config import IndustryConfig
router = APIRouter(tags=["industries"])
@router.get("/industries")
async def list_active_industries(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Public endpoint: list all active industries."""
result = await db.execute(
select(IndustryConfig)
.where(IndustryConfig.is_active == True)
.order_by(IndustryConfig.sort_order)
)
industries = result.scalars().all()
items = []
for ind in industries:
skills = []
if ind.skills:
try:
raw = json.loads(ind.skills)
if isinstance(raw, list):
skills = raw if raw and isinstance(raw[0], dict) else [{"key": s, "label": s} for s in raw]
except (json.JSONDecodeError, TypeError):
skills = []
items.append({
"id": ind.id,
"key": ind.key,
"label": ind.label,
"icon": ind.icon or "",
"description": ind.description,
"skills": skills,
"is_active": ind.is_active,
"sort_order": ind.sort_order,
})
return items
+92
View File
@@ -0,0 +1,92 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_admin_user, get_current_user
from app.models.user import User
from app.models.menu_config import MenuConfig
from app.schemas.menu import MenuConfigCreate, MenuConfigOut
from app.utils.id_gen import generate_id
router = APIRouter(tags=["menu-configs"])
@router.get("/menu-configs")
async def public_list_menu_configs(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Public endpoint: list active menu configs for frontend (frontend + both)."""
result = await db.execute(
select(MenuConfig)
.where(
MenuConfig.is_active == True,
MenuConfig.menu_target.in_(["frontend", "both"]),
)
.order_by(MenuConfig.sort_order)
)
menus = result.scalars().all()
return [
{
"id": m.id, "label": m.label, "path": m.path, "icon": m.icon,
"sort_order": m.sort_order, "is_active": m.is_active,
"parent_id": m.parent_id, "menu_type": m.menu_type, "menu_target": m.menu_target,
"is_default": m.is_default,
}
for m in menus
]
@router.get("/admin/menu-configs", response_model=list[MenuConfigOut])
async def admin_list_menu_configs(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(MenuConfig).order_by(MenuConfig.sort_order))
return result.scalars().all()
@router.post("/admin/menu-configs", response_model=MenuConfigOut)
async def create_menu_config(
req: MenuConfigCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
menu = MenuConfig(id=generate_id(), **req.model_dump())
db.add(menu)
await db.flush()
return menu
@router.put("/admin/menu-configs/{menu_id}", response_model=MenuConfigOut)
async def update_menu_config(
menu_id: str,
req: MenuConfigCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(MenuConfig).where(MenuConfig.id == menu_id))
menu = result.scalar_one_or_none()
if not menu:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="菜单不存在")
for k, v in req.model_dump().items():
setattr(menu, k, v)
await db.flush()
return menu
@router.delete("/admin/menu-configs/{menu_id}")
async def delete_menu_config(
menu_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(MenuConfig).where(MenuConfig.id == menu_id))
menu = result.scalar_one_or_none()
if not menu:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="菜单不存在")
await db.delete(menu)
await db.flush()
return {"message": "ok"}
+142
View File
@@ -0,0 +1,142 @@
import json
import logging
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.notification import Notification
from app.schemas.notification import NotificationOut, UnreadCountOut
from app.services.auth import decode_access_token
from app.services.notification import (
get_notifications,
mark_read,
mark_all_read,
get_unread_count,
create_notification,
)
logger = logging.getLogger("videogen")
CST = timezone(timedelta(hours=8))
def _to_local_str(dt):
if dt is None:
return None
if dt.tzinfo and dt.tzinfo.utcoffset(None) == timedelta(0):
dt = dt.astimezone(CST)
return dt.replace(tzinfo=None).isoformat()
router = APIRouter(prefix="/notifications", tags=["notifications"])
# Active WebSocket connections: user_id -> set of WebSockets
_active_connections: dict[str, set[WebSocket]] = {}
async def push_notification_to_user(user_id: str, notification: Notification) -> None:
"""Push a notification to all active WebSocket connections for a user."""
connections = _active_connections.get(user_id, set())
if not connections:
return
message = json.dumps({
"id": notification.id,
"title": notification.title,
"content": notification.content,
"type": notification.type,
"is_read": notification.is_read,
"created_at": _to_local_str(notification.created_at),
}, ensure_ascii=False)
dead: list[WebSocket] = []
for ws in connections:
try:
await ws.send_text(message)
except Exception:
dead.append(ws)
for ws in dead:
connections.discard(ws)
@router.websocket("/ws")
async def notifications_ws(websocket: WebSocket):
"""Authenticated WebSocket for real-time notifications."""
await websocket.accept()
# Authenticate via token query param or first message
token = websocket.query_params.get("token")
if not token:
try:
data = await websocket.receive_text()
msg = json.loads(data)
token = msg.get("token")
except Exception:
await websocket.close(code=4001, reason="Authentication required")
return
user_id = decode_access_token(token) if token else None
if not user_id or user_id.startswith("captcha:"):
await websocket.close(code=4001, reason="Invalid token")
return
# Register connection
if user_id not in _active_connections:
_active_connections[user_id] = set()
_active_connections[user_id].add(websocket)
try:
# Keep connection alive, listen for pings
while True:
data = await websocket.receive_text()
# Echo back for heartbeat
if data == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
conns = _active_connections.get(user_id)
if conns:
conns.discard(websocket)
if not conns:
_active_connections.pop(user_id, None)
@router.get("", response_model=list[NotificationOut])
async def list_notifications(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
items, _ = await get_notifications(db, current_user.id, page, page_size)
return items
@router.put("/{notification_id}/read")
async def read_notification(
notification_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await mark_read(db, notification_id, current_user.id)
return {"message": "ok"}
@router.put("/read-all")
async def read_all(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await mark_all_read(db, current_user.id)
return {"message": "ok"}
@router.get("/unread-count", response_model=UnreadCountOut)
async def unread_count(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
count = await get_unread_count(db, current_user.id)
return UnreadCountOut(count=count)
+81
View File
@@ -0,0 +1,81 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.payment_order import PaymentOrder
from app.models.recharge_package import RechargePackage
from app.schemas.payment import RechargeRequest, PaymentOrderOut
from app.services.payment import create_recharge_order, verify_wechat_callback, verify_alipay_callback, process_payment_success
router = APIRouter(prefix="/payments", tags=["payments"])
@router.post("/recharge", response_model=PaymentOrderOut)
async def recharge(
req: RechargeRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RechargePackage).where(
RechargePackage.id == req.plan,
RechargePackage.is_active == True,
)
)
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=400, detail="无效的套餐")
order = await create_recharge_order(
db,
current_user.id,
credits=pkg.credits,
price=pkg.price,
label=pkg.name,
bonus_credits=pkg.bonus_credits,
)
return order
@router.post("/wechat/callback")
async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
data = await request.json()
if not await verify_wechat_callback(data):
raise HTTPException(status_code=400, detail="签名验证失败")
order_no = data.get("out_trade_no")
result = await db.execute(
select(PaymentOrder).where(PaymentOrder.order_no == order_no)
)
order = result.scalar_one_or_none()
if order:
await process_payment_success(db, order.id)
return {"code": "SUCCESS", "message": "OK"}
@router.post("/alipay/callback")
async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
data = await request.form()
if not await verify_alipay_callback(dict(data)):
raise HTTPException(status_code=400, detail="签名验证失败")
order_no = data.get("out_trade_no")
result = await db.execute(
select(PaymentOrder).where(PaymentOrder.order_no == order_no)
)
order = result.scalar_one_or_none()
if order:
await process_payment_success(db, order.id)
return "success"
@router.get("/orders", response_model=list[PaymentOrderOut])
async def list_orders(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(PaymentOrder)
.where(PaymentOrder.user_id == current_user.id)
.order_by(PaymentOrder.created_at.desc())
)
return result.scalars().all()
+68
View File
@@ -0,0 +1,68 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.project import Project
from app.models.generation_record import GenerationRecord
from app.schemas.project import ProjectCreate, ProjectOut
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/projects", tags=["projects"])
@router.get("", response_model=list[ProjectOut])
async def list_projects(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Project)
.where(Project.user_id == current_user.id)
.order_by(Project.created_at.desc())
)
return result.scalars().all()
@router.post("", response_model=ProjectOut)
async def create_project(
req: ProjectCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
project = Project(
id=generate_id(),
user_id=current_user.id,
name=req.name,
industry=req.industry,
)
db.add(project)
await db.flush()
return project
@router.delete("/{project_id}")
async def delete_project(
project_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.user_id == current_user.id,
)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
# Cascade delete generation records
from sqlalchemy import delete
await db.execute(
delete(GenerationRecord).where(GenerationRecord.project_id == project_id)
)
await db.delete(project)
await db.flush()
return {"message": "ok"}
@@ -0,0 +1,105 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_admin_user, get_current_user
from app.models.user import User
from app.models.recharge_package import RechargePackage
from app.schemas.recharge_package import (
RechargePackageCreate,
RechargePackageUpdate,
RechargePackageOut,
)
from app.utils.id_gen import generate_id
router = APIRouter(tags=["recharge-packages"])
def _to_out(pkg: RechargePackage) -> dict:
return {
"id": pkg.id,
"name": pkg.name,
"credits": round(pkg.credits, 2),
"price": round(pkg.price, 2),
"bonus_credits": round(pkg.bonus_credits, 2),
"total_credits": round(pkg.credits + pkg.bonus_credits, 2),
"description": pkg.description,
"package_type": pkg.package_type,
"is_gift": pkg.is_gift,
"is_active": pkg.is_active,
"sort_order": pkg.sort_order,
}
@router.get("/recharge-packages")
async def list_active_packages(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Public: list active recharge packages."""
result = await db.execute(
select(RechargePackage)
.where(RechargePackage.is_active == True)
.order_by(RechargePackage.sort_order)
)
return [_to_out(p) for p in result.scalars().all()]
@router.get("/admin/recharge-packages")
async def admin_list_packages(
_admin=Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Admin: list all packages."""
result = await db.execute(
select(RechargePackage).order_by(RechargePackage.sort_order)
)
return [_to_out(p) for p in result.scalars().all()]
@router.post("/admin/recharge-packages")
async def create_package(
data: RechargePackageCreate,
_admin=Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
pkg = RechargePackage(id=generate_id(), **data.model_dump())
db.add(pkg)
await db.flush()
return _to_out(pkg)
@router.put("/admin/recharge-packages/{pkg_id}")
async def update_package(
pkg_id: str,
data: RechargePackageUpdate,
_admin=Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RechargePackage).where(RechargePackage.id == pkg_id)
)
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=404, detail="套餐不存在")
for k, v in data.model_dump(exclude_unset=True).items():
setattr(pkg, k, v)
await db.flush()
return _to_out(pkg)
@router.delete("/admin/recharge-packages/{pkg_id}")
async def delete_package(
pkg_id: str,
_admin=Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RechargePackage).where(RechargePackage.id == pkg_id)
)
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=404, detail="套餐不存在")
await db.delete(pkg)
await db.flush()
return {"ok": True}
+45
View File
@@ -0,0 +1,45 @@
from fastapi import APIRouter, HTTPException, status
from app.config import settings
from app.schemas.sms import SmsSendRequest, SmsVerifyRequest, SmsResponse
from app.services.sms import generate_and_send_sms, verify_sms_code
router = APIRouter(prefix="/sms", tags=["sms"])
@router.post("/send", response_model=SmsResponse)
async def send_sms_code(req: SmsSendRequest):
"""Send SMS verification code. Requires captcha_token in production."""
if not settings.SMS_MOCK:
if not req.captcha_token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="需要验证码",
)
from app.services.auth import decode_access_token
token_sub = decode_access_token(req.captcha_token)
if not token_sub or not token_sub.startswith("captcha:"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码无效",
)
ok = await generate_and_send_sms(req.phone)
if not ok:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="短信发送失败,请稍后重试",
)
return SmsResponse(message="验证码已发送", success=True)
@router.post("/verify", response_model=SmsResponse)
async def verify_sms(req: SmsVerifyRequest):
"""Verify SMS code."""
ok = await verify_sms_code(req.phone, req.code)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码错误或已过期",
)
return SmsResponse(message="验证成功", success=True)
+53
View File
@@ -0,0 +1,53 @@
import json
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.video_engine import VideoEngine
from app.schemas.video_engine import VideoEngineListResponse
router = APIRouter(prefix="/video-engines", tags=["video-engines"])
@router.get("", response_model=VideoEngineListResponse)
async def list_active_engines(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Public endpoint returning active video engine capabilities."""
result = await db.execute(
select(VideoEngine)
.where(VideoEngine.is_active == True)
.order_by(VideoEngine.priority.desc())
)
engines = result.scalars().all()
items = []
for e in engines:
ratios = []
resolutions = []
durations = []
try:
ratios = json.loads(e.supported_ratios) if e.supported_ratios else []
except Exception:
pass
try:
resolutions = json.loads(e.supported_resolutions) if e.supported_resolutions else []
except Exception:
pass
try:
durations = json.loads(e.supported_durations) if e.supported_durations else []
except Exception:
pass
items.append({
"id": e.id,
"name": e.name,
"provider": e.provider,
"supported_ratios": ratios,
"supported_resolutions": resolutions,
"supported_durations": durations,
})
return {"items": items}
+62
View File
@@ -0,0 +1,62 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
APP_NAME: str = "VideoGen API"
APP_VERSION: str = "1.0.0"
DEBUG: bool = False
SECRET_KEY: str = "change-me"
DATABASE_URL: str = "sqlite+aiosqlite:///./videogen.db"
REDIS_URL: str = "" # Leave empty to disable Redis (rate limiting, captcha)
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 1440
JWT_EXPIRE_REMEMBER_MINUTES: int = 10080
SEEDANCE_API_KEY: str = ""
SEEDANCE_API_BASE: str = "https://ark.cn-beijing.volces.com/api/v3"
SEEDANCE_CALLBACK_URL: str = ""
LLM_API_BASE: str = "https://api.openai.com/v1"
LLM_API_KEY: str = ""
LLM_MODEL: str = "gpt-4o"
LLM_MOCK: bool = True
ENCRYPTION_KEY: str = "changeme-32bytes-base64-key-here!!"
# SMS settings
SMS_API_URL: str = ""
SMS_API_KEY: str = ""
SMS_SIGN_NAME: str = "VideoGen"
SMS_TEMPLATE_CODE: str = "SMS_001"
SMS_MOCK: bool = True
# Payment settings
WECHAT_MCH_ID: str = ""
WECHAT_API_KEY: str = ""
WECHAT_CERT_PATH: str = ""
ALIPAY_APP_ID: str = ""
ALIPAY_PRIVATE_KEY: str = ""
ALIPAY_PUBLIC_KEY: str = ""
PAYMENT_MOCK: bool = True
STORAGE_TYPE: str = "local"
STORAGE_LOCAL_PATH: str = "./storage/videos"
STORAGE_IMAGE_LOCAL_PATH: str = "./storage/images"
UPLOAD_LOCAL_PATH: str = "./storage/uploads"
CAPTCHA_ENABLED: bool = True
BASE_URL: str = "http://ceshi.apiforeign.minzhong.cn"
CORS_ORIGINS: list[str] = ["*"]
RATE_LIMIT_ENABLED: bool = True
settings = Settings()
+67
View File
@@ -0,0 +1,67 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.base import async_session
from app.models.user import User
from app.services.auth import decode_access_token
from sqlalchemy import select
security = HTTPBearer(auto_error=False)
async def get_db():
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未登录",
)
user_id = decode_access_token(credentials.credentials)
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="登录已过期",
)
# Skip captcha tokens
if user_id.startswith("captcha:"):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的凭证",
)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号不存在或已禁用",
)
return user
async def get_admin_user(
current_user: User = Depends(get_current_user),
) -> User:
if not current_user.is_admin or current_user.user_type != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限",
)
return current_user
+590
View File
@@ -0,0 +1,590 @@
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>数据解密工具</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()
@@ -0,0 +1,41 @@
import re
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import JSONResponse
from app.config import settings
BLOCKED_UA_PATTERNS = [
re.compile(r"(?i)(curl|wget|python-requests|scrapy|httpx|go-http-client|java/)"),
]
class AntiCrawlerMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
# Skip health/docs
if request.url.path in ("/health", "/api-docs", "/openapi.json", "/api-redoc"):
return await call_next(request)
# Skip callback endpoints
if request.url.path.startswith("/api/callbacks/"):
return await call_next(request)
user_agent = request.headers.get("user-agent", "")
# Block empty User-Agent
if not user_agent:
return JSONResponse(status_code=403, content={"detail": "Forbidden"})
# Block known bot User-Agents (unless they have an API key)
api_key = request.headers.get("x-api-key")
if not api_key:
for pattern in BLOCKED_UA_PATTERNS:
if pattern.search(user_agent):
return JSONResponse(
status_code=403, content={"detail": "Forbidden"}
)
return await call_next(request)
+71
View File
@@ -0,0 +1,71 @@
import json
import logging
import time
import uuid
import os
from datetime import datetime
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from app.services.log_config import LOG_R_Q_DIR, LOG_FILENAME_FORMAT, LOG_DATE_FORMAT, encrypt_data
logger = logging.getLogger("videogen")
os.makedirs(LOG_R_Q_DIR, exist_ok=True)
def get_today_log_file() -> str:
today = datetime.now().strftime(LOG_DATE_FORMAT)
filename = LOG_FILENAME_FORMAT.format(date=today)
return os.path.join(LOG_R_Q_DIR, filename)
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
path = request.url.path
if path.startswith("/static/") or path.startswith("/videos/") or path.startswith("/images/"):
return await call_next(request)
if path in ("/internal/health", "/internal/api-docs", "/openapi.json", "/internal/api-redoc","internal/decrypt-data"):
return await call_next(request)
#GET请求不记录日志
if request.method == "GET":
return await call_next(request)
request_id = str(uuid.uuid4())[:8]
start = time.perf_counter()
request_body = {}
try:
if request.method in ("POST", "PUT", "PATCH"):
body = await request.json()
request_body = body
except Exception:
pass
response = await call_next(request)
duration_ms = round((time.perf_counter() - start) * 1000, 1)
log_entry = {
"timestamp": datetime.now().isoformat(),
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"query_params": dict(request.query_params),
"request_body": encrypt_data(request_body) if request_body else "",
"status": response.status_code,
"duration_ms": duration_ms,
"ip": request.client.host if request.client else "-",
"user_agent": request.headers.get("user-agent", "-"),
}
log_file = get_today_log_file()
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
response.headers["X-Request-ID"] = request_id
return response
@@ -0,0 +1,78 @@
import time
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import JSONResponse
from app.config import settings
from app.utils.redis import get_redis
RATE_LIMITS = {
"POST:/api/auth/login": (10, 60),
"POST:/api/auth/change-password": (5, 60),
"POST:/api/generation-records/optimize": (20, 60),
"POST:/api/generation-records/*/generate": (10, 60),
"POST:/api/generation-records/*/retry": (10, 60),
}
def _match_rate_limit(path: str, method: str) -> tuple[int, int] | None:
key = f"{method}:{path}"
if key in RATE_LIMITS:
return RATE_LIMITS[key]
# Check wildcard patterns
for pattern, limit in RATE_LIMITS.items():
pattern_method, pattern_path = pattern.split(":", 1)
if method != pattern_method:
continue
pattern_parts = pattern_path.split("/")
key_parts = path.split("/")
if len(pattern_parts) != len(key_parts):
continue
match = True
for pp, kp in zip(pattern_parts, key_parts):
if pp != "*" and pp != kp:
match = False
break
if match:
return limit
return None
class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
if not settings.RATE_LIMIT_ENABLED:
return await call_next(request)
limit_info = _match_rate_limit(request.url.path, request.method)
if not limit_info:
return await call_next(request)
max_requests, window = limit_info
# Use user_id from token or IP as identifier
identifier = request.client.host if request.client else "unknown"
redis = get_redis()
if not redis:
return await call_next(request)
redis_key = f"ratelimit:{request.method}:{request.url.path}:{identifier}"
try:
current = await redis.incr(redis_key)
if current == 1:
await redis.expire(redis_key, window)
if current > max_requests:
ttl = await redis.ttl(redis_key)
return JSONResponse(
status_code=429,
content={"detail": "请求过于频繁,请稍后重试"},
headers={"Retry-After": str(max(ttl, 1))},
)
except Exception:
pass # Redis unavailable, skip rate limiting
return await call_next(request)
@@ -0,0 +1,102 @@
import base64
import json
import logging
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from app.config import settings
logger = logging.getLogger("videogen")
def _get_aesgcm() -> AESGCM:
"""Get AESGCM instance from configured key."""
key_bytes = base64.b64decode(settings.ENCRYPTION_KEY)
if len(key_bytes) != 32:
# Pad or truncate to 32 bytes
key_bytes = (key_bytes + b"\x00" * 32)[:32]
return AESGCM(key_bytes)
def encrypt_data(plaintext: bytes) -> bytes:
"""Encrypt data using AES-256-GCM. Returns nonce + ciphertext."""
import os
aesgcm = _get_aesgcm()
nonce = os.urandom(12)
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
return base64.b64encode(nonce + ciphertext)
def decrypt_data(data: bytes) -> bytes:
"""Decrypt AES-256-GCM encrypted data."""
raw = base64.b64decode(data)
nonce = raw[:12]
ciphertext = raw[12:]
aesgcm = _get_aesgcm()
return aesgcm.decrypt(nonce, ciphertext, None)
class RequestEncryptMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
encrypted = request.headers.get("X-Encrypted", "").lower() == "true"
if not encrypted:
return await call_next(request)
# GET requests have no body to decrypt — just pass through
if request.method == "GET":
response = await call_next(request)
response_body = b""
async for chunk in response.body_iterator:
if isinstance(chunk, str):
response_body += chunk.encode()
else:
response_body += chunk
encrypted_response = encrypt_data(response_body)
return Response(
content=json.dumps({"data": encrypted_response.decode()}),
status_code=response.status_code,
headers={"X-Encrypted": "true", "Content-Type": "application/json"},
)
try:
body = await request.body()
# Frontend sends {"data": "<encrypted_base64>"}
body_json = json.loads(body)
encrypted_b64 = body_json.get("data", "")
decrypted = decrypt_data(encrypted_b64.encode())
# Replace request body with decrypted content
async def receive():
return {"type": "http.request", "body": decrypted, "more_body": False}
request._receive = receive
request._body = decrypted
response = await call_next(request)
# Encrypt response
response_body = b""
async for chunk in response.body_iterator:
if isinstance(chunk, str):
response_body += chunk.encode()
else:
response_body += chunk
encrypted_response = encrypt_data(response_body)
# Wrap in {"data": "..."} to match frontend's expected format
return Response(
content=json.dumps({"data": encrypted_response.decode()}),
status_code=response.status_code,
headers={"X-Encrypted": "true", "Content-Type": "application/json"},
)
except Exception:
logger.exception("Request decryption failed")
return Response(
content=json.dumps({"detail": "解密失败"}, ensure_ascii=False),
status_code=400,
media_type="application/json",
)
+26
View File
@@ -0,0 +1,26 @@
from app.models.base import Base, TimestampMixin, engine, async_session, init_database, close_database
from app.models.user import User
from app.models.project import Project
from app.models.generation_record import GenerationRecord
from app.models.credit_record import CreditRecord
from app.models.model_config import ModelConfig
from app.models.system_config import SystemConfig
from app.models.notification import Notification
from app.models.notification_read import NotificationRead
from app.models.payment_order import PaymentOrder
from app.models.token_usage import TokenUsage
from app.models.industry_config import IndustryConfig
from app.models.video_engine import VideoEngine
from app.models.credit_ratio import CreditRatio
from app.models.menu_config import MenuConfig
from app.models.recharge_package import RechargePackage
from app.models.operation_log import OperationLog
__all__ = [
"Base", "TimestampMixin", "engine", "async_session",
"init_database", "close_database",
"User", "Project", "GenerationRecord", "CreditRecord",
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
"MenuConfig", "RechargePackage", "OperationLog",
]
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.ext.asyncio import AsyncAttrs, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
async_session = async_sessionmaker(engine, expire_on_commit=False)
class Base(AsyncAttrs, DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
async def init_database() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def close_database() -> None:
await engine.dispose()
+18
View File
@@ -0,0 +1,18 @@
from sqlalchemy import Float, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class CreditRatio(Base, TimestampMixin):
__tablename__ = "credit_ratios"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
model_config_id: Mapped[str] = mapped_column(
String(32), ForeignKey("model_configs.id")
)
gen_type: Mapped[str] = mapped_column(String(16), default="video")
resolution: Mapped[str] = mapped_column(String(16), nullable=False)
ratio: Mapped[float] = mapped_column(Float, nullable=False)
base_credits: Mapped[float] = mapped_column(Float, default=80.0)
per_second_credits: Mapped[float] = mapped_column(Float, default=2.0)
+18
View File
@@ -0,0 +1,18 @@
from sqlalchemy import Float, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class CreditRecord(Base, TimestampMixin):
__tablename__ = "credit_records"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
type: Mapped[str] = mapped_column(String(16))
amount: Mapped[float] = mapped_column(Float)
balance_after: Mapped[float] = mapped_column(Float)
description: Mapped[str] = mapped_column(String(256))
related_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
@@ -0,0 +1,46 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, Float
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class GenerationRecord(Base, TimestampMixin):
__tablename__ = "generation_records"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
project_id: Mapped[str] = mapped_column(
String(32), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
original_prompt: Mapped[str] = mapped_column(Text)
optimized_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
gen_type: Mapped[str] = mapped_column(String(16), default="video")
duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
aspect_ratio: Mapped[str | None] = mapped_column(String(8), nullable=True)
resolution: Mapped[str | None] = mapped_column(String(8), nullable=True)
image_size: Mapped[str | None] = mapped_column(String(8), nullable=True)
image_proportion: Mapped[str | None] = mapped_column(String(8), nullable=True)
image_px: Mapped[str | None] = mapped_column(String(10), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="prompt_optimized")
video_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
media_references: Mapped[str | None] = mapped_column(Text, nullable=True)
video_url_expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
seedance_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
credits_cost: Mapped[float] = mapped_column(Float, default=0.0)
text_credits_cost: Mapped[float] = mapped_column(Float, default=0.0)
text_tokens_used: Mapped[int] = mapped_column(Integer, default=0)
video_tokens_used: Mapped[int] = mapped_column(Integer, default=0)
image_tokens_used: Mapped[int] = mapped_column(Integer, default=0)
generated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import Boolean, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class ImageEngine(Base, TimestampMixin):
__tablename__ = "image_engines"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str] = mapped_column(String(64), nullable=False)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
api_base: Mapped[str] = mapped_column(String(512), nullable=False)
api_key: Mapped[str] = mapped_column(String(256), default="")
model_name: Mapped[str] = mapped_column(String(128), default="")
supported_models: Mapped[str] = mapped_column(Text, default='["doubao-seedream-5-0-260128"]')
# {"2K":{"1:1":"2048×2048",...}, "4K":{"1:1":"4096×4096",...}}
supported_sizes: Mapped[str] = mapped_column(Text, default='{}')
default_size: Mapped[str] = mapped_column(String(32), default="2K")
generate_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
priority: Mapped[int] = mapped_column(Integer, default=0)
@@ -0,0 +1,17 @@
from sqlalchemy import Boolean, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class IndustryConfig(Base, TimestampMixin):
__tablename__ = "industry_configs"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
key: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
label: Mapped[str] = mapped_column(String(64), nullable=False)
icon: Mapped[str] = mapped_column(String(64), default="")
description: Mapped[str] = mapped_column(String(256), default="")
skills: Mapped[str] = mapped_column(Text, default="[]")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy import Boolean, Integer, String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class MenuConfig(Base, TimestampMixin):
__tablename__ = "menu_configs"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
label: Mapped[str] = mapped_column(String(64))
path: Mapped[str] = mapped_column(String(128), default="")
icon: Mapped[str] = mapped_column(String(64), default="")
sort_order: Mapped[int] = mapped_column(Integer, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
parent_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("menu_configs.id"), nullable=True)
menu_type: Mapped[str] = mapped_column(String(16), default="page") # page / group
menu_target: Mapped[str] = mapped_column(String(16), default="frontend") # frontend / admin / both
is_default: Mapped[bool] = mapped_column(Boolean, default=False) # default show for new users
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import Boolean, Float, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class ModelConfig(Base, TimestampMixin):
__tablename__ = "model_configs"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str] = mapped_column(String(64))
provider: Mapped[str] = mapped_column(String(32))
api_base: Mapped[str] = mapped_column(String(512))
api_key: Mapped[str] = mapped_column(String(256))
model_name: Mapped[str] = mapped_column(String(128))
weight: Mapped[int] = mapped_column(Integer, default=1)
max_tokens: Mapped[int] = mapped_column(Integer, default=4096)
temperature: Mapped[float] = mapped_column(Float, default=0.7)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
priority: Mapped[int] = mapped_column(Integer, default=0)
+18
View File
@@ -0,0 +1,18 @@
from sqlalchemy import Boolean, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class Notification(Base, TimestampMixin):
__tablename__ = "notifications"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(128))
content: Mapped[str] = mapped_column(Text)
type: Mapped[str] = mapped_column(String(32), default="system")
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
related_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
@@ -0,0 +1,14 @@
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class NotificationRead(Base, TimestampMixin):
__tablename__ = "notification_reads"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
notification_id: Mapped[str] = mapped_column(
String(32), ForeignKey("notifications.id"), index=True
)
user_id: Mapped[str] = mapped_column(String(32), index=True)
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class OperationLog(Base, TimestampMixin):
__tablename__ = "operation_logs"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), index=True)
username: Mapped[str] = mapped_column(String(64))
action: Mapped[str] = mapped_column(String(128)) # e.g. "创建用户", "修改菜单"
method: Mapped[str] = mapped_column(String(10)) # POST/PUT/DELETE
path: Mapped[str] = mapped_column(String(256))
detail: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON detail
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
+24
View File
@@ -0,0 +1,24 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class PaymentOrder(Base, TimestampMixin):
__tablename__ = "payment_orders"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
order_no: Mapped[str] = mapped_column(String(64), unique=True)
amount: Mapped[float] = mapped_column(Float)
credits: Mapped[float] = mapped_column(Float)
payment_method: Mapped[str] = mapped_column(String(16))
status: Mapped[str] = mapped_column(String(16), default="pending")
paid_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
+15
View File
@@ -0,0 +1,15 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class Project(Base, TimestampMixin):
__tablename__ = "projects"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(128))
industry: Mapped[str] = mapped_column(String(32))
@@ -0,0 +1,19 @@
from sqlalchemy import Boolean, Integer, String, Float
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class RechargePackage(Base, TimestampMixin):
__tablename__ = "recharge_packages"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str] = mapped_column(String(64))
credits: Mapped[float] = mapped_column(Float)
price: Mapped[float] = mapped_column(Float)
bonus_credits: Mapped[float] = mapped_column(Float, default=0.0)
description: Mapped[str | None] = mapped_column(String(256), nullable=True)
package_type: Mapped[str] = mapped_column(String(32), default="normal")
is_gift: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
+13
View File
@@ -0,0 +1,13 @@
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class SystemConfig(Base, TimestampMixin):
__tablename__ = "system_configs"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
value: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(String(256), nullable=True)
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class TokenUsage(Base, TimestampMixin):
__tablename__ = "token_usage"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
model_config_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("model_configs.id", ondelete="SET NULL"), nullable=True
)
user_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
input_tokens: Mapped[int] = mapped_column(Integer, default=0)
output_tokens: Mapped[int] = mapped_column(Integer, default=0)
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
+25
View File
@@ -0,0 +1,25 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, Integer, String, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class User(Base, TimestampMixin):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True)
phone: Mapped[str | None] = mapped_column(String(20), unique=True, nullable=True)
hashed_password: Mapped[str] = mapped_column(String(128))
avatar: Mapped[str | None] = mapped_column(String(512), nullable=True)
credits: Mapped[float] = mapped_column(Float, default=0.0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
user_type: Mapped[str] = mapped_column(String(16), default="frontend")
last_login_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True)
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class VideoEngine(Base, TimestampMixin):
__tablename__ = "video_engines"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str] = mapped_column(String(64), nullable=False)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
api_base: Mapped[str] = mapped_column(String(512), nullable=False)
api_key: Mapped[str] = mapped_column(String(256), default="")
model_name: Mapped[str] = mapped_column(String(128), default="")
supported_ratios: Mapped[str] = mapped_column(String(256), default='["16:9","4:3","1:1","3:4","9:16","21:9"]')
supported_resolutions: Mapped[str] = mapped_column(String(256), default='["480p","720p","1080p"]')
supported_durations: Mapped[str] = mapped_column(String(256), nullable=True, default='[4,5,6,7,8,9,10,11,12,13,14,15]')
max_duration: Mapped[int] = mapped_column(Integer, default=15)
generate_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
query_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
priority: Mapped[int] = mapped_column(Integer, default=0)
+97
View File
@@ -0,0 +1,97 @@
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetime, NaiveDatetimeOptional
class CreditAdjustRequest(BaseModel):
amount: int
description: str = Field(..., max_length=256)
class ModelConfigCreate(BaseModel):
name: str = Field(..., max_length=64)
provider: str = Field(..., max_length=32)
api_base: str = Field(..., max_length=512)
api_key: str = Field(..., max_length=256)
model_name: str = Field(..., max_length=128)
weight: int = Field(default=1, ge=0)
max_tokens: int = Field(default=4096, ge=1)
temperature: float = Field(default=0.7, ge=0, le=2)
is_active: bool = True
priority: int = 0
class ModelConfigOut(ModelConfigCreate):
id: str
created_at: NaiveDatetime
model_config = {"from_attributes": True}
class SystemConfigUpdate(BaseModel):
value: str
class SystemConfigOut(BaseModel):
id: str
key: str
value: str
description: str | None = None
model_config = {"from_attributes": True}
class AdminUserOut(BaseModel):
id: str
username: str
email: str | None = None
phone: str | None = None
credits: float
is_active: bool
is_admin: bool
user_type: str = "frontend"
created_at: NaiveDatetime
last_login_at: NaiveDatetimeOptional = None
allowed_menus: list | None = None
model_config = {"from_attributes": True}
class CreateUserRequest(BaseModel):
username: str | None = Field(None, max_length=64)
password: str = Field(..., min_length=6, max_length=128)
email: str | None = None
phone: str | None = None
credits: float = 0.0
user_type: str = Field(default="frontend", pattern="^(frontend|admin)$")
allowed_menus: list | None = None
class UpdateMenusRequest(BaseModel):
allowed_menus: list | None = None
class ResetPasswordRequest(BaseModel):
new_password: str = Field(..., min_length=6, max_length=128)
class OperationLogOut(BaseModel):
id: str
user_id: str
username: str
action: str
method: str
path: str
detail: str | None = None
ip: str | None = None
created_at: NaiveDatetime
model_config = {"from_attributes": True}
class AdminStatsOut(BaseModel):
total_users: int
total_projects: int
total_generations: int
total_revenue: float
credits_consumed_today: float
+24
View File
@@ -0,0 +1,24 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
captcha_token: str | None = None
remember_me: bool = False
class RegisterRequest(BaseModel):
phone: str
code: str
password: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
+17
View File
@@ -0,0 +1,17 @@
from pydantic import BaseModel
class CaptchaResponse(BaseModel):
captcha_id: str
background_image: str
slider_image: str
slider_width: int
class CaptchaVerifyRequest(BaseModel):
captcha_id: str
x_offset: int
class CaptchaTokenResponse(BaseModel):
token: str
+40
View File
@@ -0,0 +1,40 @@
from datetime import datetime, timezone, timedelta
from typing import Annotated
from pydantic import BaseModel, BeforeValidator
CST = timezone(timedelta(hours=8))
def _strip_tz(v: datetime | str | None) -> datetime | None:
if v is None:
return None
try:
dt = v if isinstance(v, datetime) else datetime.fromisoformat(str(v))
if dt.tzinfo is not None:
if dt.tzinfo.utcoffset(None) == timedelta(0):
dt = dt.astimezone(CST)
return dt.replace(tzinfo=None)
return dt
except (ValueError, TypeError):
return v if isinstance(v, datetime) else None
NaiveDatetime = Annotated[datetime, BeforeValidator(_strip_tz)]
NaiveDatetimeOptional = Annotated[datetime | None, BeforeValidator(_strip_tz)]
class ErrorResponse(BaseModel):
detail: str
class PaginationParams(BaseModel):
page: int = 1
page_size: int = 20
class PaginatedResponse(BaseModel):
total: int
page: int
page_size: int
items: list
+18
View File
@@ -0,0 +1,18 @@
from pydantic import BaseModel
from app.schemas.common import NaiveDatetime
class CreditRecordOut(BaseModel):
id: str
type: str
amount: float
description: str
created_at: NaiveDatetime
model_config = {"from_attributes": True}
class CreditBalanceOut(BaseModel):
credits: float
records: list[CreditRecordOut]
+19
View File
@@ -0,0 +1,19 @@
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetime
class CreditRatioCreate(BaseModel):
model_config_id: str = Field(..., max_length=32)
gen_type: str = Field(default="video", max_length=16)
resolution: str = Field(..., max_length=16)
ratio: float = Field(..., gt=0)
base_credits: float = Field(default=80.0, ge=0)
per_second_credits: float = Field(default=2.0, ge=0)
class CreditRatioOut(CreditRatioCreate):
id: str
created_at: NaiveDatetime
model_config = {"from_attributes": True}
+85
View File
@@ -0,0 +1,85 @@
from enum import Enum
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetime, NaiveDatetimeOptional
from app.services.operation_log import log_operation
class GenerationStatus(str, Enum):
prompt_optimized = "prompt_optimized"
generating = "generating"
completed = "completed"
failed = "failed"
class GenerationType(str, Enum):
video = "video"
image = "image"
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
RESOLUTIONS = ["480p", "720p", "1080p"]
IMAGE_SIZES = ["2K", "4K"]
class OptimizeParams(BaseModel):
project_id: str
prompt: str = Field(..., max_length=500)
gen_type: GenerationType = Field(GenerationType.video, description="生成类型:video-视频,image-图片")
duration: int | None = Field(None, description="视频时长(秒),视频生成必填")
image_size: str | None = Field(None, description="画面分辨率,图片生成使用")
image_proportion: str | None = Field(None, description="图片比例,图片生成使用")
image_px: str | None = Field(None, description="图片像素大小,图片生成使用")
references: list[dict] | None = None
idempotency_key: str | None = Field(None, max_length=64, description="幂等键,防止重复请求")
class GenerateParams(BaseModel):
aspect_ratio: str | None = None
resolution: str | None = None
image_size: str | None = None
class OptimizeResult(BaseModel):
optimized_prompt: str
text_credits_cost: float
# text_tokens_used: int
record: "GenerationRecordOut"
class GenerationRecordOut(BaseModel):
id: str
project_id: str
project_name: str
original_prompt: str
optimized_prompt: str | None = None
gen_type: str = "video"
duration: int | None = None
aspect_ratio: str | None = None
resolution: str | None = None
image_size: str | None = None
image_proportion: str | None = None
image_px: str | None = None
status: str
video_url: str | None = None
image_url: str | None = None
references: list[dict] | None = None
text_credits_cost: float = 0.0
# text_tokens_used: int = 0
credits_cost: float = 0.0
# video_tokens_used: int = 0
# image_tokens_used: int = 0
error_message: str | None = None
created_at: NaiveDatetime
generated_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
class UpdatePromptRequest(BaseModel):
optimized_prompt: str = Field(..., max_length=2000)
OptimizeResult.model_rebuild()
+37
View File
@@ -0,0 +1,37 @@
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetime
class ImageEngineCreate(BaseModel):
name: str = Field(..., max_length=64)
provider: str = Field(..., max_length=32)
api_base: str = Field(..., max_length=512)
api_key: str = Field(default="", max_length=256)
model_name: str = Field(default="", max_length=128)
supported_models: str = Field(default='["doubao-seedream-5-0-260128"]')
supported_sizes: str = Field(default='{}')
default_size: str = Field(default="2K", max_length=32)
generate_url: str = Field(default="", max_length=512)
is_active: bool = True
priority: int = 0
class ImageEngineOut(ImageEngineCreate):
id: str
created_at: NaiveDatetime
model_config = {"from_attributes": True}
class ImageEnginePublic(BaseModel):
id: str
name: str
provider: str
supported_models: list[str] = []
supported_sizes: dict[str, dict[str, str]] = {}
default_size: str = "2K"
class ImageEngineListResponse(BaseModel):
items: list[ImageEnginePublic]
+22
View File
@@ -0,0 +1,22 @@
from typing import Any
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetime
class IndustryConfigCreate(BaseModel):
key: str = Field(..., max_length=64)
label: str = Field(..., max_length=64)
icon: str = Field(default="", max_length=64)
description: str = Field(default="", max_length=256)
skills: list[dict[str, Any]] = Field(default=[])
is_active: bool = True
sort_order: int = 0
class IndustryConfigOut(IndustryConfigCreate):
id: str
created_at: NaiveDatetime
model_config = {"from_attributes": True}
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime
from pydantic import BaseModel, Field
class MenuConfigCreate(BaseModel):
label: str = Field(..., max_length=64)
path: str = Field(default="", max_length=128)
icon: str = Field(default="", max_length=64)
sort_order: int = 0
is_active: bool = True
parent_id: str | None = None
menu_type: str = "page"
menu_target: str = "frontend"
is_default: bool = False
class MenuConfigOut(MenuConfigCreate):
id: str
model_config = {"from_attributes": True}
+18
View File
@@ -0,0 +1,18 @@
from pydantic import BaseModel
from app.schemas.common import NaiveDatetime
class NotificationOut(BaseModel):
id: str
title: str
content: str
type: str
is_read: bool
created_at: NaiveDatetime
model_config = {"from_attributes": True}
class UnreadCountOut(BaseModel):
count: int
+16
View File
@@ -0,0 +1,16 @@
from pydantic import BaseModel
class RechargeRequest(BaseModel):
plan: str # package id
class PaymentOrderOut(BaseModel):
id: str
order_no: str
amount: float
credits: float
payment_method: str
status: str
model_config = {"from_attributes": True}
+18
View File
@@ -0,0 +1,18 @@
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetime
class ProjectCreate(BaseModel):
name: str = Field(..., max_length=128)
industry: str = Field(..., max_length=64)
class ProjectOut(BaseModel):
id: str
name: str
industry: str
created_at: NaiveDatetime
updated_at: NaiveDatetime
model_config = {"from_attributes": True}
@@ -0,0 +1,41 @@
from pydantic import BaseModel
class RechargePackageCreate(BaseModel):
name: str
credits: float
price: float
bonus_credits: float = 0.0
description: str | None = None
package_type: str = "normal"
is_gift: bool = False
is_active: bool = True
sort_order: int = 0
class RechargePackageUpdate(BaseModel):
name: str | None = None
credits: float | None = None
price: float | None = None
bonus_credits: float | None = None
description: str | None = None
package_type: str | None = None
is_gift: bool | None = None
is_active: bool | None = None
sort_order: int | None = None
class RechargePackageOut(BaseModel):
id: str
name: str
credits: float
price: float
bonus_credits: float
total_credits: float
description: str | None
package_type: str
is_gift: bool
is_active: bool
sort_order: int
model_config = {"from_attributes": True}
+16
View File
@@ -0,0 +1,16 @@
from pydantic import BaseModel, Field
class SmsSendRequest(BaseModel):
phone: str = Field(..., pattern=r"^1[3-9]\d{9}$", description="手机号")
captcha_token: str | None = None
class SmsVerifyRequest(BaseModel):
phone: str = Field(..., pattern=r"^1[3-9]\d{9}$", description="手机号")
code: str = Field(..., min_length=4, max_length=8, description="验证码")
class SmsResponse(BaseModel):
message: str
success: bool
+14
View File
@@ -0,0 +1,14 @@
from pydantic import BaseModel
class UserOut(BaseModel):
id: str
username: str
email: str | None = None
avatar: str | None = None
credits: float
is_admin: bool = False
user_type: str = "frontend"
allowed_menus: list | None = None
model_config = {"from_attributes": True}
+39
View File
@@ -0,0 +1,39 @@
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetime
class VideoEngineCreate(BaseModel):
name: str = Field(..., max_length=64)
provider: str = Field(..., max_length=32)
api_base: str = Field(..., max_length=512)
api_key: str = Field(default="", max_length=256)
model_name: str = Field(default="", max_length=128)
supported_ratios: str = Field(default='["16:9","4:3","1:1","3:4","9:16","21:9"]')
supported_resolutions: str = Field(default='["480p","720p","1080p"]')
supported_durations: str = Field(default='[4,5,6,7,8,9,10,11,12,13,14,15]')
max_duration: int = Field(default=15)
generate_url: str = Field(default="", max_length=512)
query_url: str = Field(default="", max_length=512)
is_active: bool = True
priority: int = 0
class VideoEngineOut(VideoEngineCreate):
id: str
created_at: NaiveDatetime
model_config = {"from_attributes": True}
class VideoEnginePublic(BaseModel):
id: str
name: str
provider: str
supported_ratios: list[str] = []
supported_resolutions: list[str] = []
supported_durations: list[int] = []
class VideoEngineListResponse(BaseModel):
items: list[VideoEnginePublic]
+51
View File
@@ -0,0 +1,51 @@
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.user import User
def hash_password(plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def create_access_token(user_id: str, remember_me: bool = False) -> str:
minutes = settings.JWT_EXPIRE_REMEMBER_MINUTES if remember_me else settings.JWT_EXPIRE_MINUTES
expire = datetime.now(timezone.utc) + timedelta(minutes=minutes)
payload = {"sub": user_id, "exp": expire}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def decode_access_token(token: str) -> str | None:
"""Decode JWT and return user_id, or None if invalid."""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]
)
return payload.get("sub")
except jwt.PyJWTError:
return None
async def authenticate_user(
db: AsyncSession, username: str, password: str
) -> User | None:
result = await db.execute(select(User).where(User.username == username))
user = result.scalar_one_or_none()
if not user:
# Try phone number lookup for frontend users
result = await db.execute(select(User).where(User.phone == username))
user = result.scalar_one_or_none()
if not user or not verify_password(password, user.hashed_password):
return None
if not user.is_active:
return None
return user
+67
View File
@@ -0,0 +1,67 @@
import base64
import random
import time
from app.services.auth import create_access_token
from app.utils.redis import get_redis
# In-memory fallback when Redis is not available
_captcha_store: dict[str, tuple[int, float]] = {}
async def generate_slider_captcha() -> dict:
"""Generate a slider captcha challenge."""
captcha_id = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=16))
redis = get_redis()
if redis:
await redis.setex(f"captcha:{captcha_id}", 300, "1")
else:
_captcha_store[captcha_id] = (True, time.time() + 300)
bg_data = _create_placeholder_image(300, 150, "#e2e8f0", "拖动滑块到最右侧完成验证")
slider_data = _create_placeholder_image(50, 50, "#6366f1", "")
return {
"captcha_id": captcha_id,
"background_image": bg_data,
"slider_image": slider_data,
"slider_width": 50,
}
async def verify_slider_captcha(captcha_id: str, x_offset: int) -> str | None:
"""Verify slider captcha. Return a captcha_token on success."""
redis = get_redis()
valid = False
if redis:
stored = await redis.get(f"captcha:{captcha_id}")
if stored:
valid = True
await redis.delete(f"captcha:{captcha_id}")
else:
entry = _captcha_store.pop(captcha_id, None)
if entry:
_, expires = entry
if time.time() < expires:
valid = True
if not valid:
return None
# User must drag at least 70% of the way (x_offset >= 182 out of 260)
if x_offset >= 180:
return create_access_token(f"captcha:{captcha_id}")
return None
def _create_placeholder_image(width: int, height: int, color: str, text: str) -> str:
svg = (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}">'
f'<rect width="100%" height="100%" fill="{color}" rx="8"/>'
f'<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" '
f'font-family="sans-serif" font-size="14" fill="#64748b">{text}</text>'
f'</svg>'
)
return f"data:image/svg+xml;base64,{base64.b64encode(svg.encode()).decode()}"
+128
View File
@@ -0,0 +1,128 @@
import math
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.credit_record import CreditRecord
from app.models.system_config import SystemConfig
from app.models.credit_ratio import CreditRatio
from app.utils.id_gen import generate_id
from app.utils.exceptions import InsufficientCreditsError
async def calc_text_credits(db: AsyncSession, input_tokens: int, output_tokens: int) -> float:
"""Calculate text credits based on actual token usage and configurable rate."""
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "text_credits_per_1000_tokens")
)
config = result.scalar_one_or_none()
rate = float(config.value) if config else 1.0
total_tokens = input_tokens + output_tokens
return round(total_tokens * rate / 1000, 2)
async def calc_video_credits(db: AsyncSession, duration: int, resolution: str) -> float:
"""Calculate video credits using CreditRatio table, with fallback to hardcoded."""
result = await db.execute(
select(CreditRatio).where(CreditRatio.resolution == resolution).limit(1)
)
ratio = result.scalar_one_or_none()
if ratio:
return round((ratio.base_credits + ratio.per_second_credits * duration) * ratio.ratio, 2)
# Fallback
base = 60.0
duration_cost = duration * 2.0
multiplier = {"4K": 2.5, "1080p": 1.5, "720p": 1.0}.get(resolution, 1.0)
return round((base + duration_cost) * multiplier, 2)
def calc_credits(duration: int, resolution: str) -> float:
"""Legacy: hardcoded credit calculation. Prefer calc_video_credits for new code."""
base = 60.0
duration_cost = duration * 2.0
multiplier = {"4K": 2.5, "1080p": 1.5, "720p": 1.0}.get(resolution, 1.0)
return round((base + duration_cost) * multiplier, 2)
async def calc_image_credits(db: AsyncSession, image_size: str) -> float:
"""Calculate image credits using CreditRatio table, with fallback to hardcoded."""
result = await db.execute(
select(CreditRatio).where(CreditRatio.gen_type == "image").where(CreditRatio.resolution == image_size).limit(1)
)
ratio = result.scalar_one_or_none()
if ratio:
return round(ratio.base_credits * ratio.ratio, 2)
# Fallback
multiplier = {"4K": 2.0, "2K": 1.0}.get(image_size, 1.0)
base_cost = 4.0
return round(base_cost * multiplier, 2)
async def deduct_credits(
db: AsyncSession,
user_id: str,
amount: float,
description: str,
related_id: str | None = None,
) -> User:
"""Atomically deduct credits from user. Raises InsufficientCreditsError."""
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user or user.credits < amount:
raise InsufficientCreditsError()
user.credits = round(user.credits - amount, 2)
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type="consume",
amount=-round(amount, 2),
balance_after=user.credits,
description=description,
related_id=related_id,
)
db.add(record)
await db.flush()
return user
async def add_credits(
db: AsyncSession,
user_id: str,
amount: float,
description: str,
related_id: str | None = None,
) -> User:
"""Add credits to user."""
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
raise ValueError("User not found")
user.credits = round(user.credits + amount, 2)
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type="recharge",
amount=round(amount, 2),
balance_after=user.credits,
description=description,
related_id=related_id,
)
db.add(record)
await db.flush()
return user
async def get_records(db: AsyncSession, user_id: str) -> list[CreditRecord]:
result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id)
.order_by(CreditRecord.created_at.desc())
)
return list(result.scalars().all())
+162
View File
@@ -0,0 +1,162 @@
import json
ARK_ERRORS = {
"MissingParameter": "请求缺少必要参数,请查阅API文档。",
"InvalidParameter": "请求包含非法参数,请查阅API文档。",
"InvalidEndpoint.ClosedEndpoint": "推理接入点处于已关闭或暂时不可用,请稍后重试,或联系管理员。",
"SensitiveContentDetected": "输入文本可能包含敏感信息,请使用其他prompt。",
"SensitiveContentDetected.SevereViolation": "输入文本可能包含严重违规相关信息,请使用其他prompt。",
"SensitiveContentDetected.Violence": "输入文本可能包含激进行为相关信息,请使用其他prompt。",
"InputTextSensitiveContentDetected": "输入文本可能包含敏感信息,请更换后重试。",
"InputImageSensitiveContentDetected": "输入图像可能包含敏感信息,请更换后重试。",
"InputVideoSensitiveContentDetected": "输入视频可能包含敏感信息,请更换后重试。",
"InputAudioSensitiveContentDetected": "输入音频可能包含敏感信息,请更换后重试。",
"OutputTextSensitiveContentDetected": "生成的文字可能包含敏感信息,请更换输入内容后重试。",
"OutputImageSensitiveContentDetected": "生成的图像可能包含敏感信息,请更换输入内容后重试。",
"OutputVideoSensitiveContentDetected": "生成的视频可能包含敏感信息,请更换输入内容后重试。",
"OutputVideoSensitiveContentDetected.PolicyViolation": "生成的视频可能涉及版权限制,请修改提示词。",
"OutputAudioSensitiveContentDetected": "生成的音频可能包含敏感信息,请更换输入内容后重试。",
"InputTextSensitiveContentDetected.PolicyViolation": "输入文本可能违反平台规定,请更换后重试。",
"InputImageSensitiveContentDetected.PolicyViolation": "输入图片可能违反平台规定,请更换后重试。",
"InputVideoSensitiveContentDetected.PolicyViolation": "输入视频可能违反平台规定,请更换后重试。",
"InputAudioSensitiveContentDetected.PolicyViolation": "输入音频可能违反平台规定,请更换后重试。",
"InputImageSensitiveContentDetected.PrivacyInformation": "输入图片可能包含真人,请更换后重试。",
"InputVideoSensitiveContentDetected.PrivacyInformation": "输入视频可能包含真人,请更换后重试。",
"InputTextRiskDetection": "风险识别产品检测到输入文本可能包含敏感信息,请更换后重试。",
"InputImageRiskDetection": "风险识别产品检测到输入图片可能包含敏感信息,请更换后重试。",
"OutputTextRiskDetection": "风险识别产品检测到输出文本可能包含敏感信息,请更换后重试。",
"OutputImageRiskDetection": "风险识别产品检测到输出图片可能包含敏感信息,请更换后重试。",
"ContentSecurityDetectionError": "风险识别产品请求失败。",
"InvalidParameter.InvalidParameter": "请求参数值不合法,请检查参数值的正确性后重试。",
"MissingParameter.MissingParameter": "缺少必要的请求参数,请确认请求参数后重试。",
"Duplicate.Tags.Key": "对象的标签存在重复Key。",
"InvalidArgumentError": "请求中的messages列表里,有消息体缺少role字段。",
"InvalidArgumentError.UnknownRole": "消息体中的role值不被支持,如user_。",
"InvalidArgumentError.InvalidImageDetail": "image_url中的detail参数值无效,只接受\"auto\", \"high\", \"low\"",
"InvalidArgumentError.InvalidPixelLimit": "用户自定义的图片像素限制无效(例如min_pixels > max_pixels,或超出了服务配置的范围)。",
"InvalidImageURL.EmptyURL": "传入的图片URL为空。",
"InvalidImageURL.InvalidFormat": "无法解析或处理图片,可能是Base64格式不正确、图片数据损坏或格式不支持。",
"OutofContextError": "当请求中包含图片时,文本和图片编码后的总token数超过了模型上下文长度限制。",
"InvalidParameter.UnsupportedParameter": "传入的参数在此推理接入点不可用。",
"InvalidSubscription": "Coding Plan套餐未订阅或已过期。",
"AuthenticationError": "请求携带的API Key或AK/SK校验未通过,请重新检查设置的鉴权凭证。",
"InvalidAccountStatus": "当前使用的账号异常。",
"OperationDenied.InvalidState": "请求所关联的Context ID处于非空闲状态,不可调用。",
"OperationDenied.ConflictedValidationSet": "无法同时上传验证集和设置训练集取样为验证集百分比,不支持该操作。",
"OperationDenied.PermissionDenied": "您没有权限访问基础模型的配置,不支持该操作。",
"OperationDenied.UnsupportedCustomizationType": "模型不支持该训练方法,不支持该操作。",
"OperationDenied.CustomizationNotSupported": "基础模型的版本不支持该训练方法,不支持该操作。",
"OperationDenied.ServiceNotOpen": "模型服务不可用,请前往激活模型服务,或提交工单联系我们。",
# "OperationDenied.ServiceOverdue": "您的账单已逾期,请前往费用中心充值。",
"OperationDenied.ServiceOverdue": "系统使用高峰,请稍后重试",
#"AccountOverdueError": "当前账号欠费(余额<0),如需继续调用,请前往费用中心进行充值。",
"AccountOverdueError": "系统使用高峰,请稍后重试",
"AccessDenied": "没有访问该资源的权限,请检查权限设置,或联系管理员添加白名单。",
"OperationDenied.UnsupportedPhase": "操作失败,操作目标在特殊状态,请检查目标是否存在或者被锁定等特殊状态中。",
"OperationDenied.FileQuotaExceeded": "当前账号已耗尽文件存储额度,如需继续使用,请删除历史文件。",
"InvalidEndpointOrModel.NotFound": "模型或者推理接入点不存在或者您无权访问它。",
"ModelNotOpen": "当前账号暂未开通模型服务,请前往开通管理页开通对应模型服务。",
"NotFound.NotFound": "指定资源找不到,请确认参数后重试。",
"InvalidEndpointOrModel.ModelIDAccessDisabled": "未能找到指定的模型ID,您的账号不允许使用模型ID来调用模型,请使用有权限的推理接入点ID来调用模型服务。",
"UnsupportedModel": "当前模型不支持Coding Plan。",
"RateLimitExceeded.EndpointRPMExceeded": "请求所关联的推理接入点已超过RPM(Requests Per Minute)限制,请稍后重试。",
"RateLimitExceeded.EndpointTPMExceeded": "请求所关联的推理接入点已超过TPM(Tokens Per Minute)限制,请稍后重试。",
"ModelAccountRpmRateLimitExceeded": "请求已超过帐户模型RPM(Requests Per Minute)限制,请稍后重试,或联系平台技术同学进行解决。",
"ModelAccountTpmRateLimitExceeded": "请求已超过帐户模型TPM(Tokens Per Minute)限制,请稍后重试,或联系平台技术同学进行解决。",
"APIAccountRpmRateLimitExceeded": "当前账号该接口的RPM(Requests Per Minute)限制已超出,请稍后重试。",
"ModelAccountIpmRateLimitExceeded": "请求已超过账户模型IPM(Images Per Minute)限制,请稍后重试,或联系平台技术同学进行解决。",
"QuotaExceeded": "当前账号对模型的免费试用额度已消耗完毕,如需继续调用,请前往开通管理页开通对应模型服务。",
"ServerOverloaded": "服务资源紧张,请稍后重试。常出现在调用流量突增或刚开始调用长时间未使用的推理接入点。",
"RequestBurstTooFast": "请求量激增触发系统保护,请放缓流量提升速度,逐步增加请求量后再尝试。",
"SetLimitExceeded": "当前账号已达到模型推理限制,模型服务已暂停。如需继续使用该模型,请联系平台。",
"InternalError": "服务内部错误,请稍后重试。",
"ServiceUnavailable": "服务暂时不可用,请稍后重试。",
"Timeout": "请求超时,请重试。",
"TaskCancelled": "任务已被取消。",
"UnsupportedOperation": "当前操作不支持,请联系管理员。",
"PermissionDenied": "没有权限执行此操作。",
"ResourceNotFound": "请求的资源不存在。",
"InvalidApiKey": "API密钥无效,请联系管理员。",
"InvalidSignature": "签名验证失败。",
"InvalidToken": "Token无效或已过期。",
"RequestTimeout": "请求超时,请重试。",
"TooManyRequests": "请求过于频繁,请稍后重试。",
"MethodNotAllowed": "不支持的HTTP方法。",
"MediaTypeNotSupported": "不支持的媒体类型。",
"RequestEntityTooLarge": "请求体过大。",
"InvalidRequest": "请求格式无效。",
"ResourceExists": "资源已存在。",
"ResourceNotReady": "资源尚未就绪。",
"GenerationFailed": "生成失败,请重试。",
"AudioGenerationFailed": "音频生成失败。",
"VideoGenerationFailed": "视频生成失败。",
"ImageGenerationFailed": "图片生成失败。",
"ModelNotFound": "模型不存在。",
"ModelNotAvailable": "模型暂不可用。",
"RegionNotSupported": "当前区域不支持。",
"AccountNotActivated": "账户尚未激活。",
"AccountSuspended": "账户已被暂停。",
"InsufficientBalance": "余额不足,请充值。",
"FeatureNotEnabled": "功能未启用。",
"VersionNotSupported": "版本不支持。",
"InvalidDuration": "无效的时长参数。",
"InvalidSize": "无效的尺寸参数。",
"InvalidFormat": "无效的格式参数。",
"InvalidAspectRatio": "无效的画面比例。",
"InvalidResolution": "无效的分辨率。",
"FrameRateNotSupported": "不支持的帧率。",
"BitrateNotSupported": "不支持的码率。",
"AudioNotSupported": "音频格式不支持。",
"VideoCodecNotSupported": "视频编码不支持。",
"WatermarkFailed": "水印添加失败。",
"TranscodeFailed": "转码失败。",
"StorageFailed": "存储失败。",
"DownloadFailed": "下载失败。",
"UploadFailed": "上传失败。",
"ProcessingFailed": "处理失败。",
"ValidationFailed": "验证失败。",
"AuthorizationFailed": "授权失败。",
"AuthenticationFailed": "认证失败。",
"SessionExpired": "会话已过期。",
"SessionNotFound": "会话不存在。",
"RateLimitReached": "达到速率限制。",
"ConcurrentLimitReached": "达到并发限制。",
"QueueFull": "队列已满。",
"ServiceMaintenance": "服务维护中。",
"ServiceUpgrade": "服务升级中。",
"DataCorrupted": "数据损坏。",
"DatabaseError": "数据库错误。",
"CacheError": "缓存错误。",
"NetworkError": "网络错误。",
"DNSFailure": "DNS解析失败。",
"ConnectionRefused": "连接被拒绝。",
"SSLHandshakeFailed": "SSL握手失败。",
"CertificateError": "证书错误。",
}
def extract_error_message(exc: Exception, service_type: str = "video") -> str:
"""Extract a clean, user-friendly error message from Ark SDK exceptions."""
raw = str(exc)
try:
idx = raw.find("{")
if idx >= 0:
data = json.loads(raw[idx:].replace("'", '"'))
err = data.get("error", {})
code = err.get("code", "")
msg = err.get("message", "")
if code in ARK_ERRORS:
return ARK_ERRORS[code]
if msg:
if "Request id:" in msg or "Request ID:" in msg:
idx_request = msg.find("Request id:")
if idx_request == -1:
idx_request = msg.find("Request ID:")
if idx_request >= 0:
msg = msg[:idx_request].strip().rstrip(".")
return msg
if code:
return f"{service_type}生成失败: {code}"
except (json.JSONDecodeError, TypeError, ValueError):
pass
return raw[:200] if len(raw) > 200 else raw
+243
View File
@@ -0,0 +1,243 @@
import base64
import json
import logging
import mimetypes
import os
from datetime import datetime
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from volcenginesdkarkruntime import AsyncArk
from app.config import settings
from app.models.image_engine import ImageEngine
from app.models.generation_record import GenerationRecord
from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data
from app.services.error_codes import extract_error_message
logger = logging.getLogger("videogen")
def _log_image_request(engine, record_id: str, request_data: dict):
"""Log image generation request to log/AiModel/YYYY-MM-DD.log"""
if not is_enabled():
return
try:
os.makedirs(LOG_DIR, exist_ok=True)
today = datetime.now().strftime(LOG_DATE_FORMAT)
log_file = os.path.join(LOG_DIR, f"{today}.log")
request_str = json.dumps(request_data, ensure_ascii=False)
request_encrypted = encrypt_data(request_data)
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"type": "image_gen_request",
"engine": engine.name,
"model": engine.model_name,
"record_id": record_id,
"request": request_encrypted,
"request_length": len(request_str),
}
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
pass
def _log_image_response(record_id: str, response_data: dict, error: str | None = None):
"""Log image generation response to log/AiModel/YYYY-MM-DD.log"""
if not is_enabled():
return
try:
os.makedirs(LOG_DIR, exist_ok=True)
today = datetime.now().strftime(LOG_DATE_FORMAT)
log_file = os.path.join(LOG_DIR, f"{today}.log")
response_encrypted = encrypt_data(response_data) if response_data else ""
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"type": "image_gen_response",
"record_id": record_id,
"response": response_encrypted,
"error": error,
}
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
pass
async def get_active_image_engine(db: AsyncSession) -> ImageEngine:
"""Get the active image engine with highest priority."""
result = await db.execute(
select(ImageEngine)
.where(ImageEngine.is_active == True)
.order_by(ImageEngine.priority.desc())
.limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
raise ValueError("没有可用的图片引擎,请联系管理员配置")
return engine
def _resolve_url(url: str) -> str:
"""Convert local path to base64 data URI, pass through remote URLs."""
# if url.startswith("http"):
# return url
# file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, url.replace("/uploads/", ""))
# if not os.path.exists(file_path):
# settings.BASE_URL + mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
# with open(file_path, "rb") as f:
# b64 = base64.b64encode(f.read()).decode()
# return f"data:{mime};base64,{b64}"
return settings.BASE_URL + url
def submit_image_task(
db,
engine: ImageEngine,
record: GenerationRecord,
) -> str:
"""Submit an image generation task via Ark SDK. Returns image_url."""
from volcenginesdkarkruntime import Ark
client = Ark(
base_url=engine.api_base,
api_key=engine.api_key,
timeout=300,
)
prompt = record.optimized_prompt
image_urls = []
if record.media_references:
try:
refs = json.loads(record.media_references)
for ref in refs:
ref_type = ref.get("type")
ref_url = ref.get("url", "")
if ref_type == "image" and ref_url:
resolved = _resolve_url(ref_url)
image_urls.append(resolved)
except (json.JSONDecodeError, TypeError):
pass
request_payload = {
"model": engine.model_name,
"prompt": prompt,
"size": record.image_size or engine.default_size,
"sequential_image_generation": "disabled",
"output_format": "png",
"response_format": "url",
"watermark": False,
}
if image_urls:
request_payload["image"] = image_urls
_log_image_request(engine, record.id, request_payload)
try:
result = client.images.generate(
model=engine.model_name,
prompt=prompt,
size=record.image_size or engine.default_size,
output_format="png",
response_format="url",
watermark=False,
image=image_urls if image_urls else None,
)
image_url = result.data[0].url
response_data = {
"model": result.model,
"created": result.created,
"data": [{"url": item.url, "size": item.size} for item in result.data] if result.data else [],
"usage": {
"generated_images": result.usage.generated_images if hasattr(result.usage, 'generated_images') else 0,
"output_tokens": result.usage.output_tokens if hasattr(result.usage, 'output_tokens') else 0,
"total_tokens": result.usage.total_tokens if hasattr(result.usage, 'total_tokens') else 0,
}
}
except httpx.TimeoutException:
error_msg = "图片生成超时,请稍后重试"
logger.error(f"Image generation timeout for record {record.id}")
_log_image_response(record.id, {}, error_msg)
raise TimeoutError(error_msg)
except Exception as e:
error_msg = str(e)
logger.error(f"Image generation failed for record {record.id}: {error_msg}")
_log_image_response(record.id, {}, error_msg)
raise
finally:
client.close()
return {
"image_url": image_url,
"image_tokens": getattr(result.usage, "total_tokens", 0),
"response_data": json.dumps(response_data, ensure_ascii=False, default=str),
"error": str(result.error) if result.error else "",
}
async def poll_image_task_status(engine: ImageEngine, task_id: str) -> dict:
"""Query image task status via Ark SDK. Returns {status, image_url, response_data}."""
client = AsyncArk(
base_url=engine.api_base,
api_key=engine.api_key,
)
result = await client.image_generation.tasks.get(task_id=task_id)
await client.close()
response_dict = {
"id": result.id,
"model": result.model,
"status": result.status,
"created_at": result.created_at,
"updated_at": result.updated_at,
}
image_url = None
image_tokens = 0
if result.status == "succeeded" and result.content:
image_url = getattr(result.content, "image_url", None)
response_dict["image_url"] = image_url
response_dict["ratio"] = getattr(result, "ratio", None)
response_dict["size"] = getattr(result, "size", None)
usage = getattr(result, "usage", None)
if usage:
response_dict["usage"] = {
"input_tokens": getattr(usage, "input_tokens", 0),
"output_tokens": getattr(usage, "output_tokens", 0),
"total_tokens": getattr(usage, "total_tokens", 0),
}
image_tokens = getattr(usage, "total_tokens", 0)
elif result.status == "failed":
response_dict["error"] = str(getattr(result, "error", "图片生成失败"))
return {
"status": result.status,
"image_url": image_url,
"image_tokens": image_tokens,
"response_data": json.dumps(response_dict, ensure_ascii=False, default=str),
"error": response_dict.get("error"),
}
async def download_image(image_url: str, dest_path: str) -> str:
"""Download image to local storage."""
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
async with httpx.AsyncClient(timeout=300) as client:
async with client.stream("GET", image_url) as response:
response.raise_for_status()
with open(dest_path, "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=8192):
f.write(chunk)
return dest_path
+326
View File
@@ -0,0 +1,326 @@
import base64
import json
import mimetypes
import os
import random
from datetime import datetime
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.model_config import ModelConfig
from app.models.token_usage import TokenUsage
from app.utils.id_gen import generate_id
from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data
def _sanitize_for_log(data):
"""Replace base64 data URIs with placeholder for readable logs."""
if isinstance(data, str):
if data.startswith("data:") and ";base64," in data:
return "[base64 image data]"
return data
if isinstance(data, dict):
return {k: _sanitize_for_log(v) for k, v in data.items()}
if isinstance(data, list):
return [_sanitize_for_log(item) for item in data]
return data
def _log_ai_request_response(config, request_data: dict, response_data: dict | None, error: str | None = None):
"""Log AI model request/response to log/AiModel/YYYY-MM-DD.log"""
if not is_enabled():
return
try:
os.makedirs(LOG_DIR, exist_ok=True)
today = datetime.now().strftime(LOG_DATE_FORMAT)
log_file = os.path.join(LOG_DIR, f"{today}.log")
request_encrypted = encrypt_data(_sanitize_for_log(request_data))
response_encrypted = encrypt_data(_sanitize_for_log(response_data)) if response_data else ""
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"model_name": config.name,
"model_id": config.model_name,
"provider": config.provider,
"api_base": config.api_base,
"request": request_encrypted,
"response": response_encrypted,
"error": error,
}
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
pass
MOCK_OPTIMIZED_PROMPTS = {
"直播": "专业直播间场景,45度斜角机位,暖色柔光打光,主播居中构图,背景虚化处理,产品特写切换流畅,镜头推进节奏感强,画面色彩饱和度高,适合电商直播推广视频。",
"产品": "高端产品展示视频,360度旋转环绕拍摄,纯白/深色渐变背景,柔光箱打光消除阴影,微距镜头捕捉产品细节,金属/玻璃材质高光反射,品牌Logo水印角落显示。",
"课程": "在线教育课程片头,明亮书房环境,讲师半身出镜,板书/屏幕录制无缝切换,字幕条底部滚动,知识要点动画弹出,背景轻音乐辅助,整体色调清新专业。",
"美食": "美食制作过程记录,俯拍+侧拍双机位切换,暖色灯光突出食材质感,慢镜头捕捉烹饪瞬间(蒸汽、油花、酱汁淋洒),成品摆盘精致特写,色调偏暖黄增进食欲。",
"品牌": "品牌形象宣传片,电影级调色(青橙对比色),航拍+地面多角度取景,城市/自然场景交替,人物情感特写穿插,品牌故事旁白叠加,片尾Logo定版动画。",
"游戏": "游戏宣传CG风格视频,高速运镜+粒子特效,角色动态捕捉流畅,技能释放光效炸裂,UI界面模拟叠加,BGM史诗感配乐,画面帧率60fps丝滑体验。",
"product": "Premium product showcase video with 360-degree rotation, clean gradient background, professional studio lighting, macro lens capturing fine details, metallic and glass material highlights, brand watermark in corner.",
"live": "Professional livestream scene with 45-degree angle camera, warm soft lighting, host centered with bokeh background, smooth product close-up transitions, vibrant saturated colors.",
"course": "Online education intro with bright study environment, instructor half-body shot, seamless screen recording transitions, animated key points overlay, clean professional tone.",
"food": "Food preparation recording with overhead and side camera switching, warm lighting highlighting textures, slow-motion cooking moments, elegant plating close-up.",
"brand": "Cinematic brand film with teal-orange color grading, aerial and ground multi-angle shots, urban and nature scenes alternating, emotional character close-ups, brand story narration.",
"game": "Game promotional CG-style video with dynamic camera movements, particle effects, character motion capture, explosive skill light effects, epic BGM, smooth 60fps visuals.",
}
def _get_default_prompt(prompt: str, gen_type: str = "video") -> tuple[str, dict]:
if gen_type == "image":
optimized = (
f"Professional high-quality image with expert composition, precise color grading, "
f"sharp focus and rich details. Theme: {prompt}. Photographic style with "
f"professional lighting and strong visual impact, suitable for commercial use."
)
else:
optimized = (
f"Professionally crafted video with expert composition, precise color grading, "
f"smooth camera movements. Theme: {prompt}. Cinematic shooting techniques with "
f"rich lighting layers and strong visual impact, suitable for commercial distribution."
)
return optimized, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
async def optimize_prompt(
db: AsyncSession,
original_prompt: str,
user_id: str | None = None,
industry_key: str | None = None,
duration: int | None = None,
image_size: str | None = None,
image_proportion: str | None = None,
image_px: str | None | None = None,
references: list[dict] | None = None,
gen_type: str = "video",
) -> tuple[str, dict]:
"""Optimize user prompt using LLM. Returns (optimized_text, token_usage_dict)."""
result = await db.execute(
select(ModelConfig)
.where(ModelConfig.is_active == True)
.order_by(ModelConfig.priority.desc())
)
configs = list(result.scalars().all())
if configs:
total_weight = sum(c.weight for c in configs)
r = random.uniform(0, total_weight)
cumulative = 0
selected = configs[0]
for c in configs:
cumulative += c.weight
if r <= cumulative:
selected = c
break
if selected.provider == "mock":
return _mock_optimize(original_prompt, gen_type)
elif selected.provider in ("openai_compatible", "sdk"):
try:
return await _call_openai_compatible(
selected, original_prompt, db, user_id, industry_key, duration,
references=references,
gen_type=gen_type,
image_size=image_size,
image_proportion=image_proportion,
image_px=image_px,
)
except Exception:
for c in configs:
if c.id == selected.id or c.provider == "mock":
continue
try:
return await _call_openai_compatible(
c, original_prompt, db, user_id, industry_key, duration,
references=references,
gen_type=gen_type,
image_size=image_size,
image_proportion=image_proportion,
image_px=image_px,
)
except Exception:
continue
raise
if settings.LLM_MOCK:
return _mock_optimize(original_prompt, gen_type)
return _get_default_prompt(original_prompt, gen_type)
def _mock_optimize(prompt: str, gen_type: str = "video") -> tuple[str, dict]:
"""Return a keyword-matched mock optimized prompt."""
for keyword, optimized in MOCK_OPTIMIZED_PROMPTS.items():
if keyword in prompt:
return optimized, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
return _get_default_prompt(prompt, gen_type)
async def _call_openai_compatible(
config: ModelConfig,
original_prompt: str,
db: AsyncSession | None = None,
user_id: str | None = None,
industry_key: str | None = None,
duration: int | None = None,
references: list[dict] | None = None,
gen_type: str = "video",
image_size: str | None = None,
image_proportion: str | None = None,
image_px: str | None | None = None,
) -> tuple[str, dict]:
"""Call an OpenAI-compatible API to optimize the prompt. Returns (content, token_usage)."""
system_prompt = None
if industry_key and db is not None:
from app.models.industry_config import IndustryConfig
result = await db.execute(
select(IndustryConfig).where(IndustryConfig.key == industry_key)
)
ind = result.scalar_one_or_none()
if ind and ind.skills:
try:
skills = json.loads(ind.skills)
if gen_type == "image":
skill_keys = ("文图理解生成图片提示词", "文图理解生成图片", "文图理解")
else:
skill_keys = ("文图理解生成视频提示词", "文图理解")
for s in skills:
if s.get("key") in skill_keys and s.get("label"):
system_prompt = s["label"]
break
except (json.JSONDecodeError, TypeError):
pass
if not system_prompt:
if gen_type == "image":
system_prompt = (
"你是一位专业的摄影师和图像创作专家。请根据用户提供的主题,"
"生成一段详细的、专业的图片生成提示词。要求:\n"
"1. 包含具体的画面构图\n"
"2. 描述光影效果和色调\n"
"3. 指定拍摄角度和镜头类型\n"
"4. 画面风格和质感\n"
"5. 整体不超过200字"
)
else:
system_prompt = (
"你是一位专业的视频导演和文案专家。请根据用户提供的视频主题,"
"生成一段详细的、专业的视频生成提示词。要求:\n"
"1. 包含具体的镜头语言(机位、运镜方式)\n"
"2. 描述光影效果和色调\n"
"3. 画面构图和视觉层次\n"
"4. 适合的节奏感和转场\n"
"5. 整体不超过200字"
)
if gen_type == "video" and duration:
system_prompt += f"\n\n请根据视频总时长{duration}秒,合理分配镜头节奏,生成适合{duration}秒视频的提示词。"
user_content = f"{original_prompt}\n\n请生成一段{duration}秒的视频提示词。"
elif gen_type == "image" and image_size:
system_prompt += f"\n\n请根据画面分辨率:{image_size},宽高比:{image_proportion},宽高像素值:{image_px},生成适合该分辨率的图片提示词。"
user_content = f"{original_prompt}\n\n请生成适合分辨率:{image_size},宽高比:{image_proportion},宽高像素值:{image_px}的图片提示词。"
else:
user_content = original_prompt
# Build multimodal user message content when images are present
image_urls = []
if references:
for ref in references:
if ref.get("type") == "image" and ref.get("url"):
image_urls.append(ref["url"])
if image_urls:
content_parts = [{"type": "text", "text": user_content}]
for img in image_urls:
if img.startswith("http"):
url = img
else:
# Local file: read and encode as base64 data URI
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, img.replace("/uploads/", ""))
mime = mimetypes.guess_type(file_path)[0] or "image/png"
with open(file_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
url = f"data:{mime};base64,{b64}"
content_parts.append({"type": "image_url", "image_url": {"url": url}})
user_message = {"role": "user", "content": content_parts}
# Log-friendly version: keep original paths instead of base64
log_user_message = {"role": "user", "content": [
{"type": "text", "text": user_content},
*[{"type": "image_url", "image_url": {"url": img}} for img in image_urls],
]}
else:
user_message = {"role": "user", "content": user_content}
log_user_message = None
async with httpx.AsyncClient(timeout=120) as client:
request_data = {
"model": config.model_name,
"messages": [
{"role": "system", "content": system_prompt},
user_message,
],
"max_tokens": config.max_tokens,
"temperature": config.temperature,
}
# Build log-friendly request data (image paths instead of base64)
if log_user_message:
log_request_data = {**request_data, "messages": [
{"role": "system", "content": system_prompt},
log_user_message,
]}
else:
log_request_data = request_data
try:
response = await client.post(
f"{config.api_base}/chat/completions",
headers={
"Authorization": f"Bearer {config.api_key}",
"Content-Type": "application/json",
},
json=request_data,
)
if response.status_code >= 400:
error_body = response.text
_log_ai_request_response(config, log_request_data, None, error=f"HTTP {response.status_code}: {error_body}")
raise RuntimeError(f"HTTP {response.status_code}: {error_body}")
data = response.json()
except RuntimeError:
raise
except Exception as e:
_log_ai_request_response(config, log_request_data, None, error=str(e))
raise RuntimeError(f"{type(e).__name__}: {e}")
# Log request/response
_log_ai_request_response(config, log_request_data, data)
# Record token usage
usage = data.get("usage", {})
input_tokens = usage.get("prompt_tokens", 0)
output_tokens = usage.get("completion_tokens", 0)
total_tokens = usage.get("total_tokens", input_tokens + output_tokens)
if db is not None:
record = TokenUsage(
id=generate_id(),
model_config_id=config.id,
user_id=user_id,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
)
db.add(record)
await db.flush()
content = data["choices"][0]["message"]["content"].strip()
token_usage = {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
}
return content, token_usage
+93
View File
@@ -0,0 +1,93 @@
"""
AI Model logging configuration.
Controls whether AI model requests/responses are logged to disk.
Easy to extend with additional log targets, formats, or filters.
"""
import os
import json
import base64
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
from app.config import settings
#16字节密钥
ENCRYPTION_KEY = b'videogen@202605!'
def encrypt_data(data: dict) -> str:
data_str = json.dumps(data, ensure_ascii=False, sort_keys=True)
data_bytes = data_str.encode("utf-8")
padder = padding.PKCS7(128).padder()
padded_data = padder.update(data_bytes) + padder.finalize()
iv = os.urandom(16)
cipher = Cipher(algorithms.AES(ENCRYPTION_KEY), modes.CBC(iv), backend=default_backend())
encryptor = cipher.encryptor()
encrypted_data = encryptor.update(padded_data) + encryptor.finalize()
return base64.b64encode(iv + encrypted_data).decode("utf-8")
def decrypt_data(encrypted_str: str) -> dict:
try:
encrypted_bytes = base64.b64decode(encrypted_str)
iv = encrypted_bytes[:16]
ciphertext = encrypted_bytes[16:]
cipher = Cipher(algorithms.AES(ENCRYPTION_KEY), modes.CBC(iv), backend=default_backend())
decryptor = cipher.decryptor()
padded_data = decryptor.update(ciphertext) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
data_bytes = unpadder.update(padded_data) + unpadder.finalize()
return json.loads(data_bytes.decode("utf-8"))
except Exception:
return {}
# ── Feature toggle ──────────────────────────────────────────
AI_LOG_ENABLED: bool = True # Set True to enable logging, or use env var AI_LOG_ENABLED=true
# ── Log output settings ────────────────────────────────────
LOG_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"log", "AiModel",
)
# 请求响应日志目录
LOG_R_Q_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"log", "RequestResponse",
)
LOG_FILENAME_FORMAT = "{date}.log" # e.g. 2026-05-12.log
LOG_DATE_FORMAT = "%Y-%m-%d"
# ── Fields to include in each log entry ────────────────────
LOG_FIELDS = [
"timestamp",
"model_name",
"model_id",
"provider",
"api_base",
"request",
"response",
"error",
]
def is_enabled() -> bool:
"""Check if AI model logging is enabled."""
env_val = os.environ.get("AI_LOG_ENABLED", "").lower()
if env_val in ("true", "1", "yes"):
return True
if env_val in ("false", "0", "no"):
return False
return AI_LOG_ENABLED
+264
View File
@@ -0,0 +1,264 @@
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import select, func, update, case, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import Notification
from app.models.notification_read import NotificationRead
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
CST = timezone(timedelta(hours=8))
def _to_local_str(dt):
if dt is None:
return None
if dt.tzinfo and dt.tzinfo.utcoffset(None) == timedelta(0):
dt = dt.astimezone(CST)
return dt.replace(tzinfo=None).isoformat()
async def create_notification(
db: AsyncSession,
user_id: str | None,
title: str,
content: str,
notif_type: str = "system",
related_id: str | None = None,
push_ws: bool = True,
) -> Notification:
"""Create a notification. user_id=None means broadcast."""
notif = Notification(
id=generate_id(),
user_id=user_id,
title=title,
content=content,
type=notif_type,
related_id=related_id,
)
db.add(notif)
await db.flush()
# Push via WebSocket if available
if push_ws:
try:
from app.utils.redis import get_redis
redis = get_redis()
if redis:
import json
payload = json.dumps(
{
"event": "notification",
"data": {
"id": notif.id,
"title": notif.title,
"content": notif.content,
"type": notif.type,
"user_id": notif.user_id,
"created_at": _to_local_str(notif.created_at),
},
}
)
if notif.user_id:
await redis.publish(f"user:{notif.user_id}:notifications", payload)
else:
await redis.publish("broadcast:notifications", payload)
except Exception:
pass
return notif
async def get_notifications(
db: AsyncSession, user_id: str, page: int = 1, page_size: int = 20
) -> tuple[list[dict], int]:
"""Get notifications for a user (personal + broadcast), with per-user read state."""
query = (
select(
Notification,
case(
(Notification.user_id.is_(None), NotificationRead.id.is_not(None)),
else_=Notification.is_read,
).label("user_is_read"),
)
.outerjoin(
NotificationRead,
(Notification.id == NotificationRead.notification_id)
& (NotificationRead.user_id == user_id),
)
.where(
or_(
Notification.user_id == user_id,
Notification.user_id.is_(None),
)
)
.order_by(Notification.created_at.desc())
)
count_query = (
select(func.count(Notification.id))
.where(
or_(
Notification.user_id == user_id,
Notification.user_id.is_(None),
)
)
)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
rows = result.all()
items = []
for notif, user_is_read in rows:
items.append({
"id": notif.id,
"user_id": notif.user_id,
"title": notif.title,
"content": notif.content,
"type": notif.type,
"is_read": bool(user_is_read),
"related_id": notif.related_id,
"created_at": notif.created_at,
})
return items, total
async def mark_read(db: AsyncSession, notification_id: str, user_id: str) -> None:
"""Mark a notification as read for a specific user."""
result = await db.execute(
select(Notification).where(Notification.id == notification_id)
)
notif = result.scalar_one_or_none()
if not notif:
return
if notif.user_id is not None:
# Personal notification: update the row directly
if notif.user_id == user_id:
notif.is_read = True
await db.flush()
else:
# Broadcast notification: use notification_reads table
existing = await db.execute(
select(NotificationRead).where(
NotificationRead.notification_id == notification_id,
NotificationRead.user_id == user_id,
)
)
if not existing.scalar_one_or_none():
db.add(NotificationRead(
id=generate_id(),
notification_id=notification_id,
user_id=user_id,
))
await db.flush()
async def mark_all_read(db: AsyncSession, user_id: str) -> None:
"""Mark all notifications as read for a user."""
# Mark personal notifications
await db.execute(
update(Notification)
.where(
Notification.user_id == user_id,
Notification.is_read == False,
)
.values(is_read=True)
)
# Mark all broadcast notifications
broadcasts = await db.execute(
select(Notification.id)
.where(Notification.user_id.is_(None))
)
broadcast_ids = [row[0] for row in broadcasts.all()]
if broadcast_ids:
existing_reads = await db.execute(
select(NotificationRead.notification_id).where(
NotificationRead.notification_id.in_(broadcast_ids),
NotificationRead.user_id == user_id,
)
)
already_read = {row[0] for row in existing_reads.all()}
for bid in broadcast_ids:
if bid not in already_read:
db.add(NotificationRead(
id=generate_id(),
notification_id=bid,
user_id=user_id,
))
await db.flush()
async def get_unread_count(db: AsyncSession, user_id: str) -> int:
"""Get unread notification count for a user."""
# Count personal unread
personal_count = (
await db.execute(
select(func.count(Notification.id)).where(
Notification.user_id == user_id,
Notification.is_read == False,
)
)
).scalar() or 0
# Count broadcast unread (not in notification_reads)
read_broadcast_ids = (
await db.execute(
select(NotificationRead.notification_id).where(
NotificationRead.user_id == user_id,
)
)
).all()
read_ids = {row[0] for row in read_broadcast_ids}
broadcast_query = select(func.count(Notification.id)).where(
Notification.user_id.is_(None),
)
if read_ids:
broadcast_query = broadcast_query.where(~Notification.id.in_(read_ids))
broadcast_count = (await db.execute(broadcast_query)).scalar() or 0
return personal_count + broadcast_count
async def get_notification_read_users(
db: AsyncSession, notification_id: str, page: int = 1, page_size: int = 50
) -> tuple[list[dict], int]:
"""Get list of users who have read a specific notification."""
from app.models.user import User
query = (
select(NotificationRead, User.username)
.join(User, NotificationRead.user_id == User.id)
.where(NotificationRead.notification_id == notification_id)
.order_by(NotificationRead.created_at.desc())
)
count_query = (
select(func.count(NotificationRead.id))
.where(NotificationRead.notification_id == notification_id)
)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
rows = result.all()
items = []
for read_record, username in rows:
items.append({
"user_id": read_record.user_id,
"username": username,
"read_at": read_record.created_at,
})
return items, total
@@ -0,0 +1,27 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.operation_log import OperationLog
from app.utils.id_gen import generate_id
async def log_operation(
db: AsyncSession,
user_id: str,
username: str,
action: str,
method: str,
path: str,
detail: str | None = None,
ip: str | None = None,
):
log = OperationLog(
id=generate_id(),
user_id=user_id,
username=username,
action=action,
method=method,
path=path,
detail=detail,
ip=ip,
)
db.add(log)
+122
View File
@@ -0,0 +1,122 @@
import logging
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.payment_order import PaymentOrder
from app.services.credits import add_credits
from app.utils.id_gen import generate_id, generate_order_no
logger = logging.getLogger("videogen")
async def create_recharge_order(
db: AsyncSession,
user_id: str,
credits: float,
price: float,
label: str,
bonus_credits: float = 0.0,
method: str = "wechat",
) -> PaymentOrder:
"""Create a payment order. In mock mode, immediately completes payment."""
total_credits = credits + bonus_credits
order = PaymentOrder(
id=generate_id(),
user_id=user_id,
order_no=generate_order_no(),
amount=price,
credits=total_credits,
payment_method=method,
status="pending",
)
db.add(order)
await db.flush()
if settings.PAYMENT_MOCK:
# Mock: immediately complete payment
order.status = "paid"
order.paid_at = datetime.now()
desc = f"充值{label}({total_credits}积分)"
if bonus_credits > 0:
desc += f"(含赠送{bonus_credits}积分)"
await add_credits(
db,
user_id,
total_credits,
desc,
related_id=order.id,
)
await db.flush()
else:
# Real payment: delegate to WeChat or Alipay
if method == "wechat":
_create_wechat_order(order)
elif method == "alipay":
_create_alipay_order(order)
return order
def _create_wechat_order(order: PaymentOrder) -> None:
"""Create a WeChat Pay order. Stub for real integration."""
if not settings.WECHAT_MCH_ID or not settings.WECHAT_API_KEY:
logger.warning("WeChat payment config missing (WECHAT_MCH_ID / WECHAT_API_KEY)")
return
logger.info(
f"WeChat order created: mch_id={settings.WECHAT_MCH_ID}, "
f"order_no={order.order_no}, amount={order.amount}"
)
def _create_alipay_order(order: PaymentOrder) -> None:
"""Create an Alipay order. Stub for real integration."""
if not settings.ALIPAY_APP_ID or not settings.ALIPAY_PRIVATE_KEY:
logger.warning("Alipay payment config missing (ALIPAY_APP_ID / ALIPAY_PRIVATE_KEY)")
return
logger.info(
f"Alipay order created: app_id={settings.ALIPAY_APP_ID}, "
f"order_no={order.order_no}, amount={order.amount}"
)
async def verify_wechat_callback(data: dict) -> bool:
"""Verify WeChat payment callback signature."""
if settings.PAYMENT_MOCK:
return True
# Real verification would use WECHAT_API_KEY to verify signature
logger.info("WeChat callback verification (real mode not implemented)")
return True
async def verify_alipay_callback(data: dict) -> bool:
"""Verify Alipay payment callback signature."""
if settings.PAYMENT_MOCK:
return True
# Real verification would use ALIPAY_PUBLIC_KEY to verify signature
logger.info("Alipay callback verification (real mode not implemented)")
return True
async def process_payment_success(db: AsyncSession, order_id: str):
"""Process successful payment: update order and add credits."""
from sqlalchemy import select
result = await db.execute(
select(PaymentOrder).where(PaymentOrder.id == order_id)
)
order = result.scalar_one_or_none()
if not order or order.status != "pending":
return
order.status = "paid"
order.paid_at = datetime.now()
await add_credits(
db,
order.user_id,
order.credits,
f"充值成功({order.credits}积分)",
related_id=order.id,
)
await db.flush()
+79
View File
@@ -0,0 +1,79 @@
import logging
import random
import time
import httpx
from app.config import settings
from app.utils.redis import get_redis
logger = logging.getLogger("videogen")
# In-memory fallback for verification codes
_sms_code_store: dict[str, tuple[str, float]] = {}
def _generate_code(length: int = 6) -> str:
return "".join(random.choices("0123456789", k=length))
async def send_sms(phone: str, code: str) -> bool:
"""Send SMS verification code. Supports mock mode and real HTTP gateway."""
if settings.SMS_MOCK or not settings.SMS_API_URL:
logger.info(f"[SMS MOCK] To={phone}, Code={code}")
return True
try:
payload = {
"phone": phone,
"code": code,
"sign_name": settings.SMS_SIGN_NAME,
"template_code": settings.SMS_TEMPLATE_CODE,
}
headers = {
"Authorization": f"Bearer {settings.SMS_API_KEY}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(settings.SMS_API_URL, json=payload, headers=headers)
resp.raise_for_status()
return True
except Exception:
logger.exception(f"SMS send failed for {phone}")
return False
async def store_sms_code(phone: str, code: str, ttl: int = 300) -> None:
"""Store SMS verification code with TTL (default 5 minutes)."""
redis = get_redis()
if redis:
await redis.setex(f"sms_code:{phone}", ttl, code)
else:
_sms_code_store[phone] = (code, time.time() + ttl)
async def generate_and_send_sms(phone: str) -> bool:
"""Generate a code, store it, and send it via SMS."""
code = _generate_code()
ok = await send_sms(phone, code)
if ok:
await store_sms_code(phone, code)
return ok
async def verify_sms_code(phone: str, code: str) -> bool:
"""Verify an SMS verification code."""
redis = get_redis()
if redis:
stored = await redis.get(f"sms_code:{phone}")
if stored and stored == code:
await redis.delete(f"sms_code:{phone}")
return True
return False
else:
entry = _sms_code_store.pop(phone, None)
if entry:
stored_code, expires = entry
if time.time() < expires and stored_code == code:
return True
return False
+217
View File
@@ -0,0 +1,217 @@
import base64
import json
import logging
import mimetypes
import os
from datetime import datetime, timezone
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from volcenginesdkarkruntime import AsyncArk
from app.config import settings
from app.models.video_engine import VideoEngine
from app.models.generation_record import GenerationRecord
from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data
from app.services.error_codes import extract_error_message
logger = logging.getLogger("videogen")
def _log_video_request(engine, record_id: str, request_data: dict):
"""Log video generation request to log/AiModel/YYYY-MM-DD.log"""
if not is_enabled():
return
try:
os.makedirs(LOG_DIR, exist_ok=True)
today = datetime.now().strftime(LOG_DATE_FORMAT)
log_file = os.path.join(LOG_DIR, f"{today}.log")
request_str = json.dumps(request_data, ensure_ascii=False)
request_encrypted = encrypt_data(request_data)
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"type": "video_gen_request",
"engine": engine.name,
"model": engine.model_name,
"record_id": record_id,
"request": request_encrypted,
"request_length": len(request_str),
}
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
pass
def _log_video_response(record_id: str, response_data: dict, error: str | None = None):
"""Log video generation response to log/AiModel/YYYY-MM-DD.log"""
if not is_enabled():
return
try:
os.makedirs(LOG_DIR, exist_ok=True)
today = datetime.now().strftime(LOG_DATE_FORMAT)
log_file = os.path.join(LOG_DIR, f"{today}.log")
response_encrypted = encrypt_data(response_data) if response_data else ""
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"type": "video_gen_response",
"record_id": record_id,
"response": response_encrypted,
"error": error,
}
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
pass
async def get_active_engine(db: AsyncSession) -> VideoEngine:
"""Get the active video engine with highest priority."""
result = await db.execute(
select(VideoEngine)
.where(VideoEngine.is_active == True)
.order_by(VideoEngine.priority.desc())
.limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
raise ValueError("没有可用的视频引擎,请联系管理员配置")
return engine
def _resolve_url(url: str) -> str:
"""Convert local path to base64 data URI, pass through remote URLs."""
# if url.startswith("http"):
# return url
# # Local file: read and encode as base64 data URI
# file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, url.replace("/uploads/", ""))
# if not os.path.exists(file_path):
# return url
# mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
# with open(file_path, "rb") as f:
# b64 = base64.b64encode(f.read()).decode()
# return f"data:{mime};base64,{b64}"
return settings.BASE_URL + url
async def submit_video_task(
db: AsyncSession,
engine: VideoEngine,
record: GenerationRecord,
) -> str:
"""Submit a video generation task via Ark SDK. Returns task_id."""
client = AsyncArk(
base_url=engine.api_base,
api_key=engine.api_key,
)
content = [{"type": "text", "text": record.optimized_prompt}]
# Add reference images/videos from media_references
if record.media_references:
try:
refs = json.loads(record.media_references)
for ref in refs:
ref_type = ref.get("type")
ref_url = ref.get("url", "")
if ref_type == "image" and ref_url:
resolved = _resolve_url(ref_url)
content.append({"type": "image_url", "image_url": {"url": resolved},"role":"reference_image"})
elif ref_type == "video" and ref_url:
resolved = _resolve_url(ref_url)
content.append({"type": "video_url", "video_url": {"url": resolved},"role":"reference_video"})
except (json.JSONDecodeError, TypeError):
pass
request_payload = {
"model": engine.model_name,
"content": content,
"ratio": record.aspect_ratio,
"duration": record.duration,
"resolution": record.resolution,
"generate_audio": True,
"watermark": False,
}
# Log request to AiModel log
_log_video_request(engine, record.id, request_payload)
try:
result = await client.content_generation.tasks.create(**request_payload)
task_id = result.id
_log_video_response(record.id, {"task_id": task_id})
except Exception as e:
_log_video_response(record.id, {}, str(e))
raise
finally:
await client.close()
return task_id
async def poll_task_status(engine: VideoEngine, task_id: str) -> dict:
"""Query task status via Ark SDK. Returns {status, video_url, response_data}."""
client = AsyncArk(
base_url=engine.api_base,
api_key=engine.api_key,
)
result = await client.content_generation.tasks.get(task_id=task_id)
await client.close()
# Serialize response
response_dict = {
"id": result.id,
"model": result.model,
"status": result.status,
"created_at": result.created_at,
"updated_at": result.updated_at,
}
video_url = None
video_tokens = 0
if result.status == "succeeded" and result.content:
video_url = getattr(result.content, "video_url", None)
response_dict["video_url"] = video_url
response_dict["duration"] = getattr(result, "duration", None)
response_dict["ratio"] = getattr(result, "ratio", None)
response_dict["resolution"] = getattr(result, "resolution", None)
# Extract usage info if present
usage = getattr(result, "usage", None)
if usage:
response_dict["usage"] = {
"input_tokens": getattr(usage, "input_tokens", 0),
"output_tokens": getattr(usage, "output_tokens", 0),
"total_tokens": getattr(usage, "total_tokens", 0),
}
video_tokens = getattr(usage, "total_tokens", 0)
elif result.status == "failed":
response_dict["error"] = str(getattr(result, "error", "视频生成失败"))
return {
"status": result.status,
"video_url": video_url,
"video_tokens": video_tokens,
"response_data": json.dumps(response_dict, ensure_ascii=False, default=str),
"error": response_dict.get("error"),
}
async def download_video(video_url: str, dest_path: str) -> str:
"""Download video to local storage."""
import os
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
async with httpx.AsyncClient(timeout=300) as client:
async with client.stream("GET", video_url) as response:
response.raise_for_status()
with open(dest_path, "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=8192):
f.write(chunk)
return dest_path
+205
View File
@@ -0,0 +1,205 @@
import asyncio
import json
import logging
import os
from datetime import datetime
from sqlalchemy import select
from app.models.base import async_session
from app.models.generation_record import GenerationRecord
from app.services.video_gen import get_active_engine, poll_task_status, download_video, _log_video_response
from app.services.image_gen import get_active_image_engine, poll_image_task_status, download_image
from app.config import settings
logger = logging.getLogger("videogen")
POLL_INTERVAL = 30 # seconds between polls
MAX_POLLS = 60 # max 30 minutes total
class TaskQueue:
def __init__(self):
self.queue: asyncio.Queue[str] = asyncio.Queue()
self.running = False
self._active: dict[str, int] = {} # record_id -> poll count
async def enqueue(self, record_id: str):
"""Add a record to the polling queue."""
await self.queue.put(record_id)
async def recover(self):
"""Recover in-progress tasks from DB on startup."""
async with async_session() as db:
result = await db.execute(
select(GenerationRecord).where(
GenerationRecord.status == "generating",
GenerationRecord.seedance_task_id.isnot(None),
)
)
records = result.scalars().all()
for record in records:
await self.queue.put(record.id)
logger.info(f"Recovered task: {record.id} (seedance: {record.seedance_task_id})")
async def run(self):
"""Main polling loop."""
self.running = True
logger.info("Video queue started")
while self.running:
try:
record_id = await asyncio.wait_for(self.queue.get(), timeout=5.0)
except asyncio.TimeoutError:
continue
try:
await self._process(record_id)
except Exception as e:
logger.error(f"Error processing {record_id}: {e}")
finally:
self.queue.task_done()
logger.info("Video queue stopped")
async def _process(self, record_id: str):
"""Process a single record: poll status and update DB."""
async with async_session() as db:
result = await db.execute(
select(GenerationRecord).where(GenerationRecord.id == record_id)
)
record = result.scalar_one_or_none()
if not record or record.status != "generating":
return
if record.gen_type == "video":
await self._process_video(db, record)
else:
await self._process_image(db, record)
async def _process_video(self, db, record):
"""Process video generation task."""
record_id = record.id
if not record.seedance_task_id:
record.status = "failed"
record.error_message = "缺少外部任务ID"
await db.commit()
return
try:
engine = await get_active_engine(db)
poll_result = await poll_task_status(engine, record.seedance_task_id)
except Exception as e:
logger.error(f"Poll error for {record_id}: {e}")
count = self._active.get(record_id, 0) + 1
self._active[record_id] = count
if count >= MAX_POLLS:
record.status = "failed"
record.error_message = f"轮询超时: {e}"
await db.commit()
del self._active[record_id]
else:
await asyncio.sleep(POLL_INTERVAL)
await self.queue.put(record_id)
return
status = poll_result["status"]
try:
resp_data = json.loads(poll_result.get("response_data", "{}"))
except (json.JSONDecodeError, TypeError):
resp_data = {}
_log_video_response(record_id, resp_data, poll_result.get("error"))
if status == "succeeded":
file_url = poll_result.get("video_url", "")
if settings.STORAGE_TYPE == "local" and file_url:
try:
date_dir = datetime.now().strftime("%Y/%m/%d")
dest_dir = os.path.join(settings.STORAGE_LOCAL_PATH, date_dir)
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, f"{record_id}.mp4")
await download_video(file_url, dest)
record.video_url = f"/videos/{date_dir}/{record_id}.mp4"
except Exception as e:
logger.warning(f"Download failed, using remote URL: {e}")
record.video_url = file_url
else:
record.video_url = file_url
record.video_tokens_used = poll_result.get("video_tokens", 0)
record.status = "completed"
record.generated_at = datetime.now()
self._active.pop(record_id, None)
await db.commit()
logger.info(f"Video task completed: {record_id}")
elif status == "failed":
record.status = "failed"
record.error_message = poll_result.get("error", "视频生成失败")
self._active.pop(record_id, None)
await db.commit()
logger.info(f"Video task failed: {record_id}")
else:
count = self._active.get(record_id, 0) + 1
self._active[record_id] = count
if count >= MAX_POLLS:
record.status = "failed"
record.error_message = "视频生成超时"
self._active.pop(record_id, None)
await db.commit()
logger.info(f"Video task timed out: {record_id}")
else:
await db.commit()
await asyncio.sleep(POLL_INTERVAL)
await self.queue.put(record_id)
async def _process_image(self, db, record):
"""Process image generation task - calls API directly."""
record_id = record.id
from app.services.image_gen import submit_image_task, download_image, _log_image_response
try:
engine = await get_active_image_engine(db)
poll_result = await asyncio.to_thread(submit_image_task, db, engine, record)
if poll_result["error"] == "":
if settings.STORAGE_TYPE == "local" and poll_result.get("image_url"):
try:
date_dir = datetime.now().strftime("%Y/%m/%d")
dest_dir = os.path.join(settings.STORAGE_IMAGE_LOCAL_PATH, date_dir)
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, f"{record_id}.png")
await download_image(poll_result.get("image_url"), dest)
record.image_url = f"/images/{date_dir}/{record_id}.png"
except Exception as e:
logger.warning(f"Download failed, using remote URL: {e}")
record.image_url = poll_result.get("image_url")
else:
record.image_url = poll_result.get("image_url")
record.image_tokens_used = poll_result.get("image_tokens", 0)
record.status = "completed"
record.generated_at = datetime.now()
await db.commit()
logger.info(f"Image task completed: {record_id}")
else:
record.status = "failed"
record.error_message = poll_result.get("error", "图片生成失败")
await db.commit()
logger.info(f"Image task failed: {record_id}")
_log_image_response(record_id, poll_result)
except Exception as e:
record.status = "failed"
record.error_message = str(e)
_log_image_response(record_id, {}, str(e))
await db.commit()
logger.error(f"Image task failed: {record_id}, error: {e}")
def stop(self):
"""Signal the queue to stop."""
self.running = False
task_queue = TaskQueue()
+29
View File
@@ -0,0 +1,29 @@
import time
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.generation_record import GenerationRecord
from app.utils.security import encrypt_temp_token, decrypt_temp_token
async def generate_temp_url(db: AsyncSession, record: GenerationRecord) -> str:
"""Generate a temporary encrypted URL for video access (1 hour expiry)."""
token = encrypt_temp_token(record.id, expires_in=3600)
record.video_url_expires_at = datetime.now().replace(second=0, microsecond=0)
# We store just the token, the full URL is constructed by the frontend
return f"/api/generation-records/{record.id}/video?token={token}"
async def validate_and_get_record_id(token: str) -> str | None:
"""Validate a temp URL token and return the record_id if valid."""
return decrypt_temp_token(token)
async def get_video_stream_url(db: AsyncSession, record_id: str) -> str | None:
"""Get the actual video URL for a record (for proxying/redirecting)."""
result = await db.execute(
select(GenerationRecord.video_url).where(GenerationRecord.id == record_id)
)
return result.scalar_one_or_none()
View File
+17
View File
@@ -0,0 +1,17 @@
from celery import Celery
from app.config import settings
if settings.REDIS_URL:
celery_app = Celery("videogen")
celery_app.conf.update(
broker_url=settings.REDIS_URL.replace("/0", "/1"),
result_backend=settings.REDIS_URL.replace("/0", "/2"),
task_serializer="json",
accept_content=["json"],
task_soft_time_limit=600,
task_time_limit=900,
worker_prefetch_multiplier=1,
)
celery_app.autodiscover_tasks(["app.tasks"])
else:
celery_app = None
+50
View File
@@ -0,0 +1,50 @@
import asyncio
from datetime import datetime, timedelta
from app.tasks.celery_app import celery_app
@celery_app.task
def cleanup_expired_video_urls():
"""Run hourly. Clear expired video URL tokens."""
asyncio.run(_cleanup_urls())
async def _cleanup_urls():
from app.models.base import async_session
from app.models.generation_record import GenerationRecord
from sqlalchemy import update
async with async_session() as db:
now = datetime.now()
await db.execute(
update(GenerationRecord)
.where(
GenerationRecord.video_url_expires_at.isnot(None),
GenerationRecord.video_url_expires_at < now,
)
.values(video_url_expires_at=None)
)
await db.commit()
@celery_app.task
def cleanup_old_notifications():
"""Run daily. Delete read notifications older than 30 days."""
asyncio.run(_cleanup_notifications())
async def _cleanup_notifications():
from app.models.base import async_session
from app.models.notification import Notification
from sqlalchemy import delete
async with async_session() as db:
cutoff = datetime.now() - timedelta(days=30)
await db.execute(
delete(Notification).where(
Notification.is_read == True,
Notification.created_at < cutoff,
)
)
await db.commit()
@@ -0,0 +1,2 @@
# Video generation is now handled by app.services.video_queue
# This file is kept as an empty shell to avoid import errors.
View File
+41
View File
@@ -0,0 +1,41 @@
from fastapi import HTTPException, status
class InsufficientCreditsError(HTTPException):
def __init__(self):
super().__init__(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail="积分不足,请充值",
)
class CaptchaFailedError(HTTPException):
def __init__(self):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码校验失败",
)
class RecordNotFoundError(HTTPException):
def __init__(self):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
detail="记录不存在",
)
class ProjectNotFoundError(HTTPException):
def __init__(self):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
detail="项目不存在",
)
class InvalidStatusError(HTTPException):
def __init__(self, detail: str = "当前状态不允许此操作"):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail=detail,
)
+16
View File
@@ -0,0 +1,16 @@
import time
import random
def generate_id() -> str:
"""Generate a time-sortable unique ID (simplified ULID-style)."""
timestamp = int(time.time() * 1000)
randomness = random.randint(0, 0xFFFFFF)
return f"{timestamp:013x}{randomness:06x}"
def generate_order_no() -> str:
"""Generate a human-readable order number."""
timestamp = int(time.time())
randomness = random.randint(1000, 9999)
return f"VG{timestamp}{randomness}"
+30
View File
@@ -0,0 +1,30 @@
from app.config import settings
redis_client = None
async def init_redis() -> None:
global redis_client
if not settings.REDIS_URL:
return
try:
from redis.asyncio import Redis
redis_client = Redis.from_url(settings.REDIS_URL, decode_responses=True)
await redis_client.ping()
except Exception:
redis_client = None
async def close_redis() -> None:
global redis_client
if redis_client:
try:
await redis_client.close()
except Exception:
pass
redis_client = None
def get_redis():
"""Return Redis client or None if not available."""
return redis_client
+53
View File
@@ -0,0 +1,53 @@
import base64
import hashlib
import hmac
import json
import time
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from app.config import settings
def get_aes_key() -> bytes:
"""Derive a 32-byte AES key from the configured encryption key."""
return hashlib.sha256(settings.ENCRYPTION_KEY.encode()).digest()
def encrypt_temp_token(record_id: str, expires_in: int = 3600) -> str:
"""Create an encrypted token containing record_id and expiry timestamp."""
payload = json.dumps({"rid": record_id, "exp": int(time.time()) + expires_in})
aesgcm = AESGCM(get_aes_key())
nonce = AESGCM.generate_key(bit_length=96)
ciphertext = aesgcm.encrypt(nonce, payload.encode(), None)
token_bytes = nonce + ciphertext
return base64.urlsafe_b64encode(token_bytes).decode()
def decrypt_temp_token(token: str) -> str | None:
"""Decrypt token and return record_id if valid and not expired."""
try:
token_bytes = base64.urlsafe_b64decode(token)
nonce = token_bytes[:12]
ciphertext = token_bytes[12:]
aesgcm = AESGCM(get_aes_key())
payload = aesgcm.decrypt(nonce, ciphertext, None)
data = json.loads(payload)
if data["exp"] < time.time():
return None
return data["rid"]
except Exception:
return None
def hmac_sign(data: str) -> str:
"""Create HMAC-SHA256 signature."""
return hmac.new(
settings.SECRET_KEY.encode(), data.encode(), hashlib.sha256
).hexdigest()
def hmac_verify(data: str, signature: str) -> bool:
"""Verify HMAC-SHA256 signature."""
expected = hmac_sign(data)
return hmac.compare_digest(expected, signature)