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="克隆已完成", ) - -