feat(model):新增MiniMax-H3视频支持
This commit is contained in:
1 parent
26c53c2228
commit
5ce16daa46
29 files changed
+3736
-98
No files matched your search
@@ -0,0 +1,329 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
"github.com/qianfree/team-api/relay/constant"
|
||||
taskminimax "github.com/qianfree/team-api/relay/taskchannel/minimax"
|
||||
)
|
||||
|
||||
// MiniMax 官方视频协议(/v2)核心处理:查询回放、任务取消。
|
||||
// 协议规格见 docs/modeldocs/minimax/video/(02-端点参考.md、04-响应对象.md)。
|
||||
// 状态机:queued/running/succeeded/failed/cancelled(网关不主动产生 cancelled 任务;
|
||||
// 上游任务被取消时经轮询映射为 FAILURE,retrieve 回放为 failed)。
|
||||
// 错误用 OpenAI 风格 OaiError(writeTaskError 输出同构,官方 v2 即此风格)。
|
||||
//
|
||||
// DELETE 端点语义(有意与官方分歧):
|
||||
// - 仅支持取消 queued 任务(取消结果以远程返回为准,上游确认才生效);
|
||||
// - 不支持删除任务记录(官方对终态任务的 delete 记录能力网关不提供,软删也不做),
|
||||
// 网关保留任务数据用于计费与审计;
|
||||
// - 任务终态后 content.url 不再回源刷新(回放缓存的上游直链,有时效)。
|
||||
|
||||
// minimaxStatusFromTask 平台任务状态 → 官方状态机映射
|
||||
func minimaxStatusFromTask(status string) string {
|
||||
switch status {
|
||||
case "IN_PROGRESS":
|
||||
return "running"
|
||||
case "SUCCESS":
|
||||
return "succeeded"
|
||||
case "FAILURE":
|
||||
return "failed"
|
||||
default:
|
||||
// NOT_START / SUBMITTED / QUEUED 及一切未知中间态都归入 queued
|
||||
return "queued"
|
||||
}
|
||||
}
|
||||
|
||||
// isMiniMaxTerminalTask 判断是否终态(succeeded/failed 对应的平台态)
|
||||
func isMiniMaxTerminalTask(status string) bool {
|
||||
return status == string(common.TaskStatusSuccess) || status == string(common.TaskStatusFailure)
|
||||
}
|
||||
|
||||
// parseMiniMaxUpstreamTask 解析 task.Data 中缓存的上游单任务查询响应。
|
||||
// 提交后首拍轮询前 Data 是提交响应 {"task_id":...} 形态,解析不出 task 时返回 nil(静默降级)。
|
||||
func parseMiniMaxUpstreamTask(data []byte) *taskminimax.VideoTask {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
var resp taskminimax.TaskQueryResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil || resp.Task == nil {
|
||||
return nil
|
||||
}
|
||||
return resp.Task
|
||||
}
|
||||
|
||||
// buildMiniMaxVideoTask 平台任务 → 官方 VideoTask 对象。
|
||||
// 任务记录是 id/status/model/时间戳的真相源;usage/resolution/duration/ratio 从
|
||||
// 缓存的上游查询响应(task.Data)富集,不可解析时缺省不输出。
|
||||
func buildMiniMaxVideoTask(task *common.AsyncTask) *taskminimax.VideoTask {
|
||||
obj := &taskminimax.VideoTask{
|
||||
ID: task.PublicTaskID,
|
||||
Model: task.ModelName,
|
||||
Status: minimaxStatusFromTask(task.Status),
|
||||
CreatedAt: task.CreatedAt.Unix(),
|
||||
UpdatedAt: task.UpdatedAt.Unix(),
|
||||
TaskType: "generation",
|
||||
Modality: "video",
|
||||
}
|
||||
|
||||
if obj.Status == "failed" {
|
||||
message := task.FailReason
|
||||
if message == "" {
|
||||
message = "video generation failed"
|
||||
}
|
||||
obj.Error = &taskminimax.VideoTaskError{Code: "video_generation_failed", Message: message}
|
||||
}
|
||||
if task.ResultURL != "" {
|
||||
obj.Content = &taskminimax.VideoTaskContent{URL: task.ResultURL}
|
||||
}
|
||||
|
||||
if upstream := parseMiniMaxUpstreamTask(task.Data); upstream != nil {
|
||||
if upstream.Usage != nil {
|
||||
obj.Usage = upstream.Usage
|
||||
}
|
||||
if upstream.Resolution != "" {
|
||||
obj.Resolution = upstream.Resolution
|
||||
}
|
||||
if upstream.Duration > 0 {
|
||||
obj.Duration = upstream.Duration
|
||||
}
|
||||
if upstream.Ratio != "" {
|
||||
obj.Ratio = upstream.Ratio
|
||||
}
|
||||
// 成品直链以任务记录的 ResultURL 为准(轮询结算时写入),上游回放仅兜底
|
||||
if obj.Content == nil && upstream.Content != nil {
|
||||
obj.Content = upstream.Content
|
||||
}
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// writeMiniMaxSubmitResponse 提交成功响应:官方 VideoGenerationV2Resp 形态
|
||||
func writeMiniMaxSubmitResponse(w http.ResponseWriter, publicTaskID string) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"task_id": publicTaskID})
|
||||
}
|
||||
|
||||
// fetchMiniMaxTaskById 查询任务并做租户双键隔离 + 平台门禁。
|
||||
// 平台门禁:/v2 端点只服务 minimax 平台任务,防止异构平台任务 ID 被塞进官方响应结构回放。
|
||||
func fetchMiniMaxTaskById(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
rc *TaskRelayContext,
|
||||
dataProvider common.TaskDataProvider,
|
||||
) (*common.AsyncTask, bool) {
|
||||
task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, taskID, rc.UserID, rc.TenantID)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "MiniMaxVideo: query task failed, taskID=%s, err=%v", taskID, err)
|
||||
writeTaskError(rc.Writer, http.StatusInternalServerError, "query task failed", "")
|
||||
return nil, false
|
||||
}
|
||||
if task == nil || task.Platform != string(constant.TaskPlatformMiniMax) {
|
||||
writeTaskError(rc.Writer, http.StatusNotFound, "No MiniMax video task found with id '"+taskID+"'.", "")
|
||||
return nil, false
|
||||
}
|
||||
return task, true
|
||||
}
|
||||
|
||||
// HandleMiniMaxVideoRetrieve GET /v2/query/video_generation/{task_id} 查询管线。
|
||||
// 响应为官方 GetVideoGenerationV2Resp 形态({"task": {...}} 外层包装)。
|
||||
func HandleMiniMaxVideoRetrieve(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
rc *TaskRelayContext,
|
||||
dataProvider common.TaskDataProvider,
|
||||
) {
|
||||
task, ok := fetchMiniMaxTaskById(ctx, taskID, rc, dataProvider)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(rc.Writer, http.StatusOK, taskminimax.TaskQueryResponse{Task: buildMiniMaxVideoTask(task)})
|
||||
}
|
||||
|
||||
// HandleMiniMaxVideoCancel DELETE /v2/video_generation/{task_id} 取消管线。
|
||||
// 仅支持取消排队中的任务,不支持删除任务记录(含软删,网关保留计费与审计数据)。
|
||||
//
|
||||
// 取消结果以远程返回为准:调用上游 DELETE,上游确认 action=cancelled 才向客户端
|
||||
// 返回成功;上游拒绝(如任务已进入 running)时原样透传上游错误。
|
||||
// 本地任务状态不在此处改写——轮询循环下一拍从上游查询到 cancelled → FAILURE → 退款,
|
||||
// 预扣退款与活跃任务计数等记账全部走既有轮询链路,避免在本端点重复实现结算。
|
||||
func HandleMiniMaxVideoCancel(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
rc *TaskRelayContext,
|
||||
dataProvider common.TaskDataProvider,
|
||||
) {
|
||||
task, ok := fetchMiniMaxTaskById(ctx, taskID, rc, dataProvider)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 终态任务既不可取消也不可删除(删除能力网关不提供)
|
||||
if isMiniMaxTerminalTask(task.Status) {
|
||||
writeTaskError(rc.Writer, http.StatusBadRequest,
|
||||
"task is already completed; only queued tasks can be cancelled", "task_not_cancellable")
|
||||
return
|
||||
}
|
||||
// 运行中的任务上游不允许取消,前置拦截省一次上游调用(本地状态若滞后,由上游裁决兜底)
|
||||
if task.Status == string(common.TaskStatusInProgress) {
|
||||
writeTaskError(rc.Writer, http.StatusBadRequest, "running tasks cannot be cancelled", "task_not_cancellable")
|
||||
return
|
||||
}
|
||||
// v1(Hailuo 系列)上游没有取消端点
|
||||
upstreamModel := task.UpstreamModel
|
||||
if upstreamModel == "" {
|
||||
upstreamModel = task.ModelName
|
||||
}
|
||||
if !taskminimax.IsV2Model(upstreamModel) {
|
||||
writeTaskError(rc.Writer, http.StatusBadRequest,
|
||||
"v1 video tasks cannot be cancelled; the MiniMax v1 API has no cancel endpoint", "task_not_cancellable")
|
||||
return
|
||||
}
|
||||
|
||||
// 提取上游任务 ID
|
||||
var pd struct {
|
||||
UpstreamTaskID string `json:"upstream_task_id"`
|
||||
}
|
||||
if err := json.Unmarshal(task.PrivateData, &pd); err != nil || pd.UpstreamTaskID == "" {
|
||||
g.Log().Errorf(ctx, "MiniMaxVideoCancel: invalid private data, taskID=%s, err=%v", taskID, err)
|
||||
writeTaskError(rc.Writer, http.StatusInternalServerError, "invalid task data", "")
|
||||
return
|
||||
}
|
||||
|
||||
// 取渠道信息(上游地址与密钥),对齐 HandleMjImageProxy 的任务→渠道回查模式
|
||||
channel, err := dataProvider.GetChannelByID(ctx, task.ChannelID)
|
||||
if err != nil || channel == nil {
|
||||
g.Log().Errorf(ctx, "MiniMaxVideoCancel: channel not found, channelID=%d, err=%v", task.ChannelID, err)
|
||||
writeTaskError(rc.Writer, http.StatusInternalServerError, "channel not found", "")
|
||||
return
|
||||
}
|
||||
|
||||
result, taskErr := taskminimax.CancelTask(channel.BaseURL, channel.ApiKey, pd.UpstreamTaskID, parseMiniMaxUseProxy(channel.Settings))
|
||||
if taskErr != nil {
|
||||
// 远程取消失败(含上游判定运行中/已结束不可取消):以远程结论为准
|
||||
g.Log().Infof(ctx, "MiniMaxVideoCancel: upstream rejected, taskID=%s, status=%d, message=%q", taskID, taskErr.StatusCode, taskErr.Message)
|
||||
writeTaskError(rc.Writer, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
|
||||
return
|
||||
}
|
||||
// 防御竞态:本地检查后任务已在上游完成时,上游会执行删记录(action=deleted)而非取消
|
||||
if result.Action != "cancelled" {
|
||||
g.Log().Infof(ctx, "MiniMaxVideoCancel: upstream action=%s (not cancelled), taskID=%s", result.Action, taskID)
|
||||
writeTaskError(rc.Writer, http.StatusBadRequest,
|
||||
"task is no longer queued; only queued tasks can be cancelled", "task_not_cancellable")
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "MiniMaxVideoCancel: task cancelled upstream, taskID=%s, upstreamTaskID=%s, tenant=%d, user=%d",
|
||||
taskID, pd.UpstreamTaskID, rc.TenantID, rc.UserID)
|
||||
|
||||
// 官方 DeleteVideoGenerationV2Resp 形态(取消分支)
|
||||
writeJSON(rc.Writer, http.StatusOK, map[string]any{
|
||||
"task_id": task.PublicTaskID,
|
||||
"action": "cancelled",
|
||||
"status": "cancelled",
|
||||
})
|
||||
}
|
||||
|
||||
// parseMiniMaxUseProxy 从渠道 settings JSONB 解析代理开关
|
||||
func parseMiniMaxUseProxy(settings json.RawMessage) bool {
|
||||
var s struct {
|
||||
UseProxy bool `json:"use_proxy"`
|
||||
}
|
||||
if len(settings) > 0 {
|
||||
_ = json.Unmarshal(settings, &s)
|
||||
}
|
||||
return s.UseProxy
|
||||
}
|
||||
|
||||
// ==================== v1(Hailuo 系列)官方协议 ====================
|
||||
|
||||
// minimaxV1StatusFromTask 平台任务状态 → v1 官方状态机映射
|
||||
// (Preparing/Queueing/Processing/Success/Fail)
|
||||
func minimaxV1StatusFromTask(status string) string {
|
||||
switch status {
|
||||
case "QUEUED":
|
||||
return "Queueing"
|
||||
case "IN_PROGRESS":
|
||||
return "Processing"
|
||||
case "SUCCESS":
|
||||
return "Success"
|
||||
case "FAILURE":
|
||||
return "Fail"
|
||||
default:
|
||||
// NOT_START / SUBMITTED 及未知早期状态
|
||||
return "Preparing"
|
||||
}
|
||||
}
|
||||
|
||||
// writeMiniMaxV1SubmitResponse v1 提交成功响应:官方 VideoGenerationResp 形态
|
||||
// (含 base_resp 成功信封)
|
||||
func writeMiniMaxV1SubmitResponse(w http.ResponseWriter, publicTaskID string) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"task_id": publicTaskID,
|
||||
"base_resp": map[string]any{"status_code": 0, "status_msg": "success"},
|
||||
})
|
||||
}
|
||||
|
||||
// parseMiniMaxV1UpstreamData 解析 task.Data 中缓存的上游 v1 查询响应(含 FetchTask
|
||||
// 二跳合并的 download_url)。非 v1 形态(v2 的 {"task":...} 包装或提交响应)返回 nil。
|
||||
func parseMiniMaxV1UpstreamData(data []byte) map[string]any {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
if _, hasTask := m["task"]; hasTask {
|
||||
return nil // v2 形态
|
||||
}
|
||||
if _, hasStatus := m["status"]; !hasStatus {
|
||||
return nil // 提交响应等非查询形态
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// HandleMiniMaxVideoV1Retrieve GET /v1/query/video_generation?task_id= 查询管线。
|
||||
// 响应为官方 QueryVideoGenerationTaskResp 形态;附加 download_url(FetchTask 二跳解析的
|
||||
// 限时直链,官方查询响应无此字段,属网关增补)与 error(失败原因)两个增补字段,
|
||||
// 官方 SDK 忽略未知字段,不影响兼容。
|
||||
func HandleMiniMaxVideoV1Retrieve(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
rc *TaskRelayContext,
|
||||
dataProvider common.TaskDataProvider,
|
||||
) {
|
||||
task, ok := fetchMiniMaxTaskById(ctx, taskID, rc, dataProvider)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"task_id": task.PublicTaskID,
|
||||
"status": minimaxV1StatusFromTask(task.Status),
|
||||
"base_resp": map[string]any{"status_code": 0, "status_msg": "success"},
|
||||
}
|
||||
if task.Status == string(common.TaskStatusFailure) && task.FailReason != "" {
|
||||
resp["error"] = task.FailReason
|
||||
}
|
||||
|
||||
// 从缓存的上游查询响应富集 file_id/video_width/video_height/download_url
|
||||
if upstream := parseMiniMaxV1UpstreamData(task.Data); upstream != nil {
|
||||
for _, key := range []string{"file_id", "video_width", "video_height", "download_url"} {
|
||||
if v, has := upstream[key]; has {
|
||||
resp[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
// 成品直链以任务记录的 ResultURL 为准(轮询结算时写入),上游回放仅兜底
|
||||
if task.ResultURL != "" {
|
||||
resp["download_url"] = task.ResultURL
|
||||
}
|
||||
|
||||
writeJSON(rc.Writer, http.StatusOK, resp)
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
)
|
||||
|
||||
// MiniMax 官方视频协议(POST /v2/video_generation)入站转换层。
|
||||
// 原生请求体解析/校验后转成任务框架通用请求体,复用任务提交管线
|
||||
// (selectTaskChannel 渠道调度 + HandleTaskSubmit 计费/提交/落库)。
|
||||
//
|
||||
// 转换产物在保留原生字段(content[]/resolution/duration/ratio/aigc_watermark)之外,
|
||||
// 双写通用词汇(prompt/seconds/images[]/metadata)——参数倍率规则的匹配源是归一化
|
||||
// 任务体(「规则跟语义走不跟入口协议走」,见 internal/logic/billing/param_multiplier.go),
|
||||
// 双写保证按通用路径配置的倍率规则对官方协议入站同样生效。
|
||||
|
||||
// minimaxVideoProtocol TaskRelayContext.Protocol 的 MiniMax 官方视频协议标识
|
||||
const minimaxVideoProtocol = "minimax_native"
|
||||
|
||||
// minimaxVideoCreateRequest MiniMax 官方创建请求的归一形态(JSON 解析后的产物)
|
||||
type minimaxVideoCreateRequest struct {
|
||||
Model string
|
||||
Prompt string // content[] 中首个非空 text 项(派生,供计费倍率匹配与审计)
|
||||
Content []any
|
||||
Resolution string
|
||||
Duration int
|
||||
Ratio string
|
||||
AigcWatermark *bool
|
||||
}
|
||||
|
||||
// minimaxVideoRequestEcho 提交时落 PrivateData.request_echo 的请求回显,
|
||||
// retrieve 时用于回显 prompt/resolution/duration/ratio(任务未轮询到上游数据前的兜底)
|
||||
type minimaxVideoRequestEcho struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Ratio string `json:"ratio,omitempty"`
|
||||
}
|
||||
|
||||
// jsonMiniMaxVideoCreateRequest JSON 编码的官方创建请求结构。
|
||||
// duration 兼容 number 与 string;content 保留 RawMessage 以校验数组形态。
|
||||
type jsonMiniMaxVideoCreateRequest struct {
|
||||
Model string `json:"model"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
Resolution string `json:"resolution"`
|
||||
Duration json.RawMessage `json:"duration"`
|
||||
Ratio string `json:"ratio"`
|
||||
AigcWatermark *bool `json:"aigc_watermark"`
|
||||
}
|
||||
|
||||
// ParseMiniMaxVideoCreateRequest 解析官方创建请求。
|
||||
// 返回的 TaskError 已携带面向客户端的 HTTP 状态码与错误信息。
|
||||
func ParseMiniMaxVideoCreateRequest(body []byte) (*minimaxVideoCreateRequest, *common.TaskError) {
|
||||
var req jsonMiniMaxVideoCreateRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body: " + err.Error()}
|
||||
}
|
||||
|
||||
out := &minimaxVideoCreateRequest{
|
||||
Model: strings.TrimSpace(req.Model),
|
||||
Resolution: strings.TrimSpace(req.Resolution),
|
||||
Ratio: strings.TrimSpace(req.Ratio),
|
||||
AigcWatermark: req.AigcWatermark,
|
||||
}
|
||||
|
||||
// content 必须是数组
|
||||
if len(req.Content) > 0 && string(req.Content) != "null" {
|
||||
var content []any
|
||||
if err := json.Unmarshal(req.Content, &content); err != nil {
|
||||
return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid content: expected an array"}
|
||||
}
|
||||
out.Content = content
|
||||
}
|
||||
|
||||
// duration 兼容 number(官方 schema)与 string(宽松兼容)
|
||||
duration, terr := parseMiniMaxDuration(req.Duration)
|
||||
if terr != nil {
|
||||
return nil, terr
|
||||
}
|
||||
out.Duration = duration
|
||||
|
||||
// 派生 prompt:content[] 中首个非空 text 项
|
||||
for _, item := range out.Content {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if t, _ := m["type"].(string); t == "text" {
|
||||
if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" {
|
||||
out.Prompt = text
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseMiniMaxDuration 解析 duration 字段(integer 或数字字符串,须为正数)
|
||||
func parseMiniMaxDuration(raw json.RawMessage) (int, *common.TaskError) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return 0, nil
|
||||
}
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(raw, &n); err == nil {
|
||||
if d, err := strconv.Atoi(n.String()); err == nil {
|
||||
return d, nil
|
||||
}
|
||||
// 小数秒向下取整(官方档位均为整数,宽松处理)
|
||||
if f, err := strconv.ParseFloat(n.String(), 64); err == nil && f > 0 {
|
||||
return int(f), nil
|
||||
}
|
||||
}
|
||||
return 0, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid duration field: must be a positive integer"}
|
||||
}
|
||||
|
||||
// ValidateMiniMaxVideoCreateRequest 请求必填校验(对齐官方 schema required:
|
||||
// model / content(含非空 text 项)/ resolution / duration;数值档位差异交给上游裁决)
|
||||
func ValidateMiniMaxVideoCreateRequest(req *minimaxVideoCreateRequest) *common.TaskError {
|
||||
if req.Model == "" {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"}
|
||||
}
|
||||
if len(req.Content) == 0 {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "content is required"}
|
||||
}
|
||||
if req.Prompt == "" {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "content must contain a non-empty text item"}
|
||||
}
|
||||
if req.Resolution == "" {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "resolution is required"}
|
||||
}
|
||||
if req.Duration <= 0 {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "duration is required"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildMiniMaxVideoTaskBody 把官方创建请求转成任务框架通用请求体(JSON 字节)。
|
||||
// callback_url 不进入通用任务体(网关轮询计费,上游回调与网关 ID 体系不兼容)。
|
||||
func BuildMiniMaxVideoTaskBody(req *minimaxVideoCreateRequest) ([]byte, error) {
|
||||
body := map[string]any{
|
||||
"model": req.Model,
|
||||
"prompt": req.Prompt,
|
||||
"content": req.Content,
|
||||
"resolution": req.Resolution,
|
||||
"seconds": strconv.Itoa(req.Duration), // 通用词汇双写(kling/volcengine 消费同一键)
|
||||
"duration": req.Duration,
|
||||
"metadata": map[string]any{
|
||||
"resolution": req.Resolution,
|
||||
"duration": req.Duration,
|
||||
},
|
||||
}
|
||||
if req.Ratio != "" {
|
||||
body["ratio"] = req.Ratio
|
||||
body["metadata"].(map[string]any)["ratio"] = req.Ratio
|
||||
}
|
||||
if req.AigcWatermark != nil {
|
||||
body["aigc_watermark"] = *req.AigcWatermark
|
||||
}
|
||||
// images[] 双写:content 中首帧/无 role 的 image_url 项提取 URL(图生视频判定与参数倍率用)
|
||||
if images := extractMiniMaxFrameImages(req.Content); len(images) > 0 {
|
||||
body["images"] = images
|
||||
}
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal minimax video task body: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// extractMiniMaxFrameImages 从 content[] 提取图片 URL(role=first_frame 或未标 role 的单图)
|
||||
func extractMiniMaxFrameImages(content []any) []string {
|
||||
images := make([]string, 0, 2)
|
||||
for _, item := range content {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if t, _ := m["type"].(string); t != "image_url" {
|
||||
continue
|
||||
}
|
||||
role, _ := m["role"].(string)
|
||||
if role != "" && role != "first_frame" {
|
||||
continue
|
||||
}
|
||||
if media, ok := m["image_url"].(map[string]any); ok {
|
||||
if url, ok := media["url"].(string); ok && url != "" {
|
||||
images = append(images, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
// NewMiniMaxRequestEcho 从归一请求构造提交回显(胶水层经 TaskRelayContext.RequestEcho 传入,
|
||||
// 提交时随 PrivateData 落库,retrieve 时在上游数据未就绪前回显)
|
||||
func NewMiniMaxRequestEcho(req *minimaxVideoCreateRequest) minimaxVideoRequestEcho {
|
||||
return minimaxVideoRequestEcho{
|
||||
Prompt: req.Prompt,
|
||||
Resolution: req.Resolution,
|
||||
Duration: req.Duration,
|
||||
Ratio: req.Ratio,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== v1(Hailuo 系列)官方协议入站 ====================
|
||||
|
||||
// minimaxVideoV1Protocol TaskRelayContext.Protocol 的 MiniMax v1 官方视频协议标识
|
||||
const minimaxVideoV1Protocol = "minimax_native_v1"
|
||||
|
||||
// minimaxVideoV1CreateRequest v1 官方创建请求的归一形态(扁平字段协议,无 content[])。
|
||||
// resolution/duration 官方可选(有默认值),零值表示未指定。
|
||||
type minimaxVideoV1CreateRequest struct {
|
||||
Model string
|
||||
Prompt string
|
||||
FirstFrameImage string
|
||||
LastFrameImage string
|
||||
SubjectReference json.RawMessage
|
||||
Duration int
|
||||
Resolution string
|
||||
PromptOptimizer *bool
|
||||
FastPretreatment *bool
|
||||
AigcWatermark *bool
|
||||
}
|
||||
|
||||
// minimaxVideoV1RequestEcho v1 提交回显(任务未轮询到上游数据前的兜底)
|
||||
type minimaxVideoV1RequestEcho struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// ParseMiniMaxVideoV1CreateRequest 解析 v1 官方创建请求(POST /v1/video_generation,扁平字段)。
|
||||
// 文生/图生/首尾帧/主体参考四种形态共用端点,按字段自然区分,duration 兼容 number 与 string。
|
||||
func ParseMiniMaxVideoV1CreateRequest(body []byte) (*minimaxVideoV1CreateRequest, *common.TaskError) {
|
||||
var raw struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
FirstFrameImage string `json:"first_frame_image"`
|
||||
LastFrameImage string `json:"last_frame_image"`
|
||||
SubjectReference json.RawMessage `json:"subject_reference"`
|
||||
Duration json.RawMessage `json:"duration"`
|
||||
Resolution string `json:"resolution"`
|
||||
PromptOptimizer *bool `json:"prompt_optimizer"`
|
||||
FastPretreatment *bool `json:"fast_pretreatment"`
|
||||
AigcWatermark *bool `json:"aigc_watermark"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body: " + err.Error()}
|
||||
}
|
||||
|
||||
duration, terr := parseMiniMaxDuration(raw.Duration)
|
||||
if terr != nil {
|
||||
return nil, terr
|
||||
}
|
||||
|
||||
return &minimaxVideoV1CreateRequest{
|
||||
Model: strings.TrimSpace(raw.Model),
|
||||
Prompt: raw.Prompt,
|
||||
FirstFrameImage: strings.TrimSpace(raw.FirstFrameImage),
|
||||
LastFrameImage: strings.TrimSpace(raw.LastFrameImage),
|
||||
SubjectReference: raw.SubjectReference,
|
||||
Duration: duration,
|
||||
Resolution: strings.TrimSpace(raw.Resolution),
|
||||
PromptOptimizer: raw.PromptOptimizer,
|
||||
FastPretreatment: raw.FastPretreatment,
|
||||
AigcWatermark: raw.AigcWatermark,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateMiniMaxVideoV1CreateRequest 请求必填校验(对齐官方四形态约束):
|
||||
// model 必填;prompt(文生)/ first_frame_image(图生)/ subject_reference(主体参考)至少其一。
|
||||
func ValidateMiniMaxVideoV1CreateRequest(req *minimaxVideoV1CreateRequest) *common.TaskError {
|
||||
if req.Model == "" {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"}
|
||||
}
|
||||
if strings.TrimSpace(req.Prompt) == "" && req.FirstFrameImage == "" && len(req.SubjectReference) == 0 {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest,
|
||||
Message: "one of prompt / first_frame_image / subject_reference is required"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildMiniMaxVideoV1TaskBody 把 v1 官方创建请求转成任务框架通用请求体。
|
||||
// callback_url 不进入任务体(网关轮询计费,上游回调与网关 ID 体系不兼容);
|
||||
// 同时双写通用词汇(seconds/images[]/metadata),保证按通用路径配置的参数倍率规则对 v1 入站同样生效。
|
||||
func BuildMiniMaxVideoV1TaskBody(req *minimaxVideoV1CreateRequest) ([]byte, error) {
|
||||
body := map[string]any{
|
||||
"model": req.Model,
|
||||
}
|
||||
if strings.TrimSpace(req.Prompt) != "" {
|
||||
body["prompt"] = req.Prompt
|
||||
}
|
||||
if req.FirstFrameImage != "" {
|
||||
body["first_frame_image"] = req.FirstFrameImage
|
||||
body["images"] = []string{req.FirstFrameImage}
|
||||
}
|
||||
if req.LastFrameImage != "" {
|
||||
body["last_frame_image"] = req.LastFrameImage
|
||||
}
|
||||
if len(req.SubjectReference) > 0 && string(req.SubjectReference) != "null" {
|
||||
body["subject_reference"] = req.SubjectReference
|
||||
}
|
||||
if req.Resolution != "" {
|
||||
body["resolution"] = req.Resolution
|
||||
}
|
||||
if req.Duration > 0 {
|
||||
body["duration"] = req.Duration
|
||||
body["seconds"] = strconv.Itoa(req.Duration)
|
||||
}
|
||||
if req.PromptOptimizer != nil {
|
||||
body["prompt_optimizer"] = *req.PromptOptimizer
|
||||
}
|
||||
if req.FastPretreatment != nil {
|
||||
body["fast_pretreatment"] = *req.FastPretreatment
|
||||
}
|
||||
if req.AigcWatermark != nil {
|
||||
body["aigc_watermark"] = *req.AigcWatermark
|
||||
}
|
||||
|
||||
// 通用词汇双写(metadata 键)
|
||||
metadata := map[string]any{}
|
||||
if req.Resolution != "" {
|
||||
metadata["resolution"] = req.Resolution
|
||||
}
|
||||
if req.Duration > 0 {
|
||||
metadata["duration"] = req.Duration
|
||||
}
|
||||
if req.FirstFrameImage != "" {
|
||||
metadata["image"] = req.FirstFrameImage
|
||||
}
|
||||
if len(metadata) > 0 {
|
||||
body["metadata"] = metadata
|
||||
}
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal minimax v1 video task body: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// NewMiniMaxV1RequestEcho 从归一请求构造 v1 提交回显
|
||||
func NewMiniMaxV1RequestEcho(req *minimaxVideoV1CreateRequest) minimaxVideoV1RequestEcho {
|
||||
return minimaxVideoV1RequestEcho{
|
||||
Prompt: req.Prompt,
|
||||
Resolution: req.Resolution,
|
||||
Duration: req.Duration,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// MiniMax 官方创建请求的解析 / 校验 / 通用任务体转换单测。
|
||||
|
||||
func TestParseMiniMaxVideoCreateRequest_Full(t *testing.T) {
|
||||
body := `{
|
||||
"model": "MiniMax-H3",
|
||||
"content": [
|
||||
{"type": "text", "text": "海浪拍打礁石"},
|
||||
{"type": "image_url", "image_url": {"url": "https://cdn.example/a.png"}, "role": "first_frame"}
|
||||
],
|
||||
"resolution": "2K",
|
||||
"duration": 8,
|
||||
"ratio": "16:9",
|
||||
"aigc_watermark": true,
|
||||
"callback_url": "https://example.com/cb"
|
||||
}`
|
||||
req, taskErr := ParseMiniMaxVideoCreateRequest([]byte(body))
|
||||
if taskErr != nil {
|
||||
t.Fatalf("parse: %+v", taskErr)
|
||||
}
|
||||
if req.Model != "MiniMax-H3" || req.Prompt != "海浪拍打礁石" || req.Resolution != "2K" || req.Duration != 8 || req.Ratio != "16:9" {
|
||||
t.Fatalf("parsed fields: %+v", req)
|
||||
}
|
||||
if req.AigcWatermark == nil || !*req.AigcWatermark {
|
||||
t.Fatalf("aigc_watermark: %v", req.AigcWatermark)
|
||||
}
|
||||
if len(req.Content) != 2 {
|
||||
t.Fatalf("content items: %d", len(req.Content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMiniMaxVideoCreateRequest_DurationString(t *testing.T) {
|
||||
// duration 宽松兼容字符串数字
|
||||
req, taskErr := ParseMiniMaxVideoCreateRequest([]byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"}],"resolution":"768P","duration":"6"}`))
|
||||
if taskErr != nil || req.Duration != 6 {
|
||||
t.Fatalf("duration string parse: %+v err=%+v", req, taskErr)
|
||||
}
|
||||
// 非法 duration 报 400
|
||||
if _, taskErr := ParseMiniMaxVideoCreateRequest([]byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"}],"resolution":"768P","duration":"abc"}`)); taskErr == nil {
|
||||
t.Fatal("expected error for invalid duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMiniMaxVideoCreateRequest_InvalidContent(t *testing.T) {
|
||||
if _, taskErr := ParseMiniMaxVideoCreateRequest([]byte(`{"model":"MiniMax-H3","content":{"type":"text"},"resolution":"768P","duration":6}`)); taskErr == nil {
|
||||
t.Fatal("expected error when content is not an array")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMiniMaxVideoCreateRequest(t *testing.T) {
|
||||
valid := &minimaxVideoCreateRequest{
|
||||
Model: "MiniMax-H3", Prompt: "hi", Content: []any{map[string]any{"type": "text", "text": "hi"}},
|
||||
Resolution: "768P", Duration: 6,
|
||||
}
|
||||
if taskErr := ValidateMiniMaxVideoCreateRequest(valid); taskErr != nil {
|
||||
t.Fatalf("valid request should pass: %+v", taskErr)
|
||||
}
|
||||
|
||||
cases := map[string]*minimaxVideoCreateRequest{
|
||||
"missing model": {Prompt: "hi", Resolution: "768P", Duration: 6},
|
||||
"missing content": {Model: "MiniMax-H3", Prompt: "hi", Resolution: "768P", Duration: 6},
|
||||
"missing text item": {Model: "MiniMax-H3", Resolution: "768P", Duration: 6},
|
||||
"missing resolution": {Model: "MiniMax-H3", Prompt: "hi", Duration: 6},
|
||||
"missing duration": {Model: "MiniMax-H3", Prompt: "hi", Resolution: "768P"},
|
||||
}
|
||||
for name, req := range cases {
|
||||
if taskErr := ValidateMiniMaxVideoCreateRequest(req); taskErr == nil {
|
||||
t.Errorf("%s: expected 400", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMiniMaxVideoTaskBody(t *testing.T) {
|
||||
req := &minimaxVideoCreateRequest{
|
||||
Model: "MiniMax-H3",
|
||||
Prompt: "海浪拍打礁石",
|
||||
Content: []any{
|
||||
map[string]any{"type": "text", "text": "海浪拍打礁石"},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://cdn.example/a.png"}, "role": "first_frame"},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://cdn.example/ref.png"}, "role": "reference_image"},
|
||||
},
|
||||
Resolution: "768P",
|
||||
Duration: 6,
|
||||
Ratio: "16:9",
|
||||
}
|
||||
body, err := BuildMiniMaxVideoTaskBody(req)
|
||||
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)
|
||||
}
|
||||
|
||||
// 原生字段保留
|
||||
if m["model"] != "MiniMax-H3" || m["prompt"] != "海浪拍打礁石" || m["resolution"] != "768P" || m["duration"] != float64(6) || m["ratio"] != "16:9" {
|
||||
t.Fatalf("native fields: %v", m)
|
||||
}
|
||||
if _, has := m["callback_url"]; has {
|
||||
t.Fatal("callback_url must never enter the task body")
|
||||
}
|
||||
|
||||
// 通用词汇双写
|
||||
if m["seconds"] != "6" {
|
||||
t.Fatalf("seconds dual-write: %v", m["seconds"])
|
||||
}
|
||||
images, _ := m["images"].([]any)
|
||||
// 只提取 first_frame / 无 role 的图片,reference_image 不算图生视频输入
|
||||
if len(images) != 1 || images[0] != "https://cdn.example/a.png" {
|
||||
t.Fatalf("images dual-write: %v", m["images"])
|
||||
}
|
||||
meta, _ := m["metadata"].(map[string]any)
|
||||
if meta["resolution"] != "768P" || meta["duration"] != float64(6) || meta["ratio"] != "16:9" {
|
||||
t.Fatalf("metadata dual-write: %v", m["metadata"])
|
||||
}
|
||||
// content 原样保留(3 项)
|
||||
if content, _ := m["content"].([]any); len(content) != 3 {
|
||||
t.Fatalf("content preserved: %v", m["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMiniMaxVideoTaskBody_Minimal(t *testing.T) {
|
||||
// 无 ratio / 无图片:ratio 与 images 键不产出
|
||||
req := &minimaxVideoCreateRequest{
|
||||
Model: "MiniMax-H3", Prompt: "hi",
|
||||
Content: []any{map[string]any{"type": "text", "text": "hi"}},
|
||||
Resolution: "768P", Duration: 4,
|
||||
}
|
||||
body, err := BuildMiniMaxVideoTaskBody(req)
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
if _, has := m["ratio"]; has {
|
||||
t.Fatalf("ratio should be absent when not specified: %v", m)
|
||||
}
|
||||
if _, has := m["images"]; has {
|
||||
t.Fatalf("images should be absent without image input: %v", m)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 端到端集成测试:MiniMax 官方协议(/v2)与 OpenAI Videos 协议(/v1/videos)→ 转换 →
|
||||
// 任务提交管线(minimax 适配器 + mock 上游)→ {"task_id"} 响应 + minimax 平台任务落库 +
|
||||
// 上游收到映射后的 v2 参数。
|
||||
|
||||
// newMiniMaxUpstream mock MiniMax v2 上游:记录提交请求体,返回官方提交响应
|
||||
func newMiniMaxUpstream(t *testing.T) (*httptest.Server, *map[string]any) {
|
||||
t.Helper()
|
||||
received := map[string]any{}
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v2/video_generation" {
|
||||
t.Errorf("unexpected upstream path: %s", r.URL.Path)
|
||||
}
|
||||
if auth := r.Header.Get("Authorization"); auth != "Bearer sk-test" {
|
||||
t.Errorf("unexpected authorization: %q", auth)
|
||||
}
|
||||
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{"task_id": "424010985738629"})
|
||||
}))
|
||||
return upstream, &received
|
||||
}
|
||||
|
||||
func miniMaxChannelMeta(baseURL string) *common.ChannelMeta {
|
||||
return &common.ChannelMeta{
|
||||
ChannelID: 21,
|
||||
ChannelType: int(constant.ProviderMiniMax),
|
||||
ChannelName: "mock-minimax",
|
||||
BaseURL: baseURL,
|
||||
ApiKey: "sk-test",
|
||||
UpstreamModelName: "MiniMax-H3",
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTaskSubmit_MiniMaxNativeProtocol_FullPipeline 官方协议全链路:
|
||||
// 原生 content[] 透传、callback_url 剥离、{"task_id"} 响应、minimax 平台落库、请求回显持久化。
|
||||
func TestHandleTaskSubmit_MiniMaxNativeProtocol_FullPipeline(t *testing.T) {
|
||||
upstream, received := newMiniMaxUpstream(t)
|
||||
defer upstream.Close()
|
||||
|
||||
// 官方请求 → 归一 → 通用任务体(与胶水层 HandleMiniMaxVideoSubmit 相同链路)
|
||||
raw := `{
|
||||
"model": "MiniMax-H3",
|
||||
"content": [
|
||||
{"type": "text", "text": "海浪拍打礁石,慢镜头。"},
|
||||
{"type": "image_url", "image_url": {"url": "https://cdn.example/first.png"}, "role": "first_frame"}
|
||||
],
|
||||
"resolution": "768P",
|
||||
"duration": 6,
|
||||
"ratio": "16:9",
|
||||
"callback_url": "https://evil.example/cb"
|
||||
}`
|
||||
req, taskErr := ParseMiniMaxVideoCreateRequest([]byte(raw))
|
||||
if taskErr != nil {
|
||||
t.Fatalf("parse: %+v", taskErr)
|
||||
}
|
||||
if taskErr := ValidateMiniMaxVideoCreateRequest(req); taskErr != nil {
|
||||
t.Fatalf("validate: %+v", taskErr)
|
||||
}
|
||||
taskBody, err := BuildMiniMaxVideoTaskBody(req)
|
||||
if err != nil {
|
||||
t.Fatalf("build task body: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
rc := &TaskRelayContext{
|
||||
TenantID: 1,
|
||||
UserID: 2,
|
||||
ApiKeyID: 3,
|
||||
RequestID: "req_mm_native",
|
||||
Writer: rec,
|
||||
Protocol: minimaxVideoProtocol,
|
||||
RelayMode: int(constant.RelayModeMiniMaxVideo),
|
||||
RequestEcho: NewMiniMaxRequestEcho(req),
|
||||
}
|
||||
data := &stubVideosSubmitProvider{}
|
||||
|
||||
HandleTaskSubmit(context.Background(), taskBody, "/v2/video_generation", http.Header{}, rc, data, &stubVideosBillingProvider{}, miniMaxChannelMeta(upstream.URL))
|
||||
|
||||
// 客户端响应:官方 VideoGenerationV2Resp 形态
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
resp := decodeVideosJSON(t, rec)
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("submit response should only carry task_id: %s", rec.Body.String())
|
||||
}
|
||||
taskID, _ := resp["task_id"].(string)
|
||||
if !strings.HasPrefix(taskID, "task_") {
|
||||
t.Fatalf("expected task_ prefixed id, got %q", taskID)
|
||||
}
|
||||
if rc.TaskID != taskID {
|
||||
t.Fatalf("rc.TaskID=%q should match response id %q", rc.TaskID, taskID)
|
||||
}
|
||||
|
||||
// 落库任务:minimax 平台 + 上游任务 ID + 请求回显
|
||||
if data.created == nil {
|
||||
t.Fatal("expected task record created")
|
||||
}
|
||||
if data.created.Platform != "minimax" {
|
||||
t.Fatalf("expected platform minimax, got %q", data.created.Platform)
|
||||
}
|
||||
var pd struct {
|
||||
UpstreamTaskID string `json:"upstream_task_id"`
|
||||
RequestEcho *minimaxVideoRequestEcho `json:"request_echo"`
|
||||
}
|
||||
if err := json.Unmarshal(data.created.PrivateData, &pd); err != nil {
|
||||
t.Fatalf("private data unmarshal: %v", err)
|
||||
}
|
||||
if pd.UpstreamTaskID != "424010985738629" {
|
||||
t.Fatalf("upstream task id: %q", pd.UpstreamTaskID)
|
||||
}
|
||||
if pd.RequestEcho == nil || pd.RequestEcho.Resolution != "768P" || pd.RequestEcho.Duration != 6 {
|
||||
t.Fatalf("request echo persisted: %+v", pd.RequestEcho)
|
||||
}
|
||||
|
||||
// 上游收到:content 原样(2 项)、官方参数、无 callback_url
|
||||
if (*received)["model"] != "MiniMax-H3" {
|
||||
t.Fatalf("upstream model: %v", (*received)["model"])
|
||||
}
|
||||
if (*received)["resolution"] != "768P" || (*received)["duration"] != float64(6) || (*received)["ratio"] != "16:9" {
|
||||
t.Fatalf("upstream params: %v", *received)
|
||||
}
|
||||
if _, has := (*received)["callback_url"]; has {
|
||||
t.Fatal("callback_url must be stripped before forwarding upstream")
|
||||
}
|
||||
content, _ := (*received)["content"].([]any)
|
||||
if len(content) != 2 {
|
||||
t.Fatalf("expected content passthrough with 2 items, got %v", (*received)["content"])
|
||||
}
|
||||
img, _ := content[1].(map[string]any)
|
||||
if img["role"] != "first_frame" {
|
||||
t.Fatalf("content roles preserved: %v", content[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTaskSubmit_OpenAIVideosToMiniMax OpenAI Videos 协议 → minimax 适配器:
|
||||
// size 原值透传(调用方直接传 MiniMax 原生档位词汇)、seconds → duration、multipart 链路复用。
|
||||
func TestHandleTaskSubmit_OpenAIVideosToMiniMax(t *testing.T) {
|
||||
upstream, received := newMiniMaxUpstream(t)
|
||||
defer upstream.Close()
|
||||
|
||||
// OpenAI multipart 创建请求:size 直传 MiniMax 原生档位 768P + 8 秒
|
||||
mBody, ct := buildMultipartVideosBody(t, map[string]string{
|
||||
"model": "MiniMax-H3",
|
||||
"prompt": "a cat surfing",
|
||||
"seconds": "8",
|
||||
"size": "768P",
|
||||
}, "", "", nil)
|
||||
req, taskErr := ParseVideosCreateRequest(mBody, ct)
|
||||
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_mm_openai",
|
||||
Writer: rec,
|
||||
Protocol: videosProtocolOpenAI,
|
||||
RelayMode: int(constant.RelayModeVideos),
|
||||
RequestEcho: NewVideosRequestEcho(req),
|
||||
}
|
||||
data := &stubVideosSubmitProvider{}
|
||||
|
||||
HandleTaskSubmit(context.Background(), taskBody, "/v1/videos", http.Header{}, rc, data, &stubVideosBillingProvider{}, miniMaxChannelMeta(upstream.URL))
|
||||
|
||||
// OpenAI 协议响应保持官方 Video 对象
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
resp := decodeVideosJSON(t, rec)
|
||||
if resp["object"] != "video" || resp["status"] != "queued" {
|
||||
t.Fatalf("openai video object: %s", rec.Body.String())
|
||||
}
|
||||
if data.created == nil || data.created.Platform != "minimax" {
|
||||
t.Fatalf("expected minimax task record, got %+v", data.created)
|
||||
}
|
||||
|
||||
// 上游收到映射后的 v2 参数:size 原值透传、seconds→duration、t2v 默认 ratio
|
||||
if (*received)["resolution"] != "768P" {
|
||||
t.Fatalf("upstream resolution passthrough: %v", (*received)["resolution"])
|
||||
}
|
||||
if (*received)["duration"] != float64(8) {
|
||||
t.Fatalf("upstream duration: %v", (*received)["duration"])
|
||||
}
|
||||
if (*received)["ratio"] != "16:9" {
|
||||
t.Fatalf("upstream ratio: %v", (*received)["ratio"])
|
||||
}
|
||||
content, _ := (*received)["content"].([]any)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("text-only content expected, got %v", (*received)["content"])
|
||||
}
|
||||
text, _ := content[0].(map[string]any)
|
||||
if text["text"] != "a cat surfing" {
|
||||
t.Fatalf("text item: %v", content[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTaskSubmit_MiniMaxV1NativeProtocol_FullPipeline v1 官方协议全链路:
|
||||
// 扁平字段透传、callback_url 剥离、{task_id, base_resp} 响应、minimax 平台落库。
|
||||
func TestHandleTaskSubmit_MiniMaxV1NativeProtocol_FullPipeline(t *testing.T) {
|
||||
// mock MiniMax v1 上游:记录提交请求体,返回官方提交响应
|
||||
received := map[string]any{}
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/video_generation" {
|
||||
t.Errorf("unexpected upstream path: %s", r.URL.Path)
|
||||
}
|
||||
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{
|
||||
"task_id": "v1-up-777",
|
||||
"base_resp": map[string]any{"status_code": 0, "status_msg": "success"},
|
||||
})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
raw := `{
|
||||
"model": "MiniMax-Hailuo-2.3",
|
||||
"prompt": "一只猫在月光下的屋顶上行走",
|
||||
"first_frame_image": "https://cdn.example/f.png",
|
||||
"duration": 10,
|
||||
"resolution": "768P",
|
||||
"callback_url": "https://evil.example/cb"
|
||||
}`
|
||||
req, taskErr := ParseMiniMaxVideoV1CreateRequest([]byte(raw))
|
||||
if taskErr != nil {
|
||||
t.Fatalf("parse: %+v", taskErr)
|
||||
}
|
||||
if taskErr := ValidateMiniMaxVideoV1CreateRequest(req); taskErr != nil {
|
||||
t.Fatalf("validate: %+v", taskErr)
|
||||
}
|
||||
taskBody, err := BuildMiniMaxVideoV1TaskBody(req)
|
||||
if err != nil {
|
||||
t.Fatalf("build task body: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
rc := &TaskRelayContext{
|
||||
TenantID: 1,
|
||||
UserID: 2,
|
||||
ApiKeyID: 3,
|
||||
RequestID: "req_mm_v1_native",
|
||||
Writer: rec,
|
||||
Protocol: minimaxVideoV1Protocol,
|
||||
RelayMode: int(constant.RelayModeMiniMaxVideo),
|
||||
RequestEcho: NewMiniMaxV1RequestEcho(req),
|
||||
}
|
||||
data := &stubVideosSubmitProvider{}
|
||||
channelMeta := &common.ChannelMeta{
|
||||
ChannelID: 22,
|
||||
ChannelType: int(constant.ProviderMiniMax),
|
||||
ChannelName: "mock-minimax",
|
||||
BaseURL: upstream.URL,
|
||||
ApiKey: "sk-test",
|
||||
UpstreamModelName: "MiniMax-Hailuo-2.3",
|
||||
}
|
||||
|
||||
HandleTaskSubmit(context.Background(), taskBody, "/v1/video_generation", http.Header{}, rc, data, &stubVideosBillingProvider{}, channelMeta)
|
||||
|
||||
// 客户端响应:官方 VideoGenerationResp 形态(含 base_resp 成功信封)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
resp := decodeVideosJSON(t, rec)
|
||||
taskID, _ := resp["task_id"].(string)
|
||||
if !strings.HasPrefix(taskID, "task_") {
|
||||
t.Fatalf("expected task_ prefixed id, got %q", taskID)
|
||||
}
|
||||
baseResp, _ := resp["base_resp"].(map[string]any)
|
||||
if baseResp == nil || baseResp["status_code"] != float64(0) {
|
||||
t.Fatalf("base_resp success envelope: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// 落库任务:minimax 平台 + 上游任务 ID + 请求回显
|
||||
if data.created == nil || data.created.Platform != "minimax" {
|
||||
t.Fatalf("expected minimax task record, got %+v", data.created)
|
||||
}
|
||||
var pd struct {
|
||||
UpstreamTaskID string `json:"upstream_task_id"`
|
||||
RequestEcho *minimaxVideoV1RequestEcho `json:"request_echo"`
|
||||
}
|
||||
if err := json.Unmarshal(data.created.PrivateData, &pd); err != nil {
|
||||
t.Fatalf("private data unmarshal: %v", err)
|
||||
}
|
||||
if pd.UpstreamTaskID != "v1-up-777" {
|
||||
t.Fatalf("upstream task id: %q", pd.UpstreamTaskID)
|
||||
}
|
||||
if pd.RequestEcho == nil || pd.RequestEcho.Duration != 10 || pd.RequestEcho.Resolution != "768P" {
|
||||
t.Fatalf("request echo persisted: %+v", pd.RequestEcho)
|
||||
}
|
||||
|
||||
// 上游收到:v1 扁平字段、无 content[]、无 callback_url
|
||||
if received["model"] != "MiniMax-Hailuo-2.3" || received["prompt"] != "一只猫在月光下的屋顶上行走" {
|
||||
t.Fatalf("upstream flat fields: %v", received)
|
||||
}
|
||||
if received["first_frame_image"] != "https://cdn.example/f.png" || received["duration"] != float64(10) || received["resolution"] != "768P" {
|
||||
t.Fatalf("upstream params: %v", received)
|
||||
}
|
||||
if _, has := received["callback_url"]; has {
|
||||
t.Fatal("callback_url must be stripped before forwarding upstream")
|
||||
}
|
||||
if _, has := received["content"]; has {
|
||||
t.Fatal("v1 request must not carry content[]")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
)
|
||||
|
||||
// MiniMax 官方协议查询/删除处理器单测(stub TaskDataProvider)。
|
||||
|
||||
// stubMiniMaxTaskProvider 返回预置任务并回查渠道
|
||||
type stubMiniMaxTaskProvider struct {
|
||||
common.TaskDataProvider
|
||||
|
||||
task *common.AsyncTask
|
||||
channel *common.ChannelBasicInfo
|
||||
}
|
||||
|
||||
func (s *stubMiniMaxTaskProvider) GetTaskByPublicIDAndUser(_ context.Context, publicTaskID string, _ int64, _ int64) (*common.AsyncTask, error) {
|
||||
if s.task != nil && s.task.PublicTaskID == publicTaskID {
|
||||
return s.task, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubMiniMaxTaskProvider) GetChannelByID(_ context.Context, _ int64) (*common.ChannelBasicInfo, error) {
|
||||
return s.channel, nil
|
||||
}
|
||||
|
||||
// newMiniMaxTask 构造 minimax 平台任务记录(带上游任务 ID,供取消链路使用)
|
||||
func newMiniMaxTask(status string) *common.AsyncTask {
|
||||
now := time.Now()
|
||||
return &common.AsyncTask{
|
||||
PublicTaskID: "task_mm_1",
|
||||
Platform: "minimax",
|
||||
Status: status,
|
||||
ModelName: "MiniMax-H3",
|
||||
TenantID: 1,
|
||||
UserID: 2,
|
||||
ChannelID: 21,
|
||||
PrivateData: json.RawMessage(`{"upstream_task_id":"424010985738629"}`),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxStatusFromTask(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"NOT_START": "queued",
|
||||
"SUBMITTED": "queued",
|
||||
"QUEUED": "queued",
|
||||
"IN_PROGRESS": "running",
|
||||
"SUCCESS": "succeeded",
|
||||
"FAILURE": "failed",
|
||||
"WHATEVER": "queued",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := minimaxStatusFromTask(in); got != want {
|
||||
t.Errorf("minimaxStatusFromTask(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMiniMaxVideoRetrieve_EnrichFromUpstreamData(t *testing.T) {
|
||||
task := newMiniMaxTask("SUCCESS")
|
||||
task.ResultURL = "https://video.cdn.example/out.mp4"
|
||||
task.Data = json.RawMessage(`{"task":{"id":"up-1","status":"succeeded","content":{"url":"https://video.cdn.example/out.mp4"},"usage":{"total_tokens":273890,"prompt_tokens":13500,"completion_tokens":260390},"resolution":"2K","duration":5,"ratio":"16:9"}}`)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}
|
||||
HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task})
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
resp := decodeVideosJSON(t, rec)
|
||||
taskObj, _ := resp["task"].(map[string]any)
|
||||
if taskObj == nil {
|
||||
t.Fatalf("expected {\"task\": {...}} wrapper: %s", rec.Body.String())
|
||||
}
|
||||
// id 用网关公开 ID(不透出上游 ID)
|
||||
if taskObj["id"] != "task_mm_1" || taskObj["status"] != "succeeded" {
|
||||
t.Fatalf("task identity: %s", rec.Body.String())
|
||||
}
|
||||
content, _ := taskObj["content"].(map[string]any)
|
||||
if content["url"] != "https://video.cdn.example/out.mp4" {
|
||||
t.Fatalf("content.url: %v", taskObj["content"])
|
||||
}
|
||||
usage, _ := taskObj["usage"].(map[string]any)
|
||||
if usage["total_tokens"] != float64(273890) {
|
||||
t.Fatalf("usage replay: %v", taskObj["usage"])
|
||||
}
|
||||
if taskObj["resolution"] != "2K" || taskObj["duration"] != float64(5) || taskObj["ratio"] != "16:9" {
|
||||
t.Fatalf("enriched specs: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMiniMaxVideoRetrieve_SubmitShapedDataDegrade 提交后首拍轮询前 Data 是提交响应形态,
|
||||
// 查询必须静默降级(缺失字段不输出,状态以任务记录为准)。
|
||||
func TestHandleMiniMaxVideoRetrieve_SubmitShapedDataDegrade(t *testing.T) {
|
||||
task := newMiniMaxTask("SUBMITTED")
|
||||
task.Data = json.RawMessage(`{"task_id":"424010985738629"}`)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}
|
||||
HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task})
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
resp := decodeVideosJSON(t, rec)
|
||||
taskObj, _ := resp["task"].(map[string]any)
|
||||
if taskObj["status"] != "queued" {
|
||||
t.Fatalf("status from record: %s", rec.Body.String())
|
||||
}
|
||||
if _, has := taskObj["usage"]; has {
|
||||
t.Fatalf("usage must be absent for submit-shaped data: %s", rec.Body.String())
|
||||
}
|
||||
if _, has := taskObj["resolution"]; has {
|
||||
t.Fatalf("resolution must be absent for submit-shaped data: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMiniMaxVideoRetrieve_PlatformGate 平台门禁:异构平台任务 ID 打 /v2 端点按 404 处理
|
||||
func TestHandleMiniMaxVideoRetrieve_PlatformGate(t *testing.T) {
|
||||
task := newMiniMaxTask("SUCCESS")
|
||||
task.Platform = "kling"
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}
|
||||
HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task})
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for non-minimax platform, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMiniMaxVideoRetrieve_FailedTask 失败任务回放官方 error 对象
|
||||
func TestHandleMiniMaxVideoRetrieve_FailedTask(t *testing.T) {
|
||||
task := newMiniMaxTask("FAILURE")
|
||||
task.FailReason = "content policy violation"
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}
|
||||
HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task})
|
||||
|
||||
resp := decodeVideosJSON(t, rec)
|
||||
taskObj, _ := resp["task"].(map[string]any)
|
||||
if taskObj["status"] != "failed" {
|
||||
t.Fatalf("failed status: %s", rec.Body.String())
|
||||
}
|
||||
errObj, _ := taskObj["error"].(map[string]any)
|
||||
if errObj["message"] != "content policy violation" {
|
||||
t.Fatalf("error object: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// newMiniMaxCancelUpstream mock MiniMax v2 取消端点(DELETE /v2/video_generation/{id}):
|
||||
// 记录路径与方法,按脚本返回成功 / 上游拒绝 / 竞态删记录三种形态。
|
||||
func newMiniMaxCancelUpstream(t *testing.T, script string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
t.Errorf("cancel must use DELETE, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v2/video_generation/424010985738629" {
|
||||
t.Errorf("unexpected cancel path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch script {
|
||||
case "ok":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"task_id": "424010985738629", "action": "cancelled", "status": "cancelled"})
|
||||
case "running":
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": "running task cannot be cancelled"}})
|
||||
case "deleted":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"task_id": "424010985738629", "action": "deleted", "status": "deleted"})
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestHandleMiniMaxVideoCancel(t *testing.T) {
|
||||
// 终态任务:不可取消、不可删除(不做软删),且不触达上游
|
||||
upstream := newMiniMaxCancelUpstream(t, "ok")
|
||||
defer upstream.Close()
|
||||
provider := &stubMiniMaxTaskProvider{
|
||||
task: newMiniMaxTask("SUCCESS"),
|
||||
channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("terminal task must not be cancellable/deletable: code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 运行中任务:前置拦截,不可取消
|
||||
provider.task = newMiniMaxTask("IN_PROGRESS")
|
||||
rec = httptest.NewRecorder()
|
||||
HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("running task must not be cancellable: code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 排队中任务:上游确认取消 → 官方 cancelled 响应
|
||||
provider.task = newMiniMaxTask("QUEUED")
|
||||
rec = httptest.NewRecorder()
|
||||
HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("queued cancel should succeed: code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
resp := decodeVideosJSON(t, rec)
|
||||
if resp["task_id"] != "task_mm_1" || resp["action"] != "cancelled" || resp["status"] != "cancelled" {
|
||||
t.Fatalf("cancel response shape: %s", rec.Body.String())
|
||||
}
|
||||
// 本地状态不由取消端点改写(轮询循环对账收敛)
|
||||
if provider.task.Status != "QUEUED" {
|
||||
t.Fatalf("cancel endpoint must not mutate local status directly: %s", provider.task.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMiniMaxVideoCancel_UpstreamRejected 远程取消失败:上游判定运行中,
|
||||
// 错误原样透传(本地状态滞后时由上游裁决兜底)。
|
||||
func TestHandleMiniMaxVideoCancel_UpstreamRejected(t *testing.T) {
|
||||
upstream := newMiniMaxCancelUpstream(t, "running")
|
||||
defer upstream.Close()
|
||||
|
||||
provider := &stubMiniMaxTaskProvider{
|
||||
task: newMiniMaxTask("QUEUED"), // 本地状态滞后:本地仍是排队中
|
||||
channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("upstream rejection must relay: code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
resp := decodeVideosJSON(t, rec)
|
||||
errObj, _ := resp["error"].(map[string]any)
|
||||
if errObj["message"] != "running task cannot be cancelled" {
|
||||
t.Fatalf("upstream message must be relayed: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMiniMaxVideoCancel_UpstreamDeletedRace 竞态:本地检查后任务已在上游完成,
|
||||
// 上游执行的是删记录(action=deleted)而非取消——不能向客户端谎报取消成功。
|
||||
func TestHandleMiniMaxVideoCancel_UpstreamDeletedRace(t *testing.T) {
|
||||
upstream := newMiniMaxCancelUpstream(t, "deleted")
|
||||
defer upstream.Close()
|
||||
|
||||
provider := &stubMiniMaxTaskProvider{
|
||||
task: newMiniMaxTask("QUEUED"),
|
||||
channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("deleted action must not be reported as cancelled: code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMiniMaxVideoCancel_V1Rejected v1(Hailuo 系列)任务:上游无取消端点,直接拒绝
|
||||
func TestHandleMiniMaxVideoCancel_V1Rejected(t *testing.T) {
|
||||
upstream := newMiniMaxCancelUpstream(t, "ok") // 上游即使可达也不应被调用
|
||||
defer upstream.Close()
|
||||
|
||||
task := newMiniMaxTask("QUEUED")
|
||||
task.ModelName = "MiniMax-Hailuo-2.3" // UpstreamModel 为空时回落 ModelName 分派
|
||||
provider := &stubMiniMaxTaskProvider{
|
||||
task: task,
|
||||
channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("v1 cancel must be rejected: code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "v1 API has no cancel endpoint") {
|
||||
t.Fatalf("reject message: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== v1(Hailuo 系列)官方协议 ====================
|
||||
|
||||
func TestMiniMaxV1StatusFromTask(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"NOT_START": "Preparing",
|
||||
"SUBMITTED": "Preparing",
|
||||
"QUEUED": "Queueing",
|
||||
"IN_PROGRESS": "Processing",
|
||||
"SUCCESS": "Success",
|
||||
"FAILURE": "Fail",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := minimaxV1StatusFromTask(in); got != want {
|
||||
t.Errorf("minimaxV1StatusFromTask(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateMiniMaxVideoV1CreateRequest(t *testing.T) {
|
||||
// 图生视频形态(prompt 可选)
|
||||
req, taskErr := ParseMiniMaxVideoV1CreateRequest([]byte(`{
|
||||
"model": "MiniMax-Hailuo-2.3",
|
||||
"first_frame_image": "https://cdn.example/f.png",
|
||||
"prompt": "让画面动起来",
|
||||
"duration": "10",
|
||||
"resolution": "768P",
|
||||
"prompt_optimizer": false,
|
||||
"callback_url": "https://example.com/cb"
|
||||
}`))
|
||||
if taskErr != nil {
|
||||
t.Fatalf("parse: %+v", taskErr)
|
||||
}
|
||||
if req.Model != "MiniMax-Hailuo-2.3" || req.FirstFrameImage != "https://cdn.example/f.png" || req.Duration != 10 || req.Resolution != "768P" {
|
||||
t.Fatalf("parsed fields: %+v", req)
|
||||
}
|
||||
if req.PromptOptimizer == nil || *req.PromptOptimizer {
|
||||
t.Fatalf("prompt_optimizer: %v", req.PromptOptimizer)
|
||||
}
|
||||
if taskErr := ValidateMiniMaxVideoV1CreateRequest(req); taskErr != nil {
|
||||
t.Fatalf("i2v form should pass: %+v", taskErr)
|
||||
}
|
||||
|
||||
// 必填校验:model 必填;prompt/first_frame_image/subject_reference 至少其一
|
||||
noModel, _ := ParseMiniMaxVideoV1CreateRequest([]byte(`{"prompt":"hi"}`))
|
||||
if taskErr := ValidateMiniMaxVideoV1CreateRequest(noModel); taskErr == nil {
|
||||
t.Fatal("expected model required")
|
||||
}
|
||||
empty, _ := ParseMiniMaxVideoV1CreateRequest([]byte(`{"model":"MiniMax-Hailuo-2.3","resolution":"768P"}`))
|
||||
if taskErr := ValidateMiniMaxVideoV1CreateRequest(empty); taskErr == nil {
|
||||
t.Fatal("expected one-of-prompt/image/subject required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMiniMaxVideoV1TaskBody(t *testing.T) {
|
||||
req := &minimaxVideoV1CreateRequest{
|
||||
Model: "MiniMax-Hailuo-2.3",
|
||||
Prompt: "海浪",
|
||||
FirstFrameImage: "https://cdn.example/f.png",
|
||||
Duration: 6,
|
||||
Resolution: "768P",
|
||||
}
|
||||
body, err := BuildMiniMaxVideoV1TaskBody(req)
|
||||
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)
|
||||
}
|
||||
|
||||
// v1 扁平字段保留
|
||||
if m["model"] != "MiniMax-Hailuo-2.3" || m["prompt"] != "海浪" || m["first_frame_image"] != "https://cdn.example/f.png" {
|
||||
t.Fatalf("flat fields: %v", m)
|
||||
}
|
||||
if m["duration"] != float64(6) || m["resolution"] != "768P" {
|
||||
t.Fatalf("duration/resolution: %v/%v", m["duration"], m["resolution"])
|
||||
}
|
||||
if _, has := m["callback_url"]; has {
|
||||
t.Fatal("callback_url must never enter the task body")
|
||||
}
|
||||
// 通用词汇双写
|
||||
if m["seconds"] != "6" {
|
||||
t.Fatalf("seconds dual-write: %v", m["seconds"])
|
||||
}
|
||||
images, _ := m["images"].([]any)
|
||||
if len(images) != 1 || images[0] != "https://cdn.example/f.png" {
|
||||
t.Fatalf("images dual-write: %v", m["images"])
|
||||
}
|
||||
meta, _ := m["metadata"].(map[string]any)
|
||||
if meta["resolution"] != "768P" || meta["duration"] != float64(6) || meta["image"] != "https://cdn.example/f.png" {
|
||||
t.Fatalf("metadata dual-write: %v", m["metadata"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMiniMaxVideoV1Retrieve(t *testing.T) {
|
||||
// 成功任务:官方 QueryVideoGenerationTaskResp 形态 + Data 富集 + ResultURL 直链
|
||||
task := newMiniMaxTask("SUCCESS")
|
||||
task.ModelName = "MiniMax-Hailuo-2.3"
|
||||
task.ResultURL = "https://cdn.example/out.mp4"
|
||||
task.Data = json.RawMessage(`{"task_id":"v1-up-1","status":"Success","file_id":"9001","video_width":1280,"video_height":720,"download_url":"https://cdn.example/out.mp4","base_resp":{"status_code":0}}`)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
HandleMiniMaxVideoV1Retrieve(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, &stubMiniMaxTaskProvider{task: task})
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
resp := decodeVideosJSON(t, rec)
|
||||
if resp["task_id"] != "task_mm_1" || resp["status"] != "Success" {
|
||||
t.Fatalf("official shape: %s", rec.Body.String())
|
||||
}
|
||||
if resp["file_id"] != "9001" || resp["video_width"] != float64(1280) || resp["video_height"] != float64(720) {
|
||||
t.Fatalf("enriched fields: %s", rec.Body.String())
|
||||
}
|
||||
if resp["download_url"] != "https://cdn.example/out.mp4" {
|
||||
t.Fatalf("download_url: %s", rec.Body.String())
|
||||
}
|
||||
baseResp, _ := resp["base_resp"].(map[string]any)
|
||||
if baseResp["status_code"] != float64(0) {
|
||||
t.Fatalf("base_resp success envelope: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// 失败任务:状态 Fail + 增补 error 字段;v2 形态 Data 不富集
|
||||
failTask := newMiniMaxTask("FAILURE")
|
||||
failTask.ModelName = "MiniMax-Hailuo-2.3"
|
||||
failTask.FailReason = "content violates policy"
|
||||
failTask.Data = json.RawMessage(`{"task":{"id":"up-1","status":"failed"}}`) // v2 形态:跳过富集
|
||||
rec = httptest.NewRecorder()
|
||||
HandleMiniMaxVideoV1Retrieve(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, &stubMiniMaxTaskProvider{task: failTask})
|
||||
|
||||
resp = decodeVideosJSON(t, rec)
|
||||
if resp["status"] != "Fail" || resp["error"] != "content violates policy" {
|
||||
t.Fatalf("failed replay: %s", rec.Body.String())
|
||||
}
|
||||
if _, has := resp["file_id"]; has {
|
||||
t.Fatalf("v2-shaped data must not enrich v1 response: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
_ "github.com/qianfree/team-api/relay/taskchannel/gemini"
|
||||
_ "github.com/qianfree/team-api/relay/taskchannel/kling"
|
||||
"github.com/qianfree/team-api/relay/taskchannel/midjourney"
|
||||
_ "github.com/qianfree/team-api/relay/taskchannel/minimax"
|
||||
_ "github.com/qianfree/team-api/relay/taskchannel/sora"
|
||||
_ "github.com/qianfree/team-api/relay/taskchannel/suno"
|
||||
_ "github.com/qianfree/team-api/relay/taskchannel/volcengine"
|
||||
@@ -329,11 +330,20 @@ func HandleTaskSubmit(
|
||||
// 设置 TaskID 供外层审计使用
|
||||
rc.TaskID = publicTaskID
|
||||
|
||||
// 11. 返回响应(OpenAI Videos 协议返回官方 Video 对象,legacy 保持原格式)
|
||||
// 11. 返回响应(OpenAI Videos 协议返回官方 Video 对象;MiniMax 官方协议返回 {"task_id"}
|
||||
//(v1 含 base_resp 信封);legacy 保持原格式)
|
||||
if rc.Protocol == videosProtocolOpenAI {
|
||||
writeVideosSubmitResponse(rc.Writer, publicTaskID, modelName, now, rc.RequestEcho)
|
||||
return
|
||||
}
|
||||
if rc.Protocol == minimaxVideoProtocol {
|
||||
writeMiniMaxSubmitResponse(rc.Writer, publicTaskID)
|
||||
return
|
||||
}
|
||||
if rc.Protocol == minimaxVideoV1Protocol {
|
||||
writeMiniMaxV1SubmitResponse(rc.Writer, publicTaskID)
|
||||
return
|
||||
}
|
||||
respBody := map[string]any{
|
||||
"id": publicTaskID,
|
||||
"status": "SUBMITTED",
|
||||
|
||||
@@ -211,25 +211,10 @@ func parseVideosInputReferenceJSON(raw json.RawMessage) (string, *common.TaskErr
|
||||
}
|
||||
}
|
||||
|
||||
// videosSizeMapping 官方四档分辨率 → 第三方通用参数(aspect_ratio + resolution)。
|
||||
// 未命中四档的 size 不做映射(第三方分辨率自由,透传 metadata.size 原值即可)。
|
||||
var videosSizeMapping = map[string]struct {
|
||||
AspectRatio string
|
||||
Resolution string
|
||||
}{
|
||||
"720x1280": {"9:16", "720p"},
|
||||
"1280x720": {"16:9", "720p"},
|
||||
"1024x1792": {"9:16", "1080p"},
|
||||
"1792x1024": {"16:9", "1080p"},
|
||||
}
|
||||
|
||||
// BuildVideosTaskBody 把归一后的创建请求转成任务框架通用请求体(JSON 字节)。
|
||||
// 顶层保留 seconds + images,metadata 写入 duration/ratio/aspect_ratio/resolution/size/image,
|
||||
// 未识别字段一律透传原值,不在此处做模型能力裁剪。
|
||||
// 参数采用「双写」策略覆盖两套适配器词汇(未知键互不干扰,json.Unmarshal 自动忽略):
|
||||
// - kling:顶层 seconds、metadata.aspect_ratio、metadata.image
|
||||
// - volcengine(seedance):顶层 seconds、images[] 数组(→content[].image_url 首帧)、
|
||||
// metadata.ratio、metadata.duration、metadata.resolution
|
||||
// 顶层保留 seconds + images,metadata 写入 duration/size/image。
|
||||
// size 只借 OpenAI 协议的字段形态、不做任何档位换算:调用方直接传目标供应商的
|
||||
// 原生分辨率词汇(如 MiniMax 的 768P/2K),原值经 metadata.size 透传给适配器。
|
||||
func BuildVideosTaskBody(req *videosCreateRequest) ([]byte, error) {
|
||||
body := map[string]any{
|
||||
"model": req.Model,
|
||||
@@ -245,12 +230,6 @@ func BuildVideosTaskBody(req *videosCreateRequest) ([]byte, error) {
|
||||
}
|
||||
if req.Size != "" {
|
||||
metadata["size"] = req.Size
|
||||
if m, ok := videosSizeMapping[req.Size]; ok {
|
||||
// aspect_ratio(kling)与 ratio(volcengine)值域同构:"16:9"/"9:16"
|
||||
metadata["aspect_ratio"] = m.AspectRatio
|
||||
metadata["ratio"] = m.AspectRatio
|
||||
metadata["resolution"] = m.Resolution
|
||||
}
|
||||
}
|
||||
if req.InputReference != "" {
|
||||
metadata["image"] = req.InputReference
|
||||
|
||||
@@ -120,12 +120,14 @@ func TestParseVideosCreateRequest_MultipartStringRef(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVideosTaskBody_FullMapping(t *testing.T) {
|
||||
// TestBuildVideosTaskBody_SizePassthrough size 只借协议字段形态、不做档位换算:
|
||||
// 原值进 metadata.size,不派生 aspect_ratio/ratio/resolution(值词汇由调用方按目标供应商原生口径提供)。
|
||||
func TestBuildVideosTaskBody_SizePassthrough(t *testing.T) {
|
||||
req := &videosCreateRequest{
|
||||
Model: "kling-v2-master",
|
||||
Prompt: "a cat",
|
||||
Seconds: "8",
|
||||
Size: "1280x720",
|
||||
Size: "768P",
|
||||
InputReference: "https://example.com/ref.png",
|
||||
}
|
||||
body, err := BuildVideosTaskBody(req)
|
||||
@@ -151,16 +153,15 @@ func TestBuildVideosTaskBody_FullMapping(t *testing.T) {
|
||||
if meta["duration"] != float64(8) {
|
||||
t.Fatalf("expected metadata.duration 8, got %v", meta["duration"])
|
||||
}
|
||||
if meta["aspect_ratio"] != "16:9" || meta["resolution"] != "720p" {
|
||||
t.Fatalf("expected 16:9/720p, got %v/%v", meta["aspect_ratio"], meta["resolution"])
|
||||
}
|
||||
// 双写:ratio 供 volcengine(seedance) 消费,images 数组供其图生视频读取
|
||||
if meta["ratio"] != "16:9" {
|
||||
t.Fatalf("expected ratio 16:9 for volcengine, got %v", meta["ratio"])
|
||||
}
|
||||
if meta["size"] != "1280x720" {
|
||||
if meta["size"] != "768P" {
|
||||
t.Fatalf("expected raw size passthrough, got %v", meta["size"])
|
||||
}
|
||||
// 不做换算:不从 size 派生任何其他参数键
|
||||
for _, key := range []string{"aspect_ratio", "ratio", "resolution"} {
|
||||
if _, has := meta[key]; has {
|
||||
t.Fatalf("size must not derive %s: %s", key, body)
|
||||
}
|
||||
}
|
||||
if meta["image"] != "https://example.com/ref.png" {
|
||||
t.Fatalf("expected metadata.image, got %v", meta["image"])
|
||||
}
|
||||
@@ -203,6 +204,9 @@ func TestBuildVideosTaskBody_UnknownSizeNoRatio(t *testing.T) {
|
||||
if _, ok := meta["ratio"]; ok {
|
||||
t.Fatal("unknown size must not map ratio")
|
||||
}
|
||||
if _, ok := meta["resolution"]; ok {
|
||||
t.Fatal("unknown size must not map resolution")
|
||||
}
|
||||
if meta["size"] != "1920x1080" {
|
||||
t.Fatalf("expected raw size passthrough, got %v", meta["size"])
|
||||
}
|
||||
|
||||
@@ -173,16 +173,13 @@ func TestHandleTaskSubmit_OpenAIVideosProtocol_FullPipeline(t *testing.T) {
|
||||
t.Fatalf("expected request echo persisted: %+v", pd.RequestEcho)
|
||||
}
|
||||
|
||||
// 6. 断言上游收到映射后的 kling 参数(seconds→duration、size→aspect_ratio)
|
||||
// 6. 断言上游收到映射后的 kling 参数(seconds→duration;size 不换算,kling 不消费该键)
|
||||
if upstreamReceived["model_name"] != "kling-v2-master" {
|
||||
t.Fatalf("upstream model_name: %v", upstreamReceived["model_name"])
|
||||
}
|
||||
if upstreamReceived["duration"] != "8" {
|
||||
t.Fatalf("upstream duration: %v", upstreamReceived["duration"])
|
||||
}
|
||||
if upstreamReceived["aspect_ratio"] != "16:9" {
|
||||
t.Fatalf("upstream aspect_ratio: %v", upstreamReceived["aspect_ratio"])
|
||||
}
|
||||
if upstreamReceived["prompt"] != "a cat surfing" {
|
||||
t.Fatalf("upstream prompt: %v", upstreamReceived["prompt"])
|
||||
}
|
||||
@@ -259,18 +256,19 @@ func TestHandleTaskSubmit_OpenAIVideosProtocol_Seedance(t *testing.T) {
|
||||
t.Fatalf("expected volcengine task record, got %+v", data.created)
|
||||
}
|
||||
|
||||
// 上游收到的 seedance 参数:content 文本+首帧图、duration/ratio/resolution 全部对上
|
||||
// 上游收到的 seedance 参数:content 文本+首帧图、duration 对上;
|
||||
// size 不再换算派生(resolution/ratio 由 volcengine 上游默认值兜底)
|
||||
if upstreamReceived["model"] != "doubao-seedance-2-0-260128" {
|
||||
t.Fatalf("upstream model: %v", upstreamReceived["model"])
|
||||
}
|
||||
if upstreamReceived["duration"] != float64(12) {
|
||||
t.Fatalf("upstream duration: %v (%T)", upstreamReceived["duration"], upstreamReceived["duration"])
|
||||
}
|
||||
if upstreamReceived["ratio"] != "16:9" {
|
||||
t.Fatalf("upstream ratio: %v", upstreamReceived["ratio"])
|
||||
if _, has := upstreamReceived["resolution"]; has {
|
||||
t.Fatalf("size must not derive resolution for volcengine: %v", upstreamReceived["resolution"])
|
||||
}
|
||||
if upstreamReceived["resolution"] != "1080p" {
|
||||
t.Fatalf("upstream resolution: %v", upstreamReceived["resolution"])
|
||||
if _, has := upstreamReceived["ratio"]; has {
|
||||
t.Fatalf("size must not derive ratio for volcengine: %v", upstreamReceived["ratio"])
|
||||
}
|
||||
content, ok := upstreamReceived["content"].([]any)
|
||||
if !ok || len(content) != 2 {
|
||||
|
||||
Reference in new issue
Block a user