chore(release): bump version to 1.9.1 and apply pending changes
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
diff --git a/python-api/app/api/v1/caption.py b/python-api/app/api/v1/caption.py
|
||||
index afae831..99a771a 100644
|
||||
--- a/python-api/app/api/v1/caption.py
|
||||
+++ b/python-api/app/api/v1/caption.py
|
||||
@@ -24,19 +24,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/caption", tags=["Caption"])
|
||||
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
@router.post("/ata/align")
|
||||
async def auto_align_caption(
|
||||
request_body: AutoAlignSubmitRequest,
|
||||
@@ -88,9 +75,3 @@ async def auto_align_caption(
|
||||
except Exception as e:
|
||||
logger.error(f"自动打轴异常: {e}")
|
||||
raise HTTPException(status_code=500, detail="字幕打轴失败,请稍后重试")
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
diff --git a/python-api/app/api/v1/points.py b/python-api/app/api/v1/points.py
|
||||
index b2c7208..23bafa9 100644
|
||||
--- a/python-api/app/api/v1/points.py
|
||||
+++ b/python-api/app/api/v1/points.py
|
||||
@@ -6,7 +6,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
-from datetime import UTC, datetime
|
||||
+from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
@@ -14,6 +14,7 @@ from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
+from app.core.exceptions import InsufficientPointsException
|
||||
from app.crud.point_recharge_order import point_recharge_order
|
||||
from app.crud.point_transaction import point_transaction
|
||||
from app.models.user import User
|
||||
@@ -35,6 +36,7 @@ router = APIRouter(prefix="/points", tags=["Points"])
|
||||
|
||||
# ── 余额查询 ──────────────────────────────────────────
|
||||
|
||||
+
|
||||
@router.get("/balance", response_model=ApiResponse[PointBalanceResponse])
|
||||
async def get_balance(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -47,6 +49,7 @@ async def get_balance(
|
||||
|
||||
# ── 流水查询 ──────────────────────────────────────────
|
||||
|
||||
+
|
||||
@router.get("/transactions", response_model=ApiResponse[PointTransactionListResponse])
|
||||
async def list_transactions(
|
||||
pagination: PaginationParams = Depends(),
|
||||
@@ -127,12 +130,13 @@ async def list_transactions(
|
||||
|
||||
# ── 充值 ──────────────────────────────────────────────
|
||||
|
||||
+
|
||||
@router.post("/recharge", response_model=ApiResponse[RechargeResponse])
|
||||
async def create_recharge_order(
|
||||
request: RechargeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
- http_request: Request = None,
|
||||
+ http_request: Request = None, # type: ignore[assignment]
|
||||
):
|
||||
"""
|
||||
创建积分充值订单(微信支付 Native 扫码)
|
||||
@@ -303,9 +307,7 @@ async def handle_wxpay_notify(
|
||||
return _wx_response()
|
||||
|
||||
# 查找订单
|
||||
- order = await point_recharge_order.get_by_out_trade_no(
|
||||
- db, out_trade_no=out_trade_no
|
||||
- )
|
||||
+ order = await point_recharge_order.get_by_out_trade_no(db, out_trade_no=out_trade_no)
|
||||
if not order:
|
||||
logger.error(f"[WechatPay] 回调订单不存在: {out_trade_no}")
|
||||
return _wx_response()
|
||||
@@ -400,9 +402,7 @@ async def query_recharge_status(
|
||||
|
||||
wxpay = get_wxpay_service()
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) as client:
|
||||
- wx_result = await wxpay.query_order(
|
||||
- client, out_trade_no=order.out_trade_no
|
||||
- )
|
||||
+ wx_result = await wxpay.query_order(client, out_trade_no=order.out_trade_no)
|
||||
|
||||
order.query_result = str(wx_result)
|
||||
trade_state = wx_result.get("trade_state", "")
|
||||
@@ -465,6 +465,7 @@ async def query_recharge_status(
|
||||
|
||||
# ── 充值档位查询 ──────────────────────────────────────
|
||||
|
||||
+
|
||||
@router.get("/recharge-options", response_model=ApiResponse[list[dict]])
|
||||
async def get_recharge_options(
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -480,6 +481,7 @@ async def get_recharge_options(
|
||||
|
||||
# ── 扣费业务类型查询 ──────────────────────────────────
|
||||
|
||||
+
|
||||
@router.get("/chargeable-types", response_model=ApiResponse[list[str]])
|
||||
async def get_chargeable_types(
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -496,6 +498,7 @@ async def get_chargeable_types(
|
||||
|
||||
# ── 积分规则查询 ──────────────────────────────────────
|
||||
|
||||
+
|
||||
@router.get("/rules", response_model=ApiResponse[list[dict]])
|
||||
async def get_points_rules(
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -530,9 +533,9 @@ async def get_points_rules(
|
||||
# ── 积分预估查询 ──────────────────────────────────────
|
||||
|
||||
|
||||
-
|
||||
# ── 今日消费统计 ──────────────────────────────────────
|
||||
|
||||
+
|
||||
@router.get("/today-consumed", response_model=ApiResponse[dict])
|
||||
async def get_today_consumed(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -545,6 +548,7 @@ async def get_today_consumed(
|
||||
|
||||
# ── 直接消费扣费(前端/Rust 层调用)───────────────────
|
||||
|
||||
+
|
||||
@router.post("/consume", response_model=ApiResponse[dict])
|
||||
async def consume_points(
|
||||
request: ConsumeRequest,
|
||||
@@ -569,12 +573,12 @@ async def consume_points(
|
||||
source_type=request.source_type,
|
||||
source_id=request.source_id,
|
||||
description=f"【{request.description or request.source_type}】",
|
||||
- allow_negative=False,
|
||||
+ allow_negative=request.allow_negative,
|
||||
)
|
||||
- except ValueError as e:
|
||||
+ except InsufficientPointsException:
|
||||
# 余额不足(在同一事务内判断,避免竞态)
|
||||
- if "积分不足" in str(e):
|
||||
- raise HTTPException(status_code=402, detail=str(e))
|
||||
+ raise
|
||||
+ except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
await db.commit()
|
||||
|
||||
@@ -587,5 +591,3 @@ async def consume_points(
|
||||
},
|
||||
message="消费成功",
|
||||
)
|
||||
-
|
||||
-
|
||||
diff --git a/python-api/app/api/v1/script.py b/python-api/app/api/v1/script.py
|
||||
index 5e7b18f..26a11a3 100644
|
||||
--- a/python-api/app/api/v1/script.py
|
||||
+++ b/python-api/app/api/v1/script.py
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.ai.model_router import get_model_router
|
||||
from app.ai.prompts import list_categories, list_prompt_files, load_prompt, render_template
|
||||
from app.api.deps import get_current_user
|
||||
+from app.core.exceptions import AITimeoutException, InsufficientPointsException
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.common import ApiResponse, success_response
|
||||
@@ -71,9 +72,8 @@ async def polish_content(
|
||||
required_points = ps._calculate_cost("polish")
|
||||
check = await ps.check_balance(db, current_user.id, required_points)
|
||||
if not check["sufficient"]:
|
||||
- raise HTTPException(
|
||||
- status_code=402,
|
||||
- detail=f"积分不足,需要 {required_points} 积分,当前余额 {check['balance']}",
|
||||
+ raise InsufficientPointsException(
|
||||
+ f"积分不足,需要 {required_points} 积分,当前余额 {check['balance']}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -99,11 +99,11 @@ async def polish_content(
|
||||
data=polished,
|
||||
message=f"{type_name}润色完成",
|
||||
)
|
||||
+ except InsufficientPointsException:
|
||||
+ raise
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
- if "积分不足" in str(e):
|
||||
- raise HTTPException(status_code=402, detail=str(e))
|
||||
logger.warning(f"[Polish] 润色失败: {e}")
|
||||
raise HTTPException(status_code=500, detail="润色失败,请检查输入内容后重试")
|
||||
except Exception as e:
|
||||
@@ -111,9 +111,6 @@ async def polish_content(
|
||||
raise HTTPException(status_code=500, detail=f"{type_name}润色失败,请稍后重试")
|
||||
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
@router.post("/generate-title", response_model=ApiResponse[GenerateTitleResponse])
|
||||
async def generate_title(
|
||||
request: GenerateTitleRequest,
|
||||
@@ -146,7 +143,11 @@ async def generate_title(
|
||||
usage_note = "- 视频画面上的标题需要精炼,聚焦核心关键词\n- 副标题与主标题形成呼应,补充说明但不喧宾夺主"
|
||||
|
||||
# 渲染用户提示词
|
||||
- title_type_desc = "大标题(主标题,提炼核心卖点,吸睛)" if request.title_type == "main" else "小标题(副标题,补充说明或制造悬念)"
|
||||
+ title_type_desc = (
|
||||
+ "大标题(主标题,提炼核心卖点,吸睛)"
|
||||
+ if request.title_type == "main"
|
||||
+ else "小标题(副标题,补充说明或制造悬念)"
|
||||
+ )
|
||||
user_prompt = render_template(
|
||||
user_template,
|
||||
title_type=request.title_type,
|
||||
@@ -163,9 +164,8 @@ async def generate_title(
|
||||
required_points = ps._calculate_cost("title")
|
||||
check = await ps.check_balance(db, current_user.id, required_points)
|
||||
if not check["sufficient"]:
|
||||
- raise HTTPException(
|
||||
- status_code=402,
|
||||
- detail=f"积分不足,需要 {required_points} 积分,当前余额 {check['balance']}",
|
||||
+ raise InsufficientPointsException(
|
||||
+ f"积分不足,需要 {required_points} 积分,当前余额 {check['balance']}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -179,10 +179,10 @@ async def generate_title(
|
||||
|
||||
title = result.content.strip() if result.content else ""
|
||||
# 去除可能的引号
|
||||
- title = title.strip('"').strip("'").strip('「」').strip('『』').strip('《》')
|
||||
+ title = title.strip('"').strip("'").strip("「」").strip("『』").strip("《》")
|
||||
# 截断到最大长度
|
||||
if len(title) > request.max_length:
|
||||
- title = title[:request.max_length]
|
||||
+ title = title[: request.max_length]
|
||||
|
||||
# 扣费
|
||||
points = ps._calculate_cost("title")
|
||||
@@ -200,15 +200,13 @@ async def generate_title(
|
||||
data=GenerateTitleResponse(title=title),
|
||||
message="标题生成成功",
|
||||
)
|
||||
+ except InsufficientPointsException:
|
||||
+ raise
|
||||
except HTTPException:
|
||||
raise
|
||||
except TimeoutError:
|
||||
logger.warning("[generate_title] 标题生成超时")
|
||||
- raise HTTPException(status_code=500, detail="标题生成超时,请重试")
|
||||
- except ValueError as e:
|
||||
- if "积分不足" in str(e):
|
||||
- raise HTTPException(status_code=402, detail=str(e))
|
||||
- raise HTTPException(status_code=500, detail=f"标题生成失败: {str(e)}")
|
||||
+ raise AITimeoutException("标题生成超时,请稍后重试")
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_title] 标题生成失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"标题生成失败: {str(e)}")
|
||||
diff --git a/python-api/app/api/v1/tasks.py b/python-api/app/api/v1/tasks.py
|
||||
index ae17fe3..55965ca 100644
|
||||
--- a/python-api/app/api/v1/tasks.py
|
||||
+++ b/python-api/app/api/v1/tasks.py
|
||||
@@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
+from app.core.exceptions import InsufficientPointsException
|
||||
from app.core.redis_client import get_redis_client
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.user import User
|
||||
@@ -38,7 +39,6 @@ class ScriptParams(BaseModel):
|
||||
category: str = Field(..., min_length=1, description="大类代码")
|
||||
filename: str = Field(..., min_length=1, description="提示词文件名")
|
||||
|
||||
-
|
||||
@field_validator("category")
|
||||
@classmethod
|
||||
def validate_category(cls, v: str) -> str:
|
||||
@@ -96,7 +96,9 @@ class VideoParams(BaseModel):
|
||||
volume: int = Field(default=0, ge=0, le=10, description="音量")
|
||||
ref_photo_url: str | None = Field(default=None, description="人脸参考图 URL")
|
||||
planned_duration: float = Field(..., gt=0, description="该分镜脚本规划时长(秒),用于余额预检")
|
||||
- total_planned_duration: float = Field(..., gt=0, description="所有分镜规划时长之和(秒),用于预检")
|
||||
+ total_planned_duration: float = Field(
|
||||
+ ..., gt=0, description="所有分镜规划时长之和(秒),用于预检"
|
||||
+ )
|
||||
batch_id: str | None = Field(default=None, description="批次ID(可选)")
|
||||
|
||||
@field_validator("video_url")
|
||||
@@ -134,6 +136,7 @@ class TaskStatusResponse(BaseModel):
|
||||
total: int = Field(0, description="总子任务数")
|
||||
result: dict | None = Field(None, description="任务结果(完成时)")
|
||||
error: str | None = Field(None, description="错误信息(失败时)")
|
||||
+ error_code: str | None = Field(None, description="错误码(失败时,如 content_violation)")
|
||||
created_at: str = Field("", description="任务创建时间(ISO格式)")
|
||||
|
||||
|
||||
@@ -222,9 +225,8 @@ async def create_task(
|
||||
f"[API] 积分不足: user={user_id}, type={task_type}, "
|
||||
f"required={required_points}, balance={check['balance']}"
|
||||
)
|
||||
- raise HTTPException(
|
||||
- status_code=402,
|
||||
- detail=f"积分不足,需要 {required_points} 积分,当前余额 {check['balance']}",
|
||||
+ raise InsufficientPointsException(
|
||||
+ f"积分不足,需要 {required_points} 积分,当前余额 {check['balance']}"
|
||||
)
|
||||
|
||||
# ── 3. 写入 Redis ──────────────────────────────────
|
||||
@@ -246,18 +248,41 @@ async def create_task(
|
||||
params=validated_params,
|
||||
)
|
||||
await registry.add_running(task_id)
|
||||
-
|
||||
- logger.info(f"[API] Task created: {task_id}, type={task_type}, user={user_id}")
|
||||
- return TaskCreateResponse(
|
||||
- task_id=task_id,
|
||||
- status="running",
|
||||
- message=f"{task_type} 任务已创建",
|
||||
- )
|
||||
-
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Failed to update registry: {e}")
|
||||
raise HTTPException(status_code=500, detail="创建任务失败:Redis写入错误")
|
||||
|
||||
+ # ── 4. 脚本生成:Redis 写入成功后再扣费 ─────────────
|
||||
+ if task_type == "script" and required_points > 0:
|
||||
+ try:
|
||||
+ async with AsyncSessionLocal() as db:
|
||||
+ await ps.consume(
|
||||
+ db,
|
||||
+ user_id=user_id,
|
||||
+ points=required_points,
|
||||
+ source_type="script",
|
||||
+ source_id=task_id,
|
||||
+ description="【脚本生成】",
|
||||
+ )
|
||||
+ await db.commit()
|
||||
+ except InsufficientPointsException:
|
||||
+ # 余额不足:将任务标记为失败,避免无费执行
|
||||
+ await registry.update(task_id, status="failed", message="积分不足")
|
||||
+ await registry.remove_running(task_id)
|
||||
+ raise
|
||||
+ except Exception as e:
|
||||
+ logger.error(f"[API] 脚本任务扣费失败: {e}")
|
||||
+ await registry.update(task_id, status="failed", message="扣费失败")
|
||||
+ await registry.remove_running(task_id)
|
||||
+ raise HTTPException(status_code=500, detail="扣费失败,请稍后重试")
|
||||
+
|
||||
+ logger.info(f"[API] Task created: {task_id}, type={task_type}, user={user_id}")
|
||||
+ return TaskCreateResponse(
|
||||
+ task_id=task_id,
|
||||
+ status="running",
|
||||
+ message=f"{task_type} 任务已创建",
|
||||
+ )
|
||||
+
|
||||
|
||||
@router.get("", response_model=list[TaskStatusResponse])
|
||||
async def list_tasks(
|
||||
@@ -294,6 +319,7 @@ async def list_tasks(
|
||||
total=task.total,
|
||||
result=None, # 列表查询不返回 result,避免数据过大
|
||||
error=task.error,
|
||||
+ error_code=task.error_code,
|
||||
created_at=task.created_at,
|
||||
)
|
||||
)
|
||||
@@ -337,6 +363,7 @@ async def get_task_status(
|
||||
total=task.total,
|
||||
result=task.result,
|
||||
error=task.error,
|
||||
+ error_code=task.error_code,
|
||||
created_at=task.created_at,
|
||||
)
|
||||
|
||||
diff --git a/python-api/app/api/v1/vidu.py b/python-api/app/api/v1/vidu.py
|
||||
index c0fb04a..401d8da 100644
|
||||
--- a/python-api/app/api/v1/vidu.py
|
||||
+++ b/python-api/app/api/v1/vidu.py
|
||||
@@ -44,10 +44,12 @@ async def vidu_callback(request: Request):
|
||||
headers_dict = dict(request.headers)
|
||||
|
||||
# 使用 APP_BASE_URL 构建 callback_url,确保与提交任务时传给 Vidu 的一致
|
||||
- #(Nginx 反向代理可能导致 request.url 的 scheme 为 http,与 Vidu 签名时的 https 不一致)
|
||||
+ # (Nginx 反向代理可能导致 request.url 的 scheme 为 http,与 Vidu 签名时的 https 不一致)
|
||||
app_base_url = get_settings().app_base_url
|
||||
callback_url = f"{app_base_url}/api/v1/vidu/callback" if app_base_url else str(request.url)
|
||||
- logger.info(f"[Vidu] 收到回调: request_url={request.url}, callback_url={callback_url}, body={body_bytes.decode('utf-8', errors='replace')[:500]}")
|
||||
+ logger.info(
|
||||
+ f"[Vidu] 收到回调: request_url={request.url}, callback_url={callback_url}, body={body_bytes.decode('utf-8', errors='replace')[:500]}"
|
||||
+ )
|
||||
|
||||
try:
|
||||
task_status = await gateway.handle_webhook(
|
||||
@@ -64,15 +66,13 @@ async def vidu_callback(request: Request):
|
||||
logger.error(f"[Vidu] 回调处理失败: {e}")
|
||||
raise HTTPException(status_code=500, detail="回调处理失败,请稍后重试")
|
||||
|
||||
- logger.info(f"[Vidu] 回调解析完成: state={task_status.state}, result={task_status.result}, error={task_status.error_message}")
|
||||
+ logger.info(
|
||||
+ f"[Vidu] 回调解析完成: state={task_status.state}, result={task_status.result}, error={task_status.error_message}"
|
||||
+ )
|
||||
|
||||
# 2. 通过 platform_task_id 反查 Async Engine 内部 task_id,更新 TaskRegistry
|
||||
- platform_task_id = (
|
||||
- task_status.result.get("task_id") if task_status.result else None
|
||||
- )
|
||||
- video_url = (
|
||||
- task_status.result.get("video_url") if task_status.result else None
|
||||
- )
|
||||
+ platform_task_id = task_status.result.get("task_id") if task_status.result else None
|
||||
+ video_url = task_status.result.get("video_url") if task_status.result else None
|
||||
|
||||
logger.info(f"[Vidu] 准备反查 internal_task_id: platform_task_id={platform_task_id}")
|
||||
|
||||
@@ -121,8 +121,6 @@ async def vidu_callback(request: Request):
|
||||
f"platform={platform_task_id}"
|
||||
)
|
||||
else:
|
||||
- logger.warning(
|
||||
- f"[Vidu] 回调无法反查内部 task_id: platform={platform_task_id}"
|
||||
- )
|
||||
+ logger.warning(f"[Vidu] 回调无法反查内部 task_id: platform={platform_task_id}")
|
||||
|
||||
return success_response(message="回调已接收")
|
||||
diff --git a/python-api/app/api/v1/voice.py b/python-api/app/api/v1/voice.py
|
||||
index ba8cac9..dad2b7b 100644
|
||||
--- a/python-api/app/api/v1/voice.py
|
||||
+++ b/python-api/app/api/v1/voice.py
|
||||
@@ -10,12 +10,13 @@ import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
+
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
-from app.core.exceptions import PlatformError
|
||||
+from app.core.exceptions import InsufficientPointsException, PlatformError
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.common import ApiResponse, success_response
|
||||
@@ -49,7 +50,9 @@ class TTSSynthesizeRequest(BaseModel):
|
||||
class VoiceCloneSubmitRequest(BaseModel):
|
||||
"""声音复刻提交请求"""
|
||||
|
||||
- source_audio_url: str | None = Field(None, description="源音频 URL(5-30秒,mp3/wav,需公开可访问)")
|
||||
+ source_audio_url: str | None = Field(
|
||||
+ None, description="源音频 URL(5-30秒,mp3/wav,需公开可访问)"
|
||||
+ )
|
||||
source_video_url: str | None = Field(None, description="源视频 URL(可选)")
|
||||
video_id: str | None = Field(None, description="历史作品ID(可选)")
|
||||
voice_name: str | None = Field(None, description="自定义音色名称(≤20字符)")
|
||||
@@ -111,7 +114,7 @@ async def synthesize_speech(
|
||||
# 宽松预检:余额为负或零时阻止,避免浪费第三方资源
|
||||
balance_info = await ps.get_user_balance(db, current_user.id)
|
||||
if balance_info["balance"] <= 0:
|
||||
- raise HTTPException(status_code=402, detail="余额不足,请先充值")
|
||||
+ raise InsufficientPointsException("余额不足,请先充值")
|
||||
|
||||
try:
|
||||
audio_url = await service.synthesize(
|
||||
@@ -137,10 +140,8 @@ async def synthesize_speech(
|
||||
allow_negative=True,
|
||||
)
|
||||
await db.commit()
|
||||
- except ValueError as e:
|
||||
- if "积分不足" in str(e):
|
||||
- raise HTTPException(status_code=402, detail=str(e))
|
||||
- logger.error(f"[Voice] TTS 扣费失败: {e}")
|
||||
+ except InsufficientPointsException:
|
||||
+ raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Voice] TTS 扣费失败: {e}")
|
||||
|
||||
@@ -165,7 +166,6 @@ async def synthesize_speech(
|
||||
raise HTTPException(status_code=500, detail="语音合成失败,请稍后重试")
|
||||
|
||||
|
||||
-
|
||||
def _normalize_voice_id(name: str | None) -> str:
|
||||
"""
|
||||
将用户输入的名称规范化为 Vidu 合法的 voice_id。
|
||||
@@ -220,9 +220,8 @@ async def submit_clone_task(
|
||||
required_points = ps._calculate_cost("voice_clone")
|
||||
check = await ps.check_balance(db, current_user.id, required_points)
|
||||
if not check["sufficient"]:
|
||||
- raise HTTPException(
|
||||
- status_code=402,
|
||||
- detail=f"积分不足,需要 {required_points} 积分,当前余额 {check['balance']}",
|
||||
+ raise InsufficientPointsException(
|
||||
+ f"积分不足,需要 {required_points} 积分,当前余额 {check['balance']}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -244,10 +243,8 @@ async def submit_clone_task(
|
||||
description="【声音复刻】",
|
||||
)
|
||||
await db.commit()
|
||||
- except ValueError as e:
|
||||
- if "积分不足" in str(e):
|
||||
- raise HTTPException(status_code=402, detail=str(e))
|
||||
- logger.error(f"[Voice] 克隆扣费失败: {e}")
|
||||
+ except InsufficientPointsException:
|
||||
+ raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Voice] 克隆扣费失败: {e}")
|
||||
|
||||
@@ -292,5 +289,3 @@ async def query_clone_task(
|
||||
),
|
||||
message="克隆已完成",
|
||||
)
|
||||
-
|
||||
-
|
||||
@@ -0,0 +1,146 @@
|
||||
diff --git a/python-api/app/core/exceptions.py b/python-api/app/core/exceptions.py
|
||||
index d9970d5..837f8d3 100644
|
||||
--- a/python-api/app/core/exceptions.py
|
||||
+++ b/python-api/app/core/exceptions.py
|
||||
@@ -24,9 +24,16 @@ class AppException(HTTPException):
|
||||
status_code: int,
|
||||
message: str = "操作失败",
|
||||
detail: dict | None = None,
|
||||
+ *,
|
||||
+ error_code: str | None = None,
|
||||
):
|
||||
- super().__init__(status_code=status_code, detail=detail or {})
|
||||
+ body = detail or {}
|
||||
+ body["message"] = message
|
||||
+ if error_code:
|
||||
+ body["error_code"] = error_code
|
||||
+ super().__init__(status_code=status_code, detail=body)
|
||||
self.message = message
|
||||
+ self.error_code = error_code
|
||||
|
||||
|
||||
class NotFoundException(AppException):
|
||||
@@ -44,7 +51,7 @@ class ValidationException(AppException):
|
||||
|
||||
def __init__(self, message: str = "参数验证失败"):
|
||||
super().__init__(
|
||||
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
message=message,
|
||||
)
|
||||
|
||||
@@ -79,6 +86,17 @@ class BusinessException(AppException):
|
||||
)
|
||||
|
||||
|
||||
+class InsufficientPointsException(AppException):
|
||||
+ """积分不足"""
|
||||
+
|
||||
+ def __init__(self, message: str = "积分不足"):
|
||||
+ super().__init__(
|
||||
+ status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
+ message=message,
|
||||
+ error_code="insufficient_points",
|
||||
+ )
|
||||
+
|
||||
+
|
||||
class ModelUnavailableException(AppException):
|
||||
"""AI 模型不可用"""
|
||||
|
||||
@@ -99,6 +117,50 @@ class TaskFailedException(AppException):
|
||||
)
|
||||
|
||||
|
||||
+class PromptNotFoundException(AppException):
|
||||
+ """提示词文件不存在"""
|
||||
+
|
||||
+ def __init__(self, message: str = "未找到提示词"):
|
||||
+ super().__init__(
|
||||
+ status_code=status.HTTP_404_NOT_FOUND,
|
||||
+ message=message,
|
||||
+ error_code="prompt_not_found",
|
||||
+ )
|
||||
+
|
||||
+
|
||||
+class AIEmptyResponseException(AppException):
|
||||
+ """AI 返回内容为空"""
|
||||
+
|
||||
+ def __init__(self, message: str = "AI 返回内容为空"):
|
||||
+ super().__init__(
|
||||
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
+ message=message,
|
||||
+ error_code="empty_result",
|
||||
+ )
|
||||
+
|
||||
+
|
||||
+class AIParseErrorException(AppException):
|
||||
+ """AI 返回内容解析失败"""
|
||||
+
|
||||
+ def __init__(self, message: str = "AI 返回格式解析失败"):
|
||||
+ super().__init__(
|
||||
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
+ message=message,
|
||||
+ error_code="parse_error",
|
||||
+ )
|
||||
+
|
||||
+
|
||||
+class AITimeoutException(AppException):
|
||||
+ """AI 调用超时"""
|
||||
+
|
||||
+ def __init__(self, message: str = "AI 请求超时,请稍后重试"):
|
||||
+ super().__init__(
|
||||
+ status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
+ message=message,
|
||||
+ error_code="timeout",
|
||||
+ )
|
||||
+
|
||||
+
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 第三方平台异常(PlatformError 体系)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
@@ -111,14 +173,15 @@ class PlatformErrorType:
|
||||
确保前端和网关能够统一处理。
|
||||
"""
|
||||
|
||||
- RATE_LIMIT = "rate_limit" # 429,可重试
|
||||
- AUTH_FAILED = "auth_failed" # 401/403,不可重试
|
||||
- TIMEOUT = "timeout" # 连接/读取超时,可重试
|
||||
- SERVER_ERROR = "server_error" # 第三方 5xx,可重试
|
||||
- BAD_REQUEST = "bad_request" # 参数错误,不可重试
|
||||
+ RATE_LIMIT = "rate_limit" # 429,可重试
|
||||
+ AUTH_FAILED = "auth_failed" # 401/403,不可重试
|
||||
+ TIMEOUT = "timeout" # 连接/读取超时,可重试
|
||||
+ SERVER_ERROR = "server_error" # 第三方 5xx,可重试
|
||||
+ BAD_REQUEST = "bad_request" # 参数错误,不可重试
|
||||
QUOTA_EXHAUSTED = "quota_exhausted" # 额度用完,不可重试(或延迟重试)
|
||||
- NOT_FOUND = "not_found" # 资源不存在,不可重试
|
||||
- UNKNOWN = "unknown" # 兜底
|
||||
+ NOT_FOUND = "not_found" # 资源不存在,不可重试
|
||||
+ CONTENT_VIOLATION = "content_violation" # 内容安全/审核不通过,不可重试
|
||||
+ UNKNOWN = "unknown" # 兜底
|
||||
|
||||
|
||||
class PlatformError(Exception):
|
||||
@@ -145,12 +208,14 @@ class PlatformError(Exception):
|
||||
retryable: bool = False,
|
||||
error_type: str = PlatformErrorType.UNKNOWN,
|
||||
status_code: int | None = None,
|
||||
+ raw_code: str | None = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.platform = platform
|
||||
self.retryable = retryable
|
||||
self.error_type = error_type
|
||||
self.status_code = status_code
|
||||
+ self.raw_code = raw_code
|
||||
|
||||
def to_http_status(self) -> int:
|
||||
"""根据 error_type 和 retryable 返回标准 HTTP 状态码"""
|
||||
@@ -161,6 +226,7 @@ class PlatformError(Exception):
|
||||
PlatformErrorType.AUTH_FAILED: 401,
|
||||
PlatformErrorType.BAD_REQUEST: 400,
|
||||
PlatformErrorType.NOT_FOUND: 404,
|
||||
+ PlatformErrorType.CONTENT_VIOLATION: 400,
|
||||
}
|
||||
if self.error_type in mapping:
|
||||
return mapping[self.error_type]
|
||||
@@ -0,0 +1,345 @@
|
||||
diff --git a/python-api/app/ai/providers/vidu_provider.py b/python-api/app/ai/providers/vidu_provider.py
|
||||
index cab5902..fccfbbf 100644
|
||||
--- a/python-api/app/ai/providers/vidu_provider.py
|
||||
+++ b/python-api/app/ai/providers/vidu_provider.py
|
||||
@@ -24,8 +24,90 @@ from app.core.exceptions import PlatformError, PlatformErrorType
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
-def _map_vidu_error(status: int, message: str) -> PlatformError:
|
||||
- """把 Vidu HTTP 错误映射为标准 PlatformError"""
|
||||
+# Vidu 错误码分类
|
||||
+_VIDU_AUDIT_ERROR_CODES = {
|
||||
+ "TaskPromptPolicyViolation",
|
||||
+ "AuditSubmitIllegal",
|
||||
+ "CreationPolicyViolation",
|
||||
+ "PhotoAuditNotPass",
|
||||
+ "AuditFailed",
|
||||
+ "ImageCheckBodyJointsFailed",
|
||||
+ "ImageCheckFaceFailed",
|
||||
+ "ImageObjectsUndetected",
|
||||
+ "FaceDetectFailure",
|
||||
+ "FaceDetectNotPass",
|
||||
+ "NoFaceDetected",
|
||||
+ "MultiFaceDetected",
|
||||
+}
|
||||
+
|
||||
+_VIDU_RETRYABLE_ERROR_CODES = {
|
||||
+ "InternalServiceFailure",
|
||||
+ "ModelUnavailable",
|
||||
+ "Unknown",
|
||||
+}
|
||||
+
|
||||
+_VIDU_RATE_LIMIT_ERROR_CODES = {
|
||||
+ "QuotaExceeded",
|
||||
+ "TooManyRequests",
|
||||
+ "SystemThrottling",
|
||||
+ "OperationInProcess",
|
||||
+}
|
||||
+
|
||||
+
|
||||
+def _extract_vidu_error_code(message: str | None) -> str | None:
|
||||
+ """从 Vidu 错误信息中提取错误码"""
|
||||
+ if not message:
|
||||
+ return None
|
||||
+ # Vidu 错误码格式:"ErrorCode: 中文描述"
|
||||
+ return message.split(":")[0].strip() or None
|
||||
+
|
||||
+
|
||||
+def _map_vidu_error(
|
||||
+ status: int,
|
||||
+ message: str,
|
||||
+ *,
|
||||
+ err_code: str | None = None,
|
||||
+) -> PlatformError:
|
||||
+ """把 Vidu HTTP 错误映射为标准 PlatformError
|
||||
+
|
||||
+ 优先根据 Vidu 业务错误码(err_code)判断类型,HTTP status 仅作为兜底。
|
||||
+ """
|
||||
+ raw_code = err_code or _extract_vidu_error_code(message)
|
||||
+
|
||||
+ # 1. 内容安全/审核类:不可重试
|
||||
+ if raw_code in _VIDU_AUDIT_ERROR_CODES:
|
||||
+ return PlatformError(
|
||||
+ message=message,
|
||||
+ platform="vidu",
|
||||
+ retryable=False,
|
||||
+ error_type=PlatformErrorType.CONTENT_VIOLATION,
|
||||
+ status_code=status,
|
||||
+ raw_code=raw_code,
|
||||
+ )
|
||||
+
|
||||
+ # 2. 平台内部/模型不可用:可重试
|
||||
+ if raw_code in _VIDU_RETRYABLE_ERROR_CODES:
|
||||
+ return PlatformError(
|
||||
+ message=message,
|
||||
+ platform="vidu",
|
||||
+ retryable=True,
|
||||
+ error_type=PlatformErrorType.SERVER_ERROR,
|
||||
+ status_code=status,
|
||||
+ raw_code=raw_code,
|
||||
+ )
|
||||
+
|
||||
+ # 3. 限流类:可重试
|
||||
+ if raw_code in _VIDU_RATE_LIMIT_ERROR_CODES:
|
||||
+ return PlatformError(
|
||||
+ message=message,
|
||||
+ platform="vidu",
|
||||
+ retryable=True,
|
||||
+ error_type=PlatformErrorType.RATE_LIMIT,
|
||||
+ status_code=status,
|
||||
+ raw_code=raw_code,
|
||||
+ )
|
||||
+
|
||||
+ # 4. HTTP status 兜底
|
||||
mapping = {
|
||||
429: (PlatformErrorType.RATE_LIMIT, True),
|
||||
401: (PlatformErrorType.AUTH_FAILED, False),
|
||||
@@ -43,6 +125,7 @@ def _map_vidu_error(status: int, message: str) -> PlatformError:
|
||||
retryable=retryable,
|
||||
error_type=error_type,
|
||||
status_code=status,
|
||||
+ raw_code=raw_code,
|
||||
)
|
||||
|
||||
|
||||
@@ -66,7 +149,9 @@ class ViduProvider:
|
||||
from app.core.platform_config import get_platform_config_loader
|
||||
|
||||
platform_config = get_platform_config_loader().get_platform("vidu")
|
||||
- self.base_url = (platform_config.base_url if platform_config else "https://api.vidu.cn").rstrip("/")
|
||||
+ self.base_url = (
|
||||
+ platform_config.base_url if platform_config else "https://api.vidu.cn"
|
||||
+ ).rstrip("/")
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError("Vidu API Key 未配置,请在 .env 中设置 VIDU_API_KEY")
|
||||
@@ -135,9 +220,12 @@ class ViduProvider:
|
||||
resp = await self.client.post(url, json=body, timeout=httpx.Timeout(120.0, connect=5.0))
|
||||
data = resp.json()
|
||||
if resp.status_code != 200 or data.get("state") == "failed":
|
||||
- msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status_code}"
|
||||
- logger.error(f"[Vidu TTS] 请求失败: url={url}, status={resp.status_code}, response={data}")
|
||||
- raise _map_vidu_error(resp.status_code, f"Vidu TTS error: {msg}")
|
||||
+ err_code = data.get("err_code") or _extract_vidu_error_code(data.get("message"))
|
||||
+ msg = err_code or data.get("message") or f"HTTP {resp.status_code}"
|
||||
+ logger.error(
|
||||
+ f"[Vidu TTS] 请求失败: url={url}, status={resp.status_code}, response={data}"
|
||||
+ )
|
||||
+ raise _map_vidu_error(resp.status_code, f"Vidu TTS error: {msg}", err_code=err_code)
|
||||
return data
|
||||
except (httpx.NetworkError, httpx.TimeoutException) as e:
|
||||
logger.error(f"[Vidu TTS] 网络错误: {e}")
|
||||
@@ -182,9 +270,14 @@ class ViduProvider:
|
||||
resp = await self.client.post(url, json=body, timeout=httpx.Timeout(120.0, connect=5.0))
|
||||
data = resp.json()
|
||||
if resp.status_code != 200 or data.get("state") == "failed":
|
||||
- msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status_code}"
|
||||
- logger.error(f"[Vidu Clone] 请求失败: url={url}, status={resp.status_code}, response={data}")
|
||||
- raise _map_vidu_error(resp.status_code, f"Vidu clone error: {msg}")
|
||||
+ err_code = data.get("err_code") or _extract_vidu_error_code(data.get("message"))
|
||||
+ msg = err_code or data.get("message") or f"HTTP {resp.status_code}"
|
||||
+ logger.error(
|
||||
+ f"[Vidu Clone] 请求失败: url={url}, status={resp.status_code}, response={data}"
|
||||
+ )
|
||||
+ raise _map_vidu_error(
|
||||
+ resp.status_code, f"Vidu clone error: {msg}", err_code=err_code
|
||||
+ )
|
||||
return data
|
||||
except (httpx.NetworkError, httpx.TimeoutException) as e:
|
||||
logger.error(f"[Vidu Clone] 网络错误: {e}")
|
||||
@@ -238,9 +331,14 @@ class ViduProvider:
|
||||
resp = await self.client.post(url, json=body)
|
||||
data = resp.json()
|
||||
if resp.status_code != 200 or data.get("state") == "failed":
|
||||
- msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status_code}"
|
||||
- logger.error(f"[Vidu LipSync] 请求失败: url={url}, status={resp.status_code}, response={data}")
|
||||
- raise _map_vidu_error(resp.status_code, f"Vidu lip-sync error: {msg}")
|
||||
+ err_code = data.get("err_code") or _extract_vidu_error_code(data.get("message"))
|
||||
+ msg = err_code or data.get("message") or f"HTTP {resp.status_code}"
|
||||
+ logger.error(
|
||||
+ f"[Vidu LipSync] 请求失败: url={url}, status={resp.status_code}, response={data}"
|
||||
+ )
|
||||
+ raise _map_vidu_error(
|
||||
+ resp.status_code, f"Vidu lip-sync error: {msg}", err_code=err_code
|
||||
+ )
|
||||
return data
|
||||
except (httpx.NetworkError, httpx.TimeoutException) as e:
|
||||
logger.error(f"[Vidu LipSync] 网络错误: {e}")
|
||||
@@ -264,9 +362,14 @@ class ViduProvider:
|
||||
resp = await self.client.get(url)
|
||||
data = resp.json()
|
||||
if resp.status_code != 200:
|
||||
- msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status_code}"
|
||||
- logger.error(f"[Vidu Query] 请求失败: url={url}, status={resp.status_code}, response={data}")
|
||||
- raise _map_vidu_error(resp.status_code, f"Vidu query task error: {msg}")
|
||||
+ err_code = data.get("err_code") or _extract_vidu_error_code(data.get("message"))
|
||||
+ msg = err_code or data.get("message") or f"HTTP {resp.status_code}"
|
||||
+ logger.error(
|
||||
+ f"[Vidu Query] 请求失败: url={url}, status={resp.status_code}, response={data}"
|
||||
+ )
|
||||
+ raise _map_vidu_error(
|
||||
+ resp.status_code, f"Vidu query task error: {msg}", err_code=err_code
|
||||
+ )
|
||||
return data
|
||||
except (httpx.NetworkError, httpx.TimeoutException) as e:
|
||||
logger.error(f"[Vidu Query] 网络错误: {e}")
|
||||
diff --git a/python-api/app/ai/providers/volcengine_caption_provider.py b/python-api/app/ai/providers/volcengine_caption_provider.py
|
||||
index 0f2f271..09ddcc7 100644
|
||||
--- a/python-api/app/ai/providers/volcengine_caption_provider.py
|
||||
+++ b/python-api/app/ai/providers/volcengine_caption_provider.py
|
||||
@@ -37,8 +37,10 @@ def _map_caption_error(status: int, message: str, code: int | None = None) -> Pl
|
||||
if code is not None and code in error_mapping:
|
||||
error_type, retryable = error_mapping[code]
|
||||
return PlatformError(
|
||||
- message, platform="volcengine_caption",
|
||||
- retryable=retryable, error_type=error_type,
|
||||
+ message,
|
||||
+ platform="volcengine_caption",
|
||||
+ retryable=retryable,
|
||||
+ error_type=error_type,
|
||||
status_code=status,
|
||||
)
|
||||
|
||||
@@ -53,8 +55,10 @@ def _map_caption_error(status: int, message: str, code: int | None = None) -> Pl
|
||||
}
|
||||
error_type, retryable = http_mapping.get(status, (PlatformErrorType.UNKNOWN, False))
|
||||
return PlatformError(
|
||||
- message, platform="volcengine_caption",
|
||||
- retryable=retryable, error_type=error_type,
|
||||
+ message,
|
||||
+ platform="volcengine_caption",
|
||||
+ retryable=retryable,
|
||||
+ error_type=error_type,
|
||||
status_code=status,
|
||||
)
|
||||
|
||||
@@ -124,7 +128,7 @@ class VolcengineCaptionProvider:
|
||||
max_lines: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
"""提交字幕生成任务,返回 {id: task_id}"""
|
||||
- params = {
|
||||
+ params: dict[str, str | int] = {
|
||||
"appid": self.appid,
|
||||
"language": language,
|
||||
"caption_type": caption_type,
|
||||
@@ -150,11 +154,15 @@ class VolcengineCaptionProvider:
|
||||
except PlatformError:
|
||||
raise
|
||||
except httpx.HTTPStatusError as e:
|
||||
- raise _map_caption_error(e.response.status_code, f"HTTP错误: {e.response.status_code}") from e
|
||||
+ raise _map_caption_error(
|
||||
+ e.response.status_code, f"HTTP错误: {e.response.status_code}"
|
||||
+ ) from e
|
||||
except (httpx.NetworkError, httpx.TimeoutException) as e:
|
||||
raise PlatformError(
|
||||
- f"字幕服务网络错误: {e}", platform="volcengine_caption",
|
||||
- retryable=True, error_type=PlatformErrorType.TIMEOUT,
|
||||
+ f"字幕服务网络错误: {e}",
|
||||
+ platform="volcengine_caption",
|
||||
+ retryable=True,
|
||||
+ error_type=PlatformErrorType.TIMEOUT,
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise _map_caption_error(500, f"提交任务失败: {str(e)}") from e
|
||||
@@ -165,7 +173,7 @@ class VolcengineCaptionProvider:
|
||||
blocking: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""查询字幕任务结果,返回原始 JSON"""
|
||||
- params = {
|
||||
+ params: dict[str, str | int] = {
|
||||
"appid": self.appid,
|
||||
"id": task_id,
|
||||
"blocking": 1 if blocking else 0,
|
||||
@@ -182,11 +190,15 @@ class VolcengineCaptionProvider:
|
||||
except PlatformError:
|
||||
raise
|
||||
except httpx.HTTPStatusError as e:
|
||||
- raise _map_caption_error(e.response.status_code, f"HTTP错误: {e.response.status_code}") from e
|
||||
+ raise _map_caption_error(
|
||||
+ e.response.status_code, f"HTTP错误: {e.response.status_code}"
|
||||
+ ) from e
|
||||
except (httpx.NetworkError, httpx.TimeoutException) as e:
|
||||
raise PlatformError(
|
||||
- f"字幕服务网络错误: {e}", platform="volcengine_caption",
|
||||
- retryable=True, error_type=PlatformErrorType.TIMEOUT,
|
||||
+ f"字幕服务网络错误: {e}",
|
||||
+ platform="volcengine_caption",
|
||||
+ retryable=True,
|
||||
+ error_type=PlatformErrorType.TIMEOUT,
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise _map_caption_error(500, f"查询任务失败: {str(e)}") from e
|
||||
@@ -201,7 +213,7 @@ class VolcengineCaptionProvider:
|
||||
sta_punc_mode: int = 3,
|
||||
) -> dict[str, Any]:
|
||||
"""提交自动字幕打轴任务,返回 {id: task_id}"""
|
||||
- params = {
|
||||
+ params: dict[str, str | int] = {
|
||||
"appid": self.appid,
|
||||
"caption_type": caption_type,
|
||||
"sta_punc_mode": sta_punc_mode,
|
||||
@@ -218,7 +230,9 @@ class VolcengineCaptionProvider:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if "id" not in data:
|
||||
- raise _map_caption_error(500, f"提交打轴任务失败: {data.get('message', '未知错误')}")
|
||||
+ raise _map_caption_error(
|
||||
+ 500, f"提交打轴任务失败: {data.get('message', '未知错误')}"
|
||||
+ )
|
||||
return data
|
||||
except PlatformError:
|
||||
raise
|
||||
diff --git a/python-api/app/ai/providers/volcengine_provider.py b/python-api/app/ai/providers/volcengine_provider.py
|
||||
index 0e2a5d5..9f029a0 100644
|
||||
--- a/python-api/app/ai/providers/volcengine_provider.py
|
||||
+++ b/python-api/app/ai/providers/volcengine_provider.py
|
||||
@@ -291,27 +291,40 @@ class VolcengineProvider(LLMProvider):
|
||||
|
||||
if status == 429 or "rate limit" in message.lower():
|
||||
return PlatformError(
|
||||
- message, platform="volcengine_ark", retryable=True,
|
||||
- error_type=PlatformErrorType.RATE_LIMIT, status_code=status,
|
||||
+ message,
|
||||
+ platform="volcengine_ark",
|
||||
+ retryable=True,
|
||||
+ error_type=PlatformErrorType.RATE_LIMIT,
|
||||
+ status_code=status,
|
||||
)
|
||||
elif status in (401, 403) or "authentication" in message.lower():
|
||||
return PlatformError(
|
||||
- message, platform="volcengine_ark", retryable=False,
|
||||
- error_type=PlatformErrorType.AUTH_FAILED, status_code=status,
|
||||
+ message,
|
||||
+ platform="volcengine_ark",
|
||||
+ retryable=False,
|
||||
+ error_type=PlatformErrorType.AUTH_FAILED,
|
||||
+ status_code=status,
|
||||
)
|
||||
elif status and status >= 500:
|
||||
return PlatformError(
|
||||
- message, platform="volcengine_ark", retryable=True,
|
||||
- error_type=PlatformErrorType.SERVER_ERROR, status_code=status,
|
||||
+ message,
|
||||
+ platform="volcengine_ark",
|
||||
+ retryable=True,
|
||||
+ error_type=PlatformErrorType.SERVER_ERROR,
|
||||
+ status_code=status,
|
||||
)
|
||||
elif "timeout" in message.lower() or isinstance(e, TimeoutError):
|
||||
return PlatformError(
|
||||
- message, platform="volcengine_ark", retryable=True,
|
||||
+ message,
|
||||
+ platform="volcengine_ark",
|
||||
+ retryable=True,
|
||||
error_type=PlatformErrorType.TIMEOUT,
|
||||
)
|
||||
else:
|
||||
return PlatformError(
|
||||
- message, platform="volcengine_ark", retryable=False,
|
||||
+ message,
|
||||
+ platform="volcengine_ark",
|
||||
+ retryable=False,
|
||||
error_type=PlatformErrorType.UNKNOWN,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
diff --git a/python-api/app/services/point_service.py b/python-api/app/services/point_service.py
|
||||
index 0e5d79e..709f78d 100644
|
||||
--- a/python-api/app/services/point_service.py
|
||||
+++ b/python-api/app/services/point_service.py
|
||||
@@ -25,7 +25,7 @@ import logging
|
||||
import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
-from typing import TYPE_CHECKING
|
||||
+from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
from sqlalchemy import select
|
||||
@@ -33,6 +33,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
+from app.core.exceptions import InsufficientPointsException
|
||||
from app.models.point_batch import PointBatch
|
||||
from app.models.point_transaction import PointTransaction
|
||||
from app.models.user_point import UserPoint
|
||||
@@ -46,11 +47,11 @@ if TYPE_CHECKING:
|
||||
_CONFIG_PATH = Path(__file__).resolve().parents[2] / "config" / "points-config.yaml"
|
||||
|
||||
|
||||
-def _load_points_config() -> dict:
|
||||
+def _load_points_config() -> dict[str, Any]:
|
||||
"""加载积分计费配置。服务启动时读取一次,后续内存中使用。"""
|
||||
if not _CONFIG_PATH.exists():
|
||||
raise FileNotFoundError(f"积分配置文件不存在: {_CONFIG_PATH}")
|
||||
- with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
+ with open(_CONFIG_PATH, encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
# 合并为统一的查询字典:source_type -> {"mode": "fixed|duration|free", ...}
|
||||
merged: dict[str, dict] = {}
|
||||
@@ -65,18 +66,22 @@ def _load_points_config() -> dict:
|
||||
return merged
|
||||
|
||||
|
||||
-POINTS_CONFIG: dict[str, dict] = _load_points_config()
|
||||
+POINTS_CONFIG: dict[str, Any] = _load_points_config()
|
||||
|
||||
|
||||
def get_recharge_options() -> list[dict]:
|
||||
"""获取充值档位配置(由后端控制,支持积分赠送)"""
|
||||
- return POINTS_CONFIG.get("_recharge_options", [])
|
||||
+ options = POINTS_CONFIG.get("_recharge_options", [])
|
||||
+ if isinstance(options, list):
|
||||
+ return options
|
||||
+ return []
|
||||
|
||||
|
||||
def get_chargeable_source_types() -> list[str]:
|
||||
"""获取所有需要扣费的业务类型列表(排除免费业务)"""
|
||||
return [
|
||||
- key for key, cfg in POINTS_CONFIG.items()
|
||||
+ key
|
||||
+ for key, cfg in POINTS_CONFIG.items()
|
||||
if not key.startswith("_") and cfg.get("mode") != "free"
|
||||
]
|
||||
|
||||
@@ -163,11 +168,10 @@ def _estimate_max_cost(source_type: str, param: dict | None = None) -> int:
|
||||
|
||||
# ── 余额查询 ──────────────────────────────────────────
|
||||
|
||||
+
|
||||
async def get_user_balance(db: AsyncSession, user_id: UUID | str) -> dict:
|
||||
"""获取用户积分余额快照(实时计算,排除已过期批次)。"""
|
||||
- result = await db.execute(
|
||||
- select(UserPoint).where(UserPoint.user_id == user_id)
|
||||
- )
|
||||
+ result = await db.execute(select(UserPoint).where(UserPoint.user_id == user_id))
|
||||
up = result.scalar_one_or_none()
|
||||
|
||||
if not up:
|
||||
@@ -182,8 +186,7 @@ async def get_user_balance(db: AsyncSession, user_id: UUID | str) -> dict:
|
||||
from sqlalchemy import func as _func
|
||||
|
||||
available_result = await db.execute(
|
||||
- select(_func.coalesce(_func.sum(PointBatch.remaining), 0))
|
||||
- .where(
|
||||
+ select(_func.coalesce(_func.sum(PointBatch.remaining), 0)).where(
|
||||
PointBatch.user_id == user_id,
|
||||
PointBatch.remaining > 0,
|
||||
PointBatch.expired_at > _now(),
|
||||
@@ -221,6 +224,7 @@ async def check_balance(
|
||||
|
||||
# ── 充值 ──────────────────────────────────────────────
|
||||
|
||||
+
|
||||
async def recharge(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -247,8 +251,7 @@ async def recharge(
|
||||
# 幂等保护:同一笔订单(order_id)只能充值一次
|
||||
if order_id:
|
||||
existing_result = await db.execute(
|
||||
- select(PointTransaction)
|
||||
- .where(
|
||||
+ select(PointTransaction).where(
|
||||
PointTransaction.source_id == str(order_id),
|
||||
PointTransaction.type == "recharge",
|
||||
)
|
||||
@@ -259,9 +262,7 @@ async def recharge(
|
||||
return existing_tx
|
||||
|
||||
# 1. 获取或创建用户积分账户
|
||||
- result = await db.execute(
|
||||
- select(UserPoint).where(UserPoint.user_id == user_id)
|
||||
- )
|
||||
+ result = await db.execute(select(UserPoint).where(UserPoint.user_id == user_id))
|
||||
up = result.scalar_one_or_none()
|
||||
|
||||
if not up:
|
||||
@@ -353,7 +354,7 @@ async def consume(
|
||||
直接扣费(后置计费)。
|
||||
|
||||
业务执行成功后调用,按实际消耗直接扣除余额。
|
||||
- 默认不允许欠费(余额不足时抛出 ValueError)。
|
||||
+ 默认不允许欠费(余额不足时抛出 InsufficientPointsException)。
|
||||
Scheduler 后置扣费等场景可设置 allow_negative=True,允许余额变负。
|
||||
|
||||
:param points: 实际消耗积分(正整数)
|
||||
@@ -383,9 +384,7 @@ async def consume(
|
||||
|
||||
# 2. 获取用户积分账户(加锁)
|
||||
result = await db.execute(
|
||||
- select(UserPoint)
|
||||
- .where(UserPoint.user_id == user_id)
|
||||
- .with_for_update()
|
||||
+ select(UserPoint).where(UserPoint.user_id == user_id).with_for_update()
|
||||
)
|
||||
up = result.scalar_one_or_none()
|
||||
|
||||
@@ -404,7 +403,7 @@ async def consume(
|
||||
# 3. 余额检查:用实时可用余额(未过期批次 remaining 总和),避免 expire_batches 延迟导致超扣
|
||||
available = sum(b.remaining for b in batches)
|
||||
if not allow_negative and available < points:
|
||||
- raise ValueError(f"积分不足,当前可用余额 {available},需要 {points} 积分")
|
||||
+ raise InsufficientPointsException(f"积分不足,当前可用余额 {available},需要 {points} 积分")
|
||||
|
||||
remaining_to_deduct = points
|
||||
for batch in batches:
|
||||
@@ -440,6 +439,7 @@ async def consume(
|
||||
|
||||
# ── 过期回收 ──────────────────────────────────────────
|
||||
|
||||
+
|
||||
async def expire_batches(db: AsyncSession) -> int:
|
||||
"""
|
||||
回收过期积分批次。返回过期积分总数。
|
||||
@@ -468,9 +468,7 @@ async def expire_batches(db: AsyncSession) -> int:
|
||||
|
||||
# 获取用户积分账户(加锁)
|
||||
up_result = await db.execute(
|
||||
- select(UserPoint)
|
||||
- .where(UserPoint.user_id == batch.user_id)
|
||||
- .with_for_update()
|
||||
+ select(UserPoint).where(UserPoint.user_id == batch.user_id).with_for_update()
|
||||
)
|
||||
up = up_result.scalar_one_or_none()
|
||||
if not up:
|
||||
diff --git a/python-api/app/services/script_service.py b/python-api/app/services/script_service.py
|
||||
index 49aa4b1..60f58d5 100644
|
||||
--- a/python-api/app/services/script_service.py
|
||||
+++ b/python-api/app/services/script_service.py
|
||||
@@ -7,9 +7,16 @@ import asyncio
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
+from typing import Any
|
||||
|
||||
from app.ai.model_router import get_model_router
|
||||
from app.ai.prompts import load_prompt_file, load_script_user_prompt
|
||||
+from app.core.exceptions import (
|
||||
+ AIEmptyResponseException,
|
||||
+ AIParseErrorException,
|
||||
+ AITimeoutException,
|
||||
+ PromptNotFoundException,
|
||||
+)
|
||||
from app.schemas.script import ScriptShot
|
||||
from app.services.ai_response_utils import (
|
||||
safe_parse_ai_json_response,
|
||||
@@ -22,12 +29,9 @@ logger = logging.getLogger(__name__)
|
||||
class ScriptService:
|
||||
"""脚本生成服务"""
|
||||
|
||||
-
|
||||
def __init__(self):
|
||||
self.prompts_dir = Path(__file__).parent.parent / "ai" / "prompts"
|
||||
|
||||
-
|
||||
-
|
||||
def _load_prompt(self, name: str) -> str:
|
||||
"""加载 Prompt 模板"""
|
||||
prompt_file = self.prompts_dir / f"{name}.txt"
|
||||
@@ -58,7 +62,7 @@ class ScriptService:
|
||||
# 加载 Prompt
|
||||
system_prompt = load_prompt_file(category, filename)
|
||||
if not system_prompt:
|
||||
- raise ValueError(f"未找到提示词: category={category}, filename={filename}")
|
||||
+ raise PromptNotFoundException(f"未找到提示词: category={category}, filename={filename}")
|
||||
|
||||
# 用户提示词
|
||||
user_prompt = load_script_user_prompt(
|
||||
@@ -75,24 +79,26 @@ class ScriptService:
|
||||
)
|
||||
|
||||
if not result.content or not result.content.strip():
|
||||
- raise ValueError("AI 返回内容为空,请检查模型配置或重试")
|
||||
+ raise AIEmptyResponseException("AI 返回内容为空,请检查模型配置或重试")
|
||||
|
||||
success, parsed_data, error_msg = safe_parse_ai_json_response(result.content)
|
||||
|
||||
if not success:
|
||||
- raise ValueError(error_msg or "AI 返回格式错误,无法解析为 JSON")
|
||||
+ raise AIParseErrorException(error_msg or "AI 返回格式错误,无法解析为 JSON")
|
||||
|
||||
try:
|
||||
shots_data = validate_and_normalize_shots(parsed_data)
|
||||
|
||||
if not shots_data:
|
||||
- raise ValueError("AI 返回的分镜数据为空或格式不正确")
|
||||
+ raise AIEmptyResponseException("AI 返回的分镜数据为空或格式不正确")
|
||||
|
||||
shots = [ScriptShot(**shot) for shot in shots_data]
|
||||
return shots
|
||||
|
||||
+ except (AIEmptyResponseException, AIParseErrorException):
|
||||
+ raise
|
||||
except Exception as e:
|
||||
- raise ValueError(f"分镜数据处理失败: {str(e)}")
|
||||
+ raise AIParseErrorException(f"分镜数据处理失败: {str(e)}")
|
||||
|
||||
async def polish_content(
|
||||
self,
|
||||
@@ -144,21 +150,23 @@ class ScriptService:
|
||||
)
|
||||
return result.content.strip()
|
||||
except TimeoutError:
|
||||
- raise ValueError("润色请求超时,请重试")
|
||||
+ raise AITimeoutException("润色请求超时,请重试")
|
||||
+ except (AIEmptyResponseException, AIParseErrorException, AITimeoutException):
|
||||
+ raise
|
||||
except Exception as e:
|
||||
- raise ValueError(f"润色失败: {str(e)}")
|
||||
+ raise AIParseErrorException(f"润色失败: {str(e)}")
|
||||
|
||||
async def check_model_health(self) -> dict:
|
||||
"""检查模型健康状态"""
|
||||
model_router = await get_model_router()
|
||||
health_results = await model_router.health_check()
|
||||
|
||||
- models = []
|
||||
+ models: list[dict[str, Any]] = []
|
||||
available_count = 0
|
||||
- recommended = None
|
||||
+ recommended: dict[str, Any] | None = None
|
||||
|
||||
for _provider_id, health in health_results.items():
|
||||
- model_info = {
|
||||
+ model_info: dict[str, Any] = {
|
||||
"id": health.id,
|
||||
"name": health.name,
|
||||
"is_available": health.is_available,
|
||||
@@ -169,9 +177,12 @@ class ScriptService:
|
||||
|
||||
if health.is_available:
|
||||
available_count += 1
|
||||
- if recommended is None or health.response_time < recommended.get(
|
||||
- "response_time", float("inf")
|
||||
- ):
|
||||
+ current_best = (
|
||||
+ float("inf")
|
||||
+ if recommended is None
|
||||
+ else float(recommended.get("response_time") or float("inf"))
|
||||
+ )
|
||||
+ if health.response_time < current_best:
|
||||
recommended = model_info
|
||||
|
||||
total = len(models)
|
||||
@@ -188,7 +199,6 @@ class ScriptService:
|
||||
"""测试指定模型连接"""
|
||||
model_router = await get_model_router()
|
||||
|
||||
-
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
diff --git a/python-api/app/services/vidu_service.py b/python-api/app/services/vidu_service.py
|
||||
index 054823f..61bbdcd 100644
|
||||
--- a/python-api/app/services/vidu_service.py
|
||||
+++ b/python-api/app/services/vidu_service.py
|
||||
@@ -207,8 +207,9 @@ class ViduService:
|
||||
error_type=PlatformErrorType.BAD_REQUEST,
|
||||
)
|
||||
|
||||
- logger.info(f"[Vidu Clone] 复刻成功: voice_id={result.data.get('voice_id')}")
|
||||
- return result.data or {}
|
||||
+ clone_data = result.data or {}
|
||||
+ logger.info(f"[Vidu Clone] 复刻成功: voice_id={clone_data.get('voice_id')}")
|
||||
+ return clone_data
|
||||
|
||||
async def query_clone_task(self, voice_id: str) -> dict[str, Any]:
|
||||
"""Vidu 声音复刻是同步接口,无独立查询。
|
||||
@@ -270,6 +271,8 @@ class ViduService:
|
||||
result_data = status.result or {}
|
||||
return {
|
||||
"state": ViduAdapter.denormalize_state(status.state),
|
||||
- "creations": [{"url": result_data.get("video_url")}] if result_data.get("video_url") else [],
|
||||
+ "creations": (
|
||||
+ [{"url": result_data.get("video_url")}] if result_data.get("video_url") else []
|
||||
+ ),
|
||||
"message": status.error_message,
|
||||
}
|
||||
diff --git a/python-api/app/services/volcengine_caption_service.py b/python-api/app/services/volcengine_caption_service.py
|
||||
index 83f59b4..9a565a5 100644
|
||||
--- a/python-api/app/services/volcengine_caption_service.py
|
||||
+++ b/python-api/app/services/volcengine_caption_service.py
|
||||
@@ -155,10 +155,7 @@ class VolcengineCaptionService:
|
||||
error_type=PlatformErrorType.BAD_REQUEST,
|
||||
)
|
||||
|
||||
- logger.warning(
|
||||
- f"{task_name}超过最大轮询次数: task_id={task_id}, "
|
||||
- f"retries={retries}"
|
||||
- )
|
||||
+ logger.warning(f"{task_name}超过最大轮询次数: task_id={task_id}, " f"retries={retries}")
|
||||
raise PlatformError(
|
||||
f"{task_name}超时,请稍后重试",
|
||||
platform="volcengine_caption",
|
||||
Reference in New Issue
Block a user