1
This commit is contained in:
@@ -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
@@ -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)}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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()}"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user