优化接口提示。

This commit is contained in:
Hommy
2026-10-18 10:01:27 +08:00
parent 885366ce7f
commit c3c0292708
18 changed files with 319 additions and 66 deletions
+1 -1
View File
@@ -185,7 +185,7 @@ The following are the core interfaces provided by CapCut Mate API, supporting a
### 📤 Material Upload
| Interface | Function | Description | Documentation Link |
|-----------|----------|-------------|-------------------|
| **upload_file** | Upload File | Server-relayed upload to object storage (max 500MB, 0.0005 CNY/MB), returns a usable signed URL | [📖 View Documentation](./docs/upload_file.md) |
| **upload_file** | Upload File | Server-relayed upload to object storage (max 500MB, 0.005 CNY/MB), returns a usable signed URL | [📖 View Documentation](./docs/upload_file.md) |
### 🛠️ Utility Tools
| Interface | Function | Description | Documentation Link |
+1 -1
View File
@@ -185,7 +185,7 @@ docker-compose pull && docker-compose up -d
### 📤素材上传
| 接口 | 功能 | 描述 | 文档链接 |
|------|------|------|----------|
| **upload_file** | 上传文件 | 服务端中转上传文件到对象存储(最大 500MB,0.0005 元/MB),返回带签名的可用 URL | [📖 查看文档](./docs/upload_file.zh.md) |
| **upload_file** | 上传文件 | 服务端中转上传文件到对象存储(最大 500MB,0.005 元/MB),返回带签名的可用 URL | [📖 查看文档](./docs/upload_file.zh.md) |
### 🛠️工具类接口
|接口 |功能 |描述 | 文档链接 |
+3 -3
View File
@@ -27,7 +27,7 @@ Submit video generation task. This interface uses asynchronous processing mode,
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| draft_url | string | ✅ | - | Complete URL of the target draft |
| apiKey | string | Per server config | - | A valid UUID; required when `ENABLE_APIKEY=true`, get one at https://jcaigc.cn |
| apiKey | string | Per server config | - | A valid UUID; required when `ENABLE_APIKEY=true`, get one at https://jcaigc.cn/user-info |
### Parameter Details
@@ -85,8 +85,8 @@ curl -X POST https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/gen_video \
|------------|---------------|-------------|----------|
| 1001 | Parameter validation failed | `apiKey` is not a valid UUID, or a parameter has the wrong type | Check parameter types and required fields |
| 2001 | Invalid draft URL | `draft_url` is missing or does not contain a `draft_id` | Pass the `draft_url` returned by `create_draft` |
| 2035 | Insufficient account balance | The account has no more than 1 point | Recharge the account and retry |
| 2036 | Invalid apiKey | `ENABLE_APIKEY=true` and `apiKey` is missing | Log in at https://jcaigc.cn to get an apiKey |
| 2035 | Insufficient account balance | The account balance must be greater than 0 | Recharge at https://jcaigc.cn/recharge and retry |
| 2036 | Invalid apiKey | `ENABLE_APIKEY=true` and `apiKey` is missing | Log in at https://jcaigc.cn/user-info to get an apiKey |
| 9998 | Internal server error | Unexpected error while submitting the task | Retry later or contact technical support |
## Notes
+3 -3
View File
@@ -27,7 +27,7 @@ POST /openapi/capcut-mate/v1/gen_video
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| draft_url | string | ✅ | - | 目标草稿的完整URL |
| apiKey | string | 按服务端配置 | - | 合法的 UUID;`ENABLE_APIKEY=true` 时必填,可登录 https://jcaigc.cn 获取 |
| apiKey | string | 按服务端配置 | - | 合法的 UUID;`ENABLE_APIKEY=true` 时必填,可登录 https://jcaigc.cn/user-info 获取 |
### 参数详解
@@ -85,8 +85,8 @@ curl -X POST https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/gen_video \
|--------|----------|------|----------|
| 1001 | 参数校验失败 | `apiKey` 不是合法 UUID,或参数类型不正确 | 检查参数类型与必填字段 |
| 2001 | 无效的草稿URL | `draft_url` 缺失或不含 `draft_id` | 传入 `create_draft` 返回的 `draft_url` |
| 2035 | 充值余额不足 | 当前余额不大于 1 | 完成充值后重试 |
| 2036 | 无效的 apiKey | `ENABLE_APIKEY=true` 且未传 `apiKey` | 登录 https://jcaigc.cn 获取 apiKey |
| 2035 | 充值余额不足 | 当前余额不大于 0 | 前往 https://jcaigc.cn/recharge 充值后重试 |
| 2036 | 无效的 apiKey | `ENABLE_APIKEY=true` 且未传 `apiKey` | 登录 https://jcaigc.cn/user-info 获取 apiKey |
| 9998 | 系统内部错误 | 提交任务时发生未知错误 | 稍后重试或联系技术支持 |
## 注意事项
+8 -8
View File
@@ -13,7 +13,7 @@ Content-Type: multipart/form-data
## Function Description
Upload a file to object storage (server-relayed). The client sends the file bytes to this service,
which relays them to object storage, charges by the actual file size (0.0005 CNY/MB) after a
which relays them to object storage, charges by the actual file size (0.005 CNY/MB) after a
successful upload, and returns a signed download URL.
Object storage credentials stay on the server and are never exposed to the client.
@@ -32,7 +32,7 @@ curl -X POST https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/upload_file \
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| file | file | ✅ | - | File to upload, max 500MB |
| apiKey | string | Per server config | - | A valid UUID; required when `ENABLE_APIKEY=true`, get one at https://jcaigc.cn |
| apiKey | string | Per server config | - | A valid UUID; required when `ENABLE_APIKEY=true`, get one at https://jcaigc.cn/user-info |
### Parameter Details
@@ -45,7 +45,7 @@ curl -X POST https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/upload_file \
#### apiKey
- **Type**: string
- **Description**: Must be a valid UUID; required when the server enforces apiKey, and the account balance must be greater than 1
- **Description**: Must be a valid UUID; required when the server enforces apiKey, and the account balance must be greater than 0
## Response Format
@@ -59,7 +59,7 @@ curl -X POST https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/upload_file \
"key": "jianchuang/2026-10-06/8f3a1c2d5e6b7a90_demo.mp4",
"size": 10485760,
"size_mb": 10.0,
"cost": 0.005,
"cost": 0.05,
"url_expire_days": 7
}
```
@@ -118,8 +118,8 @@ print("size:", result["size_mb"], "MB", "cost:", result["cost"], "CNY")
|------|---------|-------------|----------|
| 1001 | Parameter validation failed | Missing file field, apiKey is not a valid UUID, or the file is empty | Check the request payload |
| 2004 | File size exceeds the limit | File is larger than 500MB | Compress or split the file and retry |
| 2035 | Insufficient account balance | The account balance must be greater than 1 | Top up and retry |
| 2036 | Invalid apiKey | `ENABLE_APIKEY=true` and apiKey is missing or invalid | Get an apiKey at https://jcaigc.cn |
| 2035 | Insufficient account balance | The account balance must be greater than 0 and cover this upload | Recharge at https://jcaigc.cn/recharge and retry |
| 2036 | Invalid apiKey | `ENABLE_APIKEY=true` and apiKey is missing or invalid | Get an apiKey at https://jcaigc.cn/user-info |
| 2049 | Invalid file name | File name is empty or nothing usable remains after sanitization | Provide a valid file name |
| 2050 | Unsupported file type | Extension not in the built-in whitelist | Use a supported format |
| 9998 | Internal server error | No object storage configured, or the upload failed | Check the server storage configuration |
@@ -127,14 +127,14 @@ print("size:", result["size_mb"], "MB", "cost:", result["cost"], "CNY")
## Notes
1. **Server-relayed**: the file is uploaded to this service first and then relayed to object storage; storage credentials are never sent to the client
2. **Billing**: 0.0005 CNY/MB based on the actual file size (6 decimal places), charged only after a successful object storage upload
2. **Billing**: 0.005 CNY/MB based on the actual file size (6 decimal places), charged only after a successful object storage upload; the upload is rejected up front when the balance cannot cover it
3. **Size limit**: max 500MB per file; behind nginx you must also raise `client_max_body_size` (the stock 1MB default returns 413)
4. **Expiry**: the returned `url` is a signed temporary address whose lifetime comes from `VIDEO_GEN_RETENTION_DAYS` (7 days by default)
5. **Ready to use**: `url` can be passed directly to add_videos / add_images / add_audios
## Workflow
1. Validate the apiKey (when `ENABLE_APIKEY=true`) and the account balance (must be greater than 1)
1. Validate the apiKey (when `ENABLE_APIKEY=true`) and the account balance (must be greater than 0)
2. Sanitize the file name and check the extension whitelist
3. Stream the file to disk while counting bytes (reject when empty or over 500MB)
4. Upload the file to object storage (COS / OSS / TOS)
+8 -8
View File
@@ -13,7 +13,7 @@ Content-Type: multipart/form-data
## 功能描述
上传文件到对象存储(服务端中转)。客户端把文件字节提交到本服务,服务端转发上传到对象存储,
上传成功后按文件实际体积计费(0.0005 元/MB),并返回带签名的下载 URL。
上传成功后按文件实际体积计费(0.005 元/MB),并返回带签名的下载 URL。
对象存储密钥全程留在服务端,不会下发给客户端。
## 请求参数
@@ -31,7 +31,7 @@ curl -X POST https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/upload_file \
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| file | file | ✅ | - | 待上传文件,单文件最大 500MB |
| apiKey | string | 按服务端配置 | - | 合法的 UUID;`ENABLE_APIKEY=true` 时必填,可登录 https://jcaigc.cn 获取 |
| apiKey | string | 按服务端配置 | - | 合法的 UUID;`ENABLE_APIKEY=true` 时必填,可登录 https://jcaigc.cn/user-info 获取 |
### 参数详解
@@ -44,7 +44,7 @@ curl -X POST https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/upload_file \
#### apiKey
- **类型**: string
- **说明**: 必须是合法的 UUID 格式;服务端开启 apiKey 校验时必填,且充值余额需大于 1 才可上传
- **说明**: 必须是合法的 UUID 格式;服务端开启 apiKey 校验时必填,且充值余额需大于 0 才可上传
## 响应格式
@@ -58,7 +58,7 @@ curl -X POST https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/upload_file \
"key": "jianchuang/2026-10-06/8f3a1c2d5e6b7a90_demo.mp4",
"size": 10485760,
"size_mb": 10.0,
"cost": 0.005,
"cost": 0.05,
"url_expire_days": 7
}
```
@@ -117,8 +117,8 @@ print("体积:", result["size_mb"], "MB", "费用:", result["cost"], "元")
|--------|----------|------|----------|
| 1001 | 参数校验失败 | 缺少 file 字段、apiKey 不是合法 UUID,或上传了空文件 | 检查请求参数 |
| 2004 | 文件大小超出限制 | 文件超过 500MB | 压缩或切分文件后重试 |
| 2035 | 充值余额不足 | 充值余额需大于 1 才可继续使用服务 | 完成充值后重试 |
| 2036 | 无效的 apiKey | `ENABLE_APIKEY=true` 且 apiKey 缺失或非法 | 登录 https://jcaigc.cn 获取 apiKey |
| 2035 | 充值余额不足 | 充值余额需大于 0,且需覆盖本次上传费用 | 前往 https://jcaigc.cn/recharge 充值后重试 |
| 2036 | 无效的 apiKey | `ENABLE_APIKEY=true` 且 apiKey 缺失或非法 | 登录 https://jcaigc.cn/user-info 获取 apiKey |
| 2049 | 无效的文件名 | 文件名为空或清洗后没有可用字符 | 提供合法的文件名 |
| 2050 | 不支持的文件类型 | 扩展名不在内置白名单内 | 改用受支持的格式 |
| 9998 | 系统内部错误 | 未配置任何对象存储或上传失败 | 检查服务端对象存储配置 |
@@ -126,14 +126,14 @@ print("体积:", result["size_mb"], "MB", "费用:", result["cost"], "元")
## 注意事项
1. **服务端中转**:文件先上传到本服务,再由服务端上传到对象存储;对象存储密钥不会下发给客户端
2. **计费规则**:0.0005 元/MB,按文件实际字节数计算(保留 6 位小数),仅在对象存储上传成功后才扣费
2. **计费规则**:0.005 元/MB,按文件实际字节数计算(保留 6 位小数),仅在对象存储上传成功后才扣费;余额不足以覆盖本次费用时会在上传前直接拒绝
3. **大小限制**:单文件最大 500MB;若部署在 nginx 之后,需同时放开 `client_max_body_size`(默认 1MB 会直接返回 413)
4. **有效期**:返回的 `url` 为带签名的临时地址,有效期由 `VIDEO_GEN_RETENTION_DAYS` 控制(默认 7 天)
5. **上传完成即可用**:`url` 可直接传给 add_videos / add_images / add_audios 等接口使用
## 工作流程
1. 校验 apiKey(`ENABLE_APIKEY=true` 时)与充值余额(需大于 1)
1. 校验 apiKey(`ENABLE_APIKEY=true` 时)与充值余额(需大于 0)
2. 清洗文件名并校验扩展名白名单
3. 流式落盘并统计文件体积(超过 500MB 或为空则拒绝)
4. 上传文件到对象存储(COS / OSS / TOS)
+2 -2
View File
@@ -46,8 +46,8 @@ class CustomError(Enum):
VIDEO_STATUS_QUERY_FAILED = (2032, "视频任务状态查询失败", "Video task status query failed")
IMAGE_ANIMATION_GET_FAILED = (2033, "获取图片动画失败", "Get image animation failed")
AUDIO_DURATION_GET_FAILED = (2034, "获取音频时长失败", "Get audio duration failed")
INSUFFICIENT_ACCOUNT_BALANCE = (2035, "充值余额不足,当前余额需大于 1 才可继续使用服务,请完成充值后重试", "Insufficient account balance. Your balance must be greater than 1 to continue using the service. Please recharge and try again.")
INVALID_APIKEY = (2036, "无效的 apiKey,请登录官网 https://jcaigc.cn 获取", "Invalid apiKey. Please log in at https://jcaigc.cn to obtain one")
INSUFFICIENT_ACCOUNT_BALANCE = (2035, "充值余额不足,当前余额需大于 0 才可继续使用服务,请前往 https://jcaigc.cn/recharge 充值后重试", "Insufficient account balance. Your balance must be greater than 0 to continue using the service. Please recharge at https://jcaigc.cn/recharge and try again.")
INVALID_APIKEY = (2036, "无效或未提供 apiKey,请登录官网 https://jcaigc.cn/user-info 获取 apiKey", "Invalid or missing apiKey. Please log in at https://jcaigc.cn/user-info to obtain one")
INVALID_FILTER_INFO = (2037, "无效的滤镜信息,请检查 filter_infos 字段值是否正确", "Invalid filter information, please check if the value of the filter_infos field is correct.")
FILTER_ADD_FAILED = (2038, "滤镜添加失败", "Filter addition failed")
FILTER_NOT_FOUND = (2039, "滤镜未找到,请检查滤镜名称是否正确", "Filter not found, please check if the filter name is correct.")
+2 -2
View File
@@ -779,13 +779,13 @@ def get_url(gur: GetUrlRequest) -> GetUrlResponse:
@router.post(path="/upload_file", response_model=UploadFileResponse)
def upload_file(
file: UploadFile = File(..., description="待上传文件(multipart/form-data 的 file 字段),单文件最大 500MB"),
apiKey: Optional[str] = Form(default=None, description="apiKey 必须是合法的 UUID 格式;可登录官网 https://jcaigc.cn 获取"),
apiKey: Optional[str] = Form(default=None, description="apiKey 必须是合法的 UUID 格式;可登录官网 https://jcaigc.cn/user-info 获取"),
) -> UploadFileResponse:
"""
上传文件到对象存储(服务端中转,v1版本)
客户端以 multipart/form-data 提交文件,服务端中转上传到对象存储,上传成功后按文件体积
(0.0005 元/MB)计费,并返回带签名的下载 URL,可直接传给 add_videos / add_images /
(0.005 元/MB)计费,并返回带签名的下载 URL,可直接传给 add_videos / add_images /
add_audios 等接口使用。对象存储密钥全程留在服务端,不会下发给客户端。
"""
result = service.upload_file(
+1 -1
View File
@@ -10,7 +10,7 @@ class GenVideoRequest(BaseModel):
draft_url: str = Field(default="", description="草稿URL")
apiKey: Optional[str] = Field(
default=None,
description="apiKey 必须是合法的 UUID 格式;可登录官网 https://jcaigc.cn 获取",
description="apiKey 必须是合法的 UUID 格式;可登录官网 https://jcaigc.cn/user-info 获取",
)
@field_validator('apiKey')
+2 -2
View File
@@ -7,7 +7,7 @@ class UploadFileResponse(BaseModel):
key: str = Field(..., description="对象存储 object key")
size: int = Field(..., description="文件实际字节数")
size_mb: float = Field(..., description="文件体积,单位:MB(保留 3 位小数)")
cost: float = Field(..., description="本次上传费用,单位:元(0.0005 元/MB,上传成功后扣费;未启用计费时为 0)")
cost: float = Field(..., description="本次上传费用,单位:元(0.005 元/MB,上传成功后扣费;未启用计费时为 0)")
url_expire_days: int = Field(..., description="url 有效期,单位:天")
class Config:
@@ -17,7 +17,7 @@ class UploadFileResponse(BaseModel):
"key": "jianchuang/2026-10-06/8f3a1c2d5e6b7a90_demo.mp4",
"size": 10485760,
"size_mb": 10.0,
"cost": 0.005,
"cost": 0.05,
"url_expire_days": 7
}
}
+1 -1
View File
@@ -22,6 +22,6 @@ def validate_api_key_uuid(value: Optional[str]) -> Optional[str]:
uuid.UUID(value)
except ValueError:
raise ValueError(
"API密钥格式不正确,必须是合法的UUID;请登录官网 https://jcaigc.cn 获取 apiKey"
"API密钥格式不正确,必须是合法的UUID;请登录官网 https://jcaigc.cn/user-info 获取 apiKey"
)
return value
+2 -2
View File
@@ -29,8 +29,8 @@ def gen_video(draft_url: str, apiKey: str = None) -> str:
# 查询用户积分
user_points = get_user_points(apiKey)
# 检查积分是否足够(需要大于1)
if user_points <= 1:
# 检查余额是否足够(需要大于 0)
if user_points <= 0:
logger.error(f"Insufficient account balance: {user_points} for API key: {apiKey[:8]}***")
raise CustomException(CustomError.INSUFFICIENT_ACCOUNT_BALANCE)
+23 -9
View File
@@ -34,9 +34,12 @@ _ALLOWED_EXTENSIONS = frozenset({
})
def _ensure_api_key(api_key: Optional[str]) -> None:
def _ensure_api_key(api_key: Optional[str]) -> Optional[float]:
"""
校验 apiKey 与余额门槛;开启 apiKey 时余额需大于 1 才可继续(与 gen_video 一致)。
校验 apiKey 与余额门槛;开启 apiKey 时余额需大于 0 才可继续(与 gen_video 一致)。
Returns:
Optional[float]: 开启计费时返回当前充值余额,否则返回 None(供按体积精确校验用)
Raises:
CustomException: apiKey 格式非法(1001)、缺失(2036)或余额不足(2035)
@@ -47,13 +50,15 @@ def _ensure_api_key(api_key: Optional[str]) -> None:
raise CustomException(CustomError.PARAM_VALIDATION_FAILED, detail=str(e))
if not config.ENABLE_APIKEY:
return
return None
if not api_key: # 未传或空字符串
raise CustomException(CustomError.INVALID_APIKEY)
if get_user_points(api_key) <= 1:
balance = get_user_points(api_key)
if balance <= 0:
raise CustomException(CustomError.INSUFFICIENT_ACCOUNT_BALANCE)
return balance
def _extract_extension(file_name: str) -> str:
@@ -124,10 +129,11 @@ def _cleanup_temp_file(temp_file_path: Optional[str]) -> None:
def upload_file(file: UploadFile, api_key: Optional[str] = None) -> dict:
"""
服务端中转上传:接收客户端文件 → 落盘统计体积 → 上传对象存储 → 上传成功后按体积扣费 → 返回签名 URL。
服务端中转上传:接收客户端文件 → 落盘统计体积 → 校验余额 → 上传对象存储 → 按体积扣费 → 返回签名 URL。
计费规则:0.0005 元/MB,按实际字节数计算(保留 6 位小数);仅在对象存储上传成功后扣费,
扣费失败只记日志,不影响本次上传结果。
计费规则:0.005 元/MB,按实际字节数计算(保留 6 位小数);扣费在上传成功后进行,
扣费失败只记日志,不影响本次上传结果。余额不足以覆盖本次费用时直接拒绝,
避免服务端「余额不足不再透支」后仍能免费上传。
Args:
file: 客户端上传的文件(multipart/form-data 的 file 字段)
@@ -141,8 +147,8 @@ def upload_file(file: UploadFile, api_key: Optional[str] = None) -> dict:
"""
tmp_path: Optional[str] = None
try:
# 校验 apiKey 与余额门槛(余额需大于 1,与 gen_video 一致),在接收文件之前拦截
_ensure_api_key(api_key)
# 校验 apiKey 与余额门槛(余额需大于 0,与 gen_video 一致),在接收文件之前拦截
balance = _ensure_api_key(api_key)
if not file.filename:
raise CustomException(CustomError.INVALID_FILE_NAME)
@@ -161,6 +167,14 @@ def upload_file(file: UploadFile, api_key: Optional[str] = None) -> dict:
size_mb = size / (1024 * 1024)
cost = round(size_mb * UPLOAD_PRICE_PER_MB, 6)
# 扣费接口已改为「余额不足直接拒绝、不再透支」。这里在真正的对象存储上传前
# 按精确费用再校验一次,避免余额大于 0 但不够本次费用时白用一次上传。
if balance is not None and balance < cost:
raise CustomException(
CustomError.INSUFFICIENT_ACCOUNT_BALANCE,
detail=f"当前余额 {balance:.6f} 元,本次上传需要 {cost:.6f} 元"
)
# 签名下载 URL 的有效期与草稿成片一致,共用 config.VIDEO_GEN_RETENTION_DAYS
expire_days = config.VIDEO_GEN_RETENTION_DAYS
url = upload_file_to_storage(tmp_path, expire_days=expire_days, object_key=object_key)
+30 -18
View File
@@ -1,5 +1,6 @@
from typing import Dict, Any, Optional
import time
import uuid
from exceptions import CustomException, CustomError
from src.utils.logger import logger
@@ -18,19 +19,19 @@ USER_API_RETRY_INTERVAL_SEC = 1
def get_user_points(api_key: str) -> float:
"""
根据API Key获取用户积分
根据API Key获取用户充值余额(单位:元)
Args:
api_key: 用户的API Key
Returns:
float: 用户当前积分
float: 用户当前充值余额
Raises:
CustomException: 当获取积分失败时
CustomException: 当获取余额失败时
"""
try:
# 调用获取积分API
# 调用查询余额API
params = {'apiKey': api_key}
result = _call_user_api('GET', '', params=params)
@@ -55,37 +56,48 @@ def get_user_points(api_key: str) -> float:
raise CustomException(CustomError.UNKNOWN_ERROR, detail=f"Unknown error occurred while getting user points: {str(e)}")
def deduct_user_points(api_key: str, points: float, desc: str) -> bool:
def deduct_user_points(api_key: str, points: float, desc: str, request_id: Optional[str] = None) -> bool:
"""
根据API Key减少用户积分
根据API Key减少用户充值余额
Args:
api_key: 用户的API Key
points: 要减少的积分数量(必须为正数)
desc: 减少积分的原因描述
points: 要减少的余额数量(必须为正数)
desc: 减少余额的原因描述
request_id: 可选幂等键。同一次扣费在网络重试时必须传相同值,服务端只会扣一次;
不传时自动生成一个 UUID,并在本次调用的重试之间复用。
Returns:
bool: True表示扣减成功,False表示失败
bool: True表示扣减成功,False表示失败(含余额不足)
Raises:
CustomException: 仅当apiKey无效时抛出异常
"""
try:
# 调用扣减积分API
# requestId 是服务端新增的幂等键:_call_user_api 在超时/连接失败时会重试,
# 复用同一个值可以避免「第一次已扣费、响应丢失、重试再扣一次」。
if not request_id:
request_id = str(uuid.uuid4())
# 调用扣减余额API
json_data = {
'apiKey': api_key,
'points': float(points),
'desc': desc.strip()
'desc': desc.strip(),
'requestId': request_id
}
result = _call_user_api('POST', '/deduct', json_data=json_data)
code = result.get('code', -1)
if code == 0:
logger.info(f"Successfully deducted {points} points for API key {api_key[:8]}***, reason: {desc}")
return True
elif code == 10007: # API Key无效
logger.error(f"Invalid API key for deduct points: {result}, code: {code}")
raise CustomException(CustomError.INVALID_APIKEY, detail=f"{api_key}")
elif code == 10008: # 余额不足:服务端不再允许透支,本次未扣减
logger.warning(f"Insufficient balance for deduct points: {result}, points: {points}, code: {code}")
return False
else:
logger.error(f"Failed to deduct points: {result}, code: {code}")
return False
+57 -2
View File
@@ -28,6 +28,9 @@ import json
# draft_content.json 中 duration 为微秒;低于 3 秒视为空草稿,不进入剪映导出
MIN_DRAFT_EXPORT_DURATION_US = 3 * 1_000_000
# 云渲染计费单价(元/秒),按导出视频的实际时长计费
VIDEO_PRICE_PER_SECOND = 0.005
# gen_video:同时下载草稿的最大并发,超出部分在 _download_executor 队列中排队
DRAFT_DOWNLOAD_MAX_CONCURRENT = 3
@@ -437,6 +440,49 @@ class VideoGenTaskManager:
)
return False
def _ensure_export_balance(self, task: VideoGenTask) -> str:
"""
导出前的余额准入:按草稿时长预估费用,余额不足时直接失败。
扣费接口已改为「余额不足直接拒绝、不再透支」。如果只保留提交时的
「余额 > 0」门槛,余额在 0 元与本次费用之间的用户会在导出后扣费失败,
而账户余额仍大于 0,可以反复免费导出。这里按预估费用提前拦截。
Returns:
str: 空字符串表示允许导出,否则为失败原因
"""
if not config.ENABLE_APIKEY or not task.api_key:
return ""
try:
draft_content_path = os.path.join(
config.DRAFT_SAVE_PATH, task.draft_id, "draft_content.json"
)
with open(draft_content_path, 'r', encoding='utf-8') as f:
duration_us = json.load(f).get("duration", 0)
if duration_us <= 0:
return ""
estimated_cost = round(duration_us / 1_000_000 * VIDEO_PRICE_PER_SECOND, 6)
from src.utils.points import get_user_points
balance = get_user_points(task.api_key)
if balance < estimated_cost:
return (
f"充值余额不足,本次导出预计需要 {estimated_cost:.2f} 元,"
f"当前余额 {balance:.2f} 元,请前往 https://jcaigc.cn/recharge 充值后重试"
)
return ""
except Exception as e:
# 余额查询失败不阻断导出:扣费本身是尽力而为,不能因计费接口故障影响出片。
logger.warning(
"export balance check skipped: draft_id=%s error=%s",
task.draft_id,
e,
)
return ""
def _phase_download_and_prepare(self, task: VideoGenTask) -> str:
"""
下载草稿并校验时长(可并发执行)。
@@ -479,6 +525,15 @@ class VideoGenTaskManager:
)
return f"草稿中视频时长不大于3秒,请检查草稿内容: {task.draft_id}"
balance_error = self._ensure_export_balance(task)
if balance_error:
logger.error(
"export balance check failed: draft_id=%s error=%s",
task.draft_id,
balance_error,
)
return balance_error
task.progress = 40
return ""
except Exception as exc:
@@ -786,8 +841,8 @@ class VideoGenTaskManager:
# 将微秒转换为秒
video_duration = duration_us / 1_000_000 # 微秒转秒
# 计算费用:0.005元/秒
cost = video_duration * 0.005
# 计算费用
cost = video_duration * VIDEO_PRICE_PER_SECOND
# 导入扣费函数
from src.utils.points import deduct_user_points
+67
View File
@@ -0,0 +1,67 @@
"""余额接口客户端:请求字段、重试幂等与余额不足处理。"""
from unittest.mock import Mock, patch
import pytest
import requests
from src.utils.points import deduct_user_points
_API_KEY = "11111111-1111-1111-1111-111111111111"
def _response(code: int = 0, data=None) -> Mock:
resp = Mock()
resp.json.return_value = {
"code": code,
"message": "ok" if code == 0 else "error",
"traceid": "trace",
"data": data if data is not None else {},
}
resp.raise_for_status.return_value = None
return resp
def test_deduct_sends_request_id_and_payload() -> None:
with patch("src.utils.points.requests.post", return_value=_response()) as m_post:
assert deduct_user_points(_API_KEY, 0.5, "上传文件") is True
payload = m_post.call_args.kwargs["json"]
assert payload["apiKey"] == _API_KEY
assert payload["points"] == 0.5
assert payload["desc"] == "上传文件"
# 未显式传入时自动生成 UUID,满足服务端 64 字符上限
assert len(payload["requestId"]) == 36
def test_deduct_honours_explicit_request_id() -> None:
with patch("src.utils.points.requests.post", return_value=_response()) as m_post:
assert deduct_user_points(_API_KEY, 0.5, "上传文件", request_id="req-1") is True
assert m_post.call_args.kwargs["json"]["requestId"] == "req-1"
def test_deduct_retry_reuses_same_request_id() -> None:
"""超时重试必须复用同一个幂等键,否则服务端会重复扣费。"""
with (
patch(
"src.utils.points.requests.post",
side_effect=[requests.exceptions.Timeout(), _response()],
) as m_post,
patch("src.utils.points.time.sleep"),
):
assert deduct_user_points(_API_KEY, 0.5, "上传文件") is True
assert m_post.call_count == 2
request_ids = {call.kwargs["json"]["requestId"] for call in m_post.call_args_list}
assert len(request_ids) == 1
def test_deduct_insufficient_balance_returns_false() -> None:
"""服务端不再允许透支:余额不足返回 10008,客户端按失败处理。"""
with patch("src.utils.points.requests.post", return_value=_response(code=10008)):
assert deduct_user_points(_API_KEY, 0.5, "上传文件") is False
def test_deduct_invalid_apikey_returns_false() -> None:
with patch("src.utils.points.requests.post", return_value=_response(code=10007)):
assert deduct_user_points(_API_KEY, 0.5, "上传文件") is False
+21 -3
View File
@@ -41,6 +41,7 @@ def test_requires_apikey_when_enabled(service_mocks) -> None:
with pytest.raises(CustomException) as ei:
upload_file(_make_file(b"x"))
assert ei.value.err == CustomError.INVALID_APIKEY
assert "https://jcaigc.cn/user-info" in ei.value.err.cn_message
m_points.assert_not_called()
m_upload.assert_not_called()
@@ -66,15 +67,32 @@ def test_invalid_apikey_propagates(service_mocks) -> None:
def test_low_balance_rejected_before_upload(service_mocks) -> None:
m_upload, m_points, m_deduct, _ = service_mocks
m_points.return_value = 1.0 # 与 gen_video 一致:余额需大于 1
m_points.return_value = 0.0 # 与 gen_video 一致:余额需大于 0
with patch.object(config, "ENABLE_APIKEY", True):
with pytest.raises(CustomException) as ei:
upload_file(_make_file(b"x" * 1024), api_key=_API_KEY)
assert ei.value.err == CustomError.INSUFFICIENT_ACCOUNT_BALANCE
assert "https://jcaigc.cn/recharge" in ei.value.err.cn_message
m_upload.assert_not_called()
m_deduct.assert_not_called()
def test_balance_below_upload_cost_rejected_before_storage(service_mocks) -> None:
"""余额大于 0 但不够本次费用时,上传前就拒绝,避免服务端不再透支后白用一次。"""
m_upload, m_points, m_deduct, tmp_path = service_mocks
m_points.return_value = 5.0
with (
patch.object(config, "ENABLE_APIKEY", True),
patch("src.service.upload_file.UPLOAD_PRICE_PER_MB", 10.0),
):
with pytest.raises(CustomException) as ei:
upload_file(_make_file(b"x" * 1024 * 1024), api_key=_API_KEY)
assert ei.value.err == CustomError.INSUFFICIENT_ACCOUNT_BALANCE
m_upload.assert_not_called()
m_deduct.assert_not_called()
assert list(tmp_path.iterdir()) == []
def test_disallowed_extension_rejected(service_mocks) -> None:
m_upload, m_points, m_deduct, tmp_path = service_mocks
with patch.object(config, "ENABLE_APIKEY", False):
@@ -144,7 +162,7 @@ def test_success_charges_by_size(service_mocks) -> None:
assert result["url"] == "https://bucket.example/signed-url"
assert result["size"] == len(payload)
assert result["size_mb"] == round(size_mb, 3)
assert result["cost"] == expected_cost == 0.0005
assert result["cost"] == expected_cost == 0.005
assert result["url_expire_days"] == config.VIDEO_GEN_RETENTION_DAYS
assert result["key"].endswith("_我的_视频.mp4")
@@ -201,4 +219,4 @@ def test_apikey_disabled_skips_billing(service_mocks) -> None:
def test_default_max_size_is_500mb() -> None:
assert MAX_UPLOAD_SIZE_BYTES == 500 * 1024 * 1024
assert UPLOAD_PRICE_PER_MB == 0.0005
assert UPLOAD_PRICE_PER_MB == 0.005
+87
View File
@@ -68,6 +68,93 @@ class TestCheckDraftDuration:
assert VideoGenTaskManager()._check_draft_duration(_task(did)) is False
_API_KEY = "11111111-1111-1111-1111-111111111111"
def _task_with_key(draft_id: str) -> VideoGenTask:
task = _task(draft_id)
task.api_key = _API_KEY
return task
def _write_draft(draft_home: str, draft_id: str, duration_us: int) -> None:
root = os.path.join(draft_home, draft_id)
os.makedirs(root, exist_ok=True)
with open(os.path.join(root, "draft_content.json"), "w", encoding="utf-8") as f:
json.dump({"duration": duration_us}, f)
class TestExportBalanceAdmission:
"""导出前按草稿时长预估费用:余额不足时不再白用一次导出。"""
def test_insufficient_balance_returns_message(self, draft_home) -> None:
did = "balance_low"
_write_draft(draft_home, did, 10 * 1_000_000) # 10 秒 × 0.005 = 0.05 元
with (
patch("src.utils.video_task_manager.config") as cfg,
patch("src.utils.points.get_user_points", return_value=0.01),
):
cfg.DRAFT_SAVE_PATH = draft_home
cfg.ENABLE_APIKEY = True
message = VideoGenTaskManager()._ensure_export_balance(_task_with_key(did))
assert "充值余额不足" in message
assert "0.05" in message
assert "https://jcaigc.cn/recharge" in message
def test_enough_balance_allows_export(self, draft_home) -> None:
did = "balance_ok"
_write_draft(draft_home, did, 10 * 1_000_000)
with (
patch("src.utils.video_task_manager.config") as cfg,
patch("src.utils.points.get_user_points", return_value=100.0),
):
cfg.DRAFT_SAVE_PATH = draft_home
cfg.ENABLE_APIKEY = True
message = VideoGenTaskManager()._ensure_export_balance(_task_with_key(did))
assert message == ""
def test_balance_query_failure_does_not_block_export(self, draft_home) -> None:
did = "balance_down"
_write_draft(draft_home, did, 10 * 1_000_000)
with (
patch("src.utils.video_task_manager.config") as cfg,
patch(
"src.utils.points.get_user_points",
side_effect=RuntimeError("points api down"),
),
):
cfg.DRAFT_SAVE_PATH = draft_home
cfg.ENABLE_APIKEY = True
message = VideoGenTaskManager()._ensure_export_balance(_task_with_key(did))
assert message == ""
@patch("src.utils.video_task_manager.sys.platform", "win32")
@patch.object(VideoGenTaskManager, "_ensure_local_draft_ready", return_value="")
@patch.object(VideoGenTaskManager, "_download_draft", return_value="")
def test_phase_returns_insufficient_balance_message(
self, m_dl, m_ready, draft_home
) -> None:
did = "phase_balance"
_write_draft(draft_home, did, 10 * 1_000_000)
with (
patch("src.utils.video_task_manager.config") as cfg,
patch("src.utils.points.get_user_points", return_value=0.01),
):
cfg.DRAFT_SAVE_PATH = draft_home
cfg.DRAFT_DIR = draft_home
cfg.ENABLE_APIKEY = True
out = VideoGenTaskManager()._phase_download_and_prepare(_task_with_key(did))
assert out.startswith("充值余额不足")
class TestPhaseDownloadPrepareMessage:
"""时长不通过时,阶段函数返回带 draft_id 的提示文案。"""