1、增加调用 AI 视频生成能力和虚拟素材库管理的对外api

2、增加后台apikkey管理
3、增加apikey单独的模型定价
4、增加apikey调用情况
5、完善所有数据的注释增加
This commit is contained in:
2026-08-06 13:13:28 +08:00
parent a55d4d649c
commit 0c511f3451
102 changed files with 13986 additions and 41 deletions
@@ -0,0 +1,59 @@
from app.services.api_v3.auth_service import ApiKeyContext, get_api_key_dependency
from app.services.api_v3.key_service import (
create_api_key,
list_api_keys,
get_api_key,
update_api_key,
delete_api_key,
reset_quota_if_needed,
)
from app.services.api_v3.quota_service import check_quota, can_start_video_task, get_active_video_tasks_count, get_queued_video_tasks
from app.services.api_v3.usage_log_service import record_usage, get_usage_summary, list_usage_logs
from app.services.api_v3.generation_service import submit_video_generation, generate_image_sync
from app.services.api_v3.task_service import create_video_task, create_image_task, get_task, map_task_to_status_response
from app.services.api_v3.upscale_service import (
get_or_create_upscale_config,
save_upscale_config,
build_api_upscale_snapshot,
prepare_api_upscale_task,
)
from app.services.api_v3.engine_service import resolve_video_engine, resolve_image_engine, build_engine_snapshot
from app.services.api_v3.pricing_service import (
calc_api_video_price,
calc_api_image_price,
get_priced_models,
)
__all__ = [
"ApiKeyContext",
"get_api_key_dependency",
"create_api_key",
"list_api_keys",
"get_api_key",
"update_api_key",
"delete_api_key",
"reset_quota_if_needed",
"check_quota",
"can_start_video_task",
"get_active_video_tasks_count",
"get_queued_video_tasks",
"record_usage",
"get_usage_summary",
"list_usage_logs",
"submit_video_generation",
"generate_image_sync",
"create_video_task",
"create_image_task",
"get_task",
"map_task_to_status_response",
"get_or_create_upscale_config",
"save_upscale_config",
"build_api_upscale_snapshot",
"prepare_api_upscale_task",
"resolve_video_engine",
"resolve_image_engine",
"build_engine_snapshot",
"calc_api_video_price",
"calc_api_image_price",
"get_priced_models",
]
@@ -0,0 +1,99 @@
import hashlib
import json
import logging
from datetime import datetime, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db
from app.models.api.api_key import ApiKey
logger = logging.getLogger("videogen")
security = HTTPBearer(auto_error=False)
class ApiKeyContext:
"""API Key 验证上下文,携带解析后的可调用模型列表。"""
def __init__(self, api_key: ApiKey, callable_models: list[dict]):
self.api_key = api_key
self.api_key_id = api_key.id
self.callable_models = callable_models
async def get_api_key_dependency(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_db),
) -> ApiKeyContext:
"""FastAPI Dependency: 验证 API Key 并返回上下文。
验证流程:
1. 提取 Bearer <REDACTED>
2. SHA-256 哈希后查询数据库
3. 检查 is_active、deleted_at
4. 检查有效期 (valid_from, valid_until)
5. 检查配额 (quota_limit, quota_used)
6. 重置过期周期的配额
"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="缺少 Authorization 头,请提供 Bearer <REDACTED>",
)
token_hash = hashlib.sha256(credentials.credentials.encode()).hexdigest()
result = await db.execute(
select(ApiKey).where(
ApiKey.api_key_hash == token_hash,
ApiKey.is_active == True,
ApiKey.deleted_at.is_(None),
).limit(1)
)
key = result.scalar_one_or_none()
if not key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 API Key",
)
now = datetime.now(timezone.utc)
# 检查有效期
if key.valid_from and now < key.valid_from:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API Key 尚未生效",
)
if key.valid_until and now >= key.valid_until:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API Key 已过期",
)
# 配额周期重置
from app.services.api_v3.key_service import reset_quota_if_needed
key = await reset_quota_if_needed(db, key)
# 检查配额
if key.quota_limit is not None and key.quota_used >= key.quota_limit:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"API Key 配额已用尽 (已用 {key.quota_used:.2f} / 限额 {key.quota_limit:.2f})",
)
# 解析可调用模型
try:
callable_models = json.loads(key.callable_models) if key.callable_models else []
except (json.JSONDecodeError, TypeError):
callable_models = []
# 更新最后使用时间
key.last_used_at = now
return ApiKeyContext(api_key=key, callable_models=callable_models)
@@ -0,0 +1,132 @@
import json
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.image_engine import ImageEngine
from app.models.video_engine import VideoEngine
logger = logging.getLogger("videogen")
async def resolve_video_engine(
db: AsyncSession,
engine_id: str,
callable_models: list[dict],
) -> VideoEngine:
"""根据 engine_id 解析视频引擎,并验证是否在 api-key 的可调用列表中。"""
# 验证授权
allowed = {m["engine_id"] for m in callable_models if m.get("engine_type") == "video"}
if engine_id not in allowed:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"该 API Key 无权使用引擎 {engine_id}",
)
result = await db.execute(
select(VideoEngine).where(
VideoEngine.id == engine_id,
VideoEngine.is_active == True,
VideoEngine.deleted_at.is_(None),
).limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"视频引擎 {engine_id} 不存在或未启用",
)
return engine
async def resolve_image_engine(
db: AsyncSession,
engine_id: str,
callable_models: list[dict],
) -> ImageEngine:
"""根据 engine_id 解析图片引擎,并验证是否在 api-key 的可调用列表中。"""
allowed = {m["engine_id"] for m in callable_models if m.get("engine_type") == "image"}
if engine_id not in allowed:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"该 API Key 无权使用引擎 {engine_id}",
)
result = await db.execute(
select(ImageEngine).where(
ImageEngine.id == engine_id,
ImageEngine.is_active == True,
ImageEngine.deleted_at.is_(None),
).limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"图片引擎 {engine_id} 不存在或未启用",
)
return engine
def build_engine_snapshot(engine: VideoEngine | ImageEngine) -> dict:
"""构建引擎配置快照。"""
snapshot = {
"id": str(engine.id),
"name": str(engine.name),
"provider": str(getattr(engine, "provider", "")),
"api_base": str(engine.api_base),
"model_name": str(engine.model_name),
}
# 可选字段
for field in [
"supported_ratios", "supported_resolutions", "supported_durations",
"default_size", "multi_generation_enabled", "max_generation_count",
]:
val = getattr(engine, field, None)
if val is not None:
if isinstance(val, str):
try:
val = json.loads(val)
except (json.JSONDecodeError, TypeError):
pass
snapshot[field] = val
return snapshot
async def resolve_engine_by_model_name(
db: AsyncSession,
model_name: str,
callable_models: list[dict],
engine_type: str,
) -> tuple[str, VideoEngine | ImageEngine]:
"""根据模型名称查找对应的引擎。
Returns:
(engine_id, engine 对象)
"""
# 在 callable_models 中查找
target = None
for m in callable_models:
if m.get("model_name") == model_name and m.get("engine_type") == engine_type:
target = m
break
if not target:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"该 API Key 无权使用模型 {model_name}",
)
engine_id = target["engine_id"]
if engine_type == "video":
engine = await resolve_video_engine(db, engine_id, callable_models)
else:
engine = await resolve_image_engine(db, engine_id, callable_models)
return engine_id, engine
@@ -0,0 +1,148 @@
"""API v3 文件下载服务。
下载用户提供的图片/视频/音频到本地存储。
"""
import base64
import logging
import os
import re
import uuid
from datetime import datetime
from urllib.parse import urlparse
import httpx
from app.config import settings
logger = logging.getLogger("videogen")
def _get_date_str() -> str:
"""获取当前日期字符串。"""
return datetime.now().strftime("%Y%m%d")
def _get_uploads_dir() -> str:
"""获取上传文件存储目录。"""
upload_dir = os.path.join(os.path.dirname(settings.STORAGE_LOCAL_PATH), "uploads", "api")
os.makedirs(upload_dir, exist_ok=True)
return upload_dir
async def download_file_from_url(url: str, sub_dir: str = "") -> str:
"""从 URL 下载文件到本地。
Args:
url: 文件 URL
sub_dir: 子目录(如 images/videos/audios
Returns:
相对路径: /uploads/api/{sub_dir}/{date}/{filename}
"""
upload_dir = _get_uploads_dir()
date_str = _get_date_str()
# 创建目标目录
dest_dir = os.path.join(upload_dir, sub_dir, date_str)
os.makedirs(dest_dir, exist_ok=True)
# 从 URL 提取扩展名
parsed = urlparse(url)
path = parsed.path
ext = os.path.splitext(path)[1].lower()
if not ext or len(ext) > 10:
ext = ".bin" # 默认扩展名
# 生成唯一文件名
filename = f"{uuid.uuid4().hex}{ext}"
dest_path = os.path.join(dest_dir, filename)
# 下载文件
async with httpx.AsyncClient(timeout=300, follow_redirects=True) as client:
async with client.stream("GET", 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)
# 返回相对路径
rel_path = f"/uploads/api/{sub_dir}/{date_str}/{filename}"
logger.info("Downloaded file: %s -> %s", url[:80], rel_path)
return rel_path
def save_base64_file(data: str, sub_dir: str = "") -> str:
"""保存 Base64 编码的文件到本地。
Args:
data: Base64 编码的数据(可包含 data:...;base64, 前缀)
sub_dir: 子目录
Returns:
相对路径: /uploads/api/{sub_dir}/{date}/{filename}
"""
upload_dir = _get_uploads_dir()
date_str = _get_date_str()
# 创建目标目录
dest_dir = os.path.join(upload_dir, sub_dir, date_str)
os.makedirs(dest_dir, exist_ok=True)
# 解析 Base64 数据
if "," in data:
header, b64_data = data.split(",", 1)
# 从 header 提取 MIME 类型
mime_match = re.search(r"data:([^;]+)", header)
mime_type = mime_match.group(1) if mime_match else "application/octet-stream"
# 根据 MIME 类型确定扩展名
ext_map = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
"video/mp4": ".mp4",
"video/webm": ".webm",
"audio/mpeg": ".mp3",
"audio/wav": ".wav",
"audio/ogg": ".ogg",
}
ext = ext_map.get(mime_type, ".bin")
else:
b64_data = data
ext = ".bin"
# 解码并保存
try:
file_data = base64.b64decode(b64_data)
except Exception as exc:
raise ValueError(f"Base64 解码失败: {exc}")
filename = f"{uuid.uuid4().hex}{ext}"
dest_path = os.path.join(dest_dir, filename)
with open(dest_path, "wb") as f:
f.write(file_data)
rel_path = f"/uploads/api/{sub_dir}/{date_str}/{filename}"
logger.info("Saved base64 file: %s (%d bytes)", rel_path, len(file_data))
return rel_path
async def process_media_url(url: str, media_type: str) -> str:
"""处理媒体 URL:下载到本地或保存 Base64。
Args:
url: URL 或 Base64 数据
media_type: image / video / audio
Returns:
相对路径: /uploads/api/{type}/{date}/{filename}
"""
sub_dir = {"image": "images", "video": "videos", "audio": "audios"}.get(media_type, "files")
# 判断是 Base64 还是 URL
if url.startswith("data:"):
return save_base64_file(url, sub_dir)
else:
return await download_file_from_url(url, sub_dir)
@@ -0,0 +1,494 @@
import asyncio
import json
import logging
import time
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_key import ApiKey
from app.schemas.api_v3.image import ApiImageGenerateRequest, ApiImageGenerateResponse, ApiImageGenerateDataItem
from app.schemas.api_v3.video import ApiVideoCreateRequest, ApiVideoCreateResponse
from app.services.api_v3 import task_service, engine_service, upscale_service
from app.services.api_v3.quota_service import can_start_video_task
from app.services.api_v3.pricing_service import calc_api_video_price, calc_api_image_price, PricingNotConfiguredError
from app.services.api_v3.logging_service import log_model_request
logger = logging.getLogger("videogen")
async def submit_video_generation(
db: AsyncSession,
key: ApiKey,
callable_models: list[dict],
req: ApiVideoCreateRequest,
) -> ApiVideoCreateResponse:
"""提交视频生成任务(异步)。
流程:
1. 检查并发视频任务数
2. 解析引擎
3. 构建超分快照
4. 创建任务记录
5. 入队 Celery 任务
6. 返回 task_id
"""
# 1. 检查是否可以立即启动(并发限制)
can_start = await can_start_video_task(key, db)
# 2. 解析引擎
engine_id, engine = await engine_service.resolve_engine_by_model_name(
db, req.model, callable_models, "video"
)
engine_snapshot = engine_service.build_engine_snapshot(engine)
# 3. 构建超分快照
provider_resolution, upscale_enabled, upscale_snapshot_json = await upscale_service.build_api_upscale_snapshot(
db, key.id, req.resolution or "480p", req.ratio
)
# 如果超分要求不同的生成分辨率,使用超分的
final_provider_resolution = provider_resolution or req.resolution or "480p"
def _get_max_supported_duration(engine) -> int | None:
"""从引擎 supported_durations 获取最大时长。"""
try:
durations = json.loads(engine.supported_durations) if engine.supported_durations else []
return max(durations) if durations else engine.max_duration
except (json.JSONDecodeError, TypeError, ValueError):
return engine.max_duration
# 3.5 验证传入的媒体文件是否符合引擎配置要求
from app.services.api_v3.file_service import process_media_url
from app.services.video_upscale.media_service import probe_video
from fastapi import HTTPException, status # noqa: F401
input_image_count = 0
input_video_count = 0
input_audio_count = 0
input_video_duration = 0.0
local_media_refs = [] # 存储本地路径
# 统计各类媒体数量
for p in req.content:
ptype = p.type
if ptype == "image_url":
input_image_count += 1
elif ptype == "video_url":
input_video_count += 1
elif ptype == "audio_url":
input_audio_count += 1
# 视频引擎校验(本函数仅处理视频生成)
# 校验图片数量限制
if input_image_count > (engine.max_image_count or 0):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持 {engine.max_image_count} 张参考图片,当前传入 {input_image_count}",
)
# 校验视频数量限制
if input_video_count > (engine.max_video_count or 0):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持 {engine.max_video_count} 个参考视频,当前传入 {input_video_count}",
)
# 校验音频数量限制
if input_audio_count > (engine.max_audio_count or 0):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持 {engine.max_audio_count} 个参考音频,当前传入 {input_audio_count}",
)
for p in req.content:
ptype = p.type
if ptype == "text":
local_media_refs.append(p.model_dump(exclude_none=True))
continue
original_url = ""
if ptype == "image_url" and p.image_url:
original_url = p.image_url.get("url", "")
elif ptype == "video_url" and p.video_url:
original_url = p.video_url.get("url", "")
elif ptype == "audio_url" and p.audio_url:
original_url = p.audio_url.get("url", "")
# 下载文件到本地
try:
local_path = await process_media_url(original_url, ptype.replace("_url", ""))
except Exception as exc:
logger.warning("Failed to download media %s: %s", original_url[:80], exc)
local_path = original_url
# 如果是视频/音频,探测实际时长并校验
if ptype in ("video_url", "audio_url") and local_path:
try:
media_info = await probe_video(local_path)
if media_info and media_info.duration_seconds:
duration = media_info.duration_seconds
# 校验最低时长(2秒)
if duration < 2.0:
raise HTTPException(
status_code=400,
detail=f"上传的{ptype.replace('_url', '')}时长不能低于2秒,当前时长: {duration:.1f}",
)
# 校验最高时长(根据引擎 supported_durations 最大值)
max_duration = _get_max_supported_duration(engine)
if max_duration and duration > max_duration:
raise HTTPException(
status_code=400,
detail=f"上传的{ptype.replace('_url', '')}时长不能超过{max_duration}秒,当前时长: {duration:.1f}",
)
if ptype == "video_url":
input_video_duration += duration
except HTTPException:
raise
except Exception as exc:
logger.warning("Failed to probe media duration: %s", exc)
local_media_refs.append({
"type": ptype,
ptype: {"url": local_path},
"role": p.role,
})
# 3.6 计算价格(基于实际探测的视频时长)
try:
estimated_price = await calc_api_video_price(
db,
duration=req.duration or 5,
resolution=req.resolution or "480p",
engine_id=engine_id,
input_video_duration=input_video_duration,
input_image_count=input_image_count,
)
except PricingNotConfiguredError as exc:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
# 预检配额
if key.quota_limit is not None and key.quota_used + estimated_price > key.quota_limit:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配额不足 (需要 {estimated_price:.2f} 元, 剩余 {key.quota_limit - key.quota_used:.2f} 元)",
)
# 预扣配额
if estimated_price > 0:
key.quota_used = round((key.quota_used or 0.0) + estimated_price, 2)
# 4. 创建任务记录(幂等性已在路由层检查)
content_dicts = [p.model_dump(exclude_none=True) for p in req.content]
task = await task_service.create_video_task(
db=db,
api_key_id=key.id,
model_name=req.model,
engine_id=engine_id,
engine_snapshot=engine_snapshot,
content=content_dicts,
ratio=req.ratio,
duration=req.duration,
resolution=req.resolution,
provider_generation_resolution=final_provider_resolution,
upscale_enabled=upscale_enabled,
upscale_snapshot_json=upscale_snapshot_json,
idempotency_key=req.idempotency_key,
local_media_refs=local_media_refs,
)
task.credits_cost = estimated_price # 记录预扣金额
# 根据并发限制决定立即执行还是排队
if can_start:
# 立即执行
task.status = "pending"
task.pipeline_stage = "queued"
else:
# 排队等待
task.status = "queued"
task.pipeline_stage = "waiting_concurrency"
await db.commit()
# 提交时即记录使用日志(配额已预扣)
try:
from app.services.api_v3.usage_log_service import record_usage
quota_before = key.quota_used - estimated_price # 扣减前的余额
quota_after = key.quota_used # 扣减后的余额
price_detail = {
"base_price": getattr(locals(), "base_price", 0),
"per_second_price": getattr(locals(), "per_second_price", 0),
"duration": req.duration,
"resolution": req.resolution,
"ratio": req.ratio,
"total": estimated_price,
}
await record_usage(
db=db,
api_key_id=key.id,
request_type="video_create",
model_name=req.model,
gen_type="video",
status="success",
task_id=task.id,
credits_cost=estimated_price,
price_action="deduct",
resolution=req.resolution,
duration=req.duration,
quota_before=quota_before,
quota_after=quota_after,
price_detail_json=json.dumps(price_detail, ensure_ascii=False),
)
except Exception as log_exc:
logger.error("Failed to record usage on submit: %s", log_exc)
# 记录模型调用日志
log_model_request(
engine_id=engine_id,
model_name=req.model,
task_id=task.id,
params={
"ratio": req.ratio,
"duration": req.duration,
"resolution": req.resolution,
"generate_audio": req.generate_audio,
"watermark": req.watermark,
"content_count": len(req.content),
"queued": not can_start,
},
)
# 5. 只有立即执行的才入队 Celery
if can_start:
from app.tasks.api_generation_tasks import api_create_generation_task
api_create_generation_task.apply_async(
args=[task.id],
queue="gen_api_create",
)
status_str = "queued" if can_start else "pending_queue"
logger.info("API video task created: task_id=%s model=%s key=%s price=%.2f status=%s", task.id, req.model, key.id, estimated_price, status_str)
return ApiVideoCreateResponse(id=task.id)
async def generate_image_sync(
db: AsyncSession,
key: ApiKey,
callable_models: list[dict],
req: "ApiImageGenerateRequest",
start_time: float,
) -> ApiImageGenerateResponse:
"""同步生成图片。
流程:
1. 解析引擎
2. 创建任务记录
3. 调用 Volcano Ark SDK(同步)
4. 下载图片
5. 更新任务状态
6. 记录使用日志
7. 返回结果
"""
from app.services.api_v3.usage_log_service import record_usage
# 1. 解析引擎
engine_id, engine = await engine_service.resolve_engine_by_model_name(
db, req.model, callable_models, "image"
)
engine_snapshot = engine_service.build_engine_snapshot(engine)
# 2. 创建任务记录
task = await task_service.create_image_task(
db=db,
api_key_id=key.id,
model_name=req.model,
engine_id=engine_id,
engine_snapshot=engine_snapshot,
prompt=req.prompt,
size=req.size,
)
# 2.5 验证传入的媒体文件是否符合引擎配置要求
# 校验参考图片数量限制
input_image_count = len(req.image) if req.image else 0
if input_image_count > (engine.max_reference_image_count or 0):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持 {engine.max_reference_image_count} 张参考图片,当前传入 {input_image_count}",
)
# 校验组图数量限制
generation_count = req.generation_count or 1
if generation_count > (engine.multi_image_max_images or 1):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持生成 {engine.multi_image_max_images} 张图片,当前请求 {generation_count}",
)
try:
estimated_price = await calc_api_image_price(
db,
image_size=req.size or "2K",
engine_id=engine_id,
input_image_count=input_image_count,
)
except PricingNotConfiguredError as exc:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
# 预检配额
if key.quota_limit is not None and key.quota_used + estimated_price > key.quota_limit:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配额不足 (需要 {estimated_price:.2f} 元, 剩余 {key.quota_limit - key.quota_used:.2f} 元)",
)
# 预扣配额
if estimated_price > 0:
key.quota_used = round((key.quota_used or 0.0) + estimated_price, 2)
task.credits_cost = estimated_price
await db.commit()
# 提交时即记录使用日志(配额已预扣)
try:
quota_before = key.quota_used - estimated_price
quota_after = key.quota_used
price_detail = {
"base_price": getattr(locals(), "base_price", 0),
"size": req.size,
"generation_count": req.generation_count or 1,
"total": estimated_price,
}
await record_usage(
db=db,
api_key_id=key.id,
request_type="image_generate",
model_name=req.model,
gen_type="image",
status="success",
task_id=task.id,
credits_cost=estimated_price,
price_action="deduct",
resolution=req.size,
quota_before=quota_before,
quota_after=quota_after,
price_detail_json=json.dumps(price_detail, ensure_ascii=False),
)
except Exception as log_exc:
logger.error("Failed to record usage on image submit: %s", log_exc)
try:
# 3. 调用 Volcano Ark SDK(同步函数,在线程中执行)
from app.services.image_gen import submit_image_task, download_image
from app.config import settings
import os
# 构建 media_references,下载图片到本地
from app.services.api_v3.file_service import process_media_url
image_refs = []
if req.image:
for url in req.image:
try:
local_path = await process_media_url(url, "image")
except Exception as exc:
logger.warning("Failed to download image %s: %s", url[:80], exc)
local_path = url
image_refs.append({"type": "image", "url": local_path})
# 临时设置 media_references
task.media_references = json.dumps(image_refs, ensure_ascii=False) if image_refs else None
task.image_size = req.size or "2K"
await db.flush()
# 在线程中执行同步 SDK 调用
result = await asyncio.to_thread(
submit_image_task,
db,
engine,
task,
True, # include_media_references
req.generation_count or 1,
)
# 4. 下载图片
items = result.get("items", [])
downloaded_items: list[ApiImageGenerateDataItem] = []
for item in items:
url = item.get("remote_result_url")
if url:
# 下载到本地
date_dir = datetime.now().strftime("%Y%m%d")
dest_dir = f"./storage/generate/api/images/{date_dir}"
os.makedirs(dest_dir, exist_ok=True)
dest_path = os.path.join(dest_dir, f"{task.id}_{item.get('generation_index', 1)}.png")
try:
await download_image(url, dest_path)
except Exception as dl_err:
logger.warning("Image download failed: %s", dl_err)
downloaded_items.append(ApiImageGenerateDataItem(
url=dest_path, # 使用本地路径
size=item.get("size"),
output_format=item.get("output_format"),
))
elif item.get("error_message"):
downloaded_items.append(ApiImageGenerateDataItem(
url=None,
))
# 5. 使用预扣金额(不再重复扣减)
task.credits_cost = estimated_price
# 6. 更新任务状态
task.status = "completed"
task.pipeline_stage = "done"
task.generated_at = datetime.now(timezone.utc)
if downloaded_items and downloaded_items[0].url:
task.image_url = downloaded_items[0].url
await db.commit()
# 提交时已记录使用日志,成功时无需重复记录
duration_ms = int((time.perf_counter() - start_time) * 1000)
return ApiImageGenerateResponse(
created=result.get("created", int(time.time())),
data=downloaded_items,
model=result.get("model", req.model),
)
except Exception as exc:
# 失败:退回预扣配额
if estimated_price > 0:
key.quota_used = round(max(0, (key.quota_used or 0.0) - estimated_price), 2)
task.status = "failed"
task.error_message = str(exc)
task.credits_cost = 0 # 实际消耗为0(已退回)
await db.commit()
duration_ms = int((time.perf_counter() - start_time) * 1000)
quota_after_refund = key.quota_used # 退回后的余额
await record_usage(
db=db,
api_key_id=key.id,
request_type="image_generate",
model_name=req.model,
gen_type="image",
status="failed",
task_id=task.id,
credits_cost=estimated_price,
refund_amount=estimated_price,
request_duration_ms=duration_ms,
error_message=str(exc),
error_code="generation_failed",
price_action="refund",
resolution=req.size,
generation_count=req.generation_count or 1,
quota_before=quota_after_refund,
quota_after=quota_after_refund + estimated_price,
)
await db.commit()
raise
@@ -0,0 +1,151 @@
import hashlib
import json
import logging
import secrets
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_key import ApiKey
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
API_KEY_PREFIX = "vk_"
async def create_api_key(
db: AsyncSession,
company_name: str,
callable_models: list[dict] | None = None,
quota_limit: float | None = None,
quota_cycle: str | None = None,
valid_from: datetime | None = None,
valid_until: datetime | None = None,
max_concurrent_video_tasks: int | None = None,
description: str | None = None,
) -> tuple[ApiKey, str]:
"""创建新的 API Key。
Returns:
(ApiKey 对象, 明文 API Key) — 明文仅返回这一次。
"""
# 生成密钥: vk_ + 32字节随机hex
raw_key = API_KEY_PREFIX + secrets.token_hex(24) # vk_ + 48位hex = 51字符
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
key_prefix = raw_key[:8] # 前8位用于展示: vk_xxxxx
api_key = ApiKey(
id=generate_id(),
company_name=company_name,
api_key_hash=key_hash,
api_key_prefix=key_prefix,
description=description,
callable_models=json.dumps(callable_models or [], ensure_ascii=False),
quota_limit=quota_limit,
quota_cycle=quota_cycle,
quota_used=0.0,
valid_from=valid_from,
valid_until=valid_until,
max_concurrent_video_tasks=max_concurrent_video_tasks,
is_active=True,
)
api_key.set_plaintext_key(raw_key) # 加密存储完整 Key
db.add(api_key)
await db.flush()
logger.info("API Key created: id=%s company=%s prefix=%s", api_key.id, company_name, key_prefix)
return api_key, raw_key
async def list_api_keys(
db: AsyncSession,
skip: int = 0,
limit: int = 50,
company_name: str | None = None,
is_active: bool | None = None,
) -> tuple[int, list[ApiKey]]:
"""列出 API Key(分页+筛选)。"""
from sqlalchemy import func
query = select(ApiKey).where(ApiKey.deleted_at.is_(None))
count_query = select(func.count(ApiKey.id)).where(ApiKey.deleted_at.is_(None))
if company_name:
query = query.where(ApiKey.company_name.ilike(f"%{company_name}%"))
count_query = count_query.where(ApiKey.company_name.ilike(f"%{company_name}%"))
if is_active is not None:
query = query.where(ApiKey.is_active == is_active)
count_query = count_query.where(ApiKey.is_active == is_active)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
query = query.order_by(ApiKey.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
keys = list(result.scalars().all())
return total, keys
async def get_api_key(db: AsyncSession, key_id: str) -> ApiKey | None:
"""获取单个 API Key 详情。"""
result = await db.execute(
select(ApiKey).where(ApiKey.id == key_id, ApiKey.deleted_at.is_(None)).limit(1)
)
return result.scalar_one_or_none()
async def update_api_key(db: AsyncSession, key: ApiKey, **kwargs) -> ApiKey:
"""更新 API Key 配置。"""
updatable_fields = {
"company_name", "description", "callable_models",
"quota_limit", "quota_cycle", "valid_from", "valid_until",
"max_concurrent_video_tasks", "is_active",
}
for field, value in kwargs.items():
if field in updatable_fields and value is not None:
if field == "callable_models" and isinstance(value, list):
value = json.dumps(value, ensure_ascii=False)
setattr(key, field, value)
await db.flush()
return key
async def delete_api_key(db: AsyncSession, key: ApiKey) -> None:
"""软删除 API Key。"""
key.deleted_at = datetime.now(timezone.utc)
key.is_active = False
await db.flush()
async def reset_quota_if_needed(db: AsyncSession, key: ApiKey) -> ApiKey:
"""检查并重置过期周期的配额。
- daily: 如果上次重置不是今天,重置 quota_used=0
- monthly: 如果上次重置不是本月,重置 quota_used=0
"""
if key.quota_limit is None or key.quota_cycle is None:
return key
now = datetime.now(timezone.utc)
# 使用 quota_used 的 updated_at 作为周期判断依据
last_reset = key.updated_at or key.created_at
if last_reset is None:
return key
should_reset = False
if key.quota_cycle == "daily":
should_reset = last_reset.date() < now.date()
elif key.quota_cycle == "monthly":
should_reset = (last_reset.year, last_reset.month) < (now.year, now.month)
if should_reset and key.quota_used > 0:
key.quota_used = 0.0
await db.flush()
logger.info("Quota reset for API Key %s (cycle=%s)", key.id, key.quota_cycle)
return key
@@ -0,0 +1,187 @@
"""外部 API v3 日志服务。
按天分类存储在 log/api/ 目录下:
- log/api/requests/YYYY-MM-DD.log — 所有外部请求和响应
- log/api/models/YYYY-MM-DD.log — 模型调用(Volcano Ark SDK
- log/api/upscale/YYYY-MM-DD.log — 超分轮询
- log/api/errors/YYYY-MM-DD.log — 错误日志
"""
import json
import logging
import os
from datetime import datetime, timezone
# === 日志目录 ===
# video-gen-api/log/api/
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
# 上溯3级: services/api_v3 -> services -> app -> video-gen-api (即项目根目录)
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(_THIS_DIR)))
BASE_LOG_DIR = os.path.join(_BASE_DIR, "log", "api")
os.makedirs(BASE_LOG_DIR, exist_ok=True)
# 子目录
REQUESTS_LOG_DIR = os.path.join(BASE_LOG_DIR, "requests")
MODELS_LOG_DIR = os.path.join(BASE_LOG_DIR, "models")
UPSCALE_LOG_DIR = os.path.join(BASE_LOG_DIR, "upscale")
ERRORS_LOG_DIR = os.path.join(BASE_LOG_DIR, "errors")
for d in [REQUESTS_LOG_DIR, MODELS_LOG_DIR, UPSCALE_LOG_DIR, ERRORS_LOG_DIR]:
os.makedirs(d, exist_ok=True)
def _get_date_str() -> str:
"""获取当前日期字符串。"""
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
class _DailyFileHandler(logging.Handler):
"""按天写入的日志处理器。"""
def __init__(self, log_dir: str):
super().__init__()
self.log_dir = log_dir
self._current_date = None
self._file_handler = None
self._open_file()
def _open_file(self):
"""打开当天的日志文件。"""
date_str = _get_date_str()
if date_str == self._current_date and self._file_handler:
return
if self._file_handler:
self._file_handler.close()
self._current_date = date_str
filepath = os.path.join(self.log_dir, f"{date_str}.log")
self._file_handler = logging.FileHandler(filepath, encoding="utf-8")
self._file_handler.setFormatter(
logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
)
def emit(self, record):
try:
self._open_file()
self._file_handler.emit(record)
except Exception:
self.handleError(record)
def close(self):
if self._file_handler:
self._file_handler.close()
super().close()
def _create_logger(name: str, log_dir: str) -> logging.Logger:
"""创建按天写入的 Logger。"""
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
# 避免重复添加 handler
if not logger.handlers:
handler = _DailyFileHandler(log_dir)
logger.addHandler(handler)
return logger
# === Logger 实例 ===
requests_logger = _create_logger("api_v3.requests", REQUESTS_LOG_DIR)
models_logger = _create_logger("api_v3.models", MODELS_LOG_DIR)
upscale_logger = _create_logger("api_v3.upscale", UPSCALE_LOG_DIR)
errors_logger = _create_logger("api_v3.errors", ERRORS_LOG_DIR)
def _safe_json(obj) -> str:
"""安全地序列化为 JSON。"""
try:
return json.dumps(obj, ensure_ascii=False, default=str)
except Exception:
return str(obj)
# === 请求/响应日志 ===
def log_request(method: str, path: str, api_key_id: str, body: dict | None = None):
"""记录外部请求。"""
requests_logger.info(
f"REQUEST | {method} {path} | key={api_key_id} | body={_safe_json(body)}"
)
def log_response(method: str, path: str, api_key_id: str, status_code: int, body=None, duration_ms: int = 0):
"""记录外部响应。"""
requests_logger.info(
f"RESPONSE | {method} {path} | key={api_key_id} | status={status_code} | duration={duration_ms}ms | body={_safe_json(body)}"
)
def log_request_error(method: str, path: str, api_key_id: str, error: str, status_code: int = 500):
"""记录请求错误。"""
errors_logger.error(
f"REQUEST_ERROR | {method} {path} | key={api_key_id} | status={status_code} | error={error}"
)
# === 模型调用日志 ===
def log_model_request(engine_id: str, model_name: str, task_id: str, params: dict):
"""记录模型调用请求。"""
models_logger.info(
f"MODEL_REQUEST | engine={engine_id} | model={model_name} | task={task_id} | params={_safe_json(params)}"
)
def log_model_response(engine_id: str, model_name: str, task_id: str, success: bool, result: dict | None = None, error: str | None = None):
"""记录模型调用响应。"""
if success:
models_logger.info(
f"MODEL_RESPONSE | engine={engine_id} | model={model_name} | task={task_id} | success | result={_safe_json(result)}"
)
else:
models_logger.error(
f"MODEL_RESPONSE | engine={engine_id} | model={model_name} | task={task_id} | failed | error={error}"
)
errors_logger.error(
f"MODEL_ERROR | engine={engine_id} | model={model_name} | task={task_id} | error={error}"
)
# === 超分轮询日志 ===
def log_upscale_poll_start(task_id: str, api_task_id: str):
"""记录超分轮询开始。"""
upscale_logger.info(f"UPSCALE_POLL_START | task={task_id} | api_task={api_task_id}")
def log_upscale_poll(task_id: str, api_task_id: str, status: str, attempt: int, result: dict | None = None):
"""记录超分轮询状态。"""
upscale_logger.info(
f"UPSCALE_POLL | task={task_id} | api_task={api_task_id} | status={status} | attempt={attempt} | result={_safe_json(result)}"
)
def log_upscale_poll_end(task_id: str, api_task_id: str, success: bool, final_status: str, total_attempts: int):
"""记录超分轮询结束。"""
if success:
upscale_logger.info(
f"UPSCALE_POLL_END | task={task_id} | api_task={api_task_id} | success | status={final_status} | attempts={total_attempts}"
)
else:
upscale_logger.error(
f"UPSCALE_POLL_END | task={task_id} | api_task={api_task_id} | failed | status={final_status} | attempts={total_attempts}"
)
errors_logger.error(
f"UPSCALE_ERROR | task={task_id} | api_task={api_task_id} | status={final_status} | attempts={total_attempts}"
)
# === 通用错误日志 ===
def log_error(category: str, message: str, details: dict | None = None):
"""记录通用错误。"""
errors_logger.error(
f"{category} | {message} | details={_safe_json(details)}"
)
@@ -0,0 +1,173 @@
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_model_pricing import ApiModelPricing
from app.models.image_engine import ImageEngine
from app.models.video_engine import VideoEngine
logger = logging.getLogger("videogen")
class PricingNotConfiguredError(Exception):
"""模型+分辨率组合未配置价格。"""
def __init__(self, *, model_name: str, resolution: str):
self.model_name = model_name
self.resolution = resolution
super().__init__(
f"模型或引擎 '{self.model_name}' 在分辨率 '{self.resolution}' 下未配置,无法生成"
)
async def resolve_engine_display_name(db: AsyncSession, engine_id: str) -> str:
"""根据引擎 ID 解析展示名称(找不到时原样返回 ID)。"""
if not engine_id:
return engine_id or "unknown"
result = await db.execute(
select(VideoEngine.name).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
)
name = result.scalar_one_or_none()
if name:
return name
result = await db.execute(
select(ImageEngine.name).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
)
name = result.scalar_one_or_none()
return name or engine_id
async def _get_api_pricing(
db: AsyncSession,
*,
gen_type: str,
resolution: str,
engine_id: str | None = None,
) -> ApiModelPricing | None:
"""按引擎精确规则优先获取定价;找不到时回退到同类型同分辨率。
查询优先级:
1. gen_type + engine_id + resolution 精确规则
2. gen_type + resolution 下 base_price 最高规则
"""
gen_type = (gen_type or "").lower().strip()
resolution = (resolution or "").strip()
engine_id = (engine_id or "").strip() or None
if engine_id:
result = await db.execute(
select(ApiModelPricing)
.where(ApiModelPricing.gen_type == gen_type)
.where(ApiModelPricing.model_config_id == engine_id)
.where(ApiModelPricing.resolution == resolution)
.order_by(ApiModelPricing.base_price.desc(), ApiModelPricing.per_second_price.desc())
.limit(1)
)
pricing = result.scalar_one_or_none()
if pricing:
return pricing
result = await db.execute(
select(ApiModelPricing)
.where(ApiModelPricing.gen_type == gen_type)
.where(ApiModelPricing.resolution == resolution)
.order_by(ApiModelPricing.base_price.desc(), ApiModelPricing.per_second_price.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def calc_api_video_price(
db: AsyncSession,
duration: int,
resolution: str,
engine_id: str | None = None,
input_video_duration: float = 0,
input_image_count: int = 0,
) -> float:
"""计算 API 视频生成价格(元)。
未配置价格时抛出 PricingNotConfiguredError。
公式(与 credit_ratios 一致):
base_cost = (base_price + per_second_price × duration) × price_ratio
if 传入视频: += (input_video_base_price + input_video_per_second_price × input_video_duration) × input_video_ratio
if 传入图片: += (input_image_base_price + input_image_per_image_price × input_image_count) × input_image_ratio
"""
if not engine_id:
result = await db.execute(
select(VideoEngine.id)
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
.order_by(VideoEngine.priority.desc())
.limit(1)
)
engine_id = result.scalar_one_or_none()
pricing = await _get_api_pricing(db, gen_type="video", resolution=resolution, engine_id=engine_id)
if not pricing:
raise PricingNotConfiguredError(
model_name=await resolve_engine_display_name(db, engine_id),
resolution=resolution,
)
# 基础价格
base_cost = (pricing.base_price + pricing.per_second_price * duration) * pricing.price_ratio
# 传入视频附加费(每秒 × 倍率)
if input_video_duration > 0:
base_cost += (pricing.input_video_base_price + pricing.input_video_per_second_price * input_video_duration) * pricing.input_video_ratio
# 传入图片附加费(每张 × 倍率)
if input_image_count > 0:
base_cost += (pricing.input_image_base_price + pricing.input_image_per_image_price * input_image_count) * pricing.input_image_ratio
return round(base_cost, 2)
async def calc_api_image_price(
db: AsyncSession,
image_size: str,
engine_id: str | None = None,
input_image_count: int = 0,
) -> float:
"""计算 API 图片生成价格(元)。
未配置价格时抛出 PricingNotConfiguredError。
公式(与 credit_ratios 一致):
base_cost = base_price × price_ratio
if 传入图片: += (input_image_base_price + input_image_per_image_price × input_image_count) × input_image_ratio
"""
if not engine_id:
result = await db.execute(
select(ImageEngine.id)
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
.order_by(ImageEngine.priority.desc())
.limit(1)
)
engine_id = result.scalar_one_or_none()
pricing = await _get_api_pricing(db, gen_type="image", resolution=image_size, engine_id=engine_id)
if not pricing:
raise PricingNotConfiguredError(
model_name=await resolve_engine_display_name(db, engine_id),
resolution=image_size,
)
# 基础价格
base_cost = pricing.base_price * pricing.price_ratio
# 传入图片附加费(每张 × 倍率)
if input_image_count > 0:
base_cost += (pricing.input_image_base_price + pricing.input_image_per_image_price * input_image_count) * pricing.input_image_ratio
return round(base_cost, 2)
async def get_priced_models(db: AsyncSession) -> set[str]:
"""获取所有已配置价格的引擎 ID 集合。
用于过滤 /api/v3/models 接口,仅返回已定价的模型。
"""
result = await db.execute(
select(ApiModelPricing.model_config_id).distinct()
)
return {row[0] for row in result.all()}
@@ -0,0 +1,68 @@
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_generation_task import ApiGenerationTask
from app.models.api.api_key import ApiKey
logger = logging.getLogger("videogen")
async def check_quota(key: ApiKey) -> bool:
"""检查 API Key 配额是否充足。
Returns:
True = 配额充足或无限额, False = 已超限。
"""
if key.quota_limit is None:
return True
return key.quota_used < key.quota_limit
async def get_active_video_tasks_count(api_key_id: str, db: AsyncSession) -> int:
"""统计 API Key 当前活跃的视频任务数。
活跃 = status IN ('pending', 'generating', 'processing') AND gen_type='video'
"""
result = await db.execute(
select(func.count(ApiGenerationTask.id)).where(
ApiGenerationTask.api_key_id == api_key_id,
ApiGenerationTask.gen_type == "video",
ApiGenerationTask.status.in_(["pending", "generating", "processing"]),
ApiGenerationTask.deleted_at.is_(None),
)
)
return result.scalar_one() or 0
async def can_start_video_task(key: ApiKey, db: AsyncSession) -> bool:
"""检查是否可以立即启动新的视频任务。
Returns:
True = 可以立即启动, False = 需要排队。
"""
if key.max_concurrent_video_tasks is None:
return True # 无限制
current = await get_active_video_tasks_count(key.id, db)
return current < key.max_concurrent_video_tasks
async def get_queued_video_tasks(key: ApiKey, db: AsyncSession, limit: int = 10) -> list[ApiGenerationTask]:
"""获取排队的视频任务列表(按创建时间排序)。"""
result = await db.execute(
select(ApiGenerationTask).where(
ApiGenerationTask.api_key_id == key.id,
ApiGenerationTask.gen_type == "video",
ApiGenerationTask.status == "queued",
ApiGenerationTask.deleted_at.is_(None),
).order_by(ApiGenerationTask.created_at.asc()).limit(limit)
)
return list(result.scalars().all())
async def increment_quota(db: AsyncSession, key: ApiKey, credits_cost: float) -> None:
"""原子性增加配额使用量。"""
key.quota_used = round((key.quota_used or 0.0) + credits_cost, 2)
await db.flush()
@@ -0,0 +1,215 @@
import json
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_generation_task import ApiGenerationTask
from app.schemas.api_v3.video import ApiVideoStatusResponse
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
async def create_video_task(
db: AsyncSession,
api_key_id: str,
model_name: str,
engine_id: str,
engine_snapshot: dict,
content: list[dict],
ratio: str | None,
duration: int | None,
resolution: str | None,
provider_generation_resolution: str | None,
upscale_enabled: bool,
upscale_snapshot_json: str | None,
idempotency_key: str | None = None,
local_media_refs: list[dict] | None = None,
) -> ApiGenerationTask:
"""创建视频生成任务记录。"""
# 提取文本提示词
text_parts = [p.get("text", "") for p in content if p.get("type") == "text"]
original_prompt = " ".join(text_parts) if text_parts else content[0].get("text", "") if content else ""
# 构建 media_references(扁平格式,便于外部读取)
# 构建 local_media_json(嵌套格式,与 Volcano SDK 兼容)
from app.services.api_v3.file_service import process_media_url
media_refs = [] # 扁平格式: {"type": "image", "url": "...", "role": "..."}
local_media_refs = [] # 本地下载路径(嵌套格式)
for p in content:
ptype = p.get("type", "")
if ptype == "text":
continue
# 提取原始 URL(从嵌套格式中提取)
original_url = ""
media_type = ptype.replace("_url", "") # image_url -> image
if ptype == "image_url" and p.get("image_url"):
original_url = p["image_url"].get("url", "")
elif ptype == "video_url" and p.get("video_url"):
original_url = p["video_url"].get("url", "")
elif ptype == "audio_url" and p.get("audio_url"):
original_url = p["audio_url"].get("url", "")
# 存储扁平格式到 media_references
media_refs.append({
"type": media_type,
"url": original_url,
"role": p.get("role"),
})
# 下载文件到本地
try:
local_path = await process_media_url(original_url, media_type)
except Exception as exc:
logger.warning("Failed to download media %s: %s", original_url[:80], exc)
local_path = original_url
# 本地路径使用嵌套格式(与 Volcano SDK 兼容)
local_media_refs.append({
"type": ptype,
ptype: {"url": local_path},
"role": p.get("role"),
})
media_references_json = json.dumps(media_refs, ensure_ascii=False) if media_refs else None
local_media_json = json.dumps(local_media_refs, ensure_ascii=False) if local_media_refs else None
now = datetime.now(timezone.utc)
deadline = now + timedelta(hours=24)
task = ApiGenerationTask(
id=generate_id(),
api_key_id=api_key_id,
external_idempotency_key=idempotency_key,
original_prompt=original_prompt,
gen_type="video",
model_name=model_name,
duration=duration,
aspect_ratio=ratio,
resolution=resolution,
provider_generation_resolution=provider_generation_resolution,
generation_count=1,
engine_id=engine_id,
media_references=media_references_json,
local_media_json=local_media_json,
engine_snapshot_json=json.dumps(engine_snapshot, ensure_ascii=False),
status="pending",
pipeline_stage="queued",
deadline_at=deadline,
video_upscale_enabled_snapshot=upscale_enabled,
video_upscale_snapshot_json=upscale_snapshot_json,
)
db.add(task)
await db.flush()
return task
async def create_image_task(
db: AsyncSession,
api_key_id: str,
model_name: str,
engine_id: str,
engine_snapshot: dict,
prompt: str,
size: str | None,
idempotency_key: str | None = None,
) -> ApiGenerationTask:
"""创建图片生成任务记录。"""
task = ApiGenerationTask(
id=generate_id(),
api_key_id=api_key_id,
external_idempotency_key=idempotency_key,
original_prompt=prompt,
gen_type="image",
image_size=size,
generation_count=1,
engine_id=engine_id,
engine_snapshot_json=json.dumps(engine_snapshot, ensure_ascii=False),
status="processing",
pipeline_stage="creating_provider_task",
)
db.add(task)
await db.flush()
return task
async def get_task(db: AsyncSession, task_id: str, api_key_id: str) -> ApiGenerationTask | None:
"""获取任务(带所有权验证)。"""
result = await db.execute(
select(ApiGenerationTask).where(
ApiGenerationTask.id == task_id,
ApiGenerationTask.api_key_id == api_key_id,
ApiGenerationTask.deleted_at.is_(None),
).limit(1)
)
return result.scalar_one_or_none()
async def find_by_idempotency_key(db: AsyncSession, api_key_id: str, idempotency_key: str) -> ApiGenerationTask | None:
"""根据幂等键查找已存在的任务。"""
result = await db.execute(
select(ApiGenerationTask).where(
ApiGenerationTask.api_key_id == api_key_id,
ApiGenerationTask.external_idempotency_key == idempotency_key,
ApiGenerationTask.deleted_at.is_(None),
).limit(1)
)
return result.scalar_one_or_none()
def map_task_to_status_response(task: ApiGenerationTask) -> ApiVideoStatusResponse:
"""将任务对象映射为状态查询响应。"""
from app.config import settings
# 返回完整 URL(包含 BASE_URL
video_url = _make_full_url(task.video_url)
video_cover_url = _make_full_url(task.video_cover_url)
return ApiVideoStatusResponse(
task_id=task.id,
status=_map_status(task.status),
video_url=video_url,
video_cover_url=video_cover_url,
duration=task.duration,
ratio=task.aspect_ratio,
resolution=task.resolution,
error=task.error_message,
created_at=task.created_at,
completed_at=task.generated_at,
)
def _make_full_url(path: str | None) -> str | None:
"""将本地路径转换为完整 URL。"""
if not path:
return None
from app.config import settings
# 如果已经是完整 URL,直接返回
if path.startswith(("http://", "https://")):
return path
# 处理 ./storage/generate/... 格式 → /generate/...
if path.startswith("./storage"):
url_path = path[len("./storage"):]
elif path.startswith("/"):
url_path = path
else:
url_path = f"/{path}"
# 拼接 BASE_URL
base = settings.BASE_URL.rstrip("/")
return f"{base}{url_path}"
def _map_status(status: str) -> str:
"""将内部状态映射为 API 状态。"""
status_map = {
"pending": "queued",
"queued": "pending_queue",
"generating": "generating",
"processing": "generating",
"completed": "completed",
"failed": "failed",
}
return status_map.get(status, status)
@@ -0,0 +1,212 @@
import json
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_generation_task import ApiGenerationTask
from app.models.api.api_key_upscale_config import ApiKeyUpscaleConfig
from app.models.api.api_upscale_link import ApiUpscaleLink
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
async def get_or_create_upscale_config(
db: AsyncSession,
api_key_id: str,
) -> ApiKeyUpscaleConfig:
"""获取或创建 API Key 的超分配置。"""
result = await db.execute(
select(ApiKeyUpscaleConfig).where(
ApiKeyUpscaleConfig.api_key_id == api_key_id
).limit(1)
)
config = result.scalar_one_or_none()
if not config:
config = ApiKeyUpscaleConfig(
id=generate_id(),
api_key_id=api_key_id,
enabled=False,
delete_source_after_success=True,
rules_json="[]",
)
db.add(config)
await db.flush()
return config
async def save_upscale_config(
db: AsyncSession,
api_key_id: str,
enabled: bool,
delete_source_after_success: bool,
rules: list[dict],
) -> ApiKeyUpscaleConfig:
"""保存 API Key 的超分配置。"""
config = await get_or_create_upscale_config(db, api_key_id)
config.enabled = enabled
config.delete_source_after_success = delete_source_after_success
config.rules_json = json.dumps(rules, ensure_ascii=False)
await db.flush()
return config
async def build_api_upscale_snapshot(
db: AsyncSession,
api_key_id: str,
target_resolution: str,
aspect_ratio: str | None = None,
) -> tuple[str | None, bool, str | None]:
"""构建 API 超分快照。
读取 api_key_upscale_configs(而非 system_configs),
匹配目标分辨率对应的超分规则。
Returns:
(provider_generation_resolution, enabled, snapshot_json)
"""
config = await get_or_create_upscale_config(db, api_key_id)
if not config.enabled:
return None, False, None
try:
rules = json.loads(config.rules_json) if config.rules_json else []
except (json.JSONDecodeError, TypeError):
return None, False, None
# 匹配规则
matched_rule = None
for rule in rules:
if rule.get("enabled") and rule.get("target_resolution") == target_resolution:
matched_rule = rule
break
if not matched_rule:
return None, False, None
snapshot = {
"enabled": True,
"delete_source_after_success": config.delete_source_after_success,
"rule": matched_rule,
"matched_at": datetime.now(timezone.utc).isoformat(),
# 兼容现有超分流水线的 processor 字段
"processor": {
"max_attempts": 3,
"processor_key": matched_rule.get("processor_key", "volc_large_model_v1"),
},
"target_resolution": target_resolution,
"provider_generation_resolution": matched_rule.get("provider_generation_resolution", target_resolution),
"aspect_ratio": aspect_ratio,
}
provider_resolution = matched_rule.get("provider_generation_resolution", target_resolution)
snapshot_json = json.dumps(snapshot, ensure_ascii=False)
return provider_resolution, True, snapshot_json
async def prepare_api_upscale_task(
db: AsyncSession,
api_task: ApiGenerationTask,
source_local_path: str,
source_width: int = 0,
source_height: int = 0,
source_duration: float = 0.0,
) -> "VideoUpscaleTask | None":
"""为 API 任务创建超分子任务。
复用现有的 VideoUpscaleTask 表和 upscale 执行流水线。
如果已存在超分任务则返回 None(避免重复创建)。
"""
from app.models.video_upscale_task import VideoUpscaleTask
from sqlalchemy import select
# 检查是否已存在超分任务(避免重复创建)
existing = await db.execute(
select(VideoUpscaleTask).where(
VideoUpscaleTask.api_generation_task_id == api_task.id
).limit(1)
)
if existing.scalar_one_or_none():
logger.info("Upscale task already exists for API task %s, skipping", api_task.id)
return None
# 解析快照获取处理器配置
try:
snapshot = json.loads(api_task.video_upscale_snapshot_json) if api_task.video_upscale_snapshot_json else {}
except (json.JSONDecodeError, TypeError):
snapshot = {}
rule = snapshot.get("rule", {})
processor_key = rule.get("processor_key", "volc_large_model_v1")
target_resolution = rule.get("target_resolution", api_task.resolution or "1080p")
# 计算目标尺寸
target_width, target_height = _resolution_to_dimensions(target_resolution, api_task.aspect_ratio)
upscale_task = VideoUpscaleTask(
id=generate_id(),
chat_generation_task_id=None,
generation_record_id=None,
api_generation_task_id=api_task.id, # 关联 API v3 任务
processor_key=processor_key,
target_width=target_width,
target_height=target_height,
effective_target_width=target_width,
effective_target_height=target_height,
source_local_path=api_task.local_path or source_local_path, # 优先使用已下载的本地文件
source_remote_url=api_task.remote_result_url, # 火山 MediaKit 需要远程 URL
input_source_type="provider_remote",
source_file_size_bytes=0,
source_width=source_width,
source_height=source_height,
source_duration_seconds=source_duration,
status="pending",
stage="upscale_queued",
)
db.add(upscale_task)
await db.flush()
# 创建关联记录
link = ApiUpscaleLink(
id=generate_id(),
api_generation_task_id=api_task.id,
video_upscale_task_id=upscale_task.id,
)
db.add(link)
await db.flush()
logger.info(
"API upscale task prepared: api_task=%s upscale_task=%s processor=%s",
api_task.id, upscale_task.id, processor_key,
)
return upscale_task
def _resolution_to_dimensions(resolution: str, aspect_ratio: str | None) -> tuple[int, int]:
"""将分辨率名称转换为像素尺寸。"""
# 标准分辨率映射
resolution_map = {
"480p": (852, 480),
"720p": (1280, 720),
"1080p": (1920, 1080),
"2K": (2560, 1440),
"4K": (3840, 2160),
}
base = resolution_map.get(resolution, (1920, 1080))
# 根据宽高比调整
if aspect_ratio == "9:16":
return (base[1], base[0]) # 竖屏
elif aspect_ratio == "1:1":
return (base[0], base[0]) # 正方形
elif aspect_ratio == "4:3":
return (base[0], int(base[0] * 3 / 4))
return base
@@ -0,0 +1,150 @@
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_usage_log import ApiUsageLog
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
async def record_usage(
db: AsyncSession,
api_key_id: str,
request_type: str,
model_name: str,
gen_type: str,
status: str,
task_id: str | None = None,
credits_cost: float = 0.0,
tokens_used: int = 0,
request_duration_ms: int = 0,
error_message: str | None = None,
error_code: str | None = None,
request_payload_json: str | None = None,
price_action: str | None = None,
resolution: str | None = None,
duration: int | None = None,
refund_amount: float | None = None,
quota_before: float | None = None,
quota_after: float | None = None,
price_detail_json: str | None = None,
) -> ApiUsageLog:
"""记录一次 API 调用日志。"""
# 确定 price_action
if price_action:
action = price_action
elif status == "failed":
action = "refund"
else:
action = "deduct"
log = ApiUsageLog(
id=generate_id(),
api_key_id=api_key_id,
api_generation_task_id=task_id,
price_action=action,
request_type=request_type,
model_name=model_name,
gen_type=gen_type,
resolution=resolution,
duration=duration,
credits_cost=credits_cost,
refund_amount=refund_amount or 0.0,
quota_before=quota_before,
quota_after=quota_after,
tokens_used=tokens_used,
request_duration_ms=request_duration_ms,
price_detail_json=price_detail_json,
status=status,
error_message=error_message,
error_code=error_code,
request_payload_json=request_payload_json,
)
db.add(log)
await db.flush()
return log
async def list_usage_logs(
db: AsyncSession,
api_key_id: str | None = None,
skip: int = 0,
limit: int = 50,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> tuple[int, list[ApiUsageLog]]:
"""查询使用日志(分页+筛选)。"""
query = select(ApiUsageLog)
count_query = select(func.count(ApiUsageLog.id))
filters = []
if api_key_id:
filters.append(ApiUsageLog.api_key_id == api_key_id)
if start_date:
filters.append(ApiUsageLog.created_at >= start_date)
if end_date:
filters.append(ApiUsageLog.created_at <= end_date)
for f in filters:
query = query.where(f)
count_query = count_query.where(f)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
query = query.order_by(ApiUsageLog.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
logs = list(result.scalars().all())
return total, logs
async def get_usage_summary(
db: AsyncSession,
api_key_id: str | None = None,
days: int = 30,
) -> dict:
"""获取使用汇总统计。"""
now = datetime.now(timezone.utc)
start = now - timedelta(days=days)
query = select(
func.count(ApiUsageLog.id).label("total_requests"),
func.coalesce(func.sum(ApiUsageLog.credits_cost), 0).label("total_credits"),
func.coalesce(func.sum(ApiUsageLog.tokens_used), 0).label("total_tokens"),
func.coalesce(func.avg(ApiUsageLog.request_duration_ms), 0).label("avg_duration"),
).where(ApiUsageLog.created_at >= start)
if api_key_id:
query = query.where(ApiUsageLog.api_key_id == api_key_id)
result = await db.execute(query)
row = result.one()
# 成功/失败统计
success_query = select(func.count(ApiUsageLog.id)).where(
ApiUsageLog.created_at >= start,
ApiUsageLog.status == "success",
)
failed_query = select(func.count(ApiUsageLog.id)).where(
ApiUsageLog.created_at >= start,
ApiUsageLog.status == "failed",
)
if api_key_id:
success_query = success_query.where(ApiUsageLog.api_key_id == api_key_id)
failed_query = failed_query.where(ApiUsageLog.api_key_id == api_key_id)
success_result = await db.execute(success_query)
failed_result = await db.execute(failed_query)
return {
"total_requests": row.total_requests or 0,
"total_credits_cost": float(row.total_credits or 0),
"total_tokens_used": int(row.total_tokens or 0),
"avg_duration_ms": int(row.avg_duration or 0),
"success_count": success_result.scalar_one() or 0,
"failed_count": failed_result.scalar_one() or 0,
}