Просмотр исходного кода

feat(tianyiyun): add Seedance asset support

Co-Authored-By: Codex <noreply@anthropic.com>
master
fengsilin 4 недель назад
Родитель
Сommit
50ce4f7541
13 измененных файлов: 736 добавлений и 8 удалений
  1. +10
    -0
      controller/user.go
  2. +329
    -0
      docs/testing/2026-07-21-cn-tianyiyun-seedance-mini-e2e.md
  3. +24
    -0
      relay/channel/task/doubao_tianyiyun/adaptor.go
  4. +51
    -0
      relay/channel/task/doubao_tianyiyun/adaptor_test.go
  5. +1
    -0
      relay/channel/task/doubao_tianyiyun/constants.go
  6. +2
    -0
      relay/helper/matrix_usage_capability.go
  7. +9
    -0
      relay/helper/matrix_usage_capability_test.go
  8. +1
    -7
      service/asset_adapter.go
  9. +163
    -0
      service/asset_tianyiyun.go
  10. +131
    -0
      service/asset_tianyiyun_test.go
  11. +1
    -1
      setting/ratio_setting/model_pricing.go
  12. +4
    -0
      setting/ratio_setting/model_pricing_test.go
  13. +10
    -0
      web/src/components/table/users/modals/AddUserModal.jsx

+ 10
- 0
controller/user.go Просмотреть файл

@@ -837,6 +837,16 @@ func CreateUser(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel) common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
return 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! // Even for admin users, we cannot fully trust them!
cleanUser := model.User{ cleanUser := model.User{
Username: user.Username, Username: user.Username,


+ 329
- 0
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 <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` 持久化全部验证通过。

+ 24
- 0
relay/channel/task/doubao_tianyiyun/adaptor.go Просмотреть файл

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -231,6 +232,13 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
if err := taskcommon.UnmarshalMetadata(req.Metadata, &r); err != nil { if err := taskcommon.UnmarshalMetadata(req.Metadata, &r); err != nil {
return nil, errors.Wrap(err, "unmarshal metadata failed") 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 { if sec, _ := strconv.Atoi(req.Seconds); sec > 0 && r.Duration == nil {
r.Duration = lo.ToPtr(dto.IntValue(sec)) r.Duration = lo.ToPtr(dto.IntValue(sec))
} }
@@ -243,6 +251,22 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
return &r, nil 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) { func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
resTask := responseTask{} resTask := responseTask{}
if err := common.Unmarshal(respBody, &resTask); err != nil { if err := common.Unmarshal(respBody, &resTask); err != nil {


+ 51
- 0
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-0611")
require.Contains(t, models, "cdance2.0-fast-0611") require.Contains(t, models, "cdance2.0-fast-0611")
require.Contains(t, models, "cdance2.0-mini-0611")
require.Equal(t, "DoubaoVideoCompatibleTianyiYun", (&TaskAdaptor{}).GetChannelName()) 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) { func TestBuildRequestBodyPreservesTianyiYunNativeFields(t *testing.T) {
adaptor := &TaskAdaptor{} adaptor := &TaskAdaptor{}
c := newTianyiYunTaskRequestContext(t, `{ c := newTianyiYunTaskRequestContext(t, `{


+ 1
- 0
relay/channel/task/doubao_tianyiyun/constants.go Просмотреть файл

@@ -3,6 +3,7 @@ package doubao_tianyiyun
var ModelList = []string{ var ModelList = []string{
"cdance2.0-0611", "cdance2.0-0611",
"cdance2.0-fast-0611", "cdance2.0-fast-0611",
"cdance2.0-mini-0611",
} }


var ChannelName = "DoubaoVideoCompatibleTianyiYun" var ChannelName = "DoubaoVideoCompatibleTianyiYun"

+ 2
- 0
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: "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-0611", UpstreamModelName: "cdance2.0-0611", ChannelType: constant.ChannelTypeDoubaoVideoCompatibleTianyiYun},
{BillingModelName: "cdance2.0-fast-0611", UpstreamModelName: "cdance2.0-fast-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-260128", ChannelType: constant.ChannelTypeChinaMobileSeedance},
{BillingModelName: "doubao-seedance-2-0-260128", UpstreamModelName: "doubao-seedance-2.0", 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}, {BillingModelName: "doubao-seedance-2-0-fast-260128", UpstreamModelName: "doubao-seedance-2-0-fast-260128", ChannelType: constant.ChannelTypeChinaMobileSeedance},


+ 9
- 0
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")) 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) { 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-260128"))
require.True(t, SupportsMatrixUsageBilling("doubao-seedance-2-0-260128", constant.ChannelTypeChinaMobileSeedance, "doubao-seedance-2.0")) require.True(t, SupportsMatrixUsageBilling("doubao-seedance-2-0-260128", constant.ChannelTypeChinaMobileSeedance, "doubao-seedance-2.0"))


+ 1
- 7
service/asset_adapter.go Просмотреть файл

@@ -67,13 +67,7 @@ func registerDefaultAssetAdapters() {
AssetOperationAssetUpdate, AssetOperationAssetUpdate,
AssetOperationAssetDelete, AssetOperationAssetDelete,
}) })
assetAdapters[constant.ChannelTypeDoubaoVideoCompatibleTianyiYun] = NewCompatibleAssetAdapter("tianyiyun_asset", []AssetOperation{
AssetOperationAssetCreate,
AssetOperationAssetList,
AssetOperationAssetGet,
AssetOperationAssetUpdate,
AssetOperationAssetDelete,
})
assetAdapters[constant.ChannelTypeDoubaoVideoCompatibleTianyiYun] = NewTianyiYunAssetAdapter()
} }


func GetAssetAdapter(channelType int) (AssetAdapter, bool) { func GetAssetAdapter(channelType int) (AssetAdapter, bool) {


+ 163
- 0
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 ""
}

+ 131
- 0
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"))
}

+ 1
- 1
setting/ratio_setting/model_pricing.go Просмотреть файл

@@ -153,7 +153,7 @@ func SupportsAnyMatrixUsageBillingModel(modelName string) bool {
switch modelName { switch modelName {
case "seedance-2", "doubao-video", "doubao-video-seedance", case "seedance-2", "doubao-video", "doubao-video-seedance",
"doubao-seedance-2-0-260128", "doubao-seedance-2-0-fast-260128", "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-v1", "kling-v1-6", "kling-v2-6", "kling-v3",
"kling-video-o1", "kling-v3-omni": "kling-video-o1", "kling-v3-omni":
return true return true


+ 4
- 0
setting/ratio_setting/model_pricing_test.go Просмотреть файл

@@ -76,6 +76,10 @@ func TestValidatePricingConfigUsageAllowlist(t *testing.T) {
require.NoError(t, ValidatePricingConfig("seedance-2", cfg)) 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) { func TestValidatePricingConfigUsageAllowlistKlingAipingModels(t *testing.T) {
models := []string{ models := []string{
"kling-v1", "kling-v1",


+ 10
- 0
web/src/components/table/users/modals/AddUserModal.jsx Просмотреть файл

@@ -47,6 +47,7 @@ const AddUserModal = (props) => {
const getInitValues = () => ({ const getInitValues = () => ({
username: '', username: '',
display_name: '', display_name: '',
email: '',
password: '', password: '',
remark: '', remark: '',
}); });
@@ -155,6 +156,15 @@ const AddUserModal = (props) => {
showClear showClear
/> />
</Col> </Col>
<Col span={24}>
<Form.Input
field='email'
label={t('邮箱')}
placeholder={t('请输入邮箱')}
rules={[{ type: 'email', message: t('请输入有效的邮箱地址') }]}
showClear
/>
</Col>
<Col span={24}> <Col span={24}>
<Form.Input <Form.Input
field='password' field='password'


Загрузка…
Отмена
Сохранить