Merge pull request #30 from qianfree/feat/ali_wan3

添加阿里wan3模型支持
This commit is contained in:
Qian authored and GitHub committed 2026-09-23 01:02:30 +08:00
commit 2b8fe44895
41 files changed
+4277 -304

No files matched your search

+1
View File
@@ -52,6 +52,7 @@ type IAdminV1 interface {
ChangelogDelete(ctx context.Context, req *v1.ChangelogDeleteReq) (res *v1.ChangelogDeleteRes, err error)
ChangelogPublish(ctx context.Context, req *v1.ChangelogPublishReq) (res *v1.ChangelogPublishRes, err error)
ChannelList(ctx context.Context, req *v1.ChannelListReq) (res *v1.ChannelListRes, err error)
ChannelOptions(ctx context.Context, req *v1.ChannelOptionsReq) (res *v1.ChannelOptionsRes, err error)
ChannelCreate(ctx context.Context, req *v1.ChannelCreateReq) (res *v1.ChannelCreateRes, err error)
ChannelUpdate(ctx context.Context, req *v1.ChannelUpdateReq) (res *v1.ChannelUpdateRes, err error)
ChannelDelete(ctx context.Context, req *v1.ChannelDeleteReq) (res *v1.ChannelDeleteRes, err error)
+18
View File
@@ -51,6 +51,24 @@ type ChannelItem struct {
WorstModelScore float64 `json:"worst_model_score"`
}
// ChannelOptionsReq 渠道选项列表请求(下拉选择专用,不分页)
type ChannelOptionsReq struct {
g.Meta `path:"/channels/options" method:"get" mime:"json" tags:"管理后台-渠道" summary:"渠道选项列表(不分页)"`
}
// ChannelOptionsRes 渠道选项列表响应
type ChannelOptionsRes struct {
List []ChannelOptionItem `json:"list"`
}
// ChannelOptionItem 渠道选项项(精简字段)
type ChannelOptionItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type int `json:"type"`
Status string `json:"status"`
}
// ChannelCreateReq 创建渠道请求
type ChannelCreateReq struct {
g.Meta `path:"/channels" method:"post" mime:"json" tags:"管理后台-渠道" summary:"创建渠道"`
+19 -9
View File
@@ -19,13 +19,15 @@ type TaskListReq struct {
}
type ModelTaskItem struct {
ID int64 `json:"id"`
PublicTaskID string `json:"public_task_id"`
Platform string `json:"platform"`
Action string `json:"action"`
Status string `json:"status"`
Progress string `json:"progress"`
ModelName string `json:"model_name"`
ID int64 `json:"id"`
PublicTaskID string `json:"public_task_id"`
Platform string `json:"platform"`
Action string `json:"action"`
Status string `json:"status"`
Progress string `json:"progress"`
ModelName string `json:"model_name"`
// UpstreamModel 上游供应商实际使用的模型名(仅任务详情返回,排障时用于和请求模型对照)
UpstreamModel string `json:"upstream_model,omitempty"`
FailReason string `json:"fail_reason,omitempty"`
PreDeductAmount float64 `json:"pre_deduct_amount"`
ActualCost float64 `json:"actual_cost"`
@@ -35,8 +37,16 @@ type ModelTaskItem struct {
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
SubmitTime string `json:"submit_time,omitempty"`
FinishTime string `json:"finish_time,omitempty"`
CreatedAt string `json:"created_at"`
// StartTime 上游开始执行任务的时间(仅任务详情返回;详情时间线用它区分「排队耗时」与「执行耗时」)
StartTime string `json:"start_time,omitempty"`
FinishTime string `json:"finish_time,omitempty"`
CreatedAt string `json:"created_at"`
// RequestID 任务提交时的原始请求 ID(req_xxxxx,仅任务详情返回),关联 aud_request_logs.request_id
RequestID string `json:"request_id,omitempty"`
// 上游排障标识(仅任务详情返回;从 private_data 提取,其余内部字段不外露):
// UpstreamTaskID 任务句柄(上游轮询查询用);UpstreamRequestID 提交调用追踪 ID(部分上游返回)
UpstreamTaskID string `json:"upstream_task_id,omitempty"`
UpstreamRequestID string `json:"upstream_request_id,omitempty"`
}
type TaskListRes struct {
File diff suppressed because it is too large. Load diff
+8
View File
@@ -595,6 +595,14 @@ func registerRelayRoutes(server *ghttp.Server) {
group.POST("/video_generation", relay.HandleMiniMaxVideoV1Submit)
group.GET("/query/video_generation", relay.HandleMiniMaxVideoV1Retrieve)
})
// 阿里 DashScope 官方视频协议端点(wan2.x / wan3.0 通用,阿里 SDK 换 base_url 直连;
// 查询回放官方响应形态,轮询计费由网关后台完成)
server.Group("/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter)
group.POST("/services/aigc/video-generation/video-synthesis", relay.HandleAliVideoSubmit)
group.GET("/tasks/{task_id}", relay.HandleAliVideoRetrieve)
})
}
// registerLandingPage 在未内嵌前端(非 embedweb 构建)时,为后端根路径注册项目介绍页,
@@ -52,3 +52,6 @@ func (c *ControllerV1) ChannelClone(ctx context.Context, req *v1.ChannelCloneReq
func (c *ControllerV1) ChannelResetHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (res *v1.ChannelResetHealthRes, err error) {
return service.Admin().ResetChannelHealth(ctx, req)
}
func (c *ControllerV1) ChannelOptions(ctx context.Context, req *v1.ChannelOptionsReq) (res *v1.ChannelOptionsRes, err error) {
return service.Admin().ListChannelOptions(ctx, req)
}
+112
View File
@@ -0,0 +1,112 @@
package relay
import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/qianfree/team-api/internal/logic/monitor"
"github.com/qianfree/team-api/internal/middleware"
relay_constant "github.com/qianfree/team-api/relay/constant"
relay_handler "github.com/qianfree/team-api/relay/handler"
)
// 阿里 DashScope 官方视频协议入站胶水(/api/v1 端点):
// 创建请求在此完成官方请求体解析与通用任务体转换,复用任务提交管线
// (selectTaskChannel 渠道调度 + relay_handler.HandleTaskSubmit 计费/提交/落库);
// 查询直连 relay_handler 的阿里视频处理器(回放 DashScope 官方响应形态)。
// 协议文档:docs/协议文档/阿里系列/视频-wan3.0.md
// HandleAliVideoSubmit 处理 POST /api/v1/services/aigc/video-generation/video-synthesis
// (创建视频生成任务,wan2.x / wan3.0 通用)
func HandleAliVideoSubmit(r *ghttp.Request) {
body := r.GetBody()
if len(body) == 0 {
writeVideosError(r, 400, "request body is empty", "")
return
}
// 官方请求体 → 归一结构(input.media[] 原样保留)
req, taskErr := relay_handler.ParseAliVideoCreateRequest(body)
if taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
if taskErr := relay_handler.ValidateAliVideoCreateRequest(req); taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
// 转成任务框架通用请求体(适配器消费 media/metadata + 双写通用词汇)
taskBody, err := relay_handler.BuildAliVideoTaskBody(req)
if err != nil {
writeVideosError(r, 500, "build task body failed", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
Scope: r.GetCtxVar("ApiKeyScope").String(),
ClientIP: r.GetClientIp(),
KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(),
KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(),
KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(),
KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(),
KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(),
// 阿里 DashScope 官方协议:{"output":{"task_id":...}} 响应 + task_ 前缀 ID
// (协议标识与 relay/handler.aliVideoProtocol 对齐,对齐 videos.go 胶水层字面量惯例)
Protocol: "ali_native",
RelayMode: int(relay_constant.RelayModeAliVideo),
}
// 选择渠道(模型权限校验 + 调度引擎,与 /v1/video/generations 同一链路)
channelMeta, err := selectTaskChannel(r, taskBody)
if err != nil {
writeTaskChannelSelectionError(r, err)
return
}
// 响应捕获用于审计(对齐 HandleTaskSubmit 模式)
capture := NewResponseCaptureWriter(r.Response.Writer)
rc.Writer = capture
registerAsyncTask(rc.RequestID, rc.TenantID, rc.UserID, rc.ProjectID, req.Model, channelMeta, r.URL.Path)
relay_handler.HandleTaskSubmit(
r.Context(), taskBody, r.URL.Path, r.Header,
rc, taskDataProvider, taskBillingProvider, channelMeta,
)
// 提交成功后切换为任务 ID 跟踪(任务生命周期远超 HTTP 请求)
if rc.TaskID != "" {
monitor.SwitchToTaskID(rc.RequestID, rc.TaskID)
} else {
monitor.UnregisterRequest(rc.RequestID)
}
rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, taskBody, channelMeta, capture.StatusCode())
go recordTaskSubmitAudit(r, rc, capture, taskBody)
}
// HandleAliVideoRetrieve 处理 GET /api/v1/tasks/{task_id}(查询视频任务,回放官方响应)
func HandleAliVideoRetrieve(r *ghttp.Request) {
taskID := r.Get("task_id").String()
if taskID == "" {
writeVideosError(r, 400, "task id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleAliVideoRetrieve(r.Context(), taskID, rc, taskDataProvider)
}
+58
View File
@@ -55,6 +55,58 @@ func channelRuntimeStates() map[int64]dispatchadapter.ChannelRuntimeState {
return cat.ChannelRuntimeStates()
}
// channelOptionsCache 渠道下拉选项缓存(全量渠道,单个条目)。
// 日志 / 统计页的渠道筛选每次打开都要拉一次全量渠道,而渠道量级为几十且极少变动,
// 反复对 chn_channels 全表扫描纯属浪费;整表结果缓存为一个条目,写侧主动失效。
var channelOptionsCache = common.NewCache("channel_options", 300*time.Second)
// channelOptionsCacheKey 渠道下拉是全量结果,只有一个缓存条目。
const channelOptionsCacheKey = "all"
// InvalidateChannelOptionsCache 失效渠道下拉选项缓存(渠道增 / 删 / 改 / 克隆后调用)。
func InvalidateChannelOptionsCache(ctx context.Context) {
channelOptionsCache.Delete(ctx, channelOptionsCacheKey)
}
// ListChannelOptions 渠道下拉选项(不分页,返回全部渠道)。
// 刻意不做状态过滤:日志 / 统计页的渠道筛选项要覆盖历史数据里出现过的全部渠道,
// 已停用渠道同样需要能作为筛选条件;status 原样返回,由前端决定是否标记。
// 注意与 /admin/channels 的区别:后者是渠道管理列表(分页 + 健康度联表 + 运行态),
// 下拉场景不要用它 —— 那是本接口存在的意义。
func (s *sAdmin) ListChannelOptions(ctx context.Context, _ *v1.ChannelOptionsReq) (*v1.ChannelOptionsRes, error) {
list := make([]v1.ChannelOptionItem, 0)
if channelOptionsCache.GetJSON(ctx, channelOptionsCacheKey, &list) {
return &v1.ChannelOptionsRes{List: list}, nil
}
var rows []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type int `json:"type"`
Status string `json:"status"`
}
err := dao.ChnChannels.Ctx(ctx).
Fields("id, name, type, status").
OrderAsc("id").
Scan(&rows)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
list = make([]v1.ChannelOptionItem, 0, len(rows))
for _, r := range rows {
list = append(list, v1.ChannelOptionItem{
ID: r.ID,
Name: r.Name,
Type: r.Type,
Status: r.Status,
})
}
channelOptionsCache.Set(ctx, channelOptionsCacheKey, list)
return &v1.ChannelOptionsRes{List: list}, nil
}
// ListChannels 获取渠道列表
func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.ChannelListRes, error) {
query := dao.ChnChannels.Ctx(ctx).
@@ -277,6 +329,8 @@ func (s *sAdmin) CloneChannel(ctx context.Context, req *v1.ChannelCloneReq) (*v1
g.Log().Warningf(ctx, "init health score for channel %d failed: %v", newID, err)
}
InvalidateChannelOptionsCache(ctx)
return &v1.ChannelCloneRes{ID: newID}, nil
}
@@ -350,6 +404,8 @@ func (s *sAdmin) CreateChannel(ctx context.Context, req *v1.ChannelCreateReq) (*
g.Log().Warningf(ctx, "init health score for channel %d failed: %v", id, err)
}
InvalidateChannelOptionsCache(ctx)
return &v1.ChannelCreateRes{ID: id}, nil
}
@@ -490,6 +546,7 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (*
}
}
dispatchadapter.InvalidateChannel(ctx, req.ID)
InvalidateChannelOptionsCache(ctx)
if req.Status == "active" {
// 手动启用/恢复:复位熔断并开启爬坡窗口,恢复初期小流量验证(rampFactor)
dispatchadapter.MarkChannelRecovered(ctx, req.ID)
@@ -519,6 +576,7 @@ func (s *sAdmin) DeleteChannel(ctx context.Context, req *v1.ChannelDeleteReq) (*
return nil, err
}
dispatchadapter.InvalidateChannel(ctx, req.ID)
InvalidateChannelOptionsCache(ctx)
return nil, nil
}
+3
View File
@@ -314,6 +314,9 @@ func createOAuthChannelAndKey(ctx context.Context, oauthData *oauth.OAuthKeyData
return 0, 0, err
}
// OAuth 建渠道同样要让渠道下拉缓存失效(新建渠道不出现/不失效会滞后一个 TTL)
InvalidateChannelOptionsCache(ctx)
g.Log().Infof(ctx, "[OAuth] 创建 OAuth 渠道: platform=%s, channel_id=%d, key_id=%d, email=%s",
platform, channelID, keyID, oauthData.EmailAddress)
+62 -27
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"math"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gcode"
@@ -24,40 +25,67 @@ import (
"github.com/qianfree/team-api/internal/utility/export"
)
// modelOptionsCache 模型下拉选项缓存(全量模型,单个条目)。
// 下拉接口是若干管理页每次打开都要调的,而模型表极少变动;
// 整表结果缓存为一个条目、按 status/category 在内存里过滤,写侧主动失效。
var modelOptionsCache = common.NewCache("model_options", 300*time.Second)
// modelOptionsCacheKey 模型下拉是全量结果,只有一个缓存条目。
const modelOptionsCacheKey = "all"
// InvalidateModelOptionsCache 失效模型下拉选项缓存(模型增 / 删 / 改后调用)。
// 注:task 包的模型下线定时任务改 status 时不经此处,下拉最多滞后一个 TTL(300s);
// 对「按状态筛模型」的下拉而言可接受,故不为此在 task 包反向依赖 admin。
func InvalidateModelOptionsCache(ctx context.Context) {
modelOptionsCache.Delete(ctx, modelOptionsCacheKey)
}
// modelOptionRow 下拉缓存行:比出参多一个 status,供内存过滤。
// 缓存存全量(不过滤),避免 status / category 的每个组合各占一个缓存条目。
type modelOptionRow struct {
ID int64 `json:"id"`
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Status string `json:"status"`
}
// ListModelOptions 获取模型选项列表(不分页,用于下拉选择)
func (s *sAdmin) ListModelOptions(ctx context.Context, req *v1.ModelOptionsReq) (*v1.ModelOptionsRes, error) {
query := dao.MdlModels.Ctx(ctx)
if req.Status != "" {
query = query.Where("status", req.Status)
} else {
query = query.Where("status", "active")
}
if req.Category != "" {
query = query.Where("category", req.Category)
rows := make([]modelOptionRow, 0)
if !modelOptionsCache.GetJSON(ctx, modelOptionsCacheKey, &rows) {
err := dao.MdlModels.Ctx(ctx).
Fields("id, model_id, model_name, category, status").
OrderAsc("category").OrderAsc("model_id").
Scan(&rows)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if rows == nil {
rows = make([]modelOptionRow, 0)
}
modelOptionsCache.Set(ctx, modelOptionsCacheKey, rows)
}
var models []struct {
ID int64 `json:"id"`
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
// 沿用原接口语义:不传 status 即只要可用模型
status := req.Status
if status == "" {
status = "active"
}
err := query.Fields("id, model_id, model_name, category").
OrderAsc("category").OrderAsc("model_id").
Scan(&models)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
list := make([]v1.ModelOptionItem, 0, len(models))
for _, m := range models {
list := make([]v1.ModelOptionItem, 0, len(rows))
for _, r := range rows {
if r.Status != status {
continue
}
if req.Category != "" && r.Category != req.Category {
continue
}
list = append(list, v1.ModelOptionItem{
ID: m.ID,
ModelId: m.ModelId,
ModelName: m.ModelName,
Category: m.Category,
ID: r.ID,
ModelId: r.ModelId,
ModelName: r.ModelName,
Category: r.Category,
})
}
@@ -291,6 +319,8 @@ func (s *sAdmin) CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.M
return nil, err
}
InvalidateModelOptionsCache(ctx)
return &v1.ModelCreateRes{ID: id}, nil
}
@@ -397,6 +427,10 @@ func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.M
return nil, err
}
// 下拉缓存按全量存、内存过滤 status/category,故改名 / 改分类同样要失效,
// 不能只在 statusChanged 时清。
InvalidateModelOptionsCache(ctx)
if statusChanged {
// 状态变更时清除模型缓存和租户分组缓存
relay.NewDataProvider().InvalidateModelCache(oldModel.ModelId)
@@ -473,6 +507,7 @@ func (s *sAdmin) DeleteModel(ctx context.Context, req *v1.ModelDeleteReq) (*v1.M
if model.ModelId != "" {
billing.ClearModelPriceCache(ctx, model.ModelId)
}
InvalidateModelOptionsCache(ctx)
return nil, nil
}
+7 -1
View File
@@ -401,6 +401,12 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
}
return nil
})
if err != nil {
return res, err
}
return res, err
// 导入含新建 / 更新模型,逐条失效太碎,事务提交后整体失效一次下拉缓存
InvalidateModelOptionsCache(ctx)
return res, nil
}
+21
View File
@@ -2,6 +2,7 @@ package admin
import (
"context"
"encoding/json"
"time"
"github.com/gogf/gf/v2/errors/gcode"
@@ -132,6 +133,8 @@ func (s *sAdmin) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (*v1.Tas
ResultUrl string `json:"result_url"`
TenantId int64 `json:"tenant_id"`
UserId int64 `json:"user_id"`
PrivateData string `json:"private_data"`
RequestId string `json:"request_id"`
SubmitTime *gtime.Time `json:"submit_time"`
StartTime *gtime.Time `json:"start_time"`
FinishTime *gtime.Time `json:"finish_time"`
@@ -153,6 +156,7 @@ func (s *sAdmin) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (*v1.Tas
Status: task.Status,
Progress: task.Progress,
ModelName: task.ModelName,
UpstreamModel: task.UpstreamModel,
FailReason: task.FailReason,
PreDeductAmount: task.PreDeductAmount,
ActualCost: task.ActualCost,
@@ -160,15 +164,32 @@ func (s *sAdmin) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (*v1.Tas
ResultURL: task.ResultUrl,
TenantID: task.TenantId,
UserID: task.UserId,
RequestID: task.RequestId,
CreatedAt: task.CreatedAt.Format("Y-m-d H:i:s"),
}
if task.SubmitTime != nil {
item.SubmitTime = task.SubmitTime.Format("Y-m-d H:i:s")
}
if task.StartTime != nil {
item.StartTime = task.StartTime.Format("Y-m-d H:i:s")
}
if task.FinishTime != nil {
item.FinishTime = task.FinishTime.Format("Y-m-d H:i:s")
}
// 从 private_data 提取上游排障标识:仅取 upstream_task_id / upstream_request_id 两个键,
// 计费上下文等其余内部数据不外露(同步图片任务无上游任务 ID,两字段为空)
if task.PrivateData != "" {
var pd struct {
UpstreamTaskID string `json:"upstream_task_id"`
UpstreamRequestID string `json:"upstream_request_id"`
}
if json.Unmarshal([]byte(task.PrivateData), &pd) == nil {
item.UpstreamTaskID = pd.UpstreamTaskID
item.UpstreamRequestID = pd.UpstreamRequestID
}
}
// 对 re-host 到对象存储的结果图,生成新鲜的缩略图 URL 供详情弹窗内联预览,并刷新原图 URL
// (规避 result_url 24h 预签名过期)。非图片/上游直链任务查不到文件记录,保持原 result_url。
if thumb, orig := common.TaskResultImageURLs(ctx, task.TenantId, task.PublicTaskId, 600); thumb != "" {
+10
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"math"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
@@ -111,6 +112,15 @@ func LookupPerSecondPrice(prices map[string]float64, spec string) float64 {
if p, ok := prices[spec]; ok && p > 0 {
return p
}
// 大小写不敏感兜底:配置键形态因供应商词汇而异("720p" vs "720P"),
// 语义同档应优先于 "*" 通配回退,防止大写上报查不中而按兜底价计费
if lower := strings.ToLower(spec); lower != "" {
for k, p := range prices {
if strings.ToLower(k) == lower && p > 0 {
return p
}
}
}
if p, ok := prices[perSecondWildcard]; ok && p > 0 {
return p
}
+27 -3
View File
@@ -110,9 +110,33 @@ func (GenericScheme) ValidateSchemeConfig(cfg json.RawMessage) error {
return nil
}
// SettleTaskCost 通用引擎无素材计量语义:忽略 usage,回退预扣口径
// (与 estimateTaskCost 的估算公式一致,结算保持「预扣即终价,token 重算另走 RecalculateByTokens」)。
func (g GenericScheme) SettleTaskCost(pricing *PricingResult, ratios map[string]any, _ *rcommon.TaskMaterialUsage) decimal.Decimal {
// SettleTaskCost 通用引擎的结算口径:
// - per_second(按秒计费):上游返回实际输出秒数(usage.OutputSeconds > 0)时按
// 「实际秒数 × 矩阵单价 × 租户乘数 × 时段乘数」结算,多退少补——查价键优先
// usage.Resolution(实际输出分辨率,如阿里 wan3.0 usage.SR),回退 ratios 的
// spec.resolution(提交时请求档位)。实际时长与请求时长不一致(智能时长模式、
// 上游按内容微调)时以官方计量为准;usage 缺失回退预扣口径。
// - 其余模式(token / per_request / tiered)无素材计量语义:忽略 usage,回退预扣口径
// (与 estimateTaskCost 的估算公式一致,结算保持「预扣即终价,token 重算另走 RecalculateByTokens」)。
func (g GenericScheme) SettleTaskCost(pricing *PricingResult, ratios map[string]any, usage *rcommon.TaskMaterialUsage) decimal.Decimal {
if pricing.BillingMode == "per_second" && usage != nil && usage.OutputSeconds > 0 {
duration := usage.OutputSeconds
// 结算秒数钳制上限与预扣一致,防御异常大值刷扣
if duration > maxTaskDurationSeconds {
duration = maxTaskDurationSeconds
}
spec, _ := ratioString(ratios, "spec.resolution")
if usage.Resolution != "" {
spec = usage.Resolution
}
if price := LookupPerSecondPrice(pricing.PerSecondPrices, spec); price > 0 {
return NewFromFloat(price).
Mul(NewFromFloat(duration)).
Mul(NewFromFloat(pricing.TenantMultiplier)).
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
}
// 矩阵为空/全零:落到下方回退(占位/预扣口径),与预扣分支的兜底行为一致
}
return g.EstimateTaskCost(pricing, ratios, nil)
}
@@ -0,0 +1,104 @@
package billing
import (
"testing"
rcommon "github.com/qianfree/team-api/relay/common"
)
// generic per_second 按实际输出秒数结算(方案 A)的行为测试。
// 矩阵:480P=0.02 / 720P=0.05 / 1080P=0.10,"*" 兜底 0.05(对齐 wan3.0 三档定价形态)。
// perSecondTestPricing 构造 generic per_second 测试定价(乘数恒 1,租户折扣单测另行覆盖)
func perSecondTestPricing(tenantMul float64) *PricingResult {
return &PricingResult{
BillingMode: "per_second",
TenantMultiplier: tenantMul,
PerSecondPrices: map[string]float64{"480P": 0.02, "720P": 0.05, "1080P": 0.10, "*": 0.05},
}
}
// TestGenericPerSecond_SettleByActualSeconds 结算口径:上游返回实际输出秒数时按
// 「实际秒数 × 档位单价 × 租户 × 时段」结算,多退少补;usage 缺失回退预扣口径。
func TestGenericPerSecond_SettleByActualSeconds(t *testing.T) {
pricing := perSecondTestPricing(1.0)
scheme := GenericScheme{}
// 实际 5.13s(上游浮点秒)× 720P 档:请求 5s 预扣与实际有零头差,按实际结算
cost := scheme.SettleTaskCost(pricing, map[string]any{
"spec.duration": 5.0, "spec.resolution": "720P",
}, &rcommon.TaskMaterialUsage{OutputSeconds: 5.13, Resolution: "720P"})
assertDecimal(t, cost, 5.13*0.05, "settle by actual seconds")
// usage.Resolution 优先于 ratios 提交时档位(请求未指定档位,实际输出 480P)
cost = scheme.SettleTaskCost(pricing, map[string]any{
"spec.duration": 5.0,
}, &rcommon.TaskMaterialUsage{OutputSeconds: 5, Resolution: "480P"})
assertDecimal(t, cost, 5*0.02, "usage resolution wins over ratios")
// usage.Resolution 缺失回退 ratios 提交时档位;实际秒数长于请求时补扣
cost = scheme.SettleTaskCost(pricing, map[string]any{
"spec.duration": 5.0, "spec.resolution": "1080P",
}, &rcommon.TaskMaterialUsage{OutputSeconds: 12})
assertDecimal(t, cost, 12*0.10, "fallback resolution, supplement longer actual")
// 智能时长场景:预扣按上限 30s 冻结,实际生成 12s 结算退差
cost = scheme.SettleTaskCost(pricing, map[string]any{
"spec.duration": 30.0, "spec.resolution": "720P",
}, &rcommon.TaskMaterialUsage{OutputSeconds: 12, Resolution: "720P"})
assertDecimal(t, cost, 12*0.05, "smart duration refunds difference")
// 异常大秒数钳制 120s(与预扣钳制同源,防 usage 异常刷扣)
cost = scheme.SettleTaskCost(pricing, map[string]any{
"spec.duration": 5.0, "spec.resolution": "720P",
}, &rcommon.TaskMaterialUsage{OutputSeconds: 99999, Resolution: "720P"})
assertDecimal(t, cost, 120*0.05, "clamp abnormal seconds")
// 档位未命中矩阵 → "*" 兜底价
cost = scheme.SettleTaskCost(pricing, map[string]any{},
&rcommon.TaskMaterialUsage{OutputSeconds: 5, Resolution: "360P"})
assertDecimal(t, cost, 5*0.05, "unknown resolution falls back to wildcard")
// usage 为 nil:回退预扣口径(请求 5s × 720P)
cost = scheme.SettleTaskCost(pricing, map[string]any{
"spec.duration": 5.0, "spec.resolution": "720P",
}, nil)
assertDecimal(t, cost, 5*0.05, "nil usage falls back to estimate")
// usage 存在但 OutputSeconds=0:同样回退预扣口径
cost = scheme.SettleTaskCost(pricing, map[string]any{
"spec.duration": 5.0, "spec.resolution": "720P",
}, &rcommon.TaskMaterialUsage{Resolution: "720P"})
assertDecimal(t, cost, 5*0.05, "zero output seconds falls back to estimate")
// 矩阵为空/全零:落到预扣分支的占位兜底(与预扣行为一致)
empty := perSecondTestPricing(1.0)
empty.PerSecondPrices = nil
cost = scheme.SettleTaskCost(empty, map[string]any{
"spec.duration": 5.0, "spec.resolution": "720P",
}, &rcommon.TaskMaterialUsage{OutputSeconds: 5, Resolution: "720P"})
assertDecimal(t, cost, imagePlaceholderPreDeduct, "empty matrix falls back to placeholder")
}
// TestGenericPerSecond_SettleTenantMultiplier 租户折扣作用于实际秒数结算。
func TestGenericPerSecond_SettleTenantMultiplier(t *testing.T) {
pricing := perSecondTestPricing(0.5)
scheme := GenericScheme{}
cost := scheme.SettleTaskCost(pricing, map[string]any{
"spec.duration": 5.0, "spec.resolution": "720P",
}, &rcommon.TaskMaterialUsage{OutputSeconds: 5.13, Resolution: "720P"})
assertDecimal(t, cost, 5.13*0.05*0.5, "tenant multiplier applies to actual seconds")
}
// TestGenericPerSecond_NonPerSecondModesIgnoreUsage 回归:非 per_second 模式(token 等)
// 的结算不受 usage 影响,保持「预扣即终价」。
func TestGenericPerSecond_NonPerSecondModesIgnoreUsage(t *testing.T) {
scheme := GenericScheme{}
// token 模式 + usage 带秒数:忽略 usage,走预扣口径(无时长信号 → 占位兜底)
tokenPricing := &PricingResult{BillingMode: "token", OutputPrice: 3.0, TenantMultiplier: 1.0}
cost := scheme.SettleTaskCost(tokenPricing, map[string]any{},
&rcommon.TaskMaterialUsage{OutputSeconds: 100})
assertDecimal(t, cost, imagePlaceholderPreDeduct, "token mode ignores usage")
}
+5 -3
View File
@@ -438,9 +438,11 @@ func (b *TaskBillingProviderImpl) RecalculateByMaterials(ctx context.Context, te
// 取价失败不阻断结算(调用方回落预扣金额),仅返回 ok=false
return Zero, false, err
}
// 仅显式配置了计费方案的模型适用:generic 任务保持「预扣即终价」不被重估
// (素材计费语义由方案声明,未声明素材的模型不应因 usage 附带字段改变结算来源)
if pricing.Scheme == "" {
// 仅显式配置了计费方案、或 generic per_second(消费上游实际输出秒数结算)的模型适用:
// 其余 generic 任务保持「预扣即终价」不被重估(素材计费语义由方案声明,未声明素材的
// 模型不应因 usage 附带字段改变结算来源)。per_second 放行是安全的:存量按秒渠道
// (kling/volcengine/sora)适配器均不产出 MaterialUsage,结算链路到不了这里,行为不变
if pricing.Scheme == "" && pricing.BillingMode != "per_second" {
return Zero, false, nil
}
costD := LookupScheme(pricing.Scheme).SettleTaskCost(pricing, ratios, usage)
@@ -352,4 +352,15 @@ func TestLookupPerSecondPrice(t *testing.T) {
if p := LookupPerSecondPrice(map[string]float64{"720p": 0}, "720p"); p != 0 {
t.Errorf("zero price entry = %v", p)
}
// 大小写不敏感兜底:同档不同形态(配置小写/上报大写,或反之)优先于 "*" 通配回退
if p := LookupPerSecondPrice(map[string]float64{"720p": 0.5, "*": 0.9}, "720P"); p != 0.5 {
t.Errorf("case-insensitive hit = %v", p)
}
if p := LookupPerSecondPrice(map[string]float64{"768P": 0.35}, "768p"); p != 0.35 {
t.Errorf("case-insensitive hit (uppercase key) = %v", p)
}
// 大小写不敏感未命中("2k")仍回退 "*"
if p := LookupPerSecondPrice(map[string]float64{"720p": 0.5, "1080p": 1.0, "*": 0.9}, "2K"); p != 0.9 {
t.Errorf("wildcard after case-insensitive miss = %v", p)
}
}
+247 -87
View File
@@ -2,14 +2,153 @@ package common
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
)
// 关联名称字典缓存:审计 / 日志列表回填 tenant_name / username / api_key_name /
// project_name 用。
//
// 这四类都是「改一次、读十万次」的字典数据,而列表页每翻一页都要回填一次,
// 命中缓存后名称回填不再产生任何 DB 查询(原先每页固定 3 次 + 每租户 1 次查询)。
//
// TTL 300s 与项目既定的「用户信息 300s」缓存策略一致:改名最多滞后一个 TTL。
// 列表展示的是历史日志,短暂滞后可接受,因此不做写侧的主动失效。
var nameDictCache = NewCache("namedict", 300*time.Second)
// 字典种类前缀,与各自 key 拼装函数一起构成缓存 key。
const (
nameDictTenant = "tenant"
nameDictUser = "user"
nameDictApiKey = "apikey"
nameDictProject = "project"
)
// resolveNameDict 批量解析「字典标识 → 名称」:先按标识查缓存(一次 MGET 批量取回),
// 仅对未命中的标识调用 load 查库,查回的名称回填缓存。
//
// keyOf 把标识编码成缓存 key;load 只接收未命中的标识,且返回的 map 必须只以入参标识为键
// (不能自行规范化),否则回填的 key 与调用方后续的查询 key 对不上。
//
// 库里查不到的标识不回填缓存 —— 否则「刚创建、事务尚未提交」的读穿会被固化成
// 一个长期空值,之后建好也读到空。
// load 出错时降级为「只返回缓存命中部分」,调用方拿到零值名称,不阻断列表查询。
// ids 需调用方先去重。
func resolveNameDict[K comparable](
ctx context.Context,
label string,
ids []K,
keyOf func(K) string,
load func(ctx context.Context, missing []K) (map[K]string, error),
) map[K]string {
if len(ids) == 0 {
return nil
}
cacheKeys := make([]string, len(ids))
for i, id := range ids {
cacheKeys[i] = keyOf(id)
}
cached := nameDictCache.GetMany(ctx, cacheKeys)
result := make(map[K]string, len(ids))
missing := make([]K, 0, len(ids))
for i, id := range ids {
if name, ok := cached[cacheKeys[i]].(string); ok && name != "" {
result[id] = name
continue
}
missing = append(missing, id)
}
if len(missing) == 0 {
return result
}
loaded, err := load(ctx, missing)
if err != nil {
g.Log().Warningf(ctx, "[NameDict] 查询 %s 名称失败: %v", label, err)
return result
}
if len(loaded) == 0 {
return result
}
backfill := make(map[string]any, len(loaded))
for id, name := range loaded {
result[id] = name
backfill[keyOf(id)] = name
}
nameDictCache.SetMany(ctx, backfill)
return result
}
// idNameDictKey int64 主键类的字典缓存 key,形如 "tenant:12"。
func idNameDictKey(kind string, id int64) string {
return kind + ":" + strconv.FormatInt(id, 10)
}
// resolveInt64NameDict 租户 / API Key / 项目三类同构字典的解析入口(主键均为 int64)。
func resolveInt64NameDict(
ctx context.Context,
kind string,
ids []int64,
load func(ctx context.Context, missing []int64) (map[int64]string, error),
) map[int64]string {
return resolveNameDict(ctx, kind, ids, func(id int64) string {
return idNameDictKey(kind, id)
}, load)
}
// nameDictUserKey 用户字典缓存 key,形如 "user:3:45"。
// 租户维度必须编入 key:只按 user_id 缓存的话,A 租户的用户名会在 B 租户的记录上命中,
// 等于绕过了「按 ID 取对象用双键校验」的隔离约定(租户控制台也会回填这些名称)。
func nameDictUserKey(tenantID, userID int64) string {
return nameDictUser + ":" + pairKey(tenantID, userID)
}
// pairKey 拼装 "tenantID:userID"。
func pairKey(tenantID, userID int64) string {
return strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(userID, 10)
}
// uniqueInt64s 去重并保持首次出现的顺序(丢弃非正值)。
func uniqueInt64s(ids []int64) []int64 {
seen := make(map[int64]bool, len(ids))
result := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 || seen[id] {
continue
}
seen[id] = true
result = append(result, id)
}
return result
}
// parseTenantUserKey 拆解 "tenantID:userID" 复合 key。
func parseTenantUserKey(key string) (tenantID, userID int64, ok bool) {
head, tail, found := strings.Cut(key, ":")
if !found {
return 0, 0, false
}
tenantID, err := strconv.ParseInt(head, 10, 64)
if err != nil || tenantID <= 0 {
return 0, 0, false
}
userID, err = strconv.ParseInt(tail, 10, 64)
if err != nil || userID <= 0 {
return 0, 0, false
}
return tenantID, userID, true
}
// EnrichAuditRecords 为审计记录批量补充关联信息(用户名、租户名、API Key 名、项目名)。
// records 中的每条记录应包含 tenant_id、user_id、api_key_id、project_id 字段,
// 函数会从主库批量查询关联信息并回填 tenant_name、username、api_key_name、project_name 字段。
// 四类名称各自最多一次查库(且仅在缓存未命中时),与记录条数无关。
func EnrichAuditRecords(ctx context.Context, records []map[string]any) {
if len(records) == 0 {
return
@@ -45,158 +184,179 @@ func EnrichAuditRecords(ctx context.Context, records []map[string]any) {
}
// BatchQueryUserNames 批量查询用户名。
// key 格式为 "tenantID:userID",返回 map[key]username。
// 入参 key 格式为 "tenantID:userID";返回 map[key]username。
func BatchQueryUserNames(ctx context.Context, userKeys []string) map[string]string {
if len(userKeys) == 0 {
return nil
}
result := make(map[string]string, len(userKeys))
// 去重
// 先滤掉非法键:它们既不该进缓存也不该查库
keys := make([]string, 0, len(userKeys))
seen := make(map[string]bool, len(userKeys))
unique := make([]string, 0, len(userKeys))
for _, k := range userKeys {
if !seen[k] {
seen[k] = true
unique = append(unique, k)
}
}
// 拆分 tenantID 和 userID,按 tenantID 分组批量查询
tenantUsers := make(map[int64][]int64) // tenantID -> []userID
userKeyToID := make(map[string]int64) // "tenantID:userID" -> userID
for _, key := range unique {
var tenantID, userID int64
fmt.Sscanf(key, "%d:%d", &tenantID, &userID)
if tenantID > 0 && userID > 0 {
tenantUsers[tenantID] = append(tenantUsers[tenantID], userID)
userKeyToID[key] = userID
}
}
// 按 tenantID 分组查询
for tenantID, userIDs := range tenantUsers {
type row struct {
Id int64 `json:"id"`
Username string `json:"username"`
}
var rows []row
err := g.DB().Ctx(ctx).Model("tnt_users").
Where("tenant_id", tenantID).
WhereIn("id", userIDs).
Fields("id, username").
Scan(&rows)
if err != nil {
for _, key := range userKeys {
if _, _, ok := parseTenantUserKey(key); !ok {
continue
}
for _, r := range rows {
mapKey := fmt.Sprintf("%d:%d", tenantID, r.Id)
result[mapKey] = r.Username
if seen[key] {
continue
}
seen[key] = true
keys = append(keys, key)
}
if len(keys) == 0 {
return nil
}
return result
return resolveNameDict(ctx, nameDictUser, keys,
func(key string) string {
// keys 已过滤,必定可解析(忽略 ok)
tenantID, userID, _ := parseTenantUserKey(key)
return nameDictUserKey(tenantID, userID)
},
loadUserNames)
}
// loadUserNames 按 (tenant_id, user_id) 精确配对批量查询用户名。
// 先用 tenant_id IN (...) AND id IN (...) 缩小范围,再在应用层按配对过滤 ——
// 不用「只按 id 查」,否则 user_id 与 tenant_id 不匹配的脏记录会把其他租户的用户名带出来,
// 与「按 ID 取对象用双键校验」的隔离约定相悖。
func loadUserNames(ctx context.Context, keys []string) (map[string]string, error) {
type pair struct{ tenantID, userID int64 }
pairs := make([]pair, 0, len(keys))
tenantIDs := make([]int64, 0, len(keys))
userIDs := make([]int64, 0, len(keys))
for _, key := range keys {
tenantID, userID, ok := parseTenantUserKey(key)
if !ok {
continue
}
pairs = append(pairs, pair{tenantID, userID})
tenantIDs = append(tenantIDs, tenantID)
userIDs = append(userIDs, userID)
}
if len(pairs) == 0 {
return nil, nil
}
type row struct {
Id int64 `json:"id"`
TenantId int64 `json:"tenant_id"`
Username string `json:"username"`
}
var rows []row
err := g.DB().Ctx(ctx).Model("tnt_users").
WhereIn("tenant_id", uniqueInt64s(tenantIDs)).
WhereIn("id", uniqueInt64s(userIDs)).
Fields("id, tenant_id, username").
Scan(&rows)
if err != nil {
return nil, err
}
nameByPair := make(map[string]string, len(rows))
for _, r := range rows {
nameByPair[pairKey(r.TenantId, r.Id)] = r.Username
}
// 以入参 key 原样为返回键(回填缓存时 keyOf 也据此重算),不做规范化改写
result := make(map[string]string, len(keys))
for _, key := range keys {
tenantID, userID, ok := parseTenantUserKey(key)
if !ok {
continue
}
if name, found := nameByPair[pairKey(tenantID, userID)]; found {
result[key] = name
}
}
return result, nil
}
// BatchQueryTenantNames 批量查询租户名。
func BatchQueryTenantNames(ctx context.Context, tenantIDs []int64) map[int64]string {
if len(tenantIDs) == 0 {
ids := uniqueInt64s(tenantIDs)
if len(ids) == 0 {
return nil
}
result := make(map[int64]string, len(tenantIDs))
// 去重
seen := make(map[int64]bool, len(tenantIDs))
unique := make([]int64, 0, len(tenantIDs))
for _, id := range tenantIDs {
if !seen[id] {
seen[id] = true
unique = append(unique, id)
}
}
return resolveInt64NameDict(ctx, nameDictTenant, ids, loadTenantNames)
}
// loadTenantNames 从主库按主键批量查询租户名。
func loadTenantNames(ctx context.Context, tenantIDs []int64) (map[int64]string, error) {
type row struct {
Id int64 `json:"id"`
Name string `json:"name"`
}
var rows []row
err := g.DB().Ctx(ctx).Model("tnt_tenants").
WhereIn("id", unique).
WhereIn("id", tenantIDs).
Fields("id, name").
Scan(&rows)
if err != nil {
return result
return nil, err
}
result := make(map[int64]string, len(rows))
for _, r := range rows {
result[r.Id] = r.Name
}
return result
return result, nil
}
// BatchQueryApiKeyNames 批量查询 API Key 名。
func BatchQueryApiKeyNames(ctx context.Context, apiKeyIDs []int64) map[int64]string {
if len(apiKeyIDs) == 0 {
ids := uniqueInt64s(apiKeyIDs)
if len(ids) == 0 {
return nil
}
result := make(map[int64]string, len(apiKeyIDs))
// 去重
seen := make(map[int64]bool, len(apiKeyIDs))
unique := make([]int64, 0, len(apiKeyIDs))
for _, id := range apiKeyIDs {
if !seen[id] {
seen[id] = true
unique = append(unique, id)
}
}
return resolveInt64NameDict(ctx, nameDictApiKey, ids, loadApiKeyNames)
}
// loadApiKeyNames 从主库按主键批量查询 API Key 名。
func loadApiKeyNames(ctx context.Context, apiKeyIDs []int64) (map[int64]string, error) {
type row struct {
Id int64 `json:"id"`
Name string `json:"name"`
}
var rows []row
err := g.DB().Ctx(ctx).Model("api_keys").
WhereIn("id", unique).
WhereIn("id", apiKeyIDs).
Fields("id, name").
Scan(&rows)
if err != nil {
return result
return nil, err
}
result := make(map[int64]string, len(rows))
for _, r := range rows {
result[r.Id] = r.Name
}
return result
return result, nil
}
// BatchQueryProjectNames 批量查询项目名。
func BatchQueryProjectNames(ctx context.Context, projectIDs []int64) map[int64]string {
if len(projectIDs) == 0 {
ids := uniqueInt64s(projectIDs)
if len(ids) == 0 {
return nil
}
result := make(map[int64]string, len(projectIDs))
// 去重
seen := make(map[int64]bool, len(projectIDs))
unique := make([]int64, 0, len(projectIDs))
for _, id := range projectIDs {
if !seen[id] {
seen[id] = true
unique = append(unique, id)
}
}
return resolveInt64NameDict(ctx, nameDictProject, ids, loadProjectNames)
}
// loadProjectNames 从主库按主键批量查询项目名。
func loadProjectNames(ctx context.Context, projectIDs []int64) (map[int64]string, error) {
type row struct {
Id int64 `json:"id"`
Name string `json:"name"`
}
var rows []row
err := g.DB().Ctx(ctx).Model("tnt_projects").
WhereIn("id", unique).
WhereIn("id", projectIDs).
Fields("id, name").
Scan(&rows)
if err != nil {
return result
return nil, err
}
result := make(map[int64]string, len(rows))
for _, r := range rows {
result[r.Id] = r.Name
}
return result
return result, nil
}
// collectInt64Fields 从记录集合中收集指定字段的所有非零 int64 值(去重)。
@@ -250,5 +410,5 @@ func getUserKey(record map[string]any) (string, bool) {
if !ok1 || !ok2 || tenantID == 0 || userID == 0 {
return "", false
}
return fmt.Sprintf("%d:%d", tenantID, userID), true
return pairKey(tenantID, userID), true
}
+108
View File
@@ -0,0 +1,108 @@
package common
import (
"reflect"
"testing"
)
// 说明:resolveNameDict / BatchQuery* 的缓存路径依赖 Redis(g.Redis() 在未配置时 panic,
// 单测环境无 Redis 配置),故此处只覆盖不触达缓存的纯逻辑:
// key 拼装、复合 key 解析、ID 去重。缓存命中/回填行为由集成环境验证。
func TestIdNameDictKey(t *testing.T) {
cases := []struct {
kind string
id int64
want string
}{
{nameDictTenant, 12, "tenant:12"},
{nameDictApiKey, 7, "apikey:7"},
{nameDictProject, 0, "project:0"},
{nameDictTenant, -3, "tenant:-3"},
}
for _, c := range cases {
if got := idNameDictKey(c.kind, c.id); got != c.want {
t.Errorf("idNameDictKey(%q, %d) = %q, want %q", c.kind, c.id, got, c.want)
}
}
}
// TestNameDictUserKeyIsTenantScoped 用户字典 key 必须带上租户维度:
// 只按 user_id 缓存会让 A 租户的用户名在 B 租户的记录上命中(跨租户名称泄漏)。
func TestNameDictUserKeyIsTenantScoped(t *testing.T) {
if got := nameDictUserKey(3, 45); got != "user:3:45" {
t.Fatalf("nameDictUserKey(3, 45) = %q, want %q", got, "user:3:45")
}
sameUserDifferentTenant := nameDictUserKey(5, 45)
if nameDictUserKey(3, 45) == sameUserDifferentTenant {
t.Fatalf("同一 user_id 在不同租户下必须得到不同缓存 key,均得到 %q", sameUserDifferentTenant)
}
}
// TestPairKeyMatchesUserKeySuffix loadUserNames 用 pairKey 建索引、nameDictUserKey 拼缓存 key,
// 两者必须共用同一套 "tenantID:userID" 编码,否则回填的 key 与查询 key 对不上。
func TestPairKeyMatchesUserKeySuffix(t *testing.T) {
if got, want := nameDictUserKey(3, 45), nameDictUser+":"+pairKey(3, 45); got != want {
t.Fatalf("nameDictUserKey(3, 45) = %q, want %q", got, want)
}
}
// TestParseTenantUserKey 合法键正常拆解;非法键一律拒绝(BatchQueryUserNames 据此静默丢弃,
// 不能把错键当成 tenant 0 / user 0 去查库)。
func TestParseTenantUserKey(t *testing.T) {
valid := []struct {
key string
tenantID int64
userID int64
}{
{"3:45", 3, 45},
{"100:1", 100, 1},
}
for _, c := range valid {
tenantID, userID, ok := parseTenantUserKey(c.key)
if !ok || tenantID != c.tenantID || userID != c.userID {
t.Errorf("parseTenantUserKey(%q) = (%d, %d, %v), want (%d, %d, true)",
c.key, tenantID, userID, ok, c.tenantID, c.userID)
}
}
invalid := []string{
"", // 空串
"45", // 无分隔符
":45", // 缺 tenantID
"3:", // 缺 userID
"0:45", // tenantID 非正
"3:0", // userID 非正
"-3:45", // tenantID 非正
"3:-45", // userID 非正
"a:45", // 非数字
"3:b", // 非数字
"3:45:6", // 多余段(tail 非数字)
" 3:45", // 前导空格
"2147483648:99999999999999999999", // 溢出
}
for _, key := range invalid {
if tenantID, userID, ok := parseTenantUserKey(key); ok {
t.Errorf("parseTenantUserKey(%q) 应被拒绝,却返回 (%d, %d, true)", key, tenantID, userID)
}
}
}
// TestUniqueInt64s 去重保持首次出现顺序,并丢弃非正值(0 / 负数不是合法主键)。
func TestUniqueInt64s(t *testing.T) {
cases := []struct {
name string
in []int64
want []int64
}{
{"去重保序", []int64{5, 3, 5, 1, 3}, []int64{5, 3, 1}},
{"丢弃非正值", []int64{0, -1, 2, 0}, []int64{2}},
{"全非正", []int64{0, -1}, []int64{}},
{"空入参", nil, []int64{}},
}
for _, c := range cases {
if got := uniqueInt64s(c.in); !reflect.DeepEqual(got, c.want) {
t.Errorf("%s: uniqueInt64s(%v) = %v, want %v", c.name, c.in, got, c.want)
}
}
}
+89
View File
@@ -83,6 +83,95 @@ func (c *Cache) Set(ctx context.Context, key string, value any, ttl ...time.Dura
}
}
// SetMany 批量写入 L1(内存)+ L2(Redis),语义与逐 key 调用 Set 一致。
// L1 逐 key 写本进程内存;L2 逐 key SETEX —— Redis 没有「批量且带过期」的原生命令
// (MSET 不支持 TTL),故写侧仍是 N 次往返。该方法只服务于「缓存未命中后回填」,
// 调用频次受 TTL 约束,不在热路径上;热路径的批量读走 GetMany(单次 MGET)。
func (c *Cache) SetMany(ctx context.Context, values map[string]any) {
if len(values) == 0 {
return
}
expire := jitterTTL(c.ttl)
ttlSeconds := int64(math.Ceil(expire.Seconds()))
if ttlSeconds <= 0 {
// 兜底:ttl ≤ 0 时使用 24h 兜底 TTL,避免产生永久 key(与 Set 一致)
g.Log().Warningf(ctx, "[Cache] TTL <= 0 for prefix=%s, using fallback 24h TTL", c.prefix)
ttlSeconds = 86400
}
// L1 先整体落地:即使 L2 写入失败,本进程后续读取也能立即命中
for key, value := range values {
gcache.Set(ctx, c.fullKey(key), value, expire)
}
for key, value := range values {
fullKey := c.fullKey(key)
jsonBytes, err := json.Marshal(value)
if err != nil {
g.Log().Warningf(ctx, "[Cache] JSON marshal failed key=%s: %v", fullKey, err)
continue
}
if _, err = g.Redis().Do(ctx, "SETEX", fullKey, ttlSeconds, string(jsonBytes)); err != nil {
g.Log().Warningf(ctx, "[Cache] SETEX failed key=%s: %v", fullKey, err)
}
}
}
// GetMany 批量读取:先逐 key 查 L1(gcache,命中即零网络开销),
// 未命中的 key 用一次 MGET 走 L2(Redis),命中 L2 的值回填 L1。
// 返回「命中的原始 key → 值」,未命中的 key 不出现在结果中。
// key 为不含前缀的原始 key(与 Get/Set 一致)。
//
// 为什么必须批量:审计/日志列表每页要回填几十个关联名称,逐 key Get 会让冷路径
// 产生 N 次 Redis 往返,反而比直接查库更慢;MGET 把冷路径压成 1 次往返。
// Redis 不可用或 MGET 失败时只返回 L1 命中部分,未命中部分由调用方回落查库。
func (c *Cache) GetMany(ctx context.Context, keys []string) map[string]any {
result := make(map[string]any, len(keys))
if len(keys) == 0 {
return result
}
missKeys := make([]string, 0, len(keys))
missFullKeys := make([]string, 0, len(keys))
for _, key := range keys {
fullKey := c.fullKey(key)
if val, err := gcache.Get(ctx, fullKey); err == nil && val != nil {
result[key] = val.Interface()
continue
}
missKeys = append(missKeys, key)
missFullKeys = append(missFullKeys, fullKey)
}
if len(missFullKeys) == 0 {
return result
}
// L2:一次 MGET 取回全部未命中 key(缺失 key 在返回 map 中为 nil 值)
l2Values, err := g.Redis().MGet(ctx, missFullKeys...)
if err != nil {
g.Log().Warningf(ctx, "[Cache] MGET failed prefix=%s: %v", c.prefix, err)
return result
}
for i, fullKey := range missFullKeys {
v, ok := l2Values[fullKey]
if !ok || v == nil || v.IsNil() {
continue
}
jsonStr := v.String()
if jsonStr == "" {
continue
}
// 与 Get 一致:优先按 JSON 反序列化还原原始类型,非 JSON 的历史值按纯字符串兜底
var raw any
if unmarshalErr := json.Unmarshal([]byte(jsonStr), &raw); unmarshalErr != nil {
raw = jsonStr
}
gcache.Set(ctx, fullKey, raw, jitterTTL(c.ttl))
result[missKeys[i]] = raw
}
return result
}
// Get retrieves a value: L1 → L2 → miss.
// L1 returns native Go types. L2 deserializes JSON back to the original type.
func (c *Cache) Get(ctx context.Context, key string) (any, bool) {
+15 -8
View File
@@ -326,9 +326,10 @@ func processChannelTasks(ctx context.Context, channelID int64, tasks []*common.A
// privateData PrivateData 反序列化结构
type privateData struct {
UpstreamTaskID string `json:"upstream_task_id"`
TaskType string `json:"task_type"`
BillingContext struct {
UpstreamTaskID string `json:"upstream_task_id"`
UpstreamRequestID string `json:"upstream_request_id"` // 上游提交调用追踪 ID(部分上游返回,可空)
TaskType string `json:"task_type"`
BillingContext struct {
Ratios map[string]any `json:"ratios"`
ModelName string `json:"model_name"`
PreDeduct float64 `json:"pre_deduct"`
@@ -541,18 +542,24 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s
// 按秒计费任务的视频时长:取提交时计费上下文里的 spec.duration(真实秒数)。
// 只认 spec.* 事实键——旧 duration 键在部分 adaptor 是乘数语义(如 gemini 1.3),不可作为时长
// 同时提取 upstream_task_id 作为上游请求 ID 落用量日志(排障时凭它在上游定位任务)。
// 提取上游请求 ID 落用量日志(排障时凭它在上游定位任务):
// 优先 upstream_request_id(上游调用追踪 ID,如 DashScope 顶层 request_id),
// 未记录时回退 upstream_task_id(任务句柄,轮询同款标识)
durationSeconds := 0
upstreamRequestID := ""
if len(task.PrivateData) > 0 {
var pdDur struct {
UpstreamTaskID string `json:"upstream_task_id"`
BillingContext struct {
UpstreamTaskID string `json:"upstream_task_id"`
UpstreamRequestID string `json:"upstream_request_id"`
BillingContext struct {
Ratios map[string]any `json:"ratios"`
} `json:"billing_context"`
}
if json.Unmarshal(task.PrivateData, &pdDur) == nil {
upstreamRequestID = pdDur.UpstreamTaskID
upstreamRequestID = pdDur.UpstreamRequestID
if upstreamRequestID == "" {
upstreamRequestID = pdDur.UpstreamTaskID
}
if v, ok := pdDur.BillingContext.Ratios["spec.duration"].(float64); ok && v > 0 {
durationSeconds = int(v)
}
@@ -587,7 +594,7 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s
DurationSeconds: durationSeconds,
// 任务的上游请求 ID = 上游提交响应体中的任务 ID(轮询同款标识)
// 任务的上游请求 ID = 调用追踪 ID(上游返回时),否则为任务句柄(轮询同款标识)
UpstreamRequestID: upstreamRequestID,
}
+11 -2
View File
@@ -106,7 +106,12 @@ type (
DeleteChangelog(ctx context.Context, req *v1.ChangelogDeleteReq) (*v1.ChangelogDeleteRes, error)
// PublishChangelog 发布更新日志
PublishChangelog(ctx context.Context, req *v1.ChangelogPublishReq) (*v1.ChangelogPublishRes, error)
// ListChannels 获取渠道列表
// ListChannelOptions 渠道下拉选项(不分页,返回全部渠道)。
// 刻意不做状态过滤:日志 / 统计页的渠道筛选项要覆盖历史数据里出现过的全部渠道,
// 已停用渠道同样需要能作为筛选条件;status 原样返回,由前端决定是否标记。
// 注意与 /admin/channels 的区别:后者是渠道管理列表(分页 + 健康度联表 + 运行态),
// 下拉场景不要用它 —— 那是本接口存在的意义。
ListChannelOptions(ctx context.Context, _ *v1.ChannelOptionsReq) (*v1.ChannelOptionsRes, error)
ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.ChannelListRes, error)
// CloneChannel 克隆渠道
CloneChannel(ctx context.Context, req *v1.ChannelCloneReq) (*v1.ChannelCloneRes, error)
@@ -192,7 +197,11 @@ type (
// GetAllUsageLogs 获取所有租户的用量日志(管理后台)
GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListReq) (*v1.AdminUsageLogListRes, error)
// GetUsageLogDetail 获取单条用量日志详情(含 billing_snapshot / billing_summary /
// user_agent / error_message 等列表不返回的大字段)
// user_agent / error_message 等列表不返回的大字段)。
// bil_usage_logs 按 created_at 月分区且 PK 为 (id, created_at):仅按 id 查询无法
// 分区裁剪,计划为对各月分区的 Append 索引探测(每分区走本地 PK 前缀命中 id),
// 分区数随月份线性增长但单次探测为微秒级,与 upstream_request_id 反查(000023)
// 同一模式;若日后分区数显著增大,可让调用方携带 created_at 提示做裁剪。
GetUsageLogDetail(ctx context.Context, req *v1.AdminUsageLogDetailReq) (*v1.AdminUsageLogDetailRes, error)
// GetUsageLogSummary 获取用量日志统计汇总(独立接口,与列表共用筛选口径)
GetUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (*v1.AdminUsageLogSummaryRes, error)
+2
View File
@@ -72,6 +72,8 @@ type (
// (request_id / task_id / billing_summary / error_message / user_agent 等)。
// 平台侧敏感字段(upstream_model、account_cost、upstream_endpoint、billing_snapshot)
// 与列表同样禁止返回。member 角色只能查看自己的记录。
// 性能:bil_usage_logs 按 created_at 月分区,仅按 id 查询无法分区裁剪,为各分区
// Append 索引探测(见管理后台 GetUsageLogDetail 注释),当前量级可接受。
UsageLogDetail(ctx context.Context, req *v1.TenantUsageLogDetailReq) (*v1.TenantUsageLogDetailRes, error)
// UsageLogsSummary 用量日志统计汇总(与 UsageLogs 共用筛选口径:强制 tenant_id 隔离,
// member 角色只能统计自己的日志;总费用与租户端列表费用列同口径:actual_cost 优先,0/NULL 回退 total_cost)
+11
View File
@@ -55,6 +55,16 @@ type TaskAdaptor interface {
GetChannelName() string
}
// UpstreamRequestIDExtractor 供应商私有的「上游调用追踪 ID 提取」可选能力。
// 为什么需要:部分异步上游(如阿里 DashScope)不在响应头返回请求 ID,而是放在
// 提交响应体顶层(request_id 字段),通用管线无法用统一键提取。实现方从提交
// 响应体解析自家私有的追踪 ID,与 output.task_id(任务句柄)互补——提工单或
// 在上游日志定位一次提交调用时用它。未实现的上游走响应头提取或为空。
type UpstreamRequestIDExtractor interface {
// ExtractUpstreamRequestID 从上游提交响应体提取调用追踪 ID,未返回时为空串
ExtractUpstreamRequestID(body []byte) string
}
// TaskInfo 异步任务查询结果
type TaskInfo struct {
Status TaskStatusEnum
@@ -84,6 +94,7 @@ type TaskMaterialUsage struct {
InputImageCount int // 输入图片张数
InputAudioSeconds float64 // 输入音频时长(秒)
OutputSeconds float64 // 输出(生成)时长(秒)
Resolution string // 实际输出分辨率规格(如 "720P",对齐 per_second 矩阵键);空 = 上游未返回
}
// TaskStatusEnum 任务状态枚举
+3
View File
@@ -33,6 +33,7 @@ const (
RelayModeResponsesCompact // POST /v1/responses/compact
RelayModeVideos // OpenAI Videos 协议 /v1/videos(创建/查询/删除/内容下载)
RelayModeMiniMaxVideo // MiniMax 官方协议 /v2/video_generation(H3 v2 提交/查询/删除)
RelayModeAliVideo // 阿里 DashScope 官方协议 /api/v1/services/aigc/video-generation/video-synthesis(提交/查询)
)
// Path2RelayMode 根据请求路径判断 RelayMode
@@ -159,6 +160,8 @@ func (m RelayMode) String() string {
return "videos"
case RelayModeMiniMaxVideo:
return "minimax_video"
case RelayModeAliVideo:
return "ali_video"
case RelayModeMjSubmit:
return "mj_submit"
case RelayModeMjFetch:
+263
View File
@@ -0,0 +1,263 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
)
// 阿里 DashScope 官方视频协议(POST /api/v1/services/aigc/video-generation/video-synthesis)入站转换层。
// 原生请求体(model/input.prompt/input.media[]/parameters)解析校验后转成任务框架通用请求体,
// 复用任务提交管线(selectTaskChannel 渠道调度 + HandleTaskSubmit 计费/提交/落库);
// 查询端点 GET /api/v1/tasks/{task_id} 回放 DashScope 官方响应形态(协议文档
// docs/协议文档/阿里系列/视频-wan3.0.md),阿里 dashscope SDK 换 base_url 即可直连。
//
// 转换产物在保留原生字段(media[]/negative_prompt/audio_url)之外,双写通用词汇
// (prompt/seconds/metadata)——参数倍率规则的匹配源是归一化任务体(与 MiniMax 官方
// 协议入站同一约定),保证按通用路径配置的倍率规则对官方协议入站同样生效。
// aliVideoProtocol TaskRelayContext.Protocol 的阿里 DashScope 官方视频协议标识
const aliVideoProtocol = "ali_native"
// aliVideoCreateRequest DashScope 官方创建请求的归一形态(JSON 解析后的产物)
type aliVideoCreateRequest struct {
Model string
Prompt string
NegativePrompt string
AudioURL string
Media []aliVideoMedia
Parameters aliVideoParameters
}
// aliVideoMedia wan3.0 input.media 数组元素
type aliVideoMedia struct {
Type string `json:"type"`
URL string `json:"url"`
}
// aliVideoParameters DashScope parameters(wan2.x 与 wan3.0 字段并集)
type aliVideoParameters struct {
Resolution string `json:"resolution,omitempty"`
Ratio string `json:"ratio,omitempty"`
Size string `json:"size,omitempty"`
Duration *int `json:"duration,omitempty"` // wan3.0:-1 = 智能时长
Audio *bool `json:"audio,omitempty"` // wan3.0:输出音频开关
Seed *int `json:"seed,omitempty"`
PromptExtend *bool `json:"prompt_extend,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
}
// jsonAliVideoInput 官方请求的 input 节(wan2.x 扁平字段 + wan3.0 media 数组并集)
type jsonAliVideoInput struct {
Prompt string `json:"prompt"`
NegativePrompt string `json:"negative_prompt"`
AudioURL string `json:"audio_url"`
Media []aliVideoMedia `json:"media"`
}
// ParseAliVideoCreateRequest 解析官方创建请求。
// 返回的 TaskError 已携带面向客户端的 HTTP 状态码与错误信息。
func ParseAliVideoCreateRequest(body []byte) (*aliVideoCreateRequest, *common.TaskError) {
var raw struct {
Model string `json:"model"`
Input jsonAliVideoInput `json:"input"`
Parameters aliVideoParameters `json:"parameters"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body: " + err.Error()}
}
return &aliVideoCreateRequest{
Model: strings.TrimSpace(raw.Model),
Prompt: raw.Input.Prompt,
NegativePrompt: raw.Input.NegativePrompt,
AudioURL: raw.Input.AudioURL,
Media: raw.Input.Media,
Parameters: raw.Parameters,
}, nil
}
// ValidateAliVideoCreateRequest 请求必填校验(对齐官方约束:model 必填,prompt 与 media 必填其一)
func ValidateAliVideoCreateRequest(req *aliVideoCreateRequest) *common.TaskError {
if req.Model == "" {
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"}
}
if strings.TrimSpace(req.Prompt) == "" && len(req.Media) == 0 {
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "one of input.prompt / input.media is required"}
}
return nil
}
// BuildAliVideoTaskBody 把官方创建请求转成任务框架通用请求体(JSON 字节)。
// media 数组放顶层原样透传(适配器 buildWan3Media 消费);通用词汇(seconds/metadata)
// 双写保证参数倍率规则与 per_second 预扣信号(spec.duration/spec.resolution)同源。
func BuildAliVideoTaskBody(req *aliVideoCreateRequest) ([]byte, error) {
body := map[string]any{
"model": req.Model,
"prompt": req.Prompt,
}
if len(req.Media) > 0 {
body["media"] = req.Media
}
metadata := map[string]any{}
if req.NegativePrompt != "" {
body["negative_prompt"] = req.NegativePrompt
metadata["negative_prompt"] = req.NegativePrompt
}
if req.AudioURL != "" {
body["audio_url"] = req.AudioURL
metadata["audio_url"] = req.AudioURL
}
p := req.Parameters
if p.Resolution != "" {
body["resolution"] = p.Resolution
metadata["resolution"] = p.Resolution
}
if p.Ratio != "" {
body["ratio"] = p.Ratio
metadata["ratio"] = p.Ratio
}
if p.Size != "" {
metadata["size"] = p.Size
}
// duration 保留原值(含 -1 智能时长信号,供适配器预扣按上限冻结);正数另写通用 seconds
if p.Duration != nil {
metadata["duration"] = *p.Duration
if *p.Duration > 0 {
body["seconds"] = strconv.Itoa(*p.Duration)
}
}
if p.Audio != nil {
metadata["sound"] = map[bool]string{true: "on", false: "off"}[*p.Audio]
metadata["generate_audio"] = *p.Audio
}
if p.Seed != nil {
metadata["seed"] = *p.Seed
}
if p.PromptExtend != nil {
metadata["prompt_extend"] = *p.PromptExtend
}
if p.Watermark != nil {
metadata["watermark"] = *p.Watermark
}
if len(metadata) > 0 {
body["metadata"] = metadata
}
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal ali video task body: %w", err)
}
return data, nil
}
// ==================== 查询端点回放 ====================
// aliTaskResponse DashScope 任务查询响应形态(GET /api/v1/tasks/{task_id} 的回放产物)。
// usage 用 RawMessage 原样保留上游计量(duration/output_video_duration/fps/SR/ratio)
type aliTaskResponse struct {
Output struct {
TaskID string `json:"task_id"`
TaskStatus string `json:"task_status"`
SubmitTime string `json:"submit_time,omitempty"`
ScheduledTime string `json:"scheduled_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
OrigPrompt string `json:"orig_prompt,omitempty"`
VideoURL string `json:"video_url,omitempty"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
} `json:"output"`
Usage json.RawMessage `json:"usage,omitempty"`
RequestID string `json:"request_id"`
}
// aliStatusFromTask 平台任务状态 → DashScope 官方状态机映射
func aliStatusFromTask(status string) string {
switch status {
case "IN_PROGRESS":
return "RUNNING"
case "SUCCESS":
return "SUCCEEDED"
case "FAILURE":
return "FAILED"
default:
// NOT_START / SUBMITTED / QUEUED 及一切未知中间态都归入 PENDING
return "PENDING"
}
}
// buildAliTaskResponse 平台任务 → DashScope 官方查询响应。
// 优先回放 task.Data 缓存的上游查询响应(含 usage/orig_prompt/时间线,保真度最高),
// 仅覆写 output.task_id 为平台公开任务 ID、task_status 以平台状态为准(上游响应原文
// 可能滞后一轮轮询);task.Data 不可解析(提交后首拍轮询前为提交响应形态或空)时
// 构造最小响应。失败任务补 code/message(上游已带则保留)
func buildAliTaskResponse(task *common.AsyncTask) *aliTaskResponse {
resp := &aliTaskResponse{}
if len(task.Data) > 0 {
_ = json.Unmarshal(task.Data, resp)
}
status := aliStatusFromTask(task.Status)
resp.Output.TaskID = task.PublicTaskID
resp.Output.TaskStatus = status
resp.RequestID = task.RequestID
if status == "FAILED" {
if resp.Output.Code == "" {
resp.Output.Code = "TaskFailed"
}
if resp.Output.Message == "" {
resp.Output.Message = task.FailReason
if resp.Output.Message == "" {
resp.Output.Message = "video generation failed"
}
}
}
return resp
}
// HandleAliVideoRetrieve GET /api/v1/tasks/{task_id} 查询管线(回放 DashScope 响应形态)。
// 租户双键隔离(user_id + tenant_id)复用 GetTaskByPublicIDAndUser;平台门禁:该端点只服务
// ali 平台任务,防止异构平台任务 ID 被塞进 DashScope 响应结构回放(对齐 MiniMax 官方端点做法)
func HandleAliVideoRetrieve(
ctx context.Context,
taskID string,
rc *TaskRelayContext,
dataProvider common.TaskDataProvider,
) {
task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, taskID, rc.UserID, rc.TenantID)
if err != nil {
g.Log().Errorf(ctx, "AliVideoRetrieve: query task failed, taskID=%s, err=%v", taskID, err)
writeTaskError(rc.Writer, http.StatusInternalServerError, "query task failed", "")
return
}
if task == nil || task.Platform != string(constant.TaskPlatformAli) {
writeTaskError(rc.Writer, http.StatusNotFound, "task not found", "")
return
}
writeJSON(rc.Writer, http.StatusOK, buildAliTaskResponse(task))
}
// writeAliVideoSubmitResponse 提交成功响应:官方 DashScope 形态
// ({"output":{"task_status":"PENDING","task_id":...},"request_id":...})
func writeAliVideoSubmitResponse(w http.ResponseWriter, publicTaskID, requestID string, now time.Time) {
_ = now // 提交响应形态无时间字段,参数保留对齐其他协议 writer 的签名习惯
writeJSON(w, http.StatusOK, map[string]any{
"output": map[string]any{
"task_status": "PENDING",
"task_id": publicTaskID,
},
"request_id": requestID,
})
}
+205
View File
@@ -0,0 +1,205 @@
package handler
import (
"encoding/json"
"testing"
"time"
"github.com/qianfree/team-api/relay/common"
)
func TestParseAliVideoCreateRequest_Full(t *testing.T) {
body := `{
"model": "wan3.0-video",
"input": {
"prompt": "一只小猫在月光下的屋顶上奔跑",
"media": [
{"type": "first_frame", "url": "https://example.com/f.png"},
{"type": "reference_image", "url": "https://example.com/r.png"}
]
},
"parameters": {
"resolution": "1080P", "ratio": "16:9", "duration": 5,
"audio": false, "seed": 42, "prompt_extend": true, "watermark": false
}
}`
req, taskErr := ParseAliVideoCreateRequest([]byte(body))
if taskErr != nil {
t.Fatalf("unexpected error: %+v", taskErr)
}
if req.Model != "wan3.0-video" || req.Prompt != "一只小猫在月光下的屋顶上奔跑" {
t.Fatalf("unexpected request: %+v", req)
}
if len(req.Media) != 2 || req.Media[0].Type != "first_frame" || req.Media[1].URL != "https://example.com/r.png" {
t.Fatalf("unexpected media: %+v", req.Media)
}
if req.Parameters.Resolution != "1080P" || req.Parameters.Ratio != "16:9" {
t.Fatalf("unexpected parameters: %+v", req.Parameters)
}
if req.Parameters.Duration == nil || *req.Parameters.Duration != 5 {
t.Fatalf("unexpected duration: %+v", req.Parameters.Duration)
}
if req.Parameters.Audio == nil || *req.Parameters.Audio {
t.Fatalf("unexpected audio: %+v", req.Parameters.Audio)
}
}
func TestParseAliVideoCreateRequest_LegacyWan2(t *testing.T) {
// wan2.x 旧格式(扁平 input,无 media)同样可解析
body := `{"model":"wan2.2-t2v-plus","input":{"prompt":"p","negative_prompt":"np","audio_url":"https://a.mp3"},"parameters":{"size":"1024*576"}}`
req, taskErr := ParseAliVideoCreateRequest([]byte(body))
if taskErr != nil {
t.Fatalf("unexpected error: %+v", taskErr)
}
if req.Model != "wan2.2-t2v-plus" || req.Prompt != "p" || req.NegativePrompt != "np" || req.AudioURL != "https://a.mp3" {
t.Fatalf("unexpected request: %+v", req)
}
if len(req.Media) != 0 {
t.Fatalf("wan2 request must not carry media: %+v", req.Media)
}
}
func TestParseAliVideoCreateRequest_InvalidJSON(t *testing.T) {
if _, taskErr := ParseAliVideoCreateRequest([]byte(`{invalid`)); taskErr == nil || taskErr.StatusCode != 400 {
t.Fatalf("expected 400 task error, got %+v", taskErr)
}
}
func TestValidateAliVideoCreateRequest(t *testing.T) {
// 缺 model
if taskErr := ValidateAliVideoCreateRequest(&aliVideoCreateRequest{Prompt: "p"}); taskErr == nil {
t.Error("missing model should be rejected")
}
// prompt 与 media 都缺(文档约束:必填其一)
if taskErr := ValidateAliVideoCreateRequest(&aliVideoCreateRequest{Model: "wan3.0-video"}); taskErr == nil {
t.Error("missing both prompt and media should be rejected")
}
// 纯 media(无 prompt)合法
if taskErr := ValidateAliVideoCreateRequest(&aliVideoCreateRequest{
Model: "wan3.0-video",
Media: []aliVideoMedia{{Type: "reference_video", URL: "https://v.mp4"}},
}); taskErr != nil {
t.Errorf("media-only request should be accepted: %+v", taskErr)
}
}
func TestBuildAliVideoTaskBody(t *testing.T) {
duration := 5
audio := false
body, err := BuildAliVideoTaskBody(&aliVideoCreateRequest{
Model: "wan3.0-video",
Prompt: "p",
Media: []aliVideoMedia{{Type: "first_frame", URL: "https://f.png"}},
Parameters: aliVideoParameters{
Resolution: "480P", Ratio: "16:9", Duration: &duration, Audio: &audio,
},
})
if err != nil {
t.Fatalf("build: %v", err)
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// 顶层保留:model/prompt/media/通用 seconds
if m["model"] != "wan3.0-video" || m["prompt"] != "p" || m["seconds"] != "5" {
t.Fatalf("unexpected top-level body: %s", body)
}
media, ok := m["media"].([]any)
if !ok || len(media) != 1 {
t.Fatalf("expected media passthrough: %s", body)
}
// metadata 双写:duration/resolution/ratio/sound/generate_audio
meta, _ := m["metadata"].(map[string]any)
if meta["duration"] != float64(5) || meta["resolution"] != "480P" || meta["ratio"] != "16:9" {
t.Fatalf("unexpected metadata: %v", meta)
}
if meta["sound"] != "off" || meta["generate_audio"] != false {
t.Fatalf("audio switch not normalized: %v", meta)
}
}
func TestBuildAliVideoTaskBody_SmartDuration(t *testing.T) {
// duration=-1(智能时长):metadata 保留 -1 信号(适配器预扣按 30s 上限冻结),不写 seconds
minusOne := -1
body, err := BuildAliVideoTaskBody(&aliVideoCreateRequest{
Model: "wan3.0-video",
Prompt: "p",
Parameters: aliVideoParameters{Duration: &minusOne},
})
if err != nil {
t.Fatalf("build: %v", err)
}
var m map[string]any
_ = json.Unmarshal(body, &m)
if _, has := m["seconds"]; has {
t.Fatalf("smart duration must not emit seconds: %s", body)
}
meta, _ := m["metadata"].(map[string]any)
if meta["duration"] != float64(-1) {
t.Fatalf("smart duration signal lost: %v", meta)
}
}
func TestBuildAliTaskResponse(t *testing.T) {
now := time.Now()
submitted := now.Add(-time.Minute)
// 终态成功:回放上游查询响应(usage/orig_prompt 保真),覆写 task_id/status
task := &common.AsyncTask{
PublicTaskID: "task_pub_1",
RequestID: "req_1",
Platform: "ali",
Status: "SUCCESS",
CreatedAt: submitted,
Data: []byte(`{"output":{"task_id":"up-stream-id","task_status":"RUNNING","video_url":"https://v.mp4","orig_prompt":"a cat"},"usage":{"video_count":1,"duration":5.0,"output_video_duration":5.13,"fps":30,"SR":720,"ratio":"16:9"},"request_id":"up-req"}`),
}
resp := buildAliTaskResponse(task)
if resp.Output.TaskID != "task_pub_1" || resp.Output.TaskStatus != "SUCCEEDED" {
t.Fatalf("unexpected replay: %+v", resp.Output)
}
if resp.Output.VideoURL != "https://v.mp4" || resp.Output.OrigPrompt != "a cat" {
t.Fatalf("upstream fields must be preserved: %+v", resp.Output)
}
var usage map[string]any
if err := json.Unmarshal(resp.Usage, &usage); err != nil {
t.Fatalf("usage not preserved: %v", err)
}
if usage["output_video_duration"] != 5.13 || usage["SR"] != float64(720) {
t.Fatalf("usage metering lost: %v", usage)
}
if resp.RequestID != "req_1" {
t.Fatalf("request id should be platform's: %s", resp.RequestID)
}
// 非终态(提交后首拍,Data 为提交响应形态):构造 PENDING
pending := &common.AsyncTask{
PublicTaskID: "task_pub_2",
RequestID: "req_2",
Platform: "ali",
Status: "SUBMITTED",
Data: []byte(`{"output":{"task_status":"PENDING","task_id":"up-2"},"request_id":"up-req-2"}`),
}
resp = buildAliTaskResponse(pending)
if resp.Output.TaskID != "task_pub_2" || resp.Output.TaskStatus != "PENDING" {
t.Fatalf("unexpected pending replay: %+v", resp.Output)
}
// Data 为空:构造最小响应
empty := &common.AsyncTask{PublicTaskID: "task_pub_3", RequestID: "req_3", Platform: "ali", Status: "IN_PROGRESS"}
resp = buildAliTaskResponse(empty)
if resp.Output.TaskID != "task_pub_3" || resp.Output.TaskStatus != "RUNNING" {
t.Fatalf("unexpected empty-data replay: %+v", resp.Output)
}
// 失败任务:上游 code/message 保留;缺失时用平台 FailReason 兜底
failed := &common.AsyncTask{
PublicTaskID: "task_pub_4", RequestID: "req_4", Platform: "ali", Status: "FAILURE",
FailReason: "upstream quota exceeded",
Data: []byte(`{"output":{"task_id":"up-4","task_status":"FAILED","code":"Throttling"}}`),
}
resp = buildAliTaskResponse(failed)
if resp.Output.Code != "Throttling" || resp.Output.Message != "upstream quota exceeded" {
t.Fatalf("unexpected failure replay: %+v", resp.Output)
}
}
+208
View File
@@ -0,0 +1,208 @@
package handler
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
)
// 端到端集成测试:阿里 DashScope 官方协议 / OpenAI Videos 协议 → 转换 → 任务提交管线
// (ali 适配器 + mock 上游)→ task_ 前缀 ID + DashScope 官方响应形态 + 落库 platform=ali
// + 上游收到 media 数组与映射参数。
// newAliUpstream 构造 mock 阿里 DashScope 上游(校验端点/异步头,记录请求体)
func newAliUpstream(t *testing.T, received *map[string]any) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/api/v1/services/aigc/video-generation/video-synthesis") {
t.Errorf("unexpected upstream path: %s", r.URL.Path)
}
if r.Header.Get("X-DashScope-Async") != "enable" {
t.Errorf("missing X-DashScope-Async header: %v", r.Header)
}
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, received); err != nil {
t.Errorf("upstream body not json: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"output": map[string]any{"task_status": "PENDING", "task_id": "ali-up-1"},
"request_id": "up-req-1",
})
}))
}
func newAliChannelMeta(upstreamURL string) *common.ChannelMeta {
return &common.ChannelMeta{
ChannelID: 21,
ChannelType: int(constant.ProviderAli),
ChannelName: "mock-ali",
BaseURL: upstreamURL,
ApiKey: "sk-test",
UpstreamModelName: "wan3.0-video",
}
}
func TestHandleTaskSubmit_AliNativeProtocol_FullPipeline(t *testing.T) {
var upstreamReceived map[string]any
upstream := newAliUpstream(t, &upstreamReceived)
defer upstream.Close()
// 官方请求体 → 归一 → 通用任务体(与胶水层 HandleAliVideoSubmit 相同链路)
raw := `{
"model": "wan3.0-video",
"input": {
"prompt": "小猫奔跑",
"media": [{"type": "first_frame", "url": "https://example.com/f.png"}]
},
"parameters": {"resolution": "480P", "ratio": "16:9", "duration": 5, "audio": false}
}`
req, taskErr := ParseAliVideoCreateRequest([]byte(raw))
if taskErr != nil {
t.Fatalf("parse: %+v", taskErr)
}
taskBody, err := BuildAliVideoTaskBody(req)
if err != nil {
t.Fatalf("build task body: %v", err)
}
rec := httptest.NewRecorder()
rc := &TaskRelayContext{
TenantID: 1,
UserID: 2,
ApiKeyID: 3,
RequestID: "req_ali_test",
Writer: rec,
Protocol: aliVideoProtocol,
RelayMode: int(constant.RelayModeAliVideo),
}
data := &stubVideosSubmitProvider{}
HandleTaskSubmit(context.Background(), taskBody, "/api/v1/services/aigc/video-generation/video-synthesis",
http.Header{}, rc, data, &stubVideosBillingProvider{}, newAliChannelMeta(upstream.URL))
// 客户端响应:DashScope 官方形态
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
}
resp := decodeVideosJSON(t, rec)
output, _ := resp["output"].(map[string]any)
if output == nil || output["task_status"] != "PENDING" {
t.Fatalf("unexpected dashscope output: %s", rec.Body.String())
}
taskID, _ := output["task_id"].(string)
if !strings.HasPrefix(taskID, "task_") {
t.Fatalf("expected task_ prefixed id, got %q", taskID)
}
if resp["request_id"] != "req_ali_test" {
t.Fatalf("expected platform request id: %s", rec.Body.String())
}
if rc.TaskID != taskID {
t.Fatalf("rc.TaskID=%q should match response id %q", rc.TaskID, taskID)
}
// 落库任务:ali 平台 + 上游任务 ID
if data.created == nil || data.created.Platform != "ali" {
t.Fatalf("expected ali task record, got %+v", data.created)
}
var pd struct {
UpstreamTaskID string `json:"upstream_task_id"`
}
if err := json.Unmarshal(data.created.PrivateData, &pd); err != nil {
t.Fatalf("private data unmarshal: %v", err)
}
if pd.UpstreamTaskID != "ali-up-1" {
t.Fatalf("expected upstream task id captured, got %q", pd.UpstreamTaskID)
}
// 上游收到:media 数组透传 + parameters 映射(resolution/ratio/duration/audio)
input, _ := upstreamReceived["input"].(map[string]any)
if input == nil || input["prompt"] != "小猫奔跑" {
t.Fatalf("upstream input: %v", upstreamReceived["input"])
}
media, _ := input["media"].([]any)
if len(media) != 1 {
t.Fatalf("expected media array forwarded, got %v", input["media"])
}
first, _ := media[0].(map[string]any)
if first["type"] != "first_frame" || first["url"] != "https://example.com/f.png" {
t.Fatalf("unexpected media item: %v", media[0])
}
params, _ := upstreamReceived["parameters"].(map[string]any)
if params == nil || params["resolution"] != "480P" || params["ratio"] != "16:9" ||
params["duration"] != float64(5) || params["audio"] != false {
t.Fatalf("upstream parameters: %v", upstreamReceived["parameters"])
}
}
func TestHandleTaskSubmit_OpenAIVideosToWan3_Pipeline(t *testing.T) {
// OpenAI Videos 协议(multipart)→ BuildVideosTaskBody → ali 适配器:
// input_reference → media[{type:first_frame}]、seconds → duration、size 短边 → resolution、
// sound → audio
var upstreamReceived map[string]any
upstream := newAliUpstream(t, &upstreamReceived)
defer upstream.Close()
mBody := []byte(`{"model":"wan3.0-video","prompt":"小猫对着镜头打哈欠","seconds":"8","size":"1280x720","aspect_ratio":"9:16","sound":"on","input_reference":{"image_url":"https://example.com/ref.png"}}`)
req, taskErr := ParseVideosCreateRequest(mBody, "application/json")
if taskErr != nil {
t.Fatalf("parse: %+v", taskErr)
}
taskBody, err := BuildVideosTaskBody(req)
if err != nil {
t.Fatalf("build task body: %v", err)
}
rec := httptest.NewRecorder()
rc := &TaskRelayContext{
TenantID: 1,
UserID: 2,
ApiKeyID: 3,
RequestID: "req_ali_openai_test",
Writer: rec,
Protocol: aliVideoProtocol,
RelayMode: int(constant.RelayModeAliVideo),
}
data := &stubVideosSubmitProvider{}
HandleTaskSubmit(context.Background(), taskBody, "/api/v1/services/aigc/video-generation/video-synthesis",
http.Header{}, rc, data, &stubVideosBillingProvider{}, newAliChannelMeta(upstream.URL))
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
}
if data.created == nil || data.created.Platform != "ali" {
t.Fatalf("expected ali task record, got %+v", data.created)
}
// 上游收到映射后的 wan3.0 参数
input, _ := upstreamReceived["input"].(map[string]any)
media, _ := input["media"].([]any)
if len(media) != 1 {
t.Fatalf("expected first_frame media from input_reference, got %v", input["media"])
}
first, _ := media[0].(map[string]any)
if first["type"] != "first_frame" {
t.Fatalf("input_reference should map to first_frame: %v", media[0])
}
params, _ := upstreamReceived["parameters"].(map[string]any)
if params == nil || params["duration"] != float64(8) {
t.Fatalf("seconds → duration mapping lost: %v", upstreamReceived["parameters"])
}
if params["resolution"] != "720P" {
t.Fatalf("size short-edge → resolution mapping lost: %v", upstreamReceived["parameters"])
}
if params["ratio"] != "9:16" {
t.Fatalf("aspect_ratio → ratio mapping lost: %v", upstreamReceived["parameters"])
}
if params["audio"] != true {
t.Fatalf("sound → audio mapping lost: %v", upstreamReceived["parameters"])
}
}
+19 -1
View File
@@ -317,6 +317,16 @@ func HandleTaskSubmit(
}
now := time.Now()
// 上游调用追踪 ID:优先响应头(与同步对话链路 helper.ExtractUpstreamRequestID 同源,
// 覆盖 OpenAI/Anthropic 等返回标准头的上游);无候选头时由供应商可选能力从提交
// 响应体提取(如 DashScope 顶层 request_id)。与 upstream_task_id(任务句柄)互补
upstreamRequestID := helper.ExtractUpstreamRequestID(resp.Header)
if upstreamRequestID == "" {
if ex, ok := adaptor.(common.UpstreamRequestIDExtractor); ok {
upstreamRequestID = ex.ExtractUpstreamRequestID(taskData)
}
}
privateDataMap := map[string]any{
"upstream_task_id": upstreamTaskID,
"task_type": platform,
@@ -329,6 +339,9 @@ func HandleTaskSubmit(
"pre_deduct": preDeductAmount.InexactFloat64(),
},
}
if upstreamRequestID != "" {
privateDataMap["upstream_request_id"] = upstreamRequestID
}
if rc.RequestEcho != nil {
privateDataMap["request_echo"] = rc.RequestEcho
}
@@ -369,7 +382,8 @@ func HandleTaskSubmit(
dbgAttempt.MarkFinal(nil)
// 11. 返回响应(OpenAI Videos 协议返回官方 Video 对象;MiniMax 官方协议返回 {"task_id"}
//(v1 含 base_resp 信封);legacy 保持原格式)
//(v1 含 base_resp 信封);阿里 DashScope 官方协议返回 {"output":{"task_id":...}};
// legacy 保持原格式)
if rc.Protocol == videosProtocolOpenAI {
writeVideosSubmitResponse(rc.Writer, publicTaskID, modelName, now, rc.RequestEcho)
return
@@ -382,6 +396,10 @@ func HandleTaskSubmit(
writeMiniMaxV1SubmitResponse(rc.Writer, publicTaskID)
return
}
if rc.Protocol == aliVideoProtocol {
writeAliVideoSubmitResponse(rc.Writer, publicTaskID, rc.RequestID, now)
return
}
respBody := map[string]any{
"id": publicTaskID,
"status": "SUBMITTED",
+185 -13
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/qianfree/team-api/relay/common"
@@ -21,6 +22,10 @@ func init() {
// ==================== 请求/响应结构体 ====================
// wan3MaxDurationSeconds wan3.0 智能时长模式(duration=-1)的输出上限(秒)。
// 智能时长下输出长度不可预知,预扣按该上限冻结,结算按上游 usage.output_video_duration 多退少补
const wan3MaxDurationSeconds = 30.0
// dashScopeVideoRequest DashScope 视频生成请求
type dashScopeVideoRequest struct {
Model string `json:"model"`
@@ -29,9 +34,18 @@ type dashScopeVideoRequest struct {
}
type dashScopeVideoInput struct {
Prompt string `json:"prompt"`
Prompt string `json:"prompt,omitempty"`
NegativePrompt string `json:"negative_prompt,omitempty"`
AudioURL string `json:"audio_url,omitempty"`
// Media wan3.0 全能参考模式的媒体素材数组(first_frame/last_frame/reference_image/
// reference_video/reference_audio/file/link);wan2.x 旧格式不填
Media []dashScopeMedia `json:"media,omitempty"`
}
// dashScopeMedia wan3.0 input.media 数组元素
type dashScopeMedia struct {
Type string `json:"type"`
URL string `json:"url"`
}
type dashScopeVideoParams struct {
@@ -42,6 +56,8 @@ type dashScopeVideoParams struct {
PromptExtend *bool `json:"prompt_extend,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
Seed *int `json:"seed,omitempty"`
// Audio wan3.0 输出视频是否包含音频(开关价格相同)
Audio *bool `json:"audio,omitempty"`
}
// aliMetadata Ali DashScope metadata 参数结构体(用于 UnmarshalMetadata 映射)
@@ -55,6 +71,7 @@ type aliMetadata struct {
PromptExtend *bool `json:"prompt_extend,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
Seed *int `json:"seed,omitempty"`
// Audio wan3.0 输出音频开关("on"/"off" 通用词汇由 buildVideoRequest 归一,此处不承载)
}
// dashScopeImageRequest DashScope 图片生成请求
@@ -95,7 +112,7 @@ type dashScopeTaskResponse struct {
Usage struct {
Duration float64 `json:"duration"`
VideoCount int `json:"video_count"`
OutputVideoDuration int `json:"output_video_duration"`
OutputVideoDuration float64 `json:"output_video_duration"`
SR int `json:"SR"`
Ratio string `json:"ratio"`
ImageCount int `json:"image_count"`
@@ -119,14 +136,14 @@ func (a *AliAdaptor) Init(info *common.RelayInfo) {
}
// detectVideo 根据模型名判断是否为视频生成。
// 注意:万相图片模型(-t2i 文生图、-i2i 图像编辑)即使以 wan2. 开头也不是视频,必须先排除,
// 注意:万相图片模型(-t2i 文生图、-i2i 图像编辑)即使以 wan2./wan3. 开头也不是视频,必须先排除,
// 否则会被误判为视频而 POST 到 video-synthesis 端点,被上游以 "url error" 拒绝。
func (a *AliAdaptor) detectVideo(modelName string) bool {
m := strings.ToLower(modelName)
if strings.Contains(m, "-t2i") || strings.Contains(m, "-i2i") {
return false
}
return strings.HasPrefix(m, "wan2.") || strings.HasPrefix(m, "wanx2.1-t2v")
return strings.HasPrefix(m, "wan2.") || strings.HasPrefix(m, "wan3.") || strings.HasPrefix(m, "wanx2.1-t2v")
}
func (a *AliAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError {
@@ -140,6 +157,10 @@ func (a *AliAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, bod
return nil
}
// EstimateBilling 估算任务费用(提交前)。
// 注意:此处自行从 body 提取模型名判定视频/图片,不依赖 BuildRequestBody 的 a.isVideo 副作用——
// 管线中 EstimateBilling(第 6 步)先于 BuildRequestBody(第 7 步)执行,旧实现读 a.isVideo
// 恒为 false,视频时长信号从未上报(预扣恒按引擎默认 5s 估),本次修复。
func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any {
ratios := map[string]any{"base": 1.0}
var req map[string]any
@@ -147,16 +168,35 @@ func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, bod
return ratios
}
if a.isVideo {
// duration 影响计费:spec.duration 为真实秒数(per_second 用),duration 为存量信号
modelName, _ := req["model"].(string)
if a.detectVideo(modelName) {
metadata := taskchannel.ExtractMetadata(req)
var meta struct {
Duration *int `json:"duration"`
}
if taskchannel.UnmarshalMetadata(metadata, &meta) == nil && meta.Duration != nil && *meta.Duration > 0 {
ratios["duration"] = float64(*meta.Duration)
ratios["spec.duration"] = float64(*meta.Duration)
var meta aliMetadata
if taskchannel.UnmarshalMetadata(metadata, &meta) == nil {
// 分辨率事实值:per_second 矩阵查价键(480P/720P/1080P 原值)。
// OpenAI Videos 路径无 resolution 字段,wan3.0 按 size 归一档位上报,
// 与 buildVideoRequest 的折算口径同源
if meta.Resolution != "" {
ratios["spec.resolution"] = meta.Resolution
} else if strings.HasPrefix(strings.ToLower(modelName), "wan3.") && meta.Size != "" {
if r := normalizeWan3Resolution(meta.Size); r != "" {
ratios["spec.resolution"] = r
}
}
// 时长事实值:duration>0 用请求值;duration=-1(wan3.0 智能时长)按输出上限冻结,
// 结算以 usage.output_video_duration 多退少补
if meta.Duration != nil {
d := float64(*meta.Duration)
if d == -1 {
d = wan3MaxDurationSeconds
}
if d > 0 {
ratios["duration"] = d
ratios["spec.duration"] = d
}
}
}
// 顶层 seconds(OpenAI Videos 通用词汇)优先于 metadata
if seconds, ok := req["seconds"].(string); ok {
if d, err := parseInt(seconds); err == nil && d > 0 {
ratios["duration"] = float64(d)
@@ -250,8 +290,33 @@ func (a *AliAdaptor) buildVideoRequest(info *common.RelayInfo, req map[string]an
}
}
// wan3.0 专属适配:媒体素材数组 + 音频开关 + size 档位映射。
// wan2.x 保持旧格式不动(其 size 是原生尺寸语义,不可参与档位映射)
if strings.HasPrefix(strings.ToLower(modelName), "wan3.") {
dsReq.Input.Media = buildWan3Media(req, metadata)
if sound, ok := metadata["sound"].(string); ok && sound != "" {
audio := sound == "on"
params.Audio = &audio
}
if params.Resolution == "" && meta.Size != "" {
if r := normalizeWan3Resolution(meta.Size); r != "" {
params.Resolution = r
}
}
// wan3.0 协议无 size 参数:size 只作档位折算源,任何情况不透传上游
//(wan2.x 的 size 是原生尺寸语义,不进本分支、不受影响)
params.Size = ""
// ratio 兜底:通用词汇 aspect_ratio(OpenAI Videos 双写键,部分链路只写该键)
if params.Ratio == "" {
if ar, ok := metadata["aspect_ratio"].(string); ok {
params.Ratio = ar
}
}
}
if params.Resolution != "" || params.Ratio != "" || params.Size != "" ||
params.Duration != nil || params.PromptExtend != nil || params.Watermark != nil || params.Seed != nil {
params.Duration != nil || params.PromptExtend != nil || params.Watermark != nil ||
params.Seed != nil || params.Audio != nil {
dsReq.Parameters = params
}
@@ -262,6 +327,83 @@ func (a *AliAdaptor) buildVideoRequest(info *common.RelayInfo, req map[string]an
return strings.NewReader(string(data)), nil
}
// buildWan3Media 构建 wan3.0 input.media 数组(全能参考模式)。
// 优先级:顶层 media(阿里原生入站原样透传)> metadata.image(OpenAI input_reference,
// 首帧语义对齐 Sora)> 顶层 images[](通用多图词汇 → 参考图)。均无则返回 nil(纯文生视频)
func buildWan3Media(req map[string]any, metadata map[string]any) []dashScopeMedia {
if raw, ok := req["media"].([]any); ok && len(raw) > 0 {
media := make([]dashScopeMedia, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
t, _ := m["type"].(string)
u, _ := m["url"].(string)
if t == "" || u == "" {
continue
}
media = append(media, dashScopeMedia{Type: t, URL: u})
}
if len(media) > 0 {
return media
}
}
if img, ok := metadata["image"].(string); ok && img != "" {
return []dashScopeMedia{{Type: "first_frame", URL: img}}
}
if imgs, ok := req["images"].([]any); ok && len(imgs) > 0 {
media := make([]dashScopeMedia, 0, len(imgs))
for _, item := range imgs {
if u, ok := item.(string); ok && u != "" {
media = append(media, dashScopeMedia{Type: "reference_image", URL: u})
}
}
if len(media) > 0 {
return media
}
}
return nil
}
// normalizeWan3Resolution 把 size 值归一为 wan3.0 分辨率档位:
// 裸档位词汇("720P"/"480p")直接归一为大写档位;WxH 尺寸("1280x720")按短边归档;
// 其余返回空串(上游取默认 1080P)。wan2.x 的 size 是原生尺寸语义,不得走此函数。
func normalizeWan3Resolution(size string) string {
upper := strings.ToUpper(strings.TrimSpace(size))
switch upper {
case "480P", "720P", "1080P":
return upper
}
return resolutionFromSize(size)
}
// resolutionFromSize 从尺寸字符串(如 "1280x720")按短边映射 wan3.0 分辨率档位。
// 竖版尺寸(720x1280)同样按短边归档;解析失败返回空串(上游取默认 1080P)
func resolutionFromSize(size string) string {
parts := strings.SplitN(strings.ToLower(strings.TrimSpace(size)), "x", 2)
if len(parts) != 2 {
return ""
}
w, errW := strconv.Atoi(strings.TrimSpace(parts[0]))
h, errH := strconv.Atoi(strings.TrimSpace(parts[1]))
if errW != nil || errH != nil || w <= 0 || h <= 0 {
return ""
}
short := w
if h < short {
short = h
}
switch {
case short <= 500:
return "480P"
case short <= 800:
return "720P"
default:
return "1080P"
}
}
func (a *AliAdaptor) buildImageRequest(info *common.RelayInfo, req map[string]any, modelName string) (io.Reader, error) {
dsReq := dashScopeImageRequest{
Input: dashScopeImageInput{},
@@ -365,6 +507,19 @@ func (a *AliAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *commo
return result.Output.TaskID, body, nil
}
// ExtractUpstreamRequestID 从 DashScope 提交响应体顶层提取 request_id(调用追踪 ID)。
// 与 output.task_id(任务句柄,轮询查询用)互补:在阿里云日志/工单中定位一次提交调用时用它
func (a *AliAdaptor) ExtractUpstreamRequestID(body []byte) string {
var resp dashScopeSubmitResponse
if json.Unmarshal(body, &resp) != nil {
return ""
}
return resp.RequestID
}
// 确保实现上游调用追踪 ID 提取可选能力
var _ common.UpstreamRequestIDExtractor = (*AliAdaptor)(nil)
func (a *AliAdaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) {
var data struct {
TaskID string `json:"task_id"`
@@ -409,6 +564,14 @@ func (a *AliAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) {
if resp.Output.VideoURL != "" {
info.ResultURL = resp.Output.VideoURL
}
// 素材计量(wan3.0 按秒计费):实际输出秒数 + 实际分辨率,
// 供 generic per_second 结算按「实际秒数 × 实际档位单价」多退少补(方案 A)
if resp.Usage.OutputVideoDuration > 0 {
info.MaterialUsage = &common.TaskMaterialUsage{
OutputSeconds: resp.Usage.OutputVideoDuration,
Resolution: resolutionFromSR(resp.Usage.SR),
}
}
// 图片结果
if len(resp.Output.Results) > 0 {
info.ResultURL = resp.Output.Results[0].URL
@@ -448,3 +611,12 @@ func parseInt(s string) (int, error) {
_, err := fmt.Sscanf(s, "%d", &n)
return n, err
}
// resolutionFromSR 上游 usage.SR(分辨率短边整数,如 720)→ per_second 矩阵键("720P")。
// 0/异常值返回空串(结算查价回退 ratios 的 spec.resolution)
func resolutionFromSR(sr int) string {
if sr <= 0 {
return ""
}
return strconv.Itoa(sr) + "P"
}
+362
View File
@@ -0,0 +1,362 @@
package ali
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/qianfree/team-api/relay/common"
)
func newTestRelayInfo() *common.RelayInfo {
// ChannelMeta 必须非空(BuildRequestBody 读取 IsModelMapped/UpstreamModelName)
return &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}}
}
func TestDetectVideo(t *testing.T) {
a := &AliAdaptor{}
video := []string{
"wan3.0-video", "wan3.0-video-prime", "WAN3.0-VIDEO",
"wan2.7-t2v-2026-04-25", "wan2.2-t2v-plus", "wanx2.1-t2v-turbo",
}
for _, m := range video {
if !a.detectVideo(m) {
t.Errorf("%s should be detected as video", m)
}
}
image := []string{
"wanx2.1-t2i-ediff", "wanx2.5-image-edit", "wanx-v1",
"qwen-image-generation", "flux-dev",
}
for _, m := range image {
if a.detectVideo(m) {
t.Errorf("%s should be detected as image", m)
}
}
}
// 回归:EstimateBilling 不再依赖 BuildRequestBody 的 isVideo 副作用(管线中前者先于后者执行,
// 旧实现视频时长信号从未上报),此处直接对原始 body 求值。
func TestEstimateBilling_VideoSpecSignals(t *testing.T) {
a := &AliAdaptor{}
// wan3.0 + metadata duration/resolution → spec.* 上报
ratios := a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
`{"model":"wan3.0-video","prompt":"p","metadata":{"duration":8,"resolution":"720P"}}`))
if ratios["spec.duration"] != 8.0 || ratios["duration"] != 8.0 {
t.Fatalf("spec.duration signal lost: %v", ratios)
}
if ratios["spec.resolution"] != "720P" {
t.Fatalf("spec.resolution signal lost: %v", ratios)
}
// 智能时长 -1:按 wan3.0 上限 30s 冻结(结算按实际秒数多退少补)
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
`{"model":"wan3.0-video","prompt":"p","metadata":{"duration":-1,"resolution":"1080P"}}`))
if ratios["spec.duration"] != 30.0 {
t.Fatalf("smart duration should freeze at 30s: %v", ratios)
}
if ratios["spec.resolution"] != "1080P" {
t.Fatalf("spec.resolution signal lost: %v", ratios)
}
// 顶层 seconds(OpenAI Videos 通用词汇)优先于 metadata
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
`{"model":"wan3.0-video","prompt":"p","seconds":"12","metadata":{"duration":5}}`))
if ratios["spec.duration"] != 12.0 {
t.Fatalf("seconds should override metadata duration: %v", ratios)
}
// OpenAI Videos 路径无 resolution 字段:wan3.0 按 size 归一档位上报(裸档位/WxH 均可)
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
`{"model":"wan3.0-video","prompt":"p","metadata":{"size":"720P","duration":5}}`))
if ratios["spec.resolution"] != "720P" {
t.Fatalf("size tier vocabulary should report spec.resolution: %v", ratios)
}
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
`{"model":"wan3.0-video","prompt":"p","metadata":{"size":"1280x720","duration":5}}`))
if ratios["spec.resolution"] != "720P" {
t.Fatalf("size WxH should report spec.resolution: %v", ratios)
}
// wan2.x 的 size 是原生尺寸语义,不得折算档位(预扣按矩阵兜底档走)
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
`{"model":"wan2.2-t2v-plus","prompt":"p","metadata":{"size":"1024x576","duration":5}}`))
if _, has := ratios["spec.resolution"]; has {
t.Fatalf("wan2 size must not derive spec.resolution: %v", ratios)
}
// 未传时长:不产出 spec.duration(引擎按默认 5s 估)
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
`{"model":"wan3.0-video","prompt":"p"}`))
if _, has := ratios["spec.duration"]; has {
t.Fatalf("no duration signal expected: %v", ratios)
}
// 图片模型:n>1 产出 count,不产出时长/分辨率信号
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
`{"model":"wanx-v1","prompt":"p","n":4}`))
if ratios["count"] != 4.0 {
t.Fatalf("image count signal lost: %v", ratios)
}
if _, has := ratios["spec.duration"]; has {
t.Fatalf("image model must not carry duration signal: %v", ratios)
}
}
func TestBuildVideoRequest_Wan3NativeMedia(t *testing.T) {
a := &AliAdaptor{}
body := []byte(`{
"model": "wan3.0-video",
"prompt": "p",
"media": [
{"type": "reference_video", "url": "https://v.mp4"},
{"type": "reference_audio", "url": "https://a.mp3"}
],
"metadata": {"resolution": "480P", "ratio": "16:9", "duration": 5, "sound": "off"}
}`)
r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body)
if err != nil {
t.Fatalf("build: %v", err)
}
var req dashScopeVideoRequest
if err := json.NewDecoder(r).Decode(&req); err != nil {
t.Fatalf("decode: %v", err)
}
if len(req.Input.Media) != 2 || req.Input.Media[0].Type != "reference_video" || req.Input.Media[1].URL != "https://a.mp3" {
t.Fatalf("media passthrough lost: %+v", req.Input.Media)
}
if req.Parameters == nil || req.Parameters.Resolution != "480P" || req.Parameters.Ratio != "16:9" {
t.Fatalf("parameters lost: %+v", req.Parameters)
}
if req.Parameters.Audio == nil || *req.Parameters.Audio {
t.Fatalf("sound=off should map to audio=false: %+v", req.Parameters.Audio)
}
}
func TestBuildVideoRequest_OpenAIVocabulary(t *testing.T) {
a := &AliAdaptor{}
// OpenAI Videos 通用词汇:metadata.image → first_frame;size 短边 → resolution;sound → audio
body := []byte(`{
"model": "wan3.0-video",
"prompt": "p",
"seconds": "8",
"images": ["https://x/1.png"],
"metadata": {"image": "https://x/ref.png", "size": "1280x720", "sound": "on", "aspect_ratio": "9:16"}
}`)
r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body)
if err != nil {
t.Fatalf("build: %v", err)
}
var req dashScopeVideoRequest
if err := json.NewDecoder(r).Decode(&req); err != nil {
t.Fatalf("decode: %v", err)
}
if len(req.Input.Media) != 1 || req.Input.Media[0].Type != "first_frame" || req.Input.Media[0].URL != "https://x/ref.png" {
t.Fatalf("metadata.image should map to first_frame: %+v", req.Input.Media)
}
if req.Parameters == nil || req.Parameters.Duration == nil || *req.Parameters.Duration != 8 {
t.Fatalf("seconds → duration lost: %+v", req.Parameters)
}
if req.Parameters.Resolution != "720P" {
t.Fatalf("size short-edge → resolution lost: %+v", req.Parameters)
}
if req.Parameters.Size != "" {
t.Fatalf("wan3.0 must not carry size after resolution derived: %+v", req.Parameters)
}
if req.Parameters.Ratio != "9:16" {
t.Fatalf("aspect_ratio → ratio lost: %+v", req.Parameters)
}
if req.Parameters.Audio == nil || !*req.Parameters.Audio {
t.Fatalf("sound=on should map to audio=true: %+v", req.Parameters.Audio)
}
}
func TestBuildVideoRequest_MultiImagesFallback(t *testing.T) {
a := &AliAdaptor{}
// 无 metadata.image 时,顶层 images[] → reference_image 数组
body := []byte(`{"model":"wan3.0-video","prompt":"p","images":["https://x/1.png","https://x/2.png"]}`)
r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body)
if err != nil {
t.Fatalf("build: %v", err)
}
var req dashScopeVideoRequest
if err := json.NewDecoder(r).Decode(&req); err != nil {
t.Fatalf("decode: %v", err)
}
if len(req.Input.Media) != 2 || req.Input.Media[0].Type != "reference_image" || req.Input.Media[1].URL != "https://x/2.png" {
t.Fatalf("images fallback lost: %+v", req.Input.Media)
}
}
// 回归:wan2.x 旧格式不引入 wan3.0 专属行为(无 media、无 audio、size 不派生档位)
func TestBuildVideoRequest_Wan2Unchanged(t *testing.T) {
a := &AliAdaptor{}
body := []byte(`{"model":"wan2.2-t2v-plus","prompt":"p","metadata":{"size":"1024x576","duration":5}}`)
r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body)
if err != nil {
t.Fatalf("build: %v", err)
}
var req dashScopeVideoRequest
if err := json.NewDecoder(r).Decode(&req); err != nil {
t.Fatalf("decode: %v", err)
}
if len(req.Input.Media) != 0 {
t.Fatalf("wan2 request must not carry media: %+v", req.Input.Media)
}
if req.Parameters != nil && req.Parameters.Audio != nil {
t.Fatalf("wan2 request must not carry audio: %+v", req.Parameters)
}
if req.Parameters == nil || req.Parameters.Resolution != "" {
t.Fatalf("wan2 size must not derive resolution: %+v", req.Parameters)
}
if req.Parameters.Size != "1024*576" {
t.Fatalf("wan2 size passthrough broken: %+v", req.Parameters)
}
}
func TestParseTaskResult_Wan3MaterialUsage(t *testing.T) {
a := &AliAdaptor{}
body := []byte(`{
"output": {"task_id": "t1", "task_status": "SUCCEEDED", "video_url": "https://v.mp4"},
"usage": {"video_count": 1, "duration": 5.0, "output_video_duration": 5.13, "fps": 30, "SR": 720, "ratio": "16:9"}
}`)
info, err := a.ParseTaskResult(body)
if err != nil {
t.Fatalf("parse: %v", err)
}
if info.Status != common.TaskStatusSuccess || info.ResultURL != "https://v.mp4" {
t.Fatalf("unexpected info: %+v", info)
}
if info.MaterialUsage == nil {
t.Fatal("expected material usage for per_second settlement")
}
if info.MaterialUsage.OutputSeconds != 5.13 {
t.Fatalf("output seconds lost: %+v", info.MaterialUsage)
}
if info.MaterialUsage.Resolution != "720P" {
t.Fatalf("SR → resolution normalization broken: %+v", info.MaterialUsage)
}
// usage 缺失(wan2.x 响应):MaterialUsage 为 nil,结算回退预扣口径
info, err = a.ParseTaskResult([]byte(`{"output":{"task_id":"t2","task_status":"SUCCEEDED","video_url":"https://v2.mp4"}}`))
if err != nil {
t.Fatalf("parse: %v", err)
}
if info.MaterialUsage != nil {
t.Fatalf("no usage → no material usage: %+v", info.MaterialUsage)
}
// SR=0:Resolution 留空(结算查价回退 ratios 档位)
info, err = a.ParseTaskResult([]byte(`{"output":{"task_id":"t3","task_status":"SUCCEEDED","video_url":"u"},"usage":{"output_video_duration":5.0,"SR":0}}`))
if err != nil {
t.Fatalf("parse: %v", err)
}
if info.MaterialUsage == nil || info.MaterialUsage.Resolution != "" {
t.Fatalf("zero SR should yield empty resolution: %+v", info.MaterialUsage)
}
}
func TestExtractUpstreamRequestID(t *testing.T) {
a := &AliAdaptor{}
// DashScope 提交成功响应:顶层 request_id 提取,与 output.task_id 互不干扰
if got := a.ExtractUpstreamRequestID([]byte(
`{"output":{"task_id":"tsk-abc","task_status":"PENDING"},"request_id":"req-123","code":null,"message":""}`)); got != "req-123" {
t.Fatalf("request_id lost: %q", got)
}
// 错误响应同样带顶层 request_id(DashScope 错误时 code/message 非空)
if got := a.ExtractUpstreamRequestID([]byte(
`{"request_id":"req-err","code":"InvalidApiKey","message":"Invalid API-key"}`)); got != "req-err" {
t.Fatalf("error response request_id lost: %q", got)
}
// 缺失/非法 body:返回空串不报错
if got := a.ExtractUpstreamRequestID([]byte(`{"output":{"task_id":"t"}}`)); got != "" {
t.Fatalf("missing request_id should yield empty: %q", got)
}
if got := a.ExtractUpstreamRequestID([]byte(`not json`)); got != "" {
t.Fatalf("invalid body should yield empty: %q", got)
}
}
func TestResolutionFromSize(t *testing.T) {
cases := map[string]string{
"1280x720": "720P",
"720x1280": "720P", // 竖版按短边
"854x480": "480P",
"1920x1080": "1080P",
"1080x1920": "1080P",
"1024x1024": "1080P", // 正方形短边 1024 → 1080P
"invalid": "",
"ax720": "",
}
for size, want := range cases {
if got := resolutionFromSize(size); got != want {
t.Errorf("resolutionFromSize(%q) = %q, want %q", size, got, want)
}
}
}
// 裸档位词汇(OpenAI 端点 size 直传 wan3.0 原生词汇)归一;
// WxH 尺寸走短边归档,无效值返回空串
func TestNormalizeWan3Resolution(t *testing.T) {
cases := map[string]string{
"720P": "720P",
"480p": "480P", // 小写归一为大写档位
" 1080p ": "1080P",
"1280x720": "720P",
"1024x576": "720P",
"4K": "", // 非法键必须返回空串,防止把 "4K" 之类键透传上游
"768P": "", // MiniMax 档位非 wan3.0 词汇,不归一
"not-a-tie": "",
}
for size, want := range cases {
if got := normalizeWan3Resolution(size); got != want {
t.Errorf("normalizeWan3Resolution(%q) = %q, want %q", size, got, want)
}
}
}
// OpenAI 端点 size 直传 wan3.0 原生档位词汇("720P"/"480p"):
// 直接作为 resolution 下发上游,且不再透传 wan3.0 不认识的 size 参数
func TestBuildVideoRequest_SizeTierVocabulary(t *testing.T) {
for _, size := range []string{"720P", "480p", "1080p"} {
a := &AliAdaptor{}
body := []byte(`{"model":"wan3.0-video","prompt":"p","metadata":{"size":"` + size + `"}}`)
r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body)
if err != nil {
t.Fatalf("build (%s): %v", size, err)
}
var req dashScopeVideoRequest
if err := json.NewDecoder(r).Decode(&req); err != nil {
t.Fatalf("decode (%s): %v", size, err)
}
want := strings.ToUpper(size)
if req.Parameters == nil || req.Parameters.Resolution != want {
t.Fatalf("size=%s should derive resolution=%s: %+v", size, want, req.Parameters)
}
if req.Parameters.Size != "" {
t.Fatalf("wan3.0 request must not carry size param (input %s): %+v", size, req.Parameters)
}
}
}
// 回归:metadata.size 无法归一为档位(如 MiniMax 词汇误传给 wan3.0)时,
// resolution 留空走上游默认 1080P,原 size 值不再透传
func TestBuildVideoRequest_SizeUnrecognized(t *testing.T) {
a := &AliAdaptor{}
body := []byte(`{"model":"wan3.0-video","prompt":"p","metadata":{"size":"768P"}}`)
r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body)
if err != nil {
t.Fatalf("build: %v", err)
}
var req dashScopeVideoRequest
if err := json.NewDecoder(r).Decode(&req); err != nil {
t.Fatalf("decode: %v", err)
}
// 归一失败且无其他参数时 Parameters 整体为 nil(不下发 parameters 节点),
// 非 nil 时 resolution/size 必须为空(上游默认 1080P)
if req.Parameters != nil && (req.Parameters.Resolution != "" || req.Parameters.Size != "") {
t.Fatalf("unrecognized size should leave resolution/size empty: %+v", req.Parameters)
}
}
+2
View File
@@ -5,6 +5,8 @@ const channelName = "Ali"
// ModelList 阿里云 DashScope 支持异步生成的模型列表(图片 + 视频)
var ModelList = []string{
// 视频生成
"wan3.0-video",
"wan3.0-video-prime",
"wan2.7-t2v-2026-04-25",
"wan2.6-t2v",
"wan2.6-t2v-us",
+615 -38
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, onMounted, h } from 'vue'
import { ref, reactive, computed, watch, onMounted, onUnmounted, h } from 'vue'
import { useRoute } from 'vue-router'
import {
Tag, Button, Space, Popconfirm, Message,
@@ -226,12 +226,34 @@ const showDetail = ref(false)
const detailLoading = ref(false)
const detailData = ref<any>(null)
// 进行中任务在抽屉内按秒刷新「已运行」时长
const nowTs = ref(Date.now())
let heroTicker: ReturnType<typeof setInterval> | undefined
function startHeroTicker() {
stopHeroTicker()
heroTicker = setInterval(() => { nowTs.value = Date.now() }, 1000)
}
function stopHeroTicker() {
if (heroTicker) {
clearInterval(heroTicker)
heroTicker = undefined
}
}
async function openDetail(row: any) {
detailData.value = row
showDetail.value = true
await reloadDetail(row.id)
}
async function reloadDetail(id?: number) {
const taskId = id ?? detailData.value?.id
if (!taskId) return
detailLoading.value = true
try {
const res: any = await request.get(`/admin/tasks/${row.id}`)
const res: any = await request.get(`/admin/tasks/${taskId}`)
const raw = res.data?.data
if (raw?.task) {
detailData.value = raw.task
@@ -243,6 +265,142 @@ async function openDetail(row: any) {
}
}
// 状态语义色:抬头区状态点、进度条与时间线节点共用(与列表 Tag 的色彩语义一致)
const statusVisual: Record<string, { label: string; dot: string; bar: string }> = {
NOT_START: { label: '未开始', dot: 'var(--color-text-4)', bar: 'var(--color-text-4)' },
SUBMITTED: { label: '已提交', dot: 'rgb(var(--arcoblue-6))', bar: 'rgb(var(--arcoblue-6))' },
IN_PROGRESS: { label: '进行中', dot: 'rgb(var(--arcoblue-6))', bar: 'rgb(var(--arcoblue-6))' },
SUCCESS: { label: '成功', dot: 'var(--ta-success)', bar: 'var(--ta-success)' },
FAILURE: { label: '失败', dot: 'var(--ta-danger)', bar: 'var(--ta-danger)' },
}
// 平台 → 任务类型,用于抬头区类型标签(覆盖范围与 platformOptions 一致)
const platformKind: Record<string, string> = {
sora: '视频',
kling: '视频',
volcengine: '视频',
ali: '视频',
minimax: '视频',
midjourney: '图片',
suno: '音乐',
}
const detailVisual = computed(() => {
const status = String(detailData.value?.status || '')
return statusVisual[status] || { label: status || '-', dot: 'var(--color-text-4)', bar: 'var(--color-text-4)' }
})
// 进度条比值:Arco ProgressLine 的 percent 是 0~1 小数(内部 `width: percent * 100%`),
// 本仓库其余用法(AdminLayout/RealtimeMonitorPage/MonitorPage)也都传比值;传 0~100 会被
// .arco-progress-line-bar 的 max-width:100% 夹成满格,出现「失败任务进度条画满整条」的假象
const progressRatio = computed(() => {
if (detailData.value?.status === 'SUCCESS') return 1
const n = parseInt(String(detailData.value?.progress || '').replace('%', ''), 10)
if (Number.isNaN(n)) return 0
return Math.min(100, Math.max(0, n)) / 100
})
// 后端时间为 Asia/Shanghai 墙钟(YYYY-MM-DD HH:mm:ss),此处只用来算时间差,不做时区换算;
// 把 - 换成 / 以兼容 Safari 的 Date 解析
function parseTime(v?: string): number | null {
if (!v) return null
const t = new Date(String(v).replace(/-/g, '/')).getTime()
return Number.isNaN(t) ? null : t
}
function diffMs(start?: string, end?: string): number | null {
const a = parseTime(start)
const b = parseTime(end)
if (a === null || b === null) return null
return b >= a ? b - a : null
}
function formatDuration(ms: number): string {
const s = Math.floor(ms / 1000)
if (s < 60) return `${s} 秒`
const m = Math.floor(s / 60)
if (m < 60) return `${m} 分 ${s % 60} 秒`
const h = Math.floor(m / 60)
if (h < 24) return `${h} 小时 ${m % 60} 分`
return `${Math.floor(h / 24)} 天 ${h % 24} 小时`
}
// 终态显示总耗时,进行中显示至今已运行时长
const runningOrTotalText = computed(() => {
const d = detailData.value || {}
if (d.status === 'SUCCESS' || d.status === 'FAILURE') {
const total = diffMs(d.created_at, d.finish_time)
return total === null ? '' : `总耗时 ${formatDuration(total)}`
}
const since = parseTime(d.start_time) ?? parseTime(d.submit_time) ?? parseTime(d.created_at)
if (since === null) return ''
return `已运行 ${formatDuration(Math.max(0, nowTs.value - since))}`
})
const timelineNodes = computed(() => {
const d = detailData.value || {}
const status = String(d.status || '')
const active = status === 'FAILURE'
? 'var(--ta-danger)'
: status === 'SUCCESS' ? 'var(--ta-success)' : 'rgb(var(--arcoblue-6))'
const mk = (label: string, time?: string) => ({
label,
time: time || '',
short: time ? String(time).slice(11, 19) : '',
color: time ? active : 'var(--color-text-4)',
})
return [
mk('创建', d.created_at),
mk('提交上游', d.submit_time),
mk('开始执行', d.start_time),
mk('完成', d.finish_time),
]
})
const durationList = computed(() => {
const d = detailData.value || {}
const items: { label: string; ms: number | null }[] = [
{ label: '排队耗时', ms: diffMs(d.created_at, d.submit_time) },
{ label: '执行耗时', ms: diffMs(d.start_time || d.submit_time, d.finish_time) },
{ label: '总耗时', ms: diffMs(d.created_at, d.finish_time) },
]
return items
.filter((i): i is { label: string; ms: number } => i.ms !== null)
.map(i => ({ label: i.label, value: formatDuration(i.ms) }))
})
// 结果资源类型:按扩展名判定,视频/音频内联播放,图片走缩略图,其余回退为链接
const resultKind = computed<'image' | 'video' | 'audio' | 'file'>(() => {
const url = String(detailData.value?.result_url || '')
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)([?#]|$)/i.test(url)) return 'image'
if (/\.(mp4|mov|webm|m4v|mkv)([?#]|$)/i.test(url)) return 'video'
if (/\.(mp3|wav|m4a|aac|ogg|flac)([?#]|$)/i.test(url)) return 'audio'
return 'file'
})
// 复制上游排障标识到剪贴板(与上游沟通时直接粘贴)
async function copyText(text: string) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
Message.success('已复制')
} catch {
Message.error('复制失败,请手动选择复制')
}
}
watch(
[showDetail, () => detailData.value?.status],
([visible, status]) => {
const running = visible && ['NOT_START', 'SUBMITTED', 'IN_PROGRESS'].includes(String(status || ''))
if (running) startHeroTicker()
else stopHeroTicker()
},
{ immediate: true },
)
onUnmounted(stopHeroTicker)
onMounted(() => {
const route = useRoute()
if (route.query.public_task_id) {
@@ -350,46 +508,147 @@ onMounted(() => {
</ACard>
<!-- Detail Drawer -->
<ADrawer v-model:visible="showDetail" :width="620" title="任务详情">
<ASpin :loading="detailLoading">
<ADrawer v-model:visible="showDetail" :width="760" title="任务详情" unmount-on-close>
<ASpin class="detail-spin" :loading="detailLoading">
<template v-if="detailData">
<h3 class="detail-section-title">基本信息</h3>
<ADescriptions :column="2" bordered size="small">
<ADescriptionsItem label="任务ID">{{ detailData.public_task_id }}</ADescriptionsItem>
<ADescriptionsItem label="状态">
<Tag :color="statusTagColor[detailData.status]" size="small">
{{ statusTagLabel[detailData.status] || detailData.status }}
</Tag>
</ADescriptionsItem>
<ADescriptionsItem label="平台">{{ platformLabel[detailData.platform] || detailData.platform }}</ADescriptionsItem>
<ADescriptionsItem label="动作">{{ detailData.action }}</ADescriptionsItem>
<ADescriptionsItem label="模型">{{ detailData.model_name }}</ADescriptionsItem>
<ADescriptionsItem label="进度">{{ detailData.progress }}</ADescriptionsItem>
<ADescriptionsItem label="预扣金额">
<span v-if="detailData.pre_deduct_amount > 0">{{ formatBilling(detailData.pre_deduct_amount, 4) }}</span>
<span v-else>-</span>
</ADescriptionsItem>
<ADescriptionsItem label="实际费用">
<span v-if="detailData.billing_settled">{{ formatBilling(detailData.actual_cost, 4) }}</span>
<span v-else>未结算</span>
</ADescriptionsItem>
<ADescriptionsItem label="提交时间">{{ detailData.submit_time || '-' }}</ADescriptionsItem>
<ADescriptionsItem label="完成时间">{{ detailData.finish_time || '-' }}</ADescriptionsItem>
<ADescriptionsItem label="创建时间">{{ detailData.created_at }}</ADescriptionsItem>
<ADescriptionsItem label="租户ID">{{ detailData.tenant_id }}</ADescriptionsItem>
<ADescriptionsItem label="用户ID">{{ detailData.user_id }}</ADescriptionsItem>
<ADescriptionsItem v-if="detailData.fail_reason" label="失败原因" :span="2">
<span style="color: var(--color-danger-6)">{{ detailData.fail_reason }}</span>
</ADescriptionsItem>
</ADescriptions>
<!-- 状态抬头区:打开抽屉第一眼就能判断任务处境 -->
<div class="detail-hero">
<div class="detail-hero__top">
<div class="detail-hero__status">
<span class="detail-hero__dot" :style="{ background: detailVisual.dot }"></span>
<span class="detail-hero__text">{{ detailVisual.label }}</span>
<span class="detail-hero__sub">{{ detailData.model_name || '-' }} · {{ detailData.action || '-' }}</span>
</div>
<ASpace :size="6">
<ATag v-if="platformKind[detailData.platform]" size="small">{{ platformKind[detailData.platform] }}</ATag>
<ATag size="small" color="arcoblue">{{ platformLabel[detailData.platform] || detailData.platform }}</ATag>
</ASpace>
</div>
<AProgress
class="detail-hero__progress"
:percent="progressRatio"
:show-text="false"
size="small"
:color="detailVisual.bar"
/>
<div class="detail-hero__foot">
<span>进度 {{ detailData.progress || '0%' }}</span>
<span v-if="runningOrTotalText">{{ runningOrTotalText }}</span>
</div>
</div>
<!-- Result -->
<!-- 失败原因:紧跟抬头区,避免埋在字段表末尾被忽略 -->
<div v-if="detailData.fail_reason" class="detail-alert">
<span class="detail-alert__title">失败原因</span>
<span class="detail-alert__body">{{ detailData.fail_reason }}</span>
</div>
<!-- 任务ID:等宽 + 单行省略,长 ID 不再折行破坏整体对齐 -->
<div class="detail-idbar">
<div class="detail-idbar__main">
<span class="detail-idbar__label">任务ID</span>
<ATooltip :content="detailData.public_task_id" position="top" :disabled="!detailData.public_task_id">
<span class="detail-idbar__value">{{ detailData.public_task_id || '-' }}</span>
</ATooltip>
<AButton
v-if="detailData.public_task_id"
size="mini"
type="text"
@click="copyText(detailData.public_task_id)"
>复制</AButton>
</div>
<AButton size="mini" type="text" :loading="detailLoading" @click="reloadDetail()">刷新</AButton>
</div>
<h3 class="detail-section-title">任务实例</h3>
<div class="detail-grid">
<span class="detail-grid__k">平台</span>
<span class="detail-grid__v">{{ platformLabel[detailData.platform] || detailData.platform || '-' }}</span>
<span class="detail-grid__k">动作</span>
<span class="detail-grid__v">{{ detailData.action || '-' }}</span>
<span class="detail-grid__k">模型</span>
<span class="detail-grid__v detail-mono">{{ detailData.model_name || '-' }}</span>
<span class="detail-grid__k">上游模型</span>
<span class="detail-grid__v detail-mono">{{ detailData.upstream_model || '-' }}</span>
<span class="detail-grid__k">租户ID</span>
<span class="detail-grid__v">#{{ detailData.tenant_id }}</span>
<span class="detail-grid__k">用户ID</span>
<span class="detail-grid__v">#{{ detailData.user_id }}</span>
</div>
<h3 class="detail-section-title">计费</h3>
<div class="detail-metrics">
<div class="detail-metric">
<div class="detail-metric__label">预扣金额</div>
<div class="detail-metric__value">
{{ detailData.pre_deduct_amount > 0 ? formatBilling(detailData.pre_deduct_amount, 4) : '-' }}
</div>
</div>
<div class="detail-metric">
<div class="detail-metric__label">实际费用</div>
<div class="detail-metric__value">
{{ detailData.billing_settled ? formatBilling(detailData.actual_cost, 4) : '未结算' }}
</div>
</div>
<div class="detail-metric">
<div class="detail-metric__label">结算状态</div>
<div class="detail-metric__value">{{ detailData.billing_settled ? '已结算' : '待结算' }}</div>
</div>
</div>
<h3 class="detail-section-title">时间线</h3>
<div class="detail-timeline">
<span class="detail-timeline__track"></span>
<div v-for="n in timelineNodes" :key="n.label" class="detail-timeline__node">
<div class="detail-timeline__label">{{ n.label }}</div>
<span class="detail-timeline__dot" :style="{ background: n.color }"></span>
<div class="detail-timeline__time" :class="{ 'is-empty': !n.time }">{{ n.short || '—' }}</div>
</div>
</div>
<div v-if="durationList.length" class="detail-durations">
<span v-for="i in durationList" :key="i.label">{{ i.label }} {{ i.value }}</span>
</div>
<h3 class="detail-section-title">排障标识</h3>
<div class="detail-grid detail-grid--wide">
<span class="detail-grid__k">请求ID</span>
<span class="detail-grid__v">
<template v-if="detailData.request_id">
<ATooltip :content="detailData.request_id" position="top">
<span class="detail-mono detail-ellipsis">{{ detailData.request_id }}</span>
</ATooltip>
<AButton size="mini" type="text" @click="copyText(detailData.request_id)">复制</AButton>
</template>
<span v-else>-</span>
</span>
<span class="detail-grid__k">上游任务ID</span>
<span class="detail-grid__v">
<template v-if="detailData.upstream_task_id">
<ATooltip :content="detailData.upstream_task_id" position="top">
<span class="detail-mono detail-ellipsis">{{ detailData.upstream_task_id }}</span>
</ATooltip>
<AButton size="mini" type="text" @click="copyText(detailData.upstream_task_id)">复制</AButton>
</template>
<span v-else>-</span>
</span>
<span class="detail-grid__k">上游请求ID</span>
<span class="detail-grid__v">
<template v-if="detailData.upstream_request_id">
<ATooltip :content="detailData.upstream_request_id" position="top">
<span class="detail-mono detail-ellipsis">{{ detailData.upstream_request_id }}</span>
</ATooltip>
<AButton size="mini" type="text" @click="copyText(detailData.upstream_request_id)">复制</AButton>
</template>
<span v-else>-</span>
</span>
</div>
<!-- 结果:图片缩略图 / 视频音频内联播放 / 其余回退为链接 -->
<template v-if="detailData.result_url">
<h3 class="detail-section-title">结果</h3>
<div class="result-preview">
<!-- 图片:内联展示缩略图(省流量),点击在新标签打开原图 -->
<a
v-if="detailData.result_url && /\.(jpg|jpeg|png|gif|webp)(\?|$)/i.test(detailData.result_url)"
v-if="resultKind === 'image'"
:href="detailData.result_url"
target="_blank"
rel="noopener"
@@ -398,13 +657,31 @@ onMounted(() => {
<img
:src="detailData.result_thumb_url || detailData.result_url"
alt="任务结果"
style="max-width: 100%; border-radius: 6px; cursor: zoom-in;"
class="result-image"
/>
</a>
<a v-else :href="detailData.result_url" target="_blank" class="result-link">
<video
v-else-if="resultKind === 'video'"
class="result-video"
:src="detailData.result_url"
controls
preload="metadata"
></video>
<audio
v-else-if="resultKind === 'audio'"
class="result-audio"
:src="detailData.result_url"
controls
preload="metadata"
></audio>
<a v-else :href="detailData.result_url" target="_blank" rel="noopener" class="result-link">
{{ detailData.result_url }}
</a>
</div>
<div class="result-actions">
<a :href="detailData.result_url" target="_blank" rel="noopener" class="result-action">打开原文件</a>
<a class="result-action" @click="copyText(detailData.result_url)">复制链接</a>
</div>
</template>
</template>
</ASpin>
@@ -425,6 +702,137 @@ onMounted(() => {
margin-bottom: 0;
}
/* ===== 任务详情抽屉 ===== */
/* Arco Spin 根节点是 display:inline-block(arco.css: .arco-spin{display:inline-block}),
会把内容压成「最长不可换行内容」决定的收缩宽度(本页长任务ID 等 nowrap 文本 → 约 418px),
于是内部所有块级元素(hero / 告警 / idbar / 指标卡 / grid)在 720px 的抽屉里只占一半,
右侧留大片空白。这里强制撑满抽屉内容宽度。 */
.detail-spin {
display: block;
width: 100%;
}
/* 状态抬头区:状态 + 进度 + 耗时,打开抽屉第一屏即给出结论 */
.detail-hero {
background: var(--ta-bg-secondary);
border-radius: var(--ta-radius);
padding: 12px 14px;
}
.detail-hero__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.detail-hero__status {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.detail-hero__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.detail-hero__text {
font-size: 15px;
font-weight: 600;
color: var(--ta-text-primary);
flex-shrink: 0;
}
.detail-hero__sub {
font-size: 12px;
color: var(--ta-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-hero__progress {
margin-top: 10px;
}
.detail-hero__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
font-size: 12px;
color: var(--ta-text-secondary);
}
/* 失败原因告警块 */
.detail-alert {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
padding: 10px 12px;
border-radius: var(--ta-radius);
background: var(--color-danger-light-1);
border: 1px solid var(--color-danger-light-2);
}
.detail-alert__title {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--ta-danger);
line-height: 1.6;
}
.detail-alert__body {
font-size: 12px;
line-height: 1.6;
color: var(--ta-text-primary);
word-break: break-all;
}
/* 任务ID:等宽 + 单行省略,避免长 ID 折行把整行行高撑成两倍 */
.detail-idbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
padding: 5px 10px;
border: 1px solid var(--ta-border);
border-radius: var(--ta-radius);
}
.detail-idbar__main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.detail-idbar__label {
flex-shrink: 0;
font-size: 12px;
color: var(--ta-text-tertiary);
}
.detail-idbar__value {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: monospace;
font-size: 12px;
color: var(--ta-text-primary);
}
.detail-section-title {
font-size: 14px;
font-weight: 600;
@@ -436,14 +844,183 @@ onMounted(() => {
margin-top: 0;
}
/* 字段表:两列 label/value,比 bordered 表格更紧凑,且不产生空单元格 */
.detail-grid {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 64px minmax(0, 1fr);
gap: 9px 16px;
align-items: baseline;
}
.detail-grid--wide {
grid-template-columns: 76px minmax(0, 1fr);
}
.detail-grid__k {
font-size: 13px;
color: var(--ta-text-tertiary);
}
.detail-grid__v {
font-size: 13px;
color: var(--ta-text-primary);
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
word-break: break-all;
}
.detail-mono {
font-family: monospace;
}
.detail-ellipsis {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
/* 计费指标卡 */
.detail-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.detail-metric {
background: var(--ta-bg-secondary);
border-radius: var(--ta-radius);
padding: 10px 12px;
}
.detail-metric__label {
font-size: 12px;
color: var(--ta-text-tertiary);
}
.detail-metric__value {
margin-top: 4px;
font-size: 15px;
font-weight: 600;
color: var(--ta-text-primary);
}
/* 时间线:四节点等分,节点连线作为底轨 */
.detail-timeline {
position: relative;
display: flex;
}
.detail-timeline__track {
position: absolute;
top: 34px;
left: 12.5%;
right: 12.5%;
height: 1px;
background: var(--ta-border);
}
.detail-timeline__node {
position: relative;
flex: 1;
min-width: 0;
padding-top: 46px;
text-align: center;
}
.detail-timeline__label {
position: absolute;
top: 0;
left: 0;
right: 0;
font-size: 12px;
color: var(--ta-text-tertiary);
}
.detail-timeline__dot {
position: absolute;
top: 30px;
left: 50%;
width: 8px;
height: 8px;
margin-left: -4px;
border-radius: 50%;
}
.detail-timeline__time {
font-size: 12px;
color: var(--ta-text-primary);
font-family: monospace;
}
.detail-timeline__time.is-empty {
color: var(--ta-text-tertiary);
}
.detail-durations {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
margin-top: 10px;
font-size: 12px;
color: var(--ta-text-secondary);
}
.result-preview {
background: var(--color-fill-1);
border-radius: 6px;
padding: 12px;
}
.result-image {
display: block;
max-width: 100%;
border-radius: 6px;
cursor: zoom-in;
}
.result-video {
display: block;
width: 100%;
max-height: 400px;
border-radius: 6px;
background: #000;
}
.result-audio {
display: block;
width: 100%;
}
.result-actions {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 8px;
font-size: 12px;
}
.result-action {
color: rgb(var(--arcoblue-6));
cursor: pointer;
}
.result-link {
color: rgb(var(--arcoblue-6));
word-break: break-all;
}
@media (max-width: 767px) {
.detail-grid,
.detail-grid--wide {
grid-template-columns: 68px minmax(0, 1fr);
}
.detail-metrics {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -27,11 +27,12 @@ const modelOptions = ref<{ label: string; value: string }[]>([])
const expandedRows = ref<Set<string>>(new Set())
const expandLoading = ref<Set<string>>(new Set())
const channelData = ref<Map<string, any[]>>(new Map())
// 渠道下拉数据:渠道量级为几十,一次性拉全量(后端分页限制最大 100)
// 渠道下拉数据:专用不分页接口 /admin/channels/options,返回全部渠道(含已停用)。
// 不用 /admin/channels——那是渠道管理列表(分页 + 健康度联表 + 运行态 + 上限 100 条)。
async function fetchChannelOptions() {
try {
const res: any = await request.get('/admin/channels', { params: { page: 1, page_size: 100 } })
channelOptions.value = (res.data?.data?.list || res.data?.list || []).map((c: any) => ({
const res: any = await request.get('/admin/channels/options')
channelOptions.value = (res.data?.data?.list || []).map((c: any) => ({
label: c.name ? `${c.name} (#${c.id})` : `#${c.id}`,
value: c.id,
}))
+26 -21
View File
@@ -66,7 +66,6 @@ const requestTypeOptions = [
]
let tenantSearchTimer: ReturnType<typeof setTimeout> | null = null
let modelSearchTimer: ReturnType<typeof setTimeout> | null = null
async function fetchTenantOptions(keyword = '') {
try {
@@ -88,11 +87,12 @@ function handleTenantSearch(value: string) {
tenantSearchTimer = setTimeout(() => fetchTenantOptions(value), 300)
}
async function fetchModelOptions(search = '') {
// 模型下拉数据:专用不分页接口 /admin/models/options,一次拉回全部 active 模型后本地过滤。
// 不用 /admin/models 列表接口——那是模型管理页的重接口(分页 + 定价 JSONB 展开 +
// 渠道能力联表),仅取 model_id / model_name 做下拉纯属浪费。
async function fetchModelOptions() {
try {
const res: any = await request.get('/admin/models', {
params: { page: 1, page_size: 50, status: 'active', search }
})
const res: any = await request.get('/admin/models/options')
const list = res.data?.data?.list || []
modelOptions.value = list.map((m: any) => ({
label: m.model_name || m.model_id,
@@ -103,18 +103,22 @@ async function fetchModelOptions(search = '') {
}
}
function handleModelSearch(value: string) {
if (modelSearchTimer) clearTimeout(modelSearchTimer)
modelSearchTimer = setTimeout(() => fetchModelOptions(value), 300)
// 模型下拉本地过滤:候选是全量模型,label 用的是 model_name、value 是 model_id,
// 只在 label 上匹配会让「显示名不含 model_id」的模型(如中文显示名)搜不到。
// 这里对齐原先服务端 model_id LIKE ? OR model_name LIKE ? 的口径,name 与 id 任一命中即可。
function filterModelOption(input: string, option: any): boolean {
const keyword = input.toLowerCase()
return String(option?.label ?? '').toLowerCase().includes(keyword)
|| String(option?.value ?? '').toLowerCase().includes(keyword)
}
// 渠道下拉数据:渠道量级为几十,一次性拉全量(后端分页限制最大 100),本地过滤即可
// 渠道下拉数据:专用不分页接口 /admin/channels/options,返回全部渠道(含已停用,
// 历史日志要能按停用渠道筛选),一次拉全量后本地过滤。
// 不用 /admin/channels——那是渠道管理列表(分页 + 健康度联表 + 运行态 + 上限 100 条)。
async function fetchChannelOptions() {
try {
const res: any = await request.get('/admin/channels', {
params: { page: 1, page_size: 100 }
})
const list = res.data?.data?.list || res.data?.list || []
const res: any = await request.get('/admin/channels/options')
const list = res.data?.data?.list || []
channelOptions.value = list.map((c: any) => ({
label: c.name ? `${c.name} (#${c.id})` : `#${c.id}`,
value: c.id,
@@ -634,8 +638,9 @@ async function fetchSummary() {
function handleFilter() {
pagination.current = 1
fetchData()
fetchSummary() // 筛选条件变化时重新加载统计数据
// 表格数据加载完成后再刷新统计:summary 是对明细表的全量聚合,
// 与列表查询并发会争抽数据库资源、拖慢表格首屏
fetchData().then(() => fetchSummary())
}
function handleReset() {
@@ -650,8 +655,8 @@ function handleReset() {
filterUpstreamRequestId.value = undefined
filterDateRange.value = defaultTodayRange()
pagination.current = 1
fetchData()
fetchSummary() // 重置后重新加载统计数据
// 与 handleFilter 同口径:先加载表格数据,完成后再刷新统计
fetchData().then(() => fetchSummary())
}
// 刷新:清空所有筛选条件,仅按当天起始时间查询最新记录(截止留空 = 到现在)
@@ -663,8 +668,9 @@ onMounted(() => {
fetchTenantOptions()
fetchModelOptions()
fetchChannelOptions()
fetchData()
fetchSummary() // 初始加载时获取统计数据
// 表格数据加载完成后再拉统计汇总,避免 summary 的全量聚合
// 与列表首查并发争抽数据库资源(fetchData 内部已捕获异常,必 resolve)
fetchData().then(() => fetchSummary())
})
const { exporting, exportFile } = useExport({
@@ -728,9 +734,8 @@ const { exporting, exportFile } = useExport({
placeholder="模型"
allow-search
allow-clear
:filter-option="false"
:filter-option="filterModelOption"
style="width: 220px"
@search="handleModelSearch"
@change="handleFilter"
@clear="handleFilter"
/>
@@ -71,10 +71,12 @@ async function fetchTenantModels() {
}
}
// 候选模型走专用不分页接口 /admin/models/options:一次拉回全部 active 模型,
// 不受 /admin/models 的 page_size=100 上限约束(平台模型超 100 个时旧写法会漏模型)
async function fetchAllModels() {
try {
const res: any = await request.get('/admin/models', { params: { page: 1, page_size: 100, status: 'active' } })
allModels.value = res.data?.data?.list || res.data?.list || []
const res: any = await request.get('/admin/models/options')
allModels.value = res.data?.data?.list || []
} catch (err: any) {
console.error('fetchAllModels failed:', err)
}
@@ -28,6 +28,7 @@ export const categoryMetaList: CategoryMeta[] = [
{ key: 'embedding', value: 'embedding', label: '向量嵌入', shortLabel: '向量', description: '语义检索与知识库构建', icon: 'cube', from: '#a78bfa', to: '#8b5cf6', glow: '139, 92, 246' },
{ key: 'image', value: 'image', label: '图像生成', shortLabel: '图像', description: '文生图、图像理解与编辑', icon: 'eye', from: '#f472b6', to: '#ec4899', glow: '236, 72, 153' },
{ key: 'audio', value: 'audio', label: '音频处理', shortLabel: '音频', description: '语音识别、合成与音乐生成', icon: 'bell', from: '#fbbf24', to: '#f59e0b', glow: '245, 158, 11' },
{ key: 'video', value: 'video', label: '视频生成', shortLabel: '视频', description: '文生视频与视频处理', icon: 'film', from: '#fb7185', to: '#f43f5e', glow: '244, 63, 94' },
{ key: 'rerank', value: 'rerank', label: '重排序', shortLabel: '重排', description: '检索结果相关性排序', icon: 'trendingUp', from: '#22d3ee', to: '#06b6d4', glow: '6, 182, 212' },
]
@@ -149,6 +149,7 @@ const categories = [
{ value: 'embedding', label: '嵌入' },
{ value: 'image', label: '图像' },
{ value: 'audio', label: '语音' },
{ value: 'video', label: '视频' },
{ value: 'rerank', label: '重排' },
]
@@ -157,6 +158,7 @@ const categoryLabel: Record<string, string> = {
embedding: '嵌入',
image: '图像',
audio: '语音',
video: '视频',
rerank: '重排',
}
@@ -165,6 +167,7 @@ const categoryBadgeStyle: Record<string, string> = {
embedding: 'background:rgba(139,92,246,0.1);color:#7c3aed',
image: 'background:rgba(245,158,11,0.1);color:#d97706',
audio: 'background:rgba(16,185,129,0.1);color:#059669',
video: 'background:rgba(244,63,94,0.1);color:#e11d48',
rerank: 'background:rgba(107,114,128,0.1);color:#4b5563',
}
+302 -84
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, computed, h } from 'vue'
import { ref, onMounted, computed, watch, onUnmounted, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NButton, NTag, NInput, NDropdown } from 'naive-ui'
import { useRoute } from 'vue-router'
@@ -84,13 +84,14 @@ const { exporting, exportFile } = useExport({
}),
})
const statusBadge: Record<string, string> = {
NOT_START: 'bg-gray-100 text-gray-800',
SUBMITTED: 'bg-blue-100 text-blue-800',
IN_PROGRESS: 'bg-amber-100 text-amber-800',
SUCCESS: 'bg-emerald-100 text-emerald-800',
FAILURE: 'bg-red-100 text-red-800',
TIMEOUT: 'bg-orange-100 text-orange-800',
// 状态语义色:详情抬头区的状态点与进度条共用(列表徽章另走 taskStatusType 的 NTag)
const statusColor: Record<string, string> = {
NOT_START: '#9ca3af',
SUBMITTED: '#3b82f6',
IN_PROGRESS: '#f59e0b',
SUCCESS: '#10b981',
FAILURE: '#ef4444',
TIMEOUT: '#f97316',
}
const statusLabel: Record<string, string> = {
@@ -112,6 +113,17 @@ const platformLabel: Record<string, string> = {
minimax: 'MiniMax',
}
// 平台 → 任务类型,用于详情抬头区的类型标签(覆盖范围与筛选项一致)
const platformKind: Record<string, string> = {
sora: '视频',
kling: '视频',
volcengine: '视频',
ali: '视频',
minimax: '视频',
midjourney: '图片',
suno: '音乐',
}
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
function formatCost(n: number | undefined): string {
return formatBilling(n, 6)
@@ -122,6 +134,31 @@ function formatTime(s: string | undefined): string {
return s.replace('T', ' ').substring(0, 19)
}
// 后端时间为 Asia/Shanghai 墙钟(YYYY-MM-DD HH:mm:ss),此处只用来算时间差,不做时区换算;
// 把 - 换成 / 以兼容 Safari 的 Date 解析
function parseTs(s: string | undefined): number | null {
if (!s) return null
const t = new Date(s.replace('T', ' ').substring(0, 19).replace(/-/g, '/')).getTime()
return Number.isNaN(t) ? null : t
}
function diffMs(start?: string, end?: string): number | null {
const a = parseTs(start)
const b = parseTs(end)
if (a === null || b === null) return null
return b >= a ? b - a : null
}
function formatDuration(ms: number): string {
const s = Math.floor(ms / 1000)
if (s < 60) return `${s} 秒`
const m = Math.floor(s / 60)
if (m < 60) return `${m} 分 ${s % 60} 秒`
const h = Math.floor(m / 60)
if (h < 24) return `${h} 小时 ${m % 60} 分`
return `${Math.floor(h / 24)} 天 ${h % 24} 小时`
}
async function fetchTasks() {
loading.value = true
try {
@@ -155,7 +192,8 @@ async function openDetail(task: TaskItem) {
const res: any = await request.get(`/tenant/tasks/${task.id}`)
const raw = res.data?.data
if (raw?.task) {
detailTask.value = raw.task
// 详情接口不返回 username(只有列表接口 join 得到),整体替换会把它抹掉,故显式回落
detailTask.value = { ...task, ...raw.task, username: raw.task.username || task.username }
}
} catch {
// keep the list-level data
@@ -164,6 +202,130 @@ async function openDetail(task: TaskItem) {
}
}
// === 详情弹窗展示 ===
const detailStatusColor = computed(() => statusColor[detailTask.value?.status || ''] || statusColor.NOT_START)
// 进度百分比:解析后端 "50%" 字符串;成功一律补满,保证终态视觉一致
const progressPercent = computed(() => {
if (detailTask.value?.status === 'SUCCESS') return 100
const n = parseInt(String(detailTask.value?.progress || '').replace('%', ''), 10)
return Number.isNaN(n) ? 0 : Math.min(100, Math.max(0, n))
})
// 结果资源类型:图片走缩略图、视频/音频内联播放,其余才回退成链接
const resultKind = computed<'image' | 'video' | 'audio' | 'file'>(() => {
const url = detailTask.value?.result_url || ''
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)([?#]|$)/i.test(url)) return 'image'
if (/\.(mp4|mov|webm|m4v|mkv)([?#]|$)/i.test(url)) return 'video'
if (/\.(mp3|wav|m4a|aac|ogg|flac)([?#]|$)/i.test(url)) return 'audio'
return 'file'
})
const resultKindLabel = computed(() => ({ image: '图片', video: '视频', audio: '音频', file: '文件' }[resultKind.value]))
// Icon 组件按名取 path,取不到会渲染空图标并留下空白占位,故需按类型给出真实存在的图标名
const resultIconName = computed(
() => ({ image: 'photo', video: 'film', audio: 'musicalNote', file: 'link' }[resultKind.value]),
)
// 结果文件名:取 URL 路径末段(去掉查询串),供下载按钮落盘用
const resultFileName = computed(() => {
const path = (detailTask.value?.result_url || '').split('?')[0]
const base = path.substring(path.lastIndexOf('/') + 1)
try {
return base ? decodeURIComponent(base) : ''
} catch {
return base
}
})
// 进行中任务抬头区的「已运行」按秒刷新
const nowTs = ref(Date.now())
let heroTicker: ReturnType<typeof setInterval> | undefined
function startHeroTicker() {
stopHeroTicker()
heroTicker = setInterval(() => { nowTs.value = Date.now() }, 1000)
}
function stopHeroTicker() {
if (heroTicker) {
clearInterval(heroTicker)
heroTicker = undefined
}
}
watch(
[showDetail, () => detailTask.value?.status],
([visible, status]) => {
const running = visible && ['NOT_START', 'SUBMITTED', 'IN_PROGRESS'].includes(String(status || ''))
if (running) startHeroTicker()
else stopHeroTicker()
},
{ immediate: true },
)
onUnmounted(stopHeroTicker)
// 抬头区右侧文案:终态给总耗时与完成时刻,进行中给已运行时长
const detailTimingText = computed(() => {
const t = detailTask.value
if (!t) return ''
if (t.status === 'SUCCESS' || t.status === 'FAILURE' || t.status === 'TIMEOUT') {
const total = diffMs(t.created_at, t.finish_time)
if (total === null) return ''
return `总耗时 ${formatDuration(total)}${t.finish_time ? ' · ' + formatTime(t.finish_time) + ' 完成' : ''}`
}
const since = parseTs(t.submit_time) ?? parseTs(t.created_at)
if (since === null) return ''
return `已运行 ${formatDuration(Math.max(0, nowTs.value - since))}`
})
// 执行耗时 = 提交上游 → 完成,无完成时间(进行中)时显示「-」
const detailDurationText = computed(() => {
const ms = diffMs(detailTask.value?.submit_time, detailTask.value?.finish_time)
return ms === null ? '-' : formatDuration(ms)
})
// 复制反馈:按 key 区分触发源,短暂显示「已复制」后复位(可编辑文本行内提示,不额外引 toast)
const copiedKey = ref('')
async function copyText(text: string, key = 'default') {
if (!text) return
try {
await navigator.clipboard.writeText(text)
copiedKey.value = key
setTimeout(() => {
if (copiedKey.value === key) copiedKey.value = ''
}, 1500)
} catch (e) {
console.error(e)
}
}
// 下载结果:跨域直链的 download 属性会被浏览器忽略,故先取 blob 落盘;
// 对象存储未开 CORS 时 fetch 会失败,降级为新标签打开(与「打开原文件」一致)
async function downloadResult() {
const url = detailTask.value?.result_url
if (!url) return
try {
const res = await fetch(url)
if (!res.ok) throw new Error(String(res.status))
const blob = await res.blob()
const href = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = href
a.download = resultFileName.value || 'task-result'
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(href)
} catch (e) {
console.error('[task-result] 下载失败,改为新标签打开', e)
window.open(url, '_blank', 'noopener')
}
}
function applyFilters() {
page.value = 1
fetchTasks()
@@ -179,12 +341,7 @@ function resetFilters() {
fetchTasks()
}
function isImageResult(url: string | undefined): boolean {
if (!url) return false
return /\.(jpg|jpeg|png|gif|webp)(\?|$)/i.test(url)
}
// 任务状态 → NTag type 映射(statusBadge 为 bg-* 类,无法映射到 renderBadge,改为直接渲染 NTag)
// 任务状态 → NTag type 映射(statusColor 为语义色,无法映射到 renderBadge,改为直接渲染 NTag)
const taskStatusType: Record<string, 'default' | 'success' | 'info' | 'warning' | 'error' | 'primary'> = {
NOT_START: 'default',
SUBMITTED: 'info',
@@ -199,19 +356,19 @@ const columns = computed<DataTableColumns<TaskItem>>(() => [
{
title: '任务 ID',
key: 'public_task_id',
width: 160,
width: 200,
render: (row) => h('span', { class: 'font-mono text-xs text-gray-600' }, row.public_task_id),
},
{
title: '平台',
key: 'platform',
width: 110,
width: 100,
render: (row) => h('span', { class: 'text-sm font-medium text-gray-700' }, platformLabel[row.platform] || row.platform),
},
{
title: '状态',
key: 'status',
width: 110,
width: 60,
render: (row) =>
h(NTag, { type: taskStatusType[row.status] || 'default', size: 'small' }, { default: () => statusLabel[row.status] || row.status }),
},
@@ -235,13 +392,13 @@ const columns = computed<DataTableColumns<TaskItem>>(() => [
{
title: '提交时间',
key: 'submit_time',
width: 170,
width: 150,
render: (row) => h('span', { class: 'text-xs text-gray-500' }, formatTime(row.submit_time)),
},
{
title: '完成时间',
key: 'finish_time',
width: 170,
width: 150,
render: (row) => h('span', { class: 'text-xs text-gray-500' }, formatTime(row.finish_time)),
},
{
@@ -354,54 +511,99 @@ onMounted(() => {
</div>
<div v-else-if="detailTask" class="space-y-5">
<!-- 状态抬头区:打开弹窗第一眼即可判断结果与耗时 -->
<div class="rounded-xl bg-gray-50 px-4 py-3">
<div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<span class="h-2 w-2 shrink-0 rounded-full" :style="{ background: detailStatusColor }"></span>
<span class="shrink-0 text-base font-semibold text-gray-800">{{ statusLabel[detailTask.status] || detailTask.status }}</span>
<span class="truncate text-xs text-gray-500">{{ detailTask.model_name || '-' }} · {{ detailTask.action || '-' }}</span>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<span v-if="platformKind[detailTask.platform]" class="rounded-md bg-white px-2 py-0.5 text-xs font-medium text-gray-600">
{{ platformKind[detailTask.platform] }}
</span>
<span class="rounded-md bg-white px-2 py-0.5 text-xs font-medium text-primary-700">
{{ platformLabel[detailTask.platform] || detailTask.platform }}
</span>
</div>
</div>
<div class="mt-2.5 h-1.5 overflow-hidden rounded-full bg-gray-200">
<div
class="h-full rounded-full transition-all duration-500"
:style="{ width: progressPercent + '%', background: detailStatusColor }"
></div>
</div>
<div class="mt-1.5 flex items-center justify-between gap-3 text-xs text-gray-500">
<span>进度 {{ detailTask.progress || '0%' }}</span>
<span v-if="detailTimingText">{{ detailTimingText }}</span>
</div>
</div>
<!-- 失败原因:紧跟抬头区,避免埋在字段末尾被忽略 -->
<div v-if="detailTask.fail_reason" class="flex items-start gap-2 rounded-xl border border-red-100 bg-red-50 px-4 py-3">
<Icon name="xCircle" size="sm" class="mt-0.5 shrink-0 text-red-500" />
<div class="min-w-0 text-sm text-red-700">
<p class="mb-1 font-semibold">任务失败</p>
<p class="break-all leading-relaxed">{{ detailTask.fail_reason }}</p>
</div>
</div>
<!-- Basic Info -->
<div>
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<Icon name="document" size="sm" class="text-primary-500" />
基本信息
</h4>
<div class="grid grid-cols-2 gap-x-6 gap-y-2.5 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">任务 ID</span>
<span class="font-mono text-xs">{{ detailTask.public_task_id }}</span>
<!-- 任务 ID 独占一行:等宽 + 单行省略,长 ID 不再折行挤乱右侧字段的对齐 -->
<div class="flex items-center gap-2 text-sm">
<span class="w-[60px] shrink-0 text-gray-500">任务 ID</span>
<span class="min-w-0 flex-1 truncate font-mono text-xs text-gray-700" :title="detailTask.public_task_id">
{{ detailTask.public_task_id }}
</span>
<button
class="shrink-0 text-gray-400 transition-colors hover:text-primary-500"
title="复制任务 ID"
@click="copyText(detailTask.public_task_id, 'task')"
>
<Icon :name="copiedKey === 'task' ? 'check' : 'copy'" size="xs" />
</button>
</div>
<!-- label 固定宽 + 值左对齐:两端对齐留下的中间空洞会让 label 与值难以对照 -->
<div class="mt-2.5 grid grid-cols-2 gap-x-6 gap-y-2.5 text-sm">
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">平台</span>
<span class="min-w-0 truncate text-gray-700">{{ platformLabel[detailTask.platform] || detailTask.platform || '-' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">状态</span>
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium" :class="statusBadge[detailTask.status] || 'bg-gray-100 text-gray-800'">
{{ statusLabel[detailTask.status] || detailTask.status }}
</span>
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">动作</span>
<span class="min-w-0 truncate text-gray-700">{{ detailTask.action || '-' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">平台</span>
<span class="text-sm">{{ platformLabel[detailTask.platform] || detailTask.platform }}</span>
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">模型</span>
<span class="min-w-0 truncate font-mono text-xs text-gray-700">{{ detailTask.model_name || '-' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">动作</span>
<span class="text-sm">{{ detailTask.action || '-' }}</span>
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">成员</span>
<span class="min-w-0 truncate text-gray-700">{{ detailTask.username || '-' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">模型</span>
<span class="text-sm font-mono">{{ detailTask.model_name }}</span>
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">提交时间</span>
<span class="min-w-0 truncate text-xs text-gray-700">{{ formatTime(detailTask.submit_time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">进度</span>
<span class="text-sm">{{ detailTask.progress || '-' }}</span>
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">完成时间</span>
<span class="min-w-0 truncate text-xs text-gray-700">{{ formatTime(detailTask.finish_time) }}</span>
</div>
<div v-if="detailTask.username" class="flex justify-between">
<span class="text-gray-500">用户</span>
<span class="text-sm">{{ detailTask.username }}</span>
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">创建时间</span>
<span class="min-w-0 truncate text-xs text-gray-700">{{ formatTime(detailTask.created_at) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">提交时间</span>
<span class="text-xs">{{ formatTime(detailTask.submit_time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">完成时间</span>
<span class="text-xs">{{ formatTime(detailTask.finish_time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">创建时间</span>
<span class="text-xs">{{ formatTime(detailTask.created_at) }}</span>
<div class="flex items-center gap-2">
<span class="w-[60px] shrink-0 text-gray-500">执行耗时</span>
<span class="min-w-0 truncate text-xs text-gray-700">{{ detailDurationText }}</span>
</div>
</div>
</div>
@@ -412,29 +614,44 @@ onMounted(() => {
<Icon name="creditCard" size="sm" class="text-primary-500" />
费用信息
</h4>
<div class="space-y-2 text-sm">
<div class="flex items-center justify-between">
<span class="text-gray-500">预扣金额</span>
<span>{{ formatCost(detailTask.pre_deduct_amount) }}</span>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-xl bg-gray-50 px-4 py-3">
<p class="text-xs text-gray-500">预扣金额</p>
<p class="mt-1 text-base font-semibold text-gray-800">{{ formatCost(detailTask.pre_deduct_amount) }}</p>
</div>
<div class="flex items-center justify-between border-t border-gray-200 pt-2 font-semibold">
<span class="text-gray-700">实际费用</span>
<span v-if="detailTask.billing_settled" class="text-emerald-600">{{ formatCost(detailTask.actual_cost) }}</span>
<span v-else class="text-gray-400">未结算</span>
<div class="rounded-xl bg-gray-50 px-4 py-3">
<div class="flex items-center gap-1.5">
<p class="text-xs text-gray-500">实际费用</p>
<span v-if="detailTask.billing_settled" class="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium leading-tight text-emerald-600">已结算</span>
</div>
<p v-if="detailTask.billing_settled" class="mt-1 text-base font-semibold text-emerald-600">
{{ formatCost(detailTask.actual_cost) }}
</p>
<p v-else class="mt-1 text-base font-semibold text-gray-400">未结算</p>
</div>
</div>
</div>
<!-- Result -->
<div v-if="detailTask.result_url">
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<Icon name="image" size="sm" class="text-primary-500" />
生成结果
</h4>
<div class="bg-gray-50 rounded-xl p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<h4 class="text-sm font-semibold text-gray-700 flex items-center gap-2">
<Icon :name="resultIconName" size="sm" class="text-primary-500" />
生成结果
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium leading-tight text-gray-500">{{ resultKindLabel }}</span>
</h4>
<div class="flex shrink-0 items-center gap-3 text-xs">
<a :href="detailTask.result_url" target="_blank" rel="noopener" class="text-primary-600 hover:text-primary-700">打开原文件</a>
<button class="text-primary-600 hover:text-primary-700" @click="downloadResult">下载</button>
<button class="text-primary-600 hover:text-primary-700" @click="copyText(detailTask.result_url || '', 'result')">
{{ copiedKey === 'result' ? '已复制' : '复制链接' }}
</button>
</div>
</div>
<div class="bg-gray-50 rounded-xl p-3">
<!-- 图片:内联展示缩略图(省流量),点击在新标签打开原图 -->
<a
v-if="isImageResult(detailTask.result_url)"
v-if="resultKind === 'image'"
:href="detailTask.result_url"
target="_blank"
rel="noopener"
@@ -446,27 +663,28 @@ onMounted(() => {
class="max-w-full rounded-lg cursor-zoom-in"
/>
</a>
<a v-else :href="detailTask.result_url" target="_blank" class="text-primary-600 hover:text-primary-700 text-sm break-all">
<!-- 视频/音频:内联播放,客户不必再另开签名链接 -->
<video
v-else-if="resultKind === 'video'"
:src="detailTask.result_url"
class="w-full max-h-[420px] rounded-lg bg-black"
controls
preload="metadata"
></video>
<audio
v-else-if="resultKind === 'audio'"
:src="detailTask.result_url"
class="w-full"
controls
preload="metadata"
></audio>
<a v-else :href="detailTask.result_url" target="_blank" rel="noopener" class="text-primary-600 hover:text-primary-700 text-sm break-all">
{{ detailTask.result_url }}
</a>
</div>
</div>
<!-- Fail Reason -->
<div v-if="detailTask.fail_reason">
<h4 class="text-sm font-semibold text-red-600 mb-3 flex items-center gap-2">
<Icon name="xCircle" size="sm" />
失败原因
</h4>
<div class="rounded-lg bg-red-50 border border-red-100 px-4 py-3 text-sm text-red-700">
{{ detailTask.fail_reason }}
</div>
</div>
</div>
<template #footer>
<button class="btn btn-secondary" @click="showDetail = false">关闭</button>
</template>
</BaseModal>
</div>
</template>
@@ -388,8 +388,8 @@ function downloadVideo() {
{{ duration }} 秒
</span>
</div>
<!-- min=4 覆盖 MiniMax-H3 的 4 秒档;其他模型档位由上游校验 -->
<n-input-number v-model:value="duration" :min="4" :max="15" :step="1" class="w-full" />
<!-- min=2 覆盖短时长档位模型;其他模型档位由上游校验 -->
<n-input-number v-model:value="duration" :min="2" :max="30" :step="1" class="w-full" />
</div>
<div class="flex items-start justify-between gap-3">
@@ -344,6 +344,7 @@ const categoryLabel: Record<string, string> = {
embedding: '嵌入',
image: '图像',
audio: '语音',
video: '视频',
rerank: '重排',
}
@@ -352,6 +353,7 @@ const categoryBadgeClass: Record<string, string> = {
embedding: 'badge-purple',
image: 'badge-warning',
audio: 'badge-success',
video: 'badge-purple',
rerank: 'badge-gray',
}