Co-Authored-By: Codex <noreply@anthropic.com>master
| @@ -837,6 +837,16 @@ func CreateUser(c *gin.Context) { | |||
| common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel) | |||
| return | |||
| } | |||
| exist, err := model.CheckUserExistOrDeleted(user.Username, user.Email) | |||
| if err != nil { | |||
| common.ApiErrorI18n(c, i18n.MsgDatabaseError) | |||
| common.SysLog(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err)) | |||
| return | |||
| } | |||
| if exist { | |||
| common.ApiErrorI18n(c, i18n.MsgUserExists) | |||
| return | |||
| } | |||
| // Even for admin users, we cannot fully trust them! | |||
| cleanUser := model.User{ | |||
| Username: user.Username, | |||
| @@ -0,0 +1,329 @@ | |||
| # 国内站天翼云 Seedance Mini 在线测试记录 | |||
| 测试时间:2026-07-21(Asia/Shanghai) | |||
| 测试环境:国内站 `https://router.lanqi.tech`,部署镜像 | |||
| `registry.cn-hangzhou.aliyuncs.com/fengsilin/new-api:202607211136-master-fedd394e-dirty`。 | |||
| ## 脱敏说明 | |||
| - 所有请求均使用用户提供的 Bearer Token;本文不记录 Token。 | |||
| - 天翼云素材临时下载 URL 中的查询参数、凭证、签名和安全令牌均已移除。 | |||
| - 测试素材 ID 与任务 ID 保留,便于在国内站数据库和天翼云控制台审计。 | |||
| ## 前置核对 | |||
| 国内站实际数据源中存在启用的天翼云 type 60 渠道: | |||
| | 字段 | 值 | | |||
| | --- | --- | | |||
| | 渠道 ID | 16 | | |||
| | 状态 | 启用 | | |||
| | 模型 | `Doubao-Seedance-2.0`、`Doubao-Seedance-2.0-fast`、`Doubao-Seedance-2.0-mini` | | |||
| | 用户素材绑定数 | 2 | | |||
| 结论:本次请求确实可路由到国内站天翼云渠道。 | |||
| ## 1. CreateAsset:公网图片上传 | |||
| 请求: | |||
| ```http | |||
| POST /api/v1/volcengine/asset?Action=CreateAsset HTTP/1.1 | |||
| Host: router.lanqi.tech | |||
| Authorization: Bearer <redacted> | |||
| Content-Type: application/json | |||
| {"URL":"https://ark-project.tos-cn-beijing.volces.com/doc_image/r2v_tea_pic1.jpg","AssetType":"Image","Name":"new-api-tianyiyun-e2e-20260721"} | |||
| ``` | |||
| 响应(HTTP 200): | |||
| ```json | |||
| { | |||
| "ResponseMetadata": { | |||
| "RequestId": "b63e4b6b-e65e-46b1-96e2-cb6247382e21", | |||
| "Action": "CreateAsset", | |||
| "Version": "2024-01-01", | |||
| "Service": "ark", | |||
| "Region": "cn-beijing" | |||
| }, | |||
| "Result": "asset-20260721141127-zdl8v" | |||
| } | |||
| ``` | |||
| 结果:通过。网关成功将既有 Action 接口映射到天翼云素材上传接口,并返回天翼云素材 ID。 | |||
| ## 2. GetAsset:素材查询 | |||
| 请求: | |||
| ```http | |||
| POST /api/v1/volcengine/asset?Action=GetAsset HTTP/1.1 | |||
| Host: router.lanqi.tech | |||
| Authorization: Bearer <redacted> | |||
| Content-Type: application/json | |||
| {"Id":"asset-20260721141127-zdl8v"} | |||
| ``` | |||
| 响应(HTTP 200,临时 URL 已脱敏): | |||
| ```json | |||
| { | |||
| "ResponseMetadata": { | |||
| "RequestId": "86327f75-fd21-40a5-a78e-e200d7111ba2", | |||
| "Action": "GetAsset", | |||
| "Version": "2024-01-01", | |||
| "Service": "ark", | |||
| "Region": "cn-beijing" | |||
| }, | |||
| "Result": { | |||
| "AssetType": "Image", | |||
| "CreatedAt": "2026-07-21 14:11:27", | |||
| "GroupId": "group-20260720122310-q4lcv", | |||
| "Id": "asset-20260721141127-zdl8v", | |||
| "Name": "new-api-tianyiyun-e2e-20260721", | |||
| "Status": "Active", | |||
| "URL": "https://ark-media-asset.tos-cn-beijing.volces.com/<redacted>?<signed-query-redacted>", | |||
| "UpdatedAt": "2026-07-21 14:11:28" | |||
| } | |||
| } | |||
| ``` | |||
| 结果:通过。素材状态为 `Active`,可作为后续视频任务的 `asset://asset-20260721141127-zdl8v` 输入。 | |||
| ## 3. CreateAsset:data URI 本地拒绝 | |||
| 请求: | |||
| ```http | |||
| POST /api/v1/volcengine/asset?Action=CreateAsset HTTP/1.1 | |||
| Host: router.lanqi.tech | |||
| Authorization: Bearer <redacted> | |||
| Content-Type: application/json | |||
| {"URL":"data:image/png;base64,AAAA","AssetType":"Image","Name":"should-not-upload"} | |||
| ``` | |||
| 响应(HTTP 400): | |||
| ```json | |||
| { | |||
| "error": { | |||
| "message": "asset URL must use http or https", | |||
| "type": "invalid_request_error" | |||
| } | |||
| } | |||
| ``` | |||
| 结果:通过。请求在网关本地拒绝,没有创建天翼云素材。 | |||
| ## 4. Mini 视频任务:OpenAI 视频入口兼容性 | |||
| 请求: | |||
| ```http | |||
| POST /v1/video/generations HTTP/1.1 | |||
| Host: router.lanqi.tech | |||
| Authorization: Bearer <redacted> | |||
| Content-Type: application/json | |||
| { | |||
| "model": "Doubao-Seedance-2.0-mini", | |||
| "content": [ | |||
| {"type": "text", "text": "A five-second product showcase of a red apple on a clean white background. No text overlay."}, | |||
| {"type": "image_url", "role": "reference_image", "image_url": {"url": "asset://asset-20260721141127-zdl8v"}} | |||
| ], | |||
| "ratio": "16:9", | |||
| "duration": 5, | |||
| "generate_audio": false, | |||
| "watermark": false | |||
| } | |||
| ``` | |||
| 响应(HTTP 400): | |||
| ```json | |||
| { | |||
| "code": "invalid_request", | |||
| "message": "prompt is required", | |||
| "data": null | |||
| } | |||
| ``` | |||
| 结果:该入口要求顶层 `prompt`,不接受此天翼云原生 `content` 格式;未创建上游任务、未产生推理费用。 | |||
| ## 5. Mini 视频任务:天翼云原生兼容入口 | |||
| 请求: | |||
| ```http | |||
| POST /api/v3/contents/generations/tasks HTTP/1.1 | |||
| Host: router.lanqi.tech | |||
| Authorization: Bearer <redacted> | |||
| Content-Type: application/json | |||
| { | |||
| "model": "Doubao-Seedance-2.0-mini", | |||
| "content": [ | |||
| {"type": "text", "text": "A five-second product showcase of a red apple on a clean white background. No text overlay."}, | |||
| {"type": "image_url", "role": "reference_image", "image_url": {"url": "asset://asset-20260721141127-zdl8v"}} | |||
| ], | |||
| "ratio": "16:9", | |||
| "duration": 5, | |||
| "generate_audio": false, | |||
| "watermark": false | |||
| } | |||
| ``` | |||
| 响应(HTTP 400): | |||
| ```json | |||
| { | |||
| "code": "pricing_no_match", | |||
| "message": "pricing dimensions did not match any row", | |||
| "data": null | |||
| } | |||
| ``` | |||
| 结果:Matrix usage billing 模型能力校验已经通过(未出现此前的 | |||
| `model ... does not support matrix usage billing`),但该公开模型目前没有命中线上 | |||
| Matrix 定价表的维度行。请求在调用天翼云之前被拒绝,因此没有视频任务、上游任务 ID | |||
| 或 token 用量可供轮询和结算验证,也没有产生 Mini 推理费用。 | |||
| ## 6. Mini 视频任务:补充分辨率后重试 | |||
| 第 5 节请求遗漏了 `resolution`。天翼云 Mini 仅支持 480P 和 720P,本次补充 | |||
| `resolution: "480p"` 后使用相同素材重试: | |||
| ```http | |||
| POST /api/v3/contents/generations/tasks HTTP/1.1 | |||
| Host: router.lanqi.tech | |||
| Authorization: Bearer <redacted> | |||
| Content-Type: application/json | |||
| { | |||
| "model": "Doubao-Seedance-2.0-mini", | |||
| "content": [ | |||
| {"type": "text", "text": "A five-second product showcase of a red apple on a clean white background. No text overlay."}, | |||
| {"type": "image_url", "role": "reference_image", "image_url": {"url": "asset://asset-20260721141127-zdl8v"}} | |||
| ], | |||
| "resolution": "480p", | |||
| "ratio": "16:9", | |||
| "duration": 5, | |||
| "generate_audio": false, | |||
| "watermark": false | |||
| } | |||
| ``` | |||
| 响应(HTTP 401): | |||
| ```json | |||
| { | |||
| "code": "fail_to_fetch_task", | |||
| "message": "{\n \"code\": 401,\n \"message\": \"internal-error\",\n \"detail\": \"api-key access forbit\",\n \"error\": {\n \"message\": \"api-key access forbit\",\n \"type\": \"internal-error\",\n \"code\": \"401\"\n }\n}\n", | |||
| "data": null | |||
| } | |||
| ``` | |||
| 结果:补充分辨率后不再返回 `pricing_no_match`,证明 Matrix 定价行已命中、公开 | |||
| Mini 模型到天翼云上游模型的映射也已通过。请求已实际到达天翼云,但渠道配置的上游 | |||
| API Key 没有该模型调用权限(`api-key access forbit`)。没有创建成功视频任务,因而 | |||
| 没有可轮询的 `usage.total_tokens` 或结算记录。 | |||
| ## 后续动作 | |||
| 1. 在天翼云客户控制台确认该渠道 API Key 已开通并加白 `cdance2.0-mini-0611`;必要时按天翼云流程申请 Seedance 2.0 Mini 权限。 | |||
| 2. 权限开通后,保持第 6 节的请求体不变重试;成功后记录公共任务 ID、轮询响应中的 `usage.total_tokens`、预扣日志、settlement 日志、用户额度变化和 `tasks.quota`。 | |||
| 3. 继续保留本次素材 `asset-20260721141127-zdl8v`,可直接用于重试,避免重复上传。 | |||
| ## 7. Fast 模型权限检查 | |||
| 使用第 6 节相同的素材、`resolution: "480p"`、`ratio: "16:9"`、`duration: 5`,仅将模型替换为 | |||
| `Doubao-Seedance-2.0-fast`。 | |||
| 响应(HTTP 401): | |||
| ```json | |||
| { | |||
| "code": "fail_to_fetch_task", | |||
| "message": "{\n \"code\": 401,\n \"message\": \"internal-error\",\n \"detail\": \"api-key access forbit\"\n}", | |||
| "data": null | |||
| } | |||
| ``` | |||
| 结果:Fast 与 Mini 都被同一个天翼云渠道上游 API Key 拒绝。问题是上游模型权限,非网关模型映射或 Matrix 定价问题。 | |||
| ## 8. 标准 Seedance 2.0 完整提交、轮询与结算 | |||
| 使用以下请求验证该渠道的标准模型权限和 Matrix 结算链路: | |||
| ```http | |||
| POST /api/v3/contents/generations/tasks HTTP/1.1 | |||
| Host: router.lanqi.tech | |||
| Authorization: Bearer <redacted> | |||
| Content-Type: application/json | |||
| { | |||
| "model": "Doubao-Seedance-2.0", | |||
| "content": [ | |||
| {"type": "text", "text": "A five-second product showcase of a red apple on a clean white background. No text overlay."}, | |||
| {"type": "image_url", "role": "reference_image", "image_url": {"url": "asset://asset-20260721141127-zdl8v"}} | |||
| ], | |||
| "resolution": "480p", | |||
| "ratio": "16:9", | |||
| "duration": 5, | |||
| "generate_audio": false, | |||
| "watermark": false | |||
| } | |||
| ``` | |||
| 提交响应(HTTP 200): | |||
| ```json | |||
| { | |||
| "created_at": 1784615279, | |||
| "id": "task_UwH2BAtzuR8KI4Xs6KIsN3TxS9k77eQv", | |||
| "model": "Doubao-Seedance-2.0" | |||
| } | |||
| ``` | |||
| 轮询:前 6 次为 `running`;第 7 次成功。成功响应(临时下载 URL 已脱敏): | |||
| ```json | |||
| { | |||
| "content": { | |||
| "video_url": "https://ark-acg-cn-beijing.tos-cn-beijing.volces.com/<redacted>?<signed-query-redacted>" | |||
| }, | |||
| "created_at": 1784615279, | |||
| "duration": 5, | |||
| "framespersecond": 24, | |||
| "generate_audio": false, | |||
| "id": "task_UwH2BAtzuR8KI4Xs6KIsN3TxS9k77eQv", | |||
| "model": "doubao-seedance-2-0-260128", | |||
| "ratio": "16:9", | |||
| "resolution": "480p", | |||
| "status": "succeeded", | |||
| "updated_at": 1784615379, | |||
| "usage": { | |||
| "completion_tokens": 50638, | |||
| "total_tokens": 50638 | |||
| } | |||
| } | |||
| ``` | |||
| 数据库结算核对: | |||
| | 项目 | 值 | | |||
| | --- | ---: | | |||
| | 任务状态 | `SUCCESS` | | |||
| | 任务最终 quota | 166382 | | |||
| | 预扣 quota | 1642 | | |||
| | 结算补扣 quota | 164740 | | |||
| | 实际总 quota | 166382 | | |||
| | completion / total tokens | 50638 / 50638 | | |||
| | Matrix 单价 | 6.571428571429 USD / 1M tokens | | |||
| | 分组倍率 | 1 | | |||
| 结果:标准模型权限正常;素材 `asset://` 输入、天翼云异步轮询、usage token 解析、Matrix 差额结算、消费日志和 `tasks.quota` 持久化全部验证通过。 | |||
| @@ -5,6 +5,7 @@ import ( | |||
| "fmt" | |||
| "io" | |||
| "net/http" | |||
| "net/url" | |||
| "strconv" | |||
| "strings" | |||
| "time" | |||
| @@ -231,6 +232,13 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* | |||
| if err := taskcommon.UnmarshalMetadata(req.Metadata, &r); err != nil { | |||
| return nil, errors.Wrap(err, "unmarshal metadata failed") | |||
| } | |||
| for _, content := range r.Content { | |||
| if !tianyiYunMediaURLIsSupported(content.ImageURL) || | |||
| !tianyiYunMediaURLIsSupported(content.VideoURL) || | |||
| !tianyiYunMediaURLIsSupported(content.AudioURL) { | |||
| return nil, fmt.Errorf("天翼云素材仅支持公网 URL 或 asset:// 标识") | |||
| } | |||
| } | |||
| if sec, _ := strconv.Atoi(req.Seconds); sec > 0 && r.Duration == nil { | |||
| r.Duration = lo.ToPtr(dto.IntValue(sec)) | |||
| } | |||
| @@ -243,6 +251,22 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* | |||
| return &r, nil | |||
| } | |||
| func tianyiYunMediaURLIsSupported(media *MediaURL) bool { | |||
| if media == nil { | |||
| return true | |||
| } | |||
| parsedURL, err := url.Parse(strings.TrimSpace(media.URL)) | |||
| if err != nil || parsedURL.Host == "" { | |||
| return false | |||
| } | |||
| switch strings.ToLower(parsedURL.Scheme) { | |||
| case "http", "https", "asset": | |||
| return true | |||
| default: | |||
| return false | |||
| } | |||
| } | |||
| func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { | |||
| resTask := responseTask{} | |||
| if err := common.Unmarshal(respBody, &resTask); err != nil { | |||
| @@ -49,9 +49,60 @@ func TestGetModelListIncludesTianyiYunSeedanceModels(t *testing.T) { | |||
| require.Contains(t, models, "cdance2.0-0611") | |||
| require.Contains(t, models, "cdance2.0-fast-0611") | |||
| require.Contains(t, models, "cdance2.0-mini-0611") | |||
| require.Equal(t, "DoubaoVideoCompatibleTianyiYun", (&TaskAdaptor{}).GetChannelName()) | |||
| } | |||
| func TestBuildRequestBodyRejectsTianyiYunDataURI(t *testing.T) { | |||
| for _, mediaType := range []string{"image_url", "video_url", "audio_url"} { | |||
| t.Run(mediaType, func(t *testing.T) { | |||
| adaptor := &TaskAdaptor{} | |||
| c := newTianyiYunTaskRequestContext(t, `{ | |||
| "model":"cdance2.0-0611", | |||
| "metadata":{"content":[{"type":"`+mediaType+`","`+mediaType+`":{"url":"data:image/png;base64,AAAA"}}]} | |||
| }`) | |||
| _, err := adaptor.BuildRequestBody(c, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "cdance2.0-0611"}}) | |||
| require.ErrorContains(t, err, "asset://") | |||
| }) | |||
| } | |||
| } | |||
| func TestBuildRequestBodyRejectsTianyiYunUnsupportedMediaURL(t *testing.T) { | |||
| for _, mediaURL := range []string{ | |||
| "file:///tmp/input.png", | |||
| "ftp://example.test/input.png", | |||
| "aGVsbG8=", | |||
| } { | |||
| t.Run(mediaURL, func(t *testing.T) { | |||
| adaptor := &TaskAdaptor{} | |||
| c := newTianyiYunTaskRequestContext(t, `{ | |||
| "model":"cdance2.0-0611", | |||
| "metadata":{"content":[{"type":"image_url","image_url":{"url":"`+mediaURL+`"}}]} | |||
| }`) | |||
| _, err := adaptor.BuildRequestBody(c, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "cdance2.0-0611"}}) | |||
| require.ErrorContains(t, err, "asset://") | |||
| }) | |||
| } | |||
| } | |||
| func TestBuildRequestBodyPreservesTianyiYunAssetReference(t *testing.T) { | |||
| adaptor := &TaskAdaptor{} | |||
| c := newTianyiYunTaskRequestContext(t, `{ | |||
| "model":"cdance2.0-0611", | |||
| "metadata":{"content":[ | |||
| {"type":"image_url","image_url":{"url":"asset://asset-1"},"role":"reference_image"}, | |||
| {"type":"video_url","video_url":{"url":"https://example.test/input.mp4"},"role":"reference_video"} | |||
| ]} | |||
| }`) | |||
| body, err := adaptor.BuildRequestBody(c, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "cdance2.0-0611"}}) | |||
| require.NoError(t, err) | |||
| data, err := io.ReadAll(body) | |||
| require.NoError(t, err) | |||
| require.Contains(t, string(data), `"url":"asset://asset-1"`) | |||
| require.Contains(t, string(data), `"url":"https://example.test/input.mp4"`) | |||
| require.Less(t, strings.Index(string(data), "asset://asset-1"), strings.Index(string(data), "https://example.test/input.mp4")) | |||
| } | |||
| func TestBuildRequestBodyPreservesTianyiYunNativeFields(t *testing.T) { | |||
| adaptor := &TaskAdaptor{} | |||
| c := newTianyiYunTaskRequestContext(t, `{ | |||
| @@ -3,6 +3,7 @@ package doubao_tianyiyun | |||
| var ModelList = []string{ | |||
| "cdance2.0-0611", | |||
| "cdance2.0-fast-0611", | |||
| "cdance2.0-mini-0611", | |||
| } | |||
| var ChannelName = "DoubaoVideoCompatibleTianyiYun" | |||
| @@ -23,6 +23,8 @@ var matrixUsageCapabilities = []MatrixUsageCapability{ | |||
| {BillingModelName: "doubao-seedance-2-0-fast-260128", UpstreamModelName: "doubao-seedance-2-0-fast-260128", ChannelType: constant.ChannelTypeDoubaoVideoCompatibleAiping}, | |||
| {BillingModelName: "cdance2.0-0611", UpstreamModelName: "cdance2.0-0611", ChannelType: constant.ChannelTypeDoubaoVideoCompatibleTianyiYun}, | |||
| {BillingModelName: "cdance2.0-fast-0611", UpstreamModelName: "cdance2.0-fast-0611", ChannelType: constant.ChannelTypeDoubaoVideoCompatibleTianyiYun}, | |||
| {BillingModelName: "cdance2.0-mini-0611", UpstreamModelName: "cdance2.0-mini-0611", ChannelType: constant.ChannelTypeDoubaoVideoCompatibleTianyiYun}, | |||
| {BillingModelName: "Doubao-Seedance-2.0-mini", UpstreamModelName: "cdance2.0-mini-0611", ChannelType: constant.ChannelTypeDoubaoVideoCompatibleTianyiYun}, | |||
| {BillingModelName: "doubao-seedance-2-0-260128", UpstreamModelName: "doubao-seedance-2-0-260128", ChannelType: constant.ChannelTypeChinaMobileSeedance}, | |||
| {BillingModelName: "doubao-seedance-2-0-260128", UpstreamModelName: "doubao-seedance-2.0", ChannelType: constant.ChannelTypeChinaMobileSeedance}, | |||
| {BillingModelName: "doubao-seedance-2-0-fast-260128", UpstreamModelName: "doubao-seedance-2-0-fast-260128", ChannelType: constant.ChannelTypeChinaMobileSeedance}, | |||
| @@ -29,6 +29,15 @@ func TestSupportsMatrixUsageBilling_TianyiYunSeedanceChannel(t *testing.T) { | |||
| require.False(t, SupportsMatrixUsageBilling("cdance2.0-0611", constant.ChannelTypeDoubaoVideoCompatibleAiping, "cdance2.0-0611")) | |||
| } | |||
| func TestSupportsMatrixUsageBilling_TianyiYunMini(t *testing.T) { | |||
| require.True(t, SupportsMatrixUsageBilling("cdance2.0-mini-0611", constant.ChannelTypeDoubaoVideoCompatibleTianyiYun, "cdance2.0-mini-0611")) | |||
| require.False(t, SupportsMatrixUsageBilling("cdance2.0-mini-0611", constant.ChannelTypeDoubaoVideoCompatibleAiping, "cdance2.0-mini-0611")) | |||
| require.False(t, SupportsMatrixUsageBilling("cdance2.0-mini-0611", constant.ChannelTypeDoubaoVideoCompatibleTianyiYun, "other-model")) | |||
| require.True(t, SupportsMatrixUsageBilling("Doubao-Seedance-2.0-mini", constant.ChannelTypeDoubaoVideoCompatibleTianyiYun, "cdance2.0-mini-0611")) | |||
| require.False(t, SupportsMatrixUsageBilling("Doubao-Seedance-2.0-mini", constant.ChannelTypeDoubaoVideoCompatibleAiping, "cdance2.0-mini-0611")) | |||
| require.False(t, SupportsMatrixUsageBilling("Doubao-Seedance-2.0-mini", constant.ChannelTypeDoubaoVideoCompatibleTianyiYun, "other-model")) | |||
| } | |||
| func TestSupportsMatrixUsageBilling_ChinaMobileSeedanceChannel(t *testing.T) { | |||
| require.True(t, SupportsMatrixUsageBilling("doubao-seedance-2-0-260128", constant.ChannelTypeChinaMobileSeedance, "doubao-seedance-2-0-260128")) | |||
| require.True(t, SupportsMatrixUsageBilling("doubao-seedance-2-0-260128", constant.ChannelTypeChinaMobileSeedance, "doubao-seedance-2.0")) | |||
| @@ -67,13 +67,7 @@ func registerDefaultAssetAdapters() { | |||
| AssetOperationAssetUpdate, | |||
| AssetOperationAssetDelete, | |||
| }) | |||
| assetAdapters[constant.ChannelTypeDoubaoVideoCompatibleTianyiYun] = NewCompatibleAssetAdapter("tianyiyun_asset", []AssetOperation{ | |||
| AssetOperationAssetCreate, | |||
| AssetOperationAssetList, | |||
| AssetOperationAssetGet, | |||
| AssetOperationAssetUpdate, | |||
| AssetOperationAssetDelete, | |||
| }) | |||
| assetAdapters[constant.ChannelTypeDoubaoVideoCompatibleTianyiYun] = NewTianyiYunAssetAdapter() | |||
| } | |||
| func GetAssetAdapter(channelType int) (AssetAdapter, bool) { | |||
| @@ -0,0 +1,163 @@ | |||
| package service | |||
| import ( | |||
| "bytes" | |||
| "context" | |||
| "fmt" | |||
| "io" | |||
| "net/http" | |||
| "net/url" | |||
| "strings" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/QuantumNous/new-api/setting/system_setting" | |||
| ) | |||
| type TianyiYunAssetAdapter struct{} | |||
| func NewTianyiYunAssetAdapter() AssetAdapter { | |||
| return &TianyiYunAssetAdapter{} | |||
| } | |||
| func (a *TianyiYunAssetAdapter) Name() string { | |||
| return "tianyiyun_asset" | |||
| } | |||
| func (a *TianyiYunAssetAdapter) Supports(operation AssetOperation) bool { | |||
| return operation == AssetOperationAssetCreate || operation == AssetOperationAssetGet | |||
| } | |||
| func (a *TianyiYunAssetAdapter) DoAssetRequest(ctx context.Context, channel *model.Channel, req AssetRequest) (*AssetUpstreamResponse, *AssetError) { | |||
| if !a.Supports(req.Action.Operation) { | |||
| return nil, newAssetError(AssetErrorOperationNotSupported, fmt.Sprintf("asset operation %s is not supported", req.Action.Operation), http.StatusBadRequest) | |||
| } | |||
| if channel == nil { | |||
| return nil, newAssetError(AssetErrorInvalidRequest, "channel is required", http.StatusBadRequest) | |||
| } | |||
| upstreamURL, body, method, assetErr := buildTianyiYunAssetRequest(channel, req) | |||
| if assetErr != nil { | |||
| return nil, assetErr | |||
| } | |||
| fetchSetting := system_setting.GetFetchSetting() | |||
| if err := common.ValidateURLWithFetchSetting(upstreamURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { | |||
| return nil, newAssetError(AssetErrorServer, fmt.Sprintf("request blocked: %v", err), http.StatusForbidden) | |||
| } | |||
| httpReq, err := http.NewRequestWithContext(ctx, method, upstreamURL, bytes.NewReader(body)) | |||
| if err != nil { | |||
| return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) | |||
| } | |||
| httpReq.Header.Set("Accept", "application/json") | |||
| httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(channel.Key)) | |||
| if method == http.MethodPost { | |||
| httpReq.Header.Set("Content-Type", "application/json") | |||
| } | |||
| client, err := GetHttpClientWithProxy(channel.GetSetting().Proxy) | |||
| if err != nil { | |||
| return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) | |||
| } | |||
| if client == nil { | |||
| client = http.DefaultClient | |||
| } | |||
| resp, err := client.Do(httpReq) | |||
| if err != nil { | |||
| return nil, newAssetError(AssetErrorUpstream, err.Error(), http.StatusBadGateway) | |||
| } | |||
| defer resp.Body.Close() | |||
| data, err := io.ReadAll(resp.Body) | |||
| if err != nil { | |||
| return nil, newAssetError(AssetErrorUpstream, err.Error(), http.StatusBadGateway) | |||
| } | |||
| if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { | |||
| return nil, newAssetError(AssetErrorUpstream, string(data), http.StatusBadGateway) | |||
| } | |||
| return &AssetUpstreamResponse{StatusCode: resp.StatusCode, Header: resp.Header, Body: data}, nil | |||
| } | |||
| func buildTianyiYunAssetRequest(channel *model.Channel, req AssetRequest) (string, []byte, string, *AssetError) { | |||
| baseURL, err := tianyiYunAssetBaseURL(channel) | |||
| if err != nil { | |||
| return "", nil, "", newAssetError(AssetErrorInvalidRequest, err.Error(), http.StatusBadRequest) | |||
| } | |||
| switch req.Action.Operation { | |||
| case AssetOperationAssetCreate: | |||
| payload, assetErr := buildTianyiYunAssetCreatePayload(req.Body) | |||
| if assetErr != nil { | |||
| return "", nil, "", assetErr | |||
| } | |||
| data, err := common.Marshal(payload) | |||
| if err != nil { | |||
| return "", nil, "", newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) | |||
| } | |||
| return baseURL + "/api/assets/upload", data, http.MethodPost, nil | |||
| case AssetOperationAssetGet: | |||
| id := tianyiYunAssetField(req.Body, "Id", "id") | |||
| if id == "" { | |||
| return "", nil, "", newAssetError(AssetErrorInvalidRequest, "asset id is required", http.StatusBadRequest) | |||
| } | |||
| return baseURL + "/api/assets/" + url.PathEscape(id), nil, http.MethodGet, nil | |||
| default: | |||
| return "", nil, "", newAssetError(AssetErrorOperationNotSupported, fmt.Sprintf("asset operation %s is not supported", req.Action.Operation), http.StatusBadRequest) | |||
| } | |||
| } | |||
| func tianyiYunAssetBaseURL(channel *model.Channel) (string, error) { | |||
| baseURL := strings.TrimSpace(channel.GetBaseURL()) | |||
| if baseURL == "" { | |||
| baseURL = "https://ai.ctaigw.cn/v1" | |||
| } | |||
| u, err := url.Parse(baseURL) | |||
| if err != nil || u.Scheme == "" || u.Host == "" { | |||
| return "", fmt.Errorf("invalid TianyiYun asset base URL") | |||
| } | |||
| u.Path = strings.TrimRight(u.Path, "/") | |||
| if !strings.HasSuffix(u.Path, "/v1") { | |||
| u.Path += "/v1" | |||
| } | |||
| u.RawQuery = "" | |||
| u.Fragment = "" | |||
| return strings.TrimRight(u.String(), "/"), nil | |||
| } | |||
| func buildTianyiYunAssetCreatePayload(body map[string]any) (map[string]string, *AssetError) { | |||
| sourceURL := tianyiYunAssetField(body, "URL", "url") | |||
| if sourceURL == "" { | |||
| return nil, newAssetError(AssetErrorInvalidRequest, "asset URL is required", http.StatusBadRequest) | |||
| } | |||
| parsedURL, err := url.Parse(sourceURL) | |||
| if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" { | |||
| return nil, newAssetError(AssetErrorInvalidRequest, "asset URL must use http or https", http.StatusBadRequest) | |||
| } | |||
| assetType := tianyiYunAssetField(body, "AssetType", "asset_type") | |||
| if assetType != "Image" && assetType != "Video" && assetType != "Audio" { | |||
| return nil, newAssetError(AssetErrorInvalidRequest, "asset type must be Image, Video, or Audio", http.StatusBadRequest) | |||
| } | |||
| fetchSetting := system_setting.GetFetchSetting() | |||
| if err := common.ValidateURLWithFetchSetting(sourceURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { | |||
| return nil, newAssetError(AssetErrorInvalidRequest, fmt.Sprintf("asset URL blocked: %v", err), http.StatusBadRequest) | |||
| } | |||
| payload := map[string]string{"url": sourceURL, "asset_type": assetType} | |||
| if name := tianyiYunAssetField(body, "Name", "name"); name != "" { | |||
| payload["name"] = name | |||
| } | |||
| return payload, nil | |||
| } | |||
| func tianyiYunAssetField(body map[string]any, upperKey string, lowerKey string) string { | |||
| if body == nil { | |||
| return "" | |||
| } | |||
| if value, ok := body[upperKey]; ok { | |||
| if text, ok := value.(string); ok { | |||
| return strings.TrimSpace(text) | |||
| } | |||
| } | |||
| if value, ok := body[lowerKey]; ok { | |||
| if text, ok := value.(string); ok { | |||
| return strings.TrimSpace(text) | |||
| } | |||
| } | |||
| return "" | |||
| } | |||
| @@ -0,0 +1,131 @@ | |||
| package service | |||
| import ( | |||
| "context" | |||
| "io" | |||
| "net/http" | |||
| "net/http/httptest" | |||
| "strings" | |||
| "testing" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/QuantumNous/new-api/setting/system_setting" | |||
| "github.com/stretchr/testify/require" | |||
| ) | |||
| func TestTianyiYunAssetAdapterCreateAssetMapsActionRequest(t *testing.T) { | |||
| disableSSRFProtectionForTianyiYunAssetTest(t) | |||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| require.Equal(t, http.MethodPost, r.Method) | |||
| require.Equal(t, "/v1/api/assets/upload", r.URL.Path) | |||
| require.Equal(t, "Bearer key", r.Header.Get("Authorization")) | |||
| body, err := io.ReadAll(r.Body) | |||
| require.NoError(t, err) | |||
| require.JSONEq(t, `{"url":"https://example.test/a.png","asset_type":"Image","name":"cover"}`, string(body)) | |||
| _, _ = w.Write([]byte(`{"code":0,"data":{"Id":"asset-1"}}`)) | |||
| })) | |||
| defer server.Close() | |||
| adapter := NewTianyiYunAssetAdapter() | |||
| channel := &model.Channel{BaseURL: common.GetPointer(server.URL + "/v1"), Key: "key"} | |||
| response, assetErr := adapter.DoAssetRequest(context.Background(), channel, AssetRequest{ | |||
| Action: AssetActionSpec{Action: "CreateAsset", Operation: AssetOperationAssetCreate}, | |||
| Body: map[string]any{"URL": "https://example.test/a.png", "AssetType": "Image", "Name": "cover"}, | |||
| }) | |||
| require.Nil(t, assetErr) | |||
| require.Equal(t, http.StatusOK, response.StatusCode) | |||
| require.JSONEq(t, `{"code":0,"data":{"Id":"asset-1"}}`, string(response.Body)) | |||
| } | |||
| func TestTianyiYunAssetAdapterGetAssetMapsActionRequest(t *testing.T) { | |||
| disableSSRFProtectionForTianyiYunAssetTest(t) | |||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| require.Equal(t, http.MethodGet, r.Method) | |||
| require.Equal(t, "/v1/api/assets/asset-1%2Fpart", r.URL.EscapedPath()) | |||
| require.Equal(t, "Bearer key", r.Header.Get("Authorization")) | |||
| body, err := io.ReadAll(r.Body) | |||
| require.NoError(t, err) | |||
| require.Empty(t, body) | |||
| _, _ = w.Write([]byte(`{"code":0,"data":{"Id":"asset-1"}}`)) | |||
| })) | |||
| defer server.Close() | |||
| adapter := NewTianyiYunAssetAdapter() | |||
| channel := &model.Channel{BaseURL: common.GetPointer(server.URL), Key: "key"} | |||
| response, assetErr := adapter.DoAssetRequest(context.Background(), channel, AssetRequest{ | |||
| Action: AssetActionSpec{Action: "GetAsset", Operation: AssetOperationAssetGet}, | |||
| Body: map[string]any{"Id": "asset-1/part"}, | |||
| }) | |||
| require.Nil(t, assetErr) | |||
| require.Equal(t, http.StatusOK, response.StatusCode) | |||
| } | |||
| func TestTianyiYunAssetAdapterRejectsInvalidCreateRequest(t *testing.T) { | |||
| cases := []map[string]any{ | |||
| {"AssetType": "Image"}, | |||
| {"URL": "data:image/png;base64,AAAA", "AssetType": "Image"}, | |||
| {"URL": "aGVsbG8=", "AssetType": "Image"}, | |||
| {"URL": "ftp://example.test/a.png", "AssetType": "Image"}, | |||
| {"URL": "https://example.test/a.png", "AssetType": "Document"}, | |||
| } | |||
| for _, body := range cases { | |||
| t.Run("invalid", func(t *testing.T) { | |||
| adapter := NewTianyiYunAssetAdapter() | |||
| response, assetErr := adapter.DoAssetRequest(context.Background(), &model.Channel{Key: "key"}, AssetRequest{ | |||
| Action: AssetActionSpec{Action: "CreateAsset", Operation: AssetOperationAssetCreate}, Body: body, | |||
| }) | |||
| require.Nil(t, response) | |||
| require.NotNil(t, assetErr) | |||
| require.Equal(t, http.StatusBadRequest, assetErr.HTTPStatus) | |||
| }) | |||
| } | |||
| } | |||
| func disableSSRFProtectionForTianyiYunAssetTest(t *testing.T) { | |||
| t.Helper() | |||
| setting := system_setting.GetFetchSetting() | |||
| old := *setting | |||
| setting.EnableSSRFProtection = false | |||
| t.Cleanup(func() { *setting = old }) | |||
| } | |||
| func TestTianyiYunAssetAdapterRejectsPrivateSourceURL(t *testing.T) { | |||
| setting := system_setting.GetFetchSetting() | |||
| old := *setting | |||
| setting.EnableSSRFProtection = true | |||
| setting.AllowPrivateIp = false | |||
| setting.DomainFilterMode = false | |||
| setting.IpFilterMode = false | |||
| setting.DomainList = nil | |||
| setting.IpList = nil | |||
| setting.AllowedPorts = []string{"80", "443", "8080", "8443"} | |||
| t.Cleanup(func() { *setting = old }) | |||
| for _, sourceURL := range []string{"https://127.0.0.1/a.png", "http://10.0.0.1/a.png"} { | |||
| t.Run(sourceURL, func(t *testing.T) { | |||
| response, assetErr := NewTianyiYunAssetAdapter().DoAssetRequest(context.Background(), &model.Channel{Key: "key"}, AssetRequest{ | |||
| Action: AssetActionSpec{Action: "CreateAsset", Operation: AssetOperationAssetCreate}, | |||
| Body: map[string]any{"URL": sourceURL, "AssetType": "Image"}, | |||
| }) | |||
| require.Nil(t, response) | |||
| require.NotNil(t, assetErr) | |||
| require.Equal(t, http.StatusBadRequest, assetErr.HTTPStatus) | |||
| }) | |||
| } | |||
| } | |||
| func TestTianyiYunAssetAdapterSupportsOnlyCreateAndGet(t *testing.T) { | |||
| adapter := NewTianyiYunAssetAdapter() | |||
| require.True(t, adapter.Supports(AssetOperationAssetCreate)) | |||
| require.True(t, adapter.Supports(AssetOperationAssetGet)) | |||
| require.False(t, adapter.Supports(AssetOperationAssetList)) | |||
| _, assetErr := adapter.DoAssetRequest(context.Background(), &model.Channel{Key: "key"}, AssetRequest{ | |||
| Action: AssetActionSpec{Operation: AssetOperationAssetList}, | |||
| }) | |||
| require.NotNil(t, assetErr) | |||
| require.Equal(t, AssetErrorOperationNotSupported, assetErr.Type) | |||
| require.True(t, strings.Contains(assetErr.Message, "not supported")) | |||
| } | |||
| @@ -153,7 +153,7 @@ func SupportsAnyMatrixUsageBillingModel(modelName string) bool { | |||
| switch modelName { | |||
| case "seedance-2", "doubao-video", "doubao-video-seedance", | |||
| "doubao-seedance-2-0-260128", "doubao-seedance-2-0-fast-260128", | |||
| "Doubao-Seedance-2.0", "Doubao-Seedance-2.0-fast", | |||
| "Doubao-Seedance-2.0", "Doubao-Seedance-2.0-fast", "Doubao-Seedance-2.0-mini", | |||
| "kling-v1", "kling-v1-6", "kling-v2-6", "kling-v3", | |||
| "kling-video-o1", "kling-v3-omni": | |||
| return true | |||
| @@ -76,6 +76,10 @@ func TestValidatePricingConfigUsageAllowlist(t *testing.T) { | |||
| require.NoError(t, ValidatePricingConfig("seedance-2", cfg)) | |||
| } | |||
| func TestSupportsAnyMatrixUsageBillingModelIncludesTianyiYunMiniAlias(t *testing.T) { | |||
| require.True(t, SupportsAnyMatrixUsageBillingModel("Doubao-Seedance-2.0-mini")) | |||
| } | |||
| func TestValidatePricingConfigUsageAllowlistKlingAipingModels(t *testing.T) { | |||
| models := []string{ | |||
| "kling-v1", | |||
| @@ -47,6 +47,7 @@ const AddUserModal = (props) => { | |||
| const getInitValues = () => ({ | |||
| username: '', | |||
| display_name: '', | |||
| email: '', | |||
| password: '', | |||
| remark: '', | |||
| }); | |||
| @@ -155,6 +156,15 @@ const AddUserModal = (props) => { | |||
| showClear | |||
| /> | |||
| </Col> | |||
| <Col span={24}> | |||
| <Form.Input | |||
| field='email' | |||
| label={t('邮箱')} | |||
| placeholder={t('请输入邮箱')} | |||
| rules={[{ type: 'email', message: t('请输入有效的邮箱地址') }]} | |||
| showClear | |||
| /> | |||
| </Col> | |||
| <Col span={24}> | |||
| <Form.Input | |||
| field='password' | |||