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