1、增加调用 AI 视频生成能力和虚拟素材库管理的对外api
2、增加后台apikkey管理 3、增加apikey单独的模型定价 4、增加apikey调用情况 5、完善所有数据的注释增加
This commit is contained in:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user