diff --git a/controller/user.go b/controller/user.go index ff78e09..9a42627 100644 --- a/controller/user.go +++ b/controller/user.go @@ -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, diff --git a/docs/testing/2026-07-21-cn-tianyiyun-seedance-mini-e2e.md b/docs/testing/2026-07-21-cn-tianyiyun-seedance-mini-e2e.md new file mode 100644 index 0000000..16c8981 --- /dev/null +++ b/docs/testing/2026-07-21-cn-tianyiyun-seedance-mini-e2e.md @@ -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 +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 +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/?", + "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 +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 +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 +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 +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 +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/?" + }, + "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` 持久化全部验证通过。 diff --git a/relay/channel/task/doubao_tianyiyun/adaptor.go b/relay/channel/task/doubao_tianyiyun/adaptor.go index 81c17fb..ef39654 100644 --- a/relay/channel/task/doubao_tianyiyun/adaptor.go +++ b/relay/channel/task/doubao_tianyiyun/adaptor.go @@ -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 { diff --git a/relay/channel/task/doubao_tianyiyun/adaptor_test.go b/relay/channel/task/doubao_tianyiyun/adaptor_test.go index e671f44..97643b2 100644 --- a/relay/channel/task/doubao_tianyiyun/adaptor_test.go +++ b/relay/channel/task/doubao_tianyiyun/adaptor_test.go @@ -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, `{ diff --git a/relay/channel/task/doubao_tianyiyun/constants.go b/relay/channel/task/doubao_tianyiyun/constants.go index e09f5d8..0284664 100644 --- a/relay/channel/task/doubao_tianyiyun/constants.go +++ b/relay/channel/task/doubao_tianyiyun/constants.go @@ -3,6 +3,7 @@ package doubao_tianyiyun var ModelList = []string{ "cdance2.0-0611", "cdance2.0-fast-0611", + "cdance2.0-mini-0611", } var ChannelName = "DoubaoVideoCompatibleTianyiYun" diff --git a/relay/helper/matrix_usage_capability.go b/relay/helper/matrix_usage_capability.go index 3c65150..ee1e123 100644 --- a/relay/helper/matrix_usage_capability.go +++ b/relay/helper/matrix_usage_capability.go @@ -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}, diff --git a/relay/helper/matrix_usage_capability_test.go b/relay/helper/matrix_usage_capability_test.go index 76b41c7..447891f 100644 --- a/relay/helper/matrix_usage_capability_test.go +++ b/relay/helper/matrix_usage_capability_test.go @@ -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")) diff --git a/service/asset_adapter.go b/service/asset_adapter.go index 608a361..8ff0ac4 100644 --- a/service/asset_adapter.go +++ b/service/asset_adapter.go @@ -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) { diff --git a/service/asset_tianyiyun.go b/service/asset_tianyiyun.go new file mode 100644 index 0000000..052bb70 --- /dev/null +++ b/service/asset_tianyiyun.go @@ -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 "" +} diff --git a/service/asset_tianyiyun_test.go b/service/asset_tianyiyun_test.go new file mode 100644 index 0000000..b36dce1 --- /dev/null +++ b/service/asset_tianyiyun_test.go @@ -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")) +} diff --git a/setting/ratio_setting/model_pricing.go b/setting/ratio_setting/model_pricing.go index 8126b0a..b16a53f 100644 --- a/setting/ratio_setting/model_pricing.go +++ b/setting/ratio_setting/model_pricing.go @@ -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 diff --git a/setting/ratio_setting/model_pricing_test.go b/setting/ratio_setting/model_pricing_test.go index ae452be..9acc5ba 100644 --- a/setting/ratio_setting/model_pricing_test.go +++ b/setting/ratio_setting/model_pricing_test.go @@ -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", diff --git a/web/src/components/table/users/modals/AddUserModal.jsx b/web/src/components/table/users/modals/AddUserModal.jsx index 05b3620..a3b570c 100644 --- a/web/src/components/table/users/modals/AddUserModal.jsx +++ b/web/src/components/table/users/modals/AddUserModal.jsx @@ -47,6 +47,7 @@ const AddUserModal = (props) => { const getInitValues = () => ({ username: '', display_name: '', + email: '', password: '', remark: '', }); @@ -155,6 +156,15 @@ const AddUserModal = (props) => { showClear /> + + +