停止持久化 provider 原始响应、兼容响应快照、attempt/event/outbox 重复 JSON,并由标准任务结果动态生成 Kling/Keling/Volces 兼容响应。 增加事件去重与预算、极简 callback 投递、7/30 天分批清理、安全删除条件、并发迁移索引及可实际恢复的任务域排除备份。历史清理默认关闭,待兼容协议和异步恢复在线验证后单独启用。 验证:Go 全量测试与 go vet、PostgreSQL 18 集成与实际备份恢复、迁移安全测试、bash -n、ShellCheck、Compose 配置和人工发布脚本测试均通过。
162 lines
6.6 KiB
Go
162 lines
6.6 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
|
)
|
|
|
|
func TestKlingV1O1CompatibilityBody(t *testing.T) {
|
|
body, externalID, err := klingCompatTaskBody("v1", klingO1Model, map[string]any{
|
|
"prompt": "一只纸鹤飞过湖面",
|
|
"duration": json.Number("10"),
|
|
"aspect_ratio": "16:9",
|
|
"sound": "on",
|
|
"external_task_id": "client-o1-1",
|
|
"watermark_info": map[string]any{"enabled": true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build O1 compatibility body: %v", err)
|
|
}
|
|
if externalID != "client-o1-1" || body["model"] != klingO1Model || body["modelType"] != "omni_video" {
|
|
t.Fatalf("unexpected identity fields: %#v", body)
|
|
}
|
|
if body["mode"] != "pro" || body["resolution"] != "1080p" || body["audio"] != true {
|
|
t.Fatalf("unexpected V1 defaults: %#v", body)
|
|
}
|
|
content := mapListFromRequest(body["content"])
|
|
if len(content) != 1 || content[0]["type"] != "text" || content[0]["text"] != "一只纸鹤飞过湖面" {
|
|
t.Fatalf("unexpected canonical content: %#v", content)
|
|
}
|
|
}
|
|
|
|
func TestKlingV1V3OmniCompatibilityBody(t *testing.T) {
|
|
body, _, err := klingCompatTaskBody("v1", klingV3OmniModel, map[string]any{
|
|
"multi_shot": true,
|
|
"shot_type": "customize",
|
|
"mode": "4k",
|
|
"multi_prompt": []any{
|
|
map[string]any{"index": json.Number("1"), "prompt": "推近人物", "duration": json.Number("7")},
|
|
map[string]any{"index": json.Number("2"), "prompt": "切到城市远景", "duration": json.Number("8")},
|
|
},
|
|
"image_list": []any{
|
|
map[string]any{"image_url": "https://example.com/first.png", "type": "first_frame"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build 3.0 Omni compatibility body: %v", err)
|
|
}
|
|
if body["resolution"] != "2160p" {
|
|
t.Fatalf("4k mode was not normalized: %#v", body)
|
|
}
|
|
content := mapListFromRequest(body["content"])
|
|
if len(content) != 1 || content[0]["role"] != "first_frame" {
|
|
t.Fatalf("image input was not normalized: %#v", content)
|
|
}
|
|
}
|
|
|
|
func TestKlingV2CompatibilityBody(t *testing.T) {
|
|
body, externalID, err := klingCompatTaskBody("v2", klingV3OmniModel, map[string]any{
|
|
"contents": []any{
|
|
map[string]any{"type": "prompt", "text": "让角色向镜头挥手"},
|
|
map[string]any{"type": "first_frame", "url": "https://example.com/first.png"},
|
|
map[string]any{"type": "element", "id": json.Number("42")},
|
|
},
|
|
"settings": map[string]any{
|
|
"resolution": "1080p",
|
|
"duration": json.Number("15"),
|
|
"aspect_ratio": "9:16",
|
|
"audio": "native",
|
|
},
|
|
"options": map[string]any{
|
|
"external_task_id": "client-v2-1",
|
|
"callback_url": "https://example.com/callback",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build V2 compatibility body: %v", err)
|
|
}
|
|
if externalID != "client-v2-1" || body["mode"] != "pro" || body["audio"] != true {
|
|
t.Fatalf("unexpected V2 settings: %#v", body)
|
|
}
|
|
if len(mapListFromRequest(body["image_list"])) != 1 || len(mapListFromRequest(body["element_list"])) != 1 {
|
|
t.Fatalf("unexpected V2 references: %#v", body)
|
|
}
|
|
}
|
|
|
|
func TestKlingSubmissionEnvelopesDoNotReplayCurrentResult(t *testing.T) {
|
|
task := store.GatewayTask{
|
|
ID: "gateway-task", Status: "succeeded", RemoteTaskID: "upstream-task",
|
|
CompatibilityProtocol: "kling-v1-omni",
|
|
CompatibilitySourceProtocol: "kling-v1-omni",
|
|
Result: map[string]any{"data": []any{map[string]any{"url": "https://example.invalid/result.mp4"}}},
|
|
CreatedAt: time.Unix(100, 0),
|
|
UpdatedAt: time.Unix(101, 0),
|
|
}
|
|
v1 := klingV1SubmissionEnvelope(task)
|
|
v1Data, _ := v1["data"].(map[string]any)
|
|
if v1Data["task_status"] != "submitted" || v1Data["task_result"] != nil {
|
|
t.Fatalf("V1 submission envelope=%#v", v1)
|
|
}
|
|
v2 := klingV2SubmissionEnvelope(task)
|
|
v2Data, _ := v2["data"].(map[string]any)
|
|
if v2Data["status"] != "submitted" || v2Data["outputs"] != nil {
|
|
t.Fatalf("V2 submission envelope=%#v", v2)
|
|
}
|
|
}
|
|
|
|
func TestKlingCompatibilityValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
model string
|
|
body map[string]any
|
|
}{
|
|
{name: "O1 duration", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 11}},
|
|
{name: "O1 text-only flexible duration", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 3}},
|
|
{name: "O1 4k", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 5, "mode": "4k"}},
|
|
{name: "O1 multi-shot", model: klingO1Model, body: map[string]any{"multi_shot": true, "multi_prompt": []any{map[string]any{"prompt": "test", "duration": 3}}}},
|
|
{name: "custom multi-shot without prompts", model: klingV3OmniModel, body: map[string]any{"multi_shot": true, "shot_type": "customize"}},
|
|
{name: "intelligence multi-shot without prompt", model: klingV3OmniModel, body: map[string]any{"multi_shot": true, "shot_type": "intelligence"}},
|
|
{name: "video with native audio", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "sound": "on", "video_list": []any{map[string]any{"video_url": "https://example.com/input.mp4"}}}},
|
|
{name: "video duration too long", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "duration": 15, "video_list": []any{map[string]any{"video_url": "https://example.com/input.mp4"}}}},
|
|
{name: "too many references", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "image_list": []any{
|
|
map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{},
|
|
}}},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if _, _, err := klingCompatTaskBody("v1", test.model, test.body); err == nil {
|
|
t.Fatal("expected validation error")
|
|
}
|
|
})
|
|
}
|
|
if _, _, err := klingCompatTaskBody("v1", klingV3OmniModel, map[string]any{"prompt": "test", "duration": 15}); err != nil {
|
|
t.Fatalf("3.0 Omni should allow a 15-second duration: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDecodeKlingJSONRejectsTrailingData(t *testing.T) {
|
|
request := httptest.NewRequest(http.MethodPost, "/kling/v1/videos/omni-video", strings.NewReader(`{"prompt":"ok"} trailing`))
|
|
var body map[string]any
|
|
if err := decodeKlingJSON(request, &body); err == nil {
|
|
t.Fatal("expected trailing JSON error")
|
|
}
|
|
}
|
|
|
|
func TestWriteKlingCompatErrorUsesOfficialNumericEnvelope(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
writeKlingCompatError(recorder, http.StatusBadRequest, "bad request", "invalid_parameter")
|
|
var body map[string]any
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode error response: %v", err)
|
|
}
|
|
if body["code"] != float64(1001) || body["error"] != "invalid_parameter" {
|
|
t.Fatalf("unexpected error envelope: %#v", body)
|
|
}
|
|
}
|