commit
2b8fe44895
41 files changed
+4277
-304
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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,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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 任务状态枚举
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user