From c3c0292708c5680f4014dae5fb580bbcee995216 Mon Sep 17 00:00:00 2001 From: Hommy Date: Sun, 18 Oct 2026 10:01:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8E=A5=E5=8F=A3=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- README.zh.md | 2 +- docs/gen_video.md | 6 +- docs/gen_video.zh.md | 6 +- docs/upload_file.md | 16 ++--- docs/upload_file.zh.md | 16 ++--- exceptions.py | 4 +- src/router/v1.py | 4 +- src/schemas/gen_video.py | 2 +- src/schemas/upload_file.py | 4 +- src/schemas/validators.py | 2 +- src/service/gen_video.py | 4 +- src/service/upload_file.py | 32 ++++++--- src/utils/points.py | 48 +++++++++----- src/utils/video_task_manager.py | 59 ++++++++++++++++- tests/test_points.py | 67 +++++++++++++++++++ tests/test_upload_file_service.py | 24 ++++++- tests/test_video_task_draft_duration.py | 87 +++++++++++++++++++++++++ 18 files changed, 319 insertions(+), 66 deletions(-) create mode 100644 tests/test_points.py diff --git a/README.md b/README.md index 28fc623..cdadf84 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README.zh.md b/README.zh.md index 3a95aeb..7b6ab8d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -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) | ### 🛠️工具类接口 |接口 |功能 |描述 | 文档链接 | diff --git a/docs/gen_video.md b/docs/gen_video.md index 431a5bd..ce08440 100644 --- a/docs/gen_video.md +++ b/docs/gen_video.md @@ -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 diff --git a/docs/gen_video.zh.md b/docs/gen_video.zh.md index 7b0cbc7..e46b010 100644 --- a/docs/gen_video.zh.md +++ b/docs/gen_video.zh.md @@ -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 | 系统内部错误 | 提交任务时发生未知错误 | 稍后重试或联系技术支持 | ## 注意事项 diff --git a/docs/upload_file.md b/docs/upload_file.md index e297908..32d9f5a 100644 --- a/docs/upload_file.md +++ b/docs/upload_file.md @@ -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) diff --git a/docs/upload_file.zh.md b/docs/upload_file.zh.md index 6c0fefc..d0b8417 100644 --- a/docs/upload_file.zh.md +++ b/docs/upload_file.zh.md @@ -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) diff --git a/exceptions.py b/exceptions.py index d6fb81d..95f09ee 100644 --- a/exceptions.py +++ b/exceptions.py @@ -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.") diff --git a/src/router/v1.py b/src/router/v1.py index 20b29bd..9060da6 100644 --- a/src/router/v1.py +++ b/src/router/v1.py @@ -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( diff --git a/src/schemas/gen_video.py b/src/schemas/gen_video.py index 67db127..44da536 100644 --- a/src/schemas/gen_video.py +++ b/src/schemas/gen_video.py @@ -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') diff --git a/src/schemas/upload_file.py b/src/schemas/upload_file.py index 96e68c9..c293d8e 100644 --- a/src/schemas/upload_file.py +++ b/src/schemas/upload_file.py @@ -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 } } diff --git a/src/schemas/validators.py b/src/schemas/validators.py index 4bf4374..7f7b47d 100644 --- a/src/schemas/validators.py +++ b/src/schemas/validators.py @@ -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 diff --git a/src/service/gen_video.py b/src/service/gen_video.py index e1668ec..2ab1f46 100644 --- a/src/service/gen_video.py +++ b/src/service/gen_video.py @@ -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) diff --git a/src/service/upload_file.py b/src/service/upload_file.py index e4df6ee..bf325fc 100644 --- a/src/service/upload_file.py +++ b/src/service/upload_file.py @@ -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) diff --git a/src/utils/points.py b/src/utils/points.py index 474d772..4c2c894 100644 --- a/src/utils/points.py +++ b/src/utils/points.py @@ -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 diff --git a/src/utils/video_task_manager.py b/src/utils/video_task_manager.py index e2caf73..a317741 100644 --- a/src/utils/video_task_manager.py +++ b/src/utils/video_task_manager.py @@ -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 diff --git a/tests/test_points.py b/tests/test_points.py new file mode 100644 index 0000000..13df2eb --- /dev/null +++ b/tests/test_points.py @@ -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 diff --git a/tests/test_upload_file_service.py b/tests/test_upload_file_service.py index 57a5544..b2079fb 100644 --- a/tests/test_upload_file_service.py +++ b/tests/test_upload_file_service.py @@ -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 diff --git a/tests/test_video_task_draft_duration.py b/tests/test_video_task_draft_duration.py index 3f5862b..73e9506 100644 --- a/tests/test_video_task_draft_duration.py +++ b/tests/test_video_task_draft_duration.py @@ -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 的提示文案。"""