mirror of
https://github.com/Hommy-master/capcut-mate.git
synced 2026-10-30 18:44:19 +08:00
优化接口提示。
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 的提示文案。"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user