feat: 添加多维度计费系统和视频任务管理功能

计费系统:
- 新增 BillingRule 和 DimensionCollector 数据模型
- 实现 FormulaEngine 安全表达式求值引擎 (AST 白名单)
- 支持 dimension/matrix/tiered/constant 多种维度映射
- BillingRuleService 支持 Provider Model -> GlobalModel 规则回退
- CLI task_type 在计费域自动映射为 chat

视频任务增强:
- 添加 request_metadata 字段记录候选 key 和计费规则快照
- 后台轮询支持并发控制 (Semaphore + 独立 session)
- 任务终态自动写入 Usage 记录并计算成本
- 新增视频任务管理 API 和前端界面

其他改进:
- UsageService 新增 record_usage_with_custom_cost 方法
- StandardizedUsage 支持 dimensions 字段 (兼容 extra)
- 配置新增 BILLING_REQUIRE_RULE 和 BILLING_STRICT_MODE
This commit is contained in:
fawney19
2026-01-31 19:11:25 +08:00
parent dc4bb25cc2
commit 97b15afe7c
30 changed files with 4356 additions and 237 deletions

View File

@@ -0,0 +1,5 @@
"""视频任务管理 API 模块。"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,424 @@
"""视频任务管理 API 路由。
管理员可以查看所有视频任务,用户只能查看自己的任务。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.api.dashboard.routes import DashboardAdapter
from src.core.enums import UserRole
from src.database import get_db
from src.models.database import Provider, ProviderEndpoint, User, VideoTask
router = APIRouter(prefix="/api/admin/video-tasks", tags=["Admin - Video Tasks"])
pipeline = ApiRequestPipeline()
@router.get("")
async def list_video_tasks(
request: Request,
status: str | None = Query(None, description="Filter by status"),
user_id: str | None = Query(None, description="Filter by user ID (admin only)"),
model: str | None = Query(None, description="Filter by model"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
db: Session = Depends(get_db),
) -> Any:
"""
获取视频任务列表
管理员可以查看所有用户的任务,普通用户只能查看自己的任务。
**查询参数**:
- `status`: 按状态筛选pending/submitted/processing/completed/failed/cancelled
- `user_id`: 按用户 ID 筛选(仅管理员)
- `model`: 按模型筛选
- `page`: 页码,默认 1
- `page_size`: 每页数量,默认 20最大 100
**返回字段**:
- `items`: 任务列表
- `total`: 总数
- `page`: 当前页码
- `page_size`: 每页数量
- `pages`: 总页数
"""
adapter = VideoTaskListAdapter(
status=status,
user_id=user_id,
model=model,
page=page,
page_size=page_size,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/stats")
async def get_video_task_stats(
request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
获取视频任务统计
**返回字段**:
- `total`: 总任务数
- `by_status`: 按状态分组的数量
- `by_model`: 按模型分组的数量(前 10
- `today_count`: 今日任务数
"""
adapter = VideoTaskStatsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{task_id}")
async def get_video_task_detail(
task_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
获取视频任务详情
**路径参数**:
- `task_id`: 任务 ID
**返回字段**:
- 任务的完整信息,包括请求体、响应、状态等
"""
adapter = VideoTaskDetailAdapter(task_id=task_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/{task_id}/cancel")
async def cancel_video_task(
task_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
取消视频任务
**路径参数**:
- `task_id`: 任务 ID
**返回**:
- 更新后的任务信息
"""
adapter = VideoTaskCancelAdapter(task_id=task_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ==================== Adapters ====================
@dataclass
class VideoTaskListAdapter(DashboardAdapter):
"""视频任务列表适配器"""
status: str | None
user_id: str | None
model: str | None
page: int
page_size: int
async def handle(self, context: ApiRequestContext) -> Any:
db = context.db
user = context.user
is_admin = user.role == UserRole.ADMIN
query = db.query(VideoTask)
# 权限过滤:普通用户只能看自己的任务
if not is_admin:
query = query.filter(VideoTask.user_id == user.id)
elif self.user_id:
# 管理员可以按用户筛选
query = query.filter(VideoTask.user_id == self.user_id)
# 状态筛选
if self.status:
query = query.filter(VideoTask.status == self.status)
# 模型筛选
if self.model:
escaped = self.model.replace("%", "\\%").replace("_", "\\_")
query = query.filter(VideoTask.model.ilike(f"%{escaped}%"))
# 统计总数
total = query.count()
# 分页
offset = (self.page - 1) * self.page_size
tasks = (
query.order_by(VideoTask.created_at.desc()).offset(offset).limit(self.page_size).all()
)
# 获取用户信息映射
user_ids = list(set(t.user_id for t in tasks if t.user_id))
users_map = {}
if user_ids:
users = db.query(User).filter(User.id.in_(user_ids)).all()
users_map = {u.id: u.username for u in users}
# 获取 Provider 信息映射
provider_ids = list(set(t.provider_id for t in tasks if t.provider_id))
providers_map = {}
if provider_ids:
providers = db.query(Provider).filter(Provider.id.in_(provider_ids)).all()
providers_map = {p.id: p.name for p in providers}
items = []
for task in tasks:
items.append(
{
"id": task.id,
"external_task_id": task.external_task_id,
"user_id": task.user_id,
"username": users_map.get(task.user_id, "Unknown"),
"model": task.model,
"prompt": (
task.prompt[:100] + "..."
if task.prompt and len(task.prompt) > 100
else task.prompt
),
"status": task.status,
"progress_percent": task.progress_percent,
"progress_message": task.progress_message,
"provider_id": task.provider_id,
"provider_name": providers_map.get(task.provider_id, "Unknown"),
"duration_seconds": task.duration_seconds,
"resolution": task.resolution,
"aspect_ratio": task.aspect_ratio,
"video_url": task.video_url,
"error_code": task.error_code,
"error_message": task.error_message,
"poll_count": task.poll_count,
"max_poll_count": task.max_poll_count,
"created_at": task.created_at.isoformat() if task.created_at else None,
"completed_at": task.completed_at.isoformat() if task.completed_at else None,
"submitted_at": task.submitted_at.isoformat() if task.submitted_at else None,
}
)
pages = (total + self.page_size - 1) // self.page_size
return {
"items": items,
"total": total,
"page": self.page,
"page_size": self.page_size,
"pages": pages,
}
@dataclass
class VideoTaskStatsAdapter(DashboardAdapter):
"""视频任务统计适配器"""
async def handle(self, context: ApiRequestContext) -> Any:
db = context.db
user = context.user
is_admin = user.role == UserRole.ADMIN
base_query = db.query(VideoTask)
if not is_admin:
base_query = base_query.filter(VideoTask.user_id == user.id)
# 总数
total = base_query.count()
# 按状态分组
status_stats = (
base_query.with_entities(
VideoTask.status,
func.count(VideoTask.id).label("count"),
)
.group_by(VideoTask.status)
.all()
)
by_status = {stat.status: stat.count for stat in status_stats}
# 按模型分组(前 10
model_stats = (
base_query.with_entities(
VideoTask.model,
func.count(VideoTask.id).label("count"),
)
.group_by(VideoTask.model)
.order_by(func.count(VideoTask.id).desc())
.limit(10)
.all()
)
by_model = {stat.model: stat.count for stat in model_stats}
# 今日任务数
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
today_count = base_query.filter(VideoTask.created_at >= today).count()
# 管理员额外统计
result = {
"total": total,
"by_status": by_status,
"by_model": by_model,
"today_count": today_count,
}
if is_admin:
# 活跃用户数(有视频任务的用户)
active_users = db.query(func.count(func.distinct(VideoTask.user_id))).scalar() or 0
result["active_users"] = active_users
# 处理中的任务数
processing_count = (
db.query(func.count(VideoTask.id))
.filter(VideoTask.status.in_(["submitted", "queued", "processing"]))
.scalar()
or 0
)
result["processing_count"] = processing_count
return result
@dataclass
class VideoTaskDetailAdapter(DashboardAdapter):
"""视频任务详情适配器"""
task_id: str
async def handle(self, context: ApiRequestContext) -> Any:
db = context.db
user = context.user
is_admin = user.role == UserRole.ADMIN
query = db.query(VideoTask).filter(VideoTask.id == self.task_id)
if not is_admin:
query = query.filter(VideoTask.user_id == user.id)
task = query.first()
if not task:
raise HTTPException(status_code=404, detail="Video task not found")
# 获取用户信息
task_user = db.query(User).filter(User.id == task.user_id).first()
username = task_user.username if task_user else "Unknown"
# 获取 Provider 信息
provider = db.query(Provider).filter(Provider.id == task.provider_id).first()
provider_name = provider.name if provider else "Unknown"
# 获取 Endpoint 信息
endpoint = (
db.query(ProviderEndpoint).filter(ProviderEndpoint.id == task.endpoint_id).first()
)
endpoint_info = None
if endpoint:
endpoint_info = {
"id": endpoint.id,
"base_url": endpoint.base_url,
"api_format": str(endpoint.api_format),
}
return {
"id": task.id,
"external_task_id": task.external_task_id,
"user_id": task.user_id,
"username": username,
"api_key_id": task.api_key_id,
"provider_id": task.provider_id,
"provider_name": provider_name,
"endpoint_id": task.endpoint_id,
"endpoint": endpoint_info,
"key_id": task.key_id,
"client_api_format": task.client_api_format,
"provider_api_format": task.provider_api_format,
"format_converted": task.format_converted,
"model": task.model,
"prompt": task.prompt,
"original_request_body": task.original_request_body,
"converted_request_body": task.converted_request_body,
"duration_seconds": task.duration_seconds,
"resolution": task.resolution,
"aspect_ratio": task.aspect_ratio,
"size": task.size,
"status": task.status,
"progress_percent": task.progress_percent,
"progress_message": task.progress_message,
"video_url": task.video_url,
"video_urls": task.video_urls,
"thumbnail_url": task.thumbnail_url,
"video_size_bytes": task.video_size_bytes,
"video_expires_at": (
task.video_expires_at.isoformat() if task.video_expires_at else None
),
"stored_video_path": task.stored_video_path,
"storage_provider": task.storage_provider,
"error_code": task.error_code,
"error_message": task.error_message,
"retry_count": task.retry_count,
"max_retries": task.max_retries,
"poll_interval_seconds": task.poll_interval_seconds,
"next_poll_at": task.next_poll_at.isoformat() if task.next_poll_at else None,
"poll_count": task.poll_count,
"max_poll_count": task.max_poll_count,
"created_at": task.created_at.isoformat() if task.created_at else None,
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
"submitted_at": task.submitted_at.isoformat() if task.submitted_at else None,
"completed_at": task.completed_at.isoformat() if task.completed_at else None,
"request_metadata": task.request_metadata,
}
@dataclass
class VideoTaskCancelAdapter(DashboardAdapter):
"""视频任务取消适配器"""
task_id: str
async def handle(self, context: ApiRequestContext) -> Any:
from src.core.api_format.conversion.internal_video import VideoStatus
db = context.db
user = context.user
is_admin = user.role == UserRole.ADMIN
query = db.query(VideoTask).filter(VideoTask.id == self.task_id)
if not is_admin:
query = query.filter(VideoTask.user_id == user.id)
task = query.first()
if not task:
raise HTTPException(status_code=404, detail="Video task not found")
# 只能取消进行中的任务
if task.status in [
VideoStatus.COMPLETED.value,
VideoStatus.FAILED.value,
VideoStatus.CANCELLED.value,
]:
raise HTTPException(
status_code=400, detail=f"Cannot cancel task with status: {task.status}"
)
# 更新状态
task.status = VideoStatus.CANCELLED.value
task.updated_at = datetime.now(timezone.utc)
task.completed_at = datetime.now(timezone.utc)
db.commit()
return {
"id": task.id,
"status": task.status,
"message": "Task cancelled successfully",
}