commit cbb2ea005486de98e72cb563dee41a2f9718291d Author: renzhiyuan <465386466@qq.com> Date: Mon Sep 28 08:25:44 2026 +0800 11 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/jimeng.iml b/.idea/jimeng.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/jimeng.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5f10538 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..d7cf09a --- /dev/null +++ b/client/client.go @@ -0,0 +1,133 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" + + "jimeng/signer" +) + +// Client 即梦API客户端 +type Client struct { + signer *signer.Config + hc *http.Client +} + +// NewClient 创建客户端 +func NewClient(ak, sk, host string) *Client { + return &Client{ + signer: &signer.Config{ + AccessKey: ak, + SecretKey: sk, + Host: host, + }, + hc: &http.Client{Timeout: 30 * time.Second}, + } +} + +// post 统一发送带签名的 POST 请求 +func (c *Client) post(path string, body any, out any) error { + b, err := json.Marshal(body) + if err != nil { + return err + } + log.Printf("[HTTP] POST %s Body=%s", path, string(b)) + hdrs, err := c.signer.Headers(&signer.Request{ + Method: "POST", + Path: path, + Body: b, + ContentType: "application/json", + }) + if err != nil { + return err + } + req, err := http.NewRequest("POST", c.signer.Host+path, bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + for k, v := range hdrs { + req.Header.Set(k, v) + } + resp, err := c.hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + rb, _ := io.ReadAll(resp.Body) + log.Printf("[HTTP] Response status=%d body=%s", resp.StatusCode, string(rb)) + if err := json.Unmarshal(rb, out); err != nil { + return fmt.Errorf("decode resp: %w, body=%s", err, string(rb)) + } + return nil +} + +// SubmitNovelTask 提交短剧任务 +func (c *Client) SubmitNovelTask(input map[string]interface{}) (map[string]interface{}, error) { + req := map[string]interface{}{ + "capability_key": "pippit_novel_agent", + "action_key": input["action_key"], + "run_id": fmt.Sprintf("biz-%d", time.Now().UnixNano()), + "input": input, + } + var out map[string]interface{} + if err := c.post("/agent_openapi/v1/novel/submit", req, &out); err != nil { + return nil, err + } + return out, nil +} + +// QueryNovelTask 查询短剧任务 +func (c *Client) QueryNovelTask(taskID, runID string) (map[string]interface{}, error) { + body := map[string]string{} + if taskID != "" { + body["task_id"] = taskID + } else { + body["run_id"] = runID + } + var out map[string]interface{} + if err := c.post("/agent_openapi/v1/novel/query", body, &out); err != nil { + return nil, err + } + return out, nil +} + +// SubmitVideoTask 提交视频任务(带货营销/剧情营销/短片创作) +func (c *Client) SubmitVideoTask(capabilityKey string, input map[string]interface{}) (map[string]interface{}, error) { + req := map[string]interface{}{ + "capability_key": capabilityKey, + "action_key": "generate_video", + "run_id": fmt.Sprintf("biz-%d", time.Now().UnixNano()), + } + // 带货营销和剧情营销使用 avatar_marketing_input,短片创作使用 input + if capabilityKey == "pippit_video_part_agent" { + req["input"] = input + } else { + req["avatar_marketing_input"] = input + } + var out map[string]interface{} + if err := c.post("/agent_openapi/v1/video/submit", req, &out); err != nil { + return nil, err + } + return out, nil +} + +// QueryVideoTask 查询视频任务 +func (c *Client) QueryVideoTask(taskID, runID string) (map[string]interface{}, error) { + body := map[string]string{} + if taskID != "" { + body["task_id"] = taskID + } else { + body["run_id"] = runID + } + var out map[string]interface{} + if err := c.post("/agent_openapi/v1/video/query", body, &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..e254667 --- /dev/null +++ b/config.yaml @@ -0,0 +1,26 @@ +server: + port: 8080 + +api: + access_key: "ak_Oh19gpPv0po9ZFM45LBHdN2B" + secret_key: "sk_nF8beggPmNiDfwQxVD1oRfCdW3jtOwBLay8b0DSLuho" + host: "https://jimeng.jianying.com" + +store: + data_file: "data/tasks.json" + +test_product: + - name: "化妆品" + url: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/huazhuangpin.png" + - name: "礼盒" + url: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/lihe.png" + - name: "蛋糕" + url: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/dangao.png" + +test_model: + - name: "女模特1" + url: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/woman2.png" + - name: "女模特2" + url: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/woman1.png" + - name: "男模特1" + url: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png" diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..64fbe64 --- /dev/null +++ b/config/config.go @@ -0,0 +1,65 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +// Config 全局配置 +type Config struct { + Server ServerConfig `yaml:"server"` + API APIConfig `yaml:"api"` + Store StoreConfig `yaml:"store"` + TestProduct []TestDataItem `yaml:"test_product"` + TestModel []TestDataItem `yaml:"test_model"` +} + +// TestDataItem 测试数据项 +type TestDataItem struct { + Name string `yaml:"name" json:"name"` + URL string `yaml:"url" json:"url"` +} + +// ServerConfig 服务器配置 +type ServerConfig struct { + Port int `yaml:"port"` +} + +// APIConfig API配置 +type APIConfig struct { + AccessKey string `yaml:"access_key"` + SecretKey string `yaml:"secret_key"` + Host string `yaml:"host"` +} + +// StoreConfig 存储配置 +type StoreConfig struct { + DataFile string `yaml:"data_file"` +} + +// Load 加载配置文件 +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + // 设置默认值 + if cfg.Server.Port == 0 { + cfg.Server.Port = 8080 + } + if cfg.API.Host == "" { + cfg.API.Host = "https://jimeng.jianying.com" + } + if cfg.Store.DataFile == "" { + cfg.Store.DataFile = "data/tasks.json" + } + + return &cfg, nil +} diff --git a/data/tasks.json b/data/tasks.json new file mode 100644 index 0000000..6a739de --- /dev/null +++ b/data/tasks.json @@ -0,0 +1,251 @@ +[ + { + "id": "1790352548052288100", + "type": "video", + "capability": "pippit_avatar_marketing_agent", + "action": "generate_video", + "status": "", + "thread_id": "", + "asset_id": "", + "run_id": "", + "input": { + "enable_watermark": false, + "model_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png" + ], + "product_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/dangao.png" + ], + "product_name": "蛋糕", + "prompt": "好吃" + }, + "output": null, + "error_code": "", + "error_msg": "", + "created_at": "2026-09-26T00:09:08.0522881+08:00", + "updated_at": "2026-09-26T00:09:59.1266034+08:00", + "elapsed_sec": 0, + "usage": null + }, + { + "id": "1790554523724154000", + "type": "video", + "capability": "pippit_avatar_marketing_agent", + "action": "generate_video", + "status": "QUEUED", + "thread_id": "", + "asset_id": "", + "run_id": "biz-1790554523337556900", + "input": { + "enable_watermark": false, + "model_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/woman2.png" + ], + "product_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/lihe.png" + ], + "product_name": "礼盒", + "prompt": "非常便宜" + }, + "output": null, + "error_code": "", + "error_msg": "", + "created_at": "2026-09-28T08:15:23.724154+08:00", + "updated_at": "2026-09-28T08:15:23.724154+08:00", + "elapsed_sec": 0, + "usage": null + }, + { + "id": "1790352614814653300", + "type": "video", + "capability": "pippit_avatar_marketing_agent", + "action": "generate_video", + "status": "", + "thread_id": "", + "asset_id": "", + "run_id": "", + "input": { + "enable_watermark": false, + "model_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png" + ], + "product_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/huazhuangpin.png" + ], + "product_name": "化妆品", + "prompt": "11" + }, + "output": null, + "error_code": "", + "error_msg": "", + "created_at": "2026-09-26T00:10:14.8146533+08:00", + "updated_at": "2026-09-26T00:11:05.8792352+08:00", + "elapsed_sec": 0, + "usage": null + }, + { + "id": "1790542259382038400", + "type": "video", + "capability": "pippit_avatar_marketing_agent", + "action": "generate_video", + "status": "", + "thread_id": "", + "asset_id": "", + "run_id": "", + "input": { + "enable_watermark": false, + "model_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png" + ], + "product_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/huazhuangpin.png" + ], + "product_name": "" + }, + "output": null, + "error_code": "", + "error_msg": "", + "created_at": "2026-09-28T04:50:59.3820384+08:00", + "updated_at": "2026-09-28T04:51:41.252295+08:00", + "elapsed_sec": 0, + "usage": null + }, + { + "id": "1790542379408153800", + "type": "video", + "capability": "pippit_avatar_marketing_agent", + "action": "generate_video", + "status": "QUEUED", + "thread_id": "", + "asset_id": "", + "run_id": "biz-1790542379225626100", + "input": { + "enable_watermark": true, + "model_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png" + ], + "product_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/huazhuangpin.png" + ], + "product_name": "测试" + }, + "output": null, + "error_code": "", + "error_msg": "", + "created_at": "2026-09-28T04:52:59.4081538+08:00", + "updated_at": "2026-09-28T04:52:59.4081538+08:00", + "elapsed_sec": 0, + "usage": null + }, + { + "id": "1790542424748799600", + "type": "video", + "capability": "pippit_avatar_marketing_agent", + "action": "generate_video", + "status": "QUEUED", + "thread_id": "", + "asset_id": "", + "run_id": "biz-1790542424496945300", + "input": { + "enable_watermark": true, + "model_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/woman1.png" + ], + "product_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/huazhuangpin.png" + ], + "product_name": "测试" + }, + "output": null, + "error_code": "", + "error_msg": "", + "created_at": "2026-09-28T04:53:44.7487996+08:00", + "updated_at": "2026-09-28T04:53:44.7487996+08:00", + "elapsed_sec": 0, + "usage": null + }, + { + "id": "1790351272455941700", + "type": "video", + "capability": "pippit_avatar_marketing_agent", + "action": "generate_video", + "status": "", + "thread_id": "", + "asset_id": "", + "run_id": "", + "input": { + "enable_watermark": false, + "model_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png" + ], + "product_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/huazhuangpin.png" + ], + "product_name": "", + "prompt": "111" + }, + "output": null, + "error_code": "", + "error_msg": "", + "created_at": "2026-09-25T23:47:52.4559417+08:00", + "updated_at": "2026-09-28T04:52:20.7042357+08:00", + "elapsed_sec": 0, + "usage": null + }, + { + "id": "1790351604073897100", + "type": "video", + "capability": "pippit_avatar_marketing_agent", + "action": "generate_video", + "status": "", + "thread_id": "", + "asset_id": "", + "run_id": "", + "input": { + "enable_watermark": false, + "model_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png" + ], + "product_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/dangao.png" + ], + "product_name": "蛋糕", + "prompt": "好吃" + }, + "output": null, + "error_code": "", + "error_msg": "", + "created_at": "2026-09-25T23:53:24.0738971+08:00", + "updated_at": "2026-09-28T04:52:20.7185271+08:00", + "elapsed_sec": 0, + "usage": null + }, + { + "id": "1790351753610019500", + "type": "video", + "capability": "pippit_avatar_marketing_agent", + "action": "generate_video", + "status": "", + "thread_id": "", + "asset_id": "", + "run_id": "", + "input": { + "enable_watermark": false, + "model_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/model/man1.png" + ], + "product_image_url_list": [ + "https://attachment-public.oss-cn-hangzhou.aliyuncs.com/jimengtest/product/dangao.png" + ], + "product_name": "蛋糕", + "prompt": "好吃" + }, + "output": null, + "error_code": "", + "error_msg": "", + "created_at": "2026-09-25T23:55:53.6100195+08:00", + "updated_at": "2026-09-28T04:52:20.71+08:00", + "elapsed_sec": 0, + "usage": null + } +] \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fa7d006 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module jimeng + +go 1.26.4 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler/handler.go b/handler/handler.go new file mode 100644 index 0000000..55c467a --- /dev/null +++ b/handler/handler.go @@ -0,0 +1,295 @@ +package handler + +import ( + "encoding/json" + "net/http" + "time" + + "jimeng/client" + "jimeng/config" + "jimeng/model" + "jimeng/store" +) + +// Handler HTTP处理器 +type Handler struct { + client *client.Client + store *store.Store + cfg *config.Config +} + +// NewHandler 创建处理器 +func NewHandler(c *client.Client, s *store.Store, cfg *config.Config) *Handler { + return &Handler{client: c, store: s, cfg: cfg} +} + +// RegisterRoutes 注册路由 +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/novel/submit", h.submitNovel) + mux.HandleFunc("/api/novel/query", h.queryNovel) + mux.HandleFunc("/api/video/submit", h.submitVideo) + mux.HandleFunc("/api/video/query", h.queryVideo) + mux.HandleFunc("/api/tasks", h.listTasks) + mux.HandleFunc("/api/task", h.getTask) + mux.HandleFunc("/api/task/delete", h.deleteTask) + mux.HandleFunc("/api/testdata", h.getTestData) +} + +func (h *Handler) submitNovel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + var input map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + result, err := h.client.SubmitNovelTask(input) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // 创建本地任务记录 + task := &model.Task{ + ID: store.GenerateID(), + Type: "novel", + Capability: "pippit_novel_agent", + Action: getString(input, "action_key"), + Status: "CREATED", + ThreadID: getString(input, "thread_id"), + AssetID: getString(input, "asset_id"), + RunID: getString(result, "run_id"), + Input: input, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if data, ok := result["data"].(map[string]interface{}); ok { + task.Status = getString(data, "status") + if task.RunID == "" { + task.RunID = getString(data, "run_id") + } + if task.ThreadID == "" { + task.ThreadID = getString(data, "thread_id") + } + } + if err := h.store.CreateTask(task); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // 将本地 task_id 和 run_id 注入响应 + if data, ok := result["data"].(map[string]interface{}); ok { + if getString(data, "task_id") == "" { + data["task_id"] = task.ID + } + if getString(data, "run_id") == "" { + data["run_id"] = task.RunID + } + } + json.NewEncoder(w).Encode(result) +} + +func (h *Handler) queryNovel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + TaskID string `json:"task_id"` + RunID string `json:"run_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + result, err := h.client.QueryNovelTask(req.TaskID, req.RunID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // 更新本地任务状态 + if data, ok := result["data"].(map[string]interface{}); ok { + taskID := req.TaskID + if taskID == "" { + // 通过run_id查找任务 + tasks := h.store.ListTasks() + for _, t := range tasks { + if t.RunID == req.RunID { + taskID = t.ID + break + } + } + } + if task, ok := h.store.GetTask(taskID); ok { + task.Status = getString(data, "status") + task.ThreadID = getString(data, "thread_id") + task.UpdatedAt = time.Now() + if usage, ok := data["usage"].(map[string]interface{}); ok { + task.Usage = usage + } + if novelData, ok := data["novel_data"].(map[string]interface{}); ok { + task.Output = novelData + } + h.store.UpdateTask(task) + } + } + json.NewEncoder(w).Encode(result) +} + +func (h *Handler) submitVideo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + CapabilityKey string `json:"capability_key"` + Input map[string]interface{} `json:"input"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + result, err := h.client.SubmitVideoTask(req.CapabilityKey, req.Input) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // 创建本地任务记录 + task := &model.Task{ + ID: store.GenerateID(), + Type: "video", + Capability: req.CapabilityKey, + Action: "generate_video", + Status: "CREATED", + RunID: getString(result, "run_id"), + Input: req.Input, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if data, ok := result["data"].(map[string]interface{}); ok { + task.Status = getString(data, "status") + if task.RunID == "" { + task.RunID = getString(data, "run_id") + } + } + if err := h.store.CreateTask(task); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // 将本地 task_id 和 run_id 注入响应,确保前端能拿到 + if data, ok := result["data"].(map[string]interface{}); ok { + if getString(data, "task_id") == "" { + data["task_id"] = task.ID + } + if getString(data, "run_id") == "" { + data["run_id"] = task.RunID + } + } + json.NewEncoder(w).Encode(result) +} + +func (h *Handler) queryVideo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + TaskID string `json:"task_id"` + RunID string `json:"run_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + result, err := h.client.QueryVideoTask(req.TaskID, req.RunID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // 更新本地任务状态 + if data, ok := result["data"].(map[string]interface{}); ok { + taskID := req.TaskID + if taskID == "" { + tasks := h.store.ListTasks() + for _, t := range tasks { + if t.RunID == req.RunID { + taskID = t.ID + break + } + } + } + if task, ok := h.store.GetTask(taskID); ok { + task.Status = getString(data, "status") + task.UpdatedAt = time.Now() + if usage, ok := data["usage"].(map[string]interface{}); ok { + task.Usage = usage + } + if artifact, ok := data["video_artifact"].(map[string]interface{}); ok { + task.Output = artifact + } + if errCode, ok := data["err_code"].(string); ok { + task.ErrorCode = errCode + } + if errMsg, ok := data["err_msg"].(string); ok { + task.ErrorMsg = errMsg + } + h.store.UpdateTask(task) + } + } + json.NewEncoder(w).Encode(result) +} + +func (h *Handler) listTasks(w http.ResponseWriter, r *http.Request) { + tasks := h.store.ListTasks() + json.NewEncoder(w).Encode(tasks) +} + +func (h *Handler) getTask(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "id required", http.StatusBadRequest) + return + } + task, ok := h.store.GetTask(id) + if !ok { + http.Error(w, "task not found", http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(task) +} + +func (h *Handler) deleteTask(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + ID string `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := h.store.DeleteTask(req.ID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write([]byte(`{"success":true}`)) +} + +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func (h *Handler) getTestData(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "test_product": h.cfg.TestProduct, + "test_model": h.cfg.TestModel, + } + json.NewEncoder(w).Encode(resp) +} diff --git a/jimeng.exe b/jimeng.exe new file mode 100644 index 0000000..65f83f5 Binary files /dev/null and b/jimeng.exe differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..8a5dc0c --- /dev/null +++ b/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "embed" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + + "jimeng/client" + "jimeng/config" + "jimeng/handler" + "jimeng/store" +) + +//go:embed web/* +var webFS embed.FS + +func main() { + configPath := flag.String("config", "config.yaml", "配置文件路径") + flag.Parse() + + // 加载配置 + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("加载配置失败: %v", err) + } + + // 创建存储 + s, err := store.NewStore(cfg.Store.DataFile) + if err != nil { + log.Fatalf("创建存储失败: %v", err) + } + + // 创建API客户端 + c := client.NewClient(cfg.API.AccessKey, cfg.API.SecretKey, cfg.API.Host) + + // 创建处理器 + h := handler.NewHandler(c, s, cfg) + + // 创建路由 + mux := http.NewServeMux() + + // 注册API路由 + h.RegisterRoutes(mux) + + // 注册静态文件服务 + webContent, _ := fs.Sub(webFS, "web") + fileServer := http.FileServer(http.FS(webContent)) + mux.Handle("/", fileServer) + + // 启动服务器 + addr := fmt.Sprintf(":%d", cfg.Server.Port) + log.Printf("服务器启动在 http://localhost%s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("服务器启动失败: %v", err) + } +} diff --git a/model/model.go b/model/model.go new file mode 100644 index 0000000..1acbd2c --- /dev/null +++ b/model/model.go @@ -0,0 +1,31 @@ +package model + +import "time" + +// Task 任务信息 +type Task struct { + ID string `json:"id"` + Type string `json:"type"` // novel, video + Capability string `json:"capability"` // capability_key + Action string `json:"action"` // action_key + Status string `json:"status"` // CREATED, QUEUED, RUNNING, SUCCESS, FAILED, CANCELED + ThreadID string `json:"thread_id"` // 短剧会话ID + AssetID string `json:"asset_id"` // 资产ID + RunID string `json:"run_id"` // 幂等键 + Input map[string]interface{} `json:"input"` // 输入参数 + Output map[string]interface{} `json:"output"` // 输出结果 + ErrorCode string `json:"error_code"` // 错误码 + ErrorMsg string `json:"error_msg"` // 错误信息 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ElapsedSec int64 `json:"elapsed_sec"` // 耗时秒 + Usage map[string]interface{} `json:"usage"` // 计费信息 +} + +// APIResponse 通用API响应 +type APIResponse struct { + Code string `json:"code"` + Message string `json:"message"` + RequestID string `json:"request_id"` + Data map[string]interface{} `json:"data"` +} diff --git a/signer/signer.go b/signer/signer.go new file mode 100644 index 0000000..5c81230 --- /dev/null +++ b/signer/signer.go @@ -0,0 +1,109 @@ +package signer + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "log" + "net/url" + "sort" + "strings" + "time" +) + +// Config 签名配置 +type Config struct { + AccessKey string + SecretKey string + Host string // https://jimeng.jianying.com +} + +// Request 签名请求信息 +type Request struct { + Method string // 恒为 POST + Path string // 如 /agent_openapi/v1/video/submit + Query url.Values // 通常为空 + Body []byte // JSON 序列化后的请求体 + ContentType string // 恒为 application/json +} + +// Headers 生成鉴权相关请求头 +func (c *Config) Headers(req *Request) (map[string]string, error) { + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + nonce, err := randomNonce() + if err != nil { + return nil, err + } + bodySHA := sha256Hex(req.Body) + canonicalQuery := canonicalQueryString(req.Query) + contentType := req.ContentType + if contentType == "" { + contentType = "application/json" + } + + stringToSign := strings.Join([]string{ + req.Method, req.Path, canonicalQuery, contentType, + bodySHA, timestamp, nonce, c.AccessKey, + }, "\n") + + signature := hmacSHA256Base64URL(c.SecretKey, stringToSign) + + // 调试日志:打印签名详情,便于排查 10006 错误 + log.Printf("[SIGN] Path=%s BodyLen=%d BodySHA256=%s", req.Path, len(req.Body), bodySHA) + log.Printf("[SIGN] StringToSign=\n%s", stringToSign) + log.Printf("[SIGN] Signature=%s", signature) + + return map[string]string{ + "X-Agent-Access-Key": c.AccessKey, + "X-Agent-Timestamp": timestamp, + "X-Agent-Nonce": nonce, + "X-Agent-Body-SHA256": bodySHA, + "X-Agent-Signature-Method": "HMAC-SHA256", + "X-Agent-Version": "v1", + "X-Agent-Signature": signature, + }, nil +} + +func randomNonce() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) // 小写 +} + +// query 按参数名 ASCII 升序,key/value 各自 RFC3986 编码后 & 连接 +func canonicalQueryString(v url.Values) string { + if len(v) == 0 { + return "" + } + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + var parts []string + for _, k := range keys { + for _, val := range v[k] { + parts = append(parts, escape(k)+"="+escape(val)) + } + } + return strings.Join(parts, "&") +} + +// RFC3986:空格 -> %20,~ 不编码 +func escape(s string) string { return url.QueryEscape(s) } + +func hmacSHA256Base64URL(key, data string) string { + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte(data)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) // Raw 即无 padding +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..f60dca1 --- /dev/null +++ b/store/store.go @@ -0,0 +1,126 @@ +package store + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "jimeng/model" +) + +// Store 任务存储 +type Store struct { + mu sync.RWMutex + tasks map[string]*model.Task + dataFile string +} + +// NewStore 创建存储实例 +func NewStore(dataFile string) (*Store, error) { + s := &Store{ + tasks: make(map[string]*model.Task), + dataFile: dataFile, + } + // 确保目录存在 + dir := filepath.Dir(dataFile) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + // 加载已有数据 + if err := s.load(); err != nil && !os.IsNotExist(err) { + return nil, err + } + return s, nil +} + +// load 从文件加载任务 +func (s *Store) load() error { + data, err := os.ReadFile(s.dataFile) + if err != nil { + return err + } + var tasks []*model.Task + if err := json.Unmarshal(data, &tasks); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + for _, t := range tasks { + s.tasks[t.ID] = t + } + return nil +} + +// save 保存任务到文件 +func (s *Store) save() error { + s.mu.RLock() + tasks := make([]*model.Task, 0, len(s.tasks)) + for _, t := range s.tasks { + tasks = append(tasks, t) + } + s.mu.RUnlock() + data, err := json.MarshalIndent(tasks, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.dataFile, data, 0644) +} + +// CreateTask 创建任务 +func (s *Store) CreateTask(t *model.Task) error { + s.mu.Lock() + s.tasks[t.ID] = t + s.mu.Unlock() + return s.save() +} + +// GetTask 获取任务 +func (s *Store) GetTask(id string) (*model.Task, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + t, ok := s.tasks[id] + return t, ok +} + +// UpdateTask 更新任务 +func (s *Store) UpdateTask(t *model.Task) error { + s.mu.Lock() + s.tasks[t.ID] = t + s.mu.Unlock() + return s.save() +} + +// ListTasks 列出所有任务 +func (s *Store) ListTasks() []*model.Task { + s.mu.RLock() + defer s.mu.RUnlock() + tasks := make([]*model.Task, 0, len(s.tasks)) + for _, t := range s.tasks { + tasks = append(tasks, t) + } + // 按创建时间倒序 + for i := 0; i < len(tasks); i++ { + for j := i + 1; j < len(tasks); j++ { + if tasks[i].CreatedAt.Before(tasks[j].CreatedAt) { + tasks[i], tasks[j] = tasks[j], tasks[i] + } + } + } + return tasks +} + +// DeleteTask 删除任务 +func (s *Store) DeleteTask(id string) error { + s.mu.Lock() + delete(s.tasks, id) + s.mu.Unlock() + return s.save() +} + +// GenerateID 生成任务ID +func GenerateID() string { + return fmt.Sprintf("%d", time.Now().UnixNano()) +} diff --git a/web/css/style.css b/web/css/style.css new file mode 100644 index 0000000..f040151 --- /dev/null +++ b/web/css/style.css @@ -0,0 +1,478 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary: #6366f1; + --primary-dark: #4f46e5; + --success: #10b981; + --warning: #f59e0b; + --danger: #ef4444; + --bg: #f8fafc; + --card: #ffffff; + --text: #1e293b; + --text-light: #64748b; + --border: #e2e8f0; + --radius: 12px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + padding-bottom: 70px; +} + +/* Header */ +.header { + background: linear-gradient(135deg, var(--primary), var(--primary-dark)); + color: white; + padding: 20px; + text-align: center; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); +} + +.header h1 { + font-size: 20px; + font-weight: 600; +} + +/* Navigation */ +.nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--card); + display: flex; + justify-content: space-around; + padding: 10px 0; + box-shadow: 0 -2px 10px rgba(0,0,0,0.1); + z-index: 100; +} + +.nav-item { + display: flex; + flex-direction: column; + align-items: center; + text-decoration: none; + color: var(--text-light); + font-size: 12px; + padding: 5px 10px; + transition: color 0.2s; +} + +.nav-item.active { + color: var(--primary); +} + +.nav-item svg { + width: 24px; + height: 24px; + margin-bottom: 4px; +} + +/* Container */ +.container { + padding: 20px; + max-width: 600px; + margin: 0 auto; +} + +/* Card */ +.card { + background: var(--card); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 16px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.card-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; + color: var(--text); +} + +/* Form */ +.form-group { + margin-bottom: 16px; +} + +.form-label { + display: block; + font-size: 14px; + font-weight: 500; + margin-bottom: 6px; + color: var(--text); +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 14px; + transition: border-color 0.2s; +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--primary); +} + +.form-textarea { + resize: vertical; + min-height: 80px; +} + +/* Button */ +.btn { + display: inline-block; + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + text-align: center; + text-decoration: none; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover { + background: var(--primary-dark); +} + +.btn-success { + background: var(--success); + color: white; +} + +.btn-danger { + background: var(--danger); + color: white; +} + +.btn-block { + display: block; + width: 100%; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Status Badge */ +.badge { + display: inline-block; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +.badge-created { + background: #e0e7ff; + color: #3730a3; +} + +.badge-queued { + background: #fef3c7; + color: #92400e; +} + +.badge-running { + background: #dbeafe; + color: #1e40af; +} + +.badge-success { + background: #d1fae5; + color: #065f46; +} + +.badge-failed { + background: #fee2e2; + color: #991b1b; +} + +.badge-canceled { + background: #f3f4f6; + color: #374151; +} + +/* Task List */ +.task-item { + background: var(--card); + border-radius: var(--radius); + padding: 16px; + margin-bottom: 12px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.task-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.task-title { + font-size: 14px; + font-weight: 600; +} + +.task-meta { + font-size: 12px; + color: var(--text-light); + margin-bottom: 8px; +} + +.task-actions { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.task-actions .btn { + padding: 8px 16px; + font-size: 13px; +} + +/* Result */ +.result { + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: var(--radius); + padding: 16px; + margin-top: 16px; +} + +.result-title { + font-size: 14px; + font-weight: 600; + color: #065f46; + margin-bottom: 8px; +} + +.result-video { + width: 100%; + border-radius: 8px; + margin-top: 12px; +} + +/* Loading */ +.loading { + text-align: center; + padding: 40px; + color: var(--text-light); +} + +.spinner { + border: 3px solid var(--border); + border-top: 3px solid var(--primary); + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 0 auto 16px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Empty State */ +.empty { + text-align: center; + padding: 60px 20px; + color: var(--text-light); +} + +.empty svg { + width: 80px; + height: 80px; + margin-bottom: 16px; + opacity: 0.5; +} + +/* Alert */ +.alert { + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 14px; +} + +.alert-success { + background: #d1fae5; + color: #065f46; +} + +.alert-error { + background: #fee2e2; + color: #991b1b; +} + +.alert-info { + background: #dbeafe; + color: #1e40af; +} + +/* Tabs */ +.tabs { + display: flex; + background: var(--card); + border-radius: var(--radius); + padding: 4px; + margin-bottom: 20px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.tab { + flex: 1; + padding: 10px; + text-align: center; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s; + color: var(--text-light); +} + +.tab.active { + background: var(--primary); + color: white; +} + +/* Stage indicator */ +.stages { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.stage { + padding: 6px 12px; + border-radius: 16px; + font-size: 12px; + background: var(--border); + color: var(--text-light); +} + +.stage.active { + background: var(--primary); + color: white; +} + +.stage.completed { + background: var(--success); + color: white; +} + +/* Test Data Picker */ +.btn-testdata { + display: inline-block; + margin-top: 8px; + padding: 6px 12px; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 6px; + font-size: 13px; + color: #0369a1; + cursor: pointer; + transition: all 0.2s; +} + +.btn-testdata:hover { + background: #e0f2fe; + border-color: #7dd3fc; +} + +.testdata-picker { + margin-top: 10px; + padding: 12px; + background: #fafafa; + border: 1px solid var(--border); + border-radius: 8px; +} + +.testdata-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px; + margin-bottom: 8px; + background: white; + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.testdata-item:hover { + border-color: var(--primary); + background: #f5f3ff; +} + +.testdata-item.selected { + border-color: var(--primary); + background: #ede9fe; +} + +.testdata-item:last-child { + margin-bottom: 0; +} + +.testdata-thumb { + width: 50px; + height: 50px; + object-fit: cover; + border-radius: 6px; + border: 1px solid var(--border); +} + +.testdata-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.testdata-name { + font-size: 14px; + font-weight: 500; + color: var(--text); +} + +.testdata-url { + font-size: 11px; + color: var(--text-light); +} + +.testdata-empty { + text-align: center; + padding: 20px; + color: var(--text-light); + font-size: 13px; +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..292440c --- /dev/null +++ b/web/index.html @@ -0,0 +1,62 @@ + + + + + + 即梦AI创作平台 + + + +
+

即梦AI创作平台

+
+ +
+
+
短剧/漫剧创作
+

多阶段流水线:剧本解析 → 角色/场景生成 → 分镜设计 → 视频生成 → 成片合成

+
+ 8个阶段 + 流水线 +
+
+ +
+
视频生成
+

支持带货营销、剧情营销、短片创作三种模式

+
+ 带货营销 + 剧情营销 + 短片创作 +
+
+ +
+
任务管理
+

查看所有任务状态、查看结果、下载视频

+
+ 实时状态 +
+
+
+ + + + diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..88f62b3 --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,100 @@ +// API调用工具 +const API = { + async post(url, data) { + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!resp.ok) { + const err = await resp.text(); + throw new Error(err); + } + return resp.json(); + }, + async get(url) { + const resp = await fetch(url); + if (!resp.ok) { + const err = await resp.text(); + throw new Error(err); + } + return resp.json(); + } +}; + +// 状态显示 +function getStatusBadge(status) { + const map = { + 'CREATED': '已创建', + 'QUEUED': '排队中', + 'RUNNING': '执行中', + 'SUCCESS': '成功', + 'FAILED': '失败', + 'CANCELED': '已取消' + }; + return map[status] || `${status}`; +} + +// 格式化时间 +function formatTime(ts) { + if (!ts) return '-'; + const d = new Date(ts); + return d.toLocaleString('zh-CN'); +} + +// 显示提示 +function showAlert(msg, type = 'info') { + const alert = document.createElement('div'); + alert.className = `alert alert-${type}`; + alert.textContent = msg; + const container = document.querySelector('.container'); + container.insertBefore(alert, container.firstChild); + setTimeout(() => alert.remove(), 5000); +} + +// 轮询任务状态 +function pollTask(taskId, queryFn, onUpdate, interval = 3000) { + let timer; + const poll = async () => { + try { + const result = await queryFn(taskId); + onUpdate(result); + const status = result?.data?.status; + if (status === 'SUCCESS' || status === 'FAILED' || status === 'CANCELED') { + clearInterval(timer); + } + } catch (err) { + console.error('轮询失败:', err); + } + }; + poll(); + timer = setInterval(poll, interval); + return () => clearInterval(timer); +} + +// 获取能力名称 +function getCapabilityName(key) { + const map = { + 'pippit_novel_agent': '短剧/漫剧', + 'pippit_avatar_marketing_agent': '带货营销', + 'pippit_story_marketing_agent': '剧情营销', + 'pippit_video_part_agent': '短片创作' + }; + return map[key] || key; +} + +// 获取动作名称 +function getActionName(key) { + const map = { + 'script_analysis': '剧本解析', + 'narration_design': '旁白改编', + 'character_generate': '角色生成', + 'scene_generate': '场景生成', + 'storyboard_design': '分镜设计', + 'shot_video_generate': '分镜短片', + 'shot_video_generate_fast': '分镜短片(Fast)', + 'shot_video_compose': '成片合成', + 'generate_video': '生成视频' + }; + return map[key] || key; +} diff --git a/web/novel.html b/web/novel.html new file mode 100644 index 0000000..ccff0ff --- /dev/null +++ b/web/novel.html @@ -0,0 +1,234 @@ + + + + + + 短剧/漫剧创作 + + + +
+

短剧/漫剧创作

+
+ +
+ +
+
1.剧本解析
+
2.旁白改编
+
3.角色生成
+
4.场景生成
+
5.分镜设计
+
6.分镜短片
+
7.成片合成
+
+ + +
+
剧本解析
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + +
+ + + + + + + diff --git a/web/static/model/女模特1.png b/web/static/model/女模特1.png new file mode 100644 index 0000000..5eceeda Binary files /dev/null and b/web/static/model/女模特1.png differ diff --git a/web/static/model/女模特2.png b/web/static/model/女模特2.png new file mode 100644 index 0000000..49b1cec Binary files /dev/null and b/web/static/model/女模特2.png differ diff --git a/web/static/model/男模特1.png b/web/static/model/男模特1.png new file mode 100644 index 0000000..4fddd98 Binary files /dev/null and b/web/static/model/男模特1.png differ diff --git a/web/static/product/化妆品.png b/web/static/product/化妆品.png new file mode 100644 index 0000000..0eddd91 Binary files /dev/null and b/web/static/product/化妆品.png differ diff --git a/web/static/product/礼盒.png b/web/static/product/礼盒.png new file mode 100644 index 0000000..dc76f65 Binary files /dev/null and b/web/static/product/礼盒.png differ diff --git a/web/static/product/蛋糕.png b/web/static/product/蛋糕.png new file mode 100644 index 0000000..27f65b5 Binary files /dev/null and b/web/static/product/蛋糕.png differ diff --git a/web/tasks.html b/web/tasks.html new file mode 100644 index 0000000..e522dd0 --- /dev/null +++ b/web/tasks.html @@ -0,0 +1,230 @@ + + + + + + 任务管理 + + + +
+

任务管理

+
+ +
+
+
+
+

加载任务列表...

+
+
+
+ + + + + + + + + + diff --git a/web/video.html b/web/video.html new file mode 100644 index 0000000..7446191 --- /dev/null +++ b/web/video.html @@ -0,0 +1,337 @@ + + + + + + 视频生成 + + + +
+

视频生成

+
+ +
+
+
带货营销
+
剧情营销
+
短片创作
+
+ + +
+
带货营销视频
+
+ + +
+
+ + + + +
+
+ + + + +
+
+ + +
+
+ +
+ +
+
+ + + + + + +
+ + + + + + + diff --git a/work.md b/work.md new file mode 100644 index 0000000..8f8a14f --- /dev/null +++ b/work.md @@ -0,0 +1,587 @@ +# 即梦 API · Golang 对接最小参考 + +## 0. 全局须知(对接前必读) + +- 生成任务消耗 **token**(仅超级会员限时可用);token 包独立购买,**与即梦网页端积分不通用**。 +- 任务提交按能力策略**预扣** token;成功按实际用量结算(多退少补);**失败全额返还**。 +- 产物链接**有效期为 24 小时**,生成后请在 24h 内下载保存,逾期失效。 +- 限流:任务提交 10 qps(用户级),任务查询 20 qps(用户级)。 +- 所有接口均为 `POST`,`Content-Type: application/json`。 + +--- + +## 1. 鉴权(AK/SK 签名)★必须完整实现 + +调用方持一对 `AccessKey(AK)` / `SecretKey(SK)`。**SK 只在创建时明文返回一次,服务端不提供二次查询**,自行妥善保存。 + +### 1.1 请求头(8 个字段全部必填) + +| Header | 必填 | 说明 | +|---|---|---| +| `X-Agent-Access-Key` | 是 | AK,服务端签发字符串 | +| `X-Agent-Timestamp` | 是 | 请求发起 Unix 时间戳(秒)。服务端允许 ±300 秒偏差,超出视为重放 | +| `X-Agent-Nonce` | 是 | 本次随机串(推荐 UUID)。同一 AK+Nonce 在时间窗口内去重,防重放 | +| `X-Agent-Body-SHA256` | 是 | HTTP Body 的 SHA-256 摘要(**小写十六进制**);空 Body 也要传空字符串的 SHA-256 | +| `X-Agent-Signature-Method` | 是 | 固定 `HMAC-SHA256` | +| `X-Agent-Version` | 是 | 固定 `v1` | +| `X-Agent-Signature` | 是 | 签名结果,Base64URL 编码(**不含 padding**),长度 = HMAC-SHA256 输出 32 字节 | +| `Content-Type` | 是 | 固定 `application/json`(**该字段参与签名**) | + +### 1.2 签名算法 + +按顺序拼接 8 个字段,字段间用换行符 `\n` 分隔构造 `StringToSign`,再用 SK 做 HMAC-SHA256: + +``` +StringToSign = METHOD + "\n" + + PATH + "\n" + + CANONICAL_QUERY + "\n" + + CONTENT_TYPE + "\n" + + SHA256(RequestBody) + "\n" + + TIMESTAMP + "\n" + + NONCE + "\n" + + ACCESS_KEY +Signature = Base64URL( HMAC-SHA256(SK, StringToSign) ) +``` + +各字段规则: + +- `METHOD`:HTTP 方法全大写,本 API 均为 `POST`。 +- `PATH`:请求路径含前缀,如 `/agent_openapi/v1/video/submit`。 +- `CANONICAL_QUERY`:query string 按参数名 **ASCII 升序**排序,每个 key/value 分别按 **RFC3986** 做 URL Encode(空格编码为 `%20`,`~` 保留不编码),最后用 `&` 连接;无 query 时为空字符串。 +- `CONTENT_TYPE`:与请求头完全一致,即 `application/json`。 +- `SHA256(RequestBody)`:Body 的**小写** SHA-256 十六进制,与 `X-Agent-Body-SHA256` 头一致。 +- `TIMESTAMP / NONCE / ACCESS_KEY`:与对应请求头完全一致。 + +### 1.3 Golang 签名实现(标准库,无第三方依赖) + +```go +package main + +import ( + "bytes" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +const ( + host = "https://jimeng.jianying.com" + path = "/agent_openapi/v1/video/submit" + method = "POST" + contentType = "application/json" + accessKey = "your_access_key" + secretKey = "your_secret_key" +) + +func main() { + body := []byte(`{"capability_key":"pippit_avatar_marketing_agent","action_key":"generate_video","run_id":"biz-avatar-005","avatar_marketing_input":{"product_name":"【高级美容护肤品拍一发三】虾青素抗皱面霜紧致修护补水保湿身体","product_image_url_list":["https://lf3-static.bytednsdoc.com/obj/eden-cn/upseh7fkuhm/product1.jpeg"],"model_image_url_list":["https://lf3-static.bytednsdoc.com/obj/eden-cn/upseh7fkuhm/model.jpeg"]}}`) + query := url.Values{} + + bodySHA256 := sha256Hex(body) + canonicalQuery := canonicalQueryString(query) + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + nonce, err := randomNonce() + if err != nil { + panic(err) + } + + stringToSign := method + "\n" + + path + "\n" + + canonicalQuery + "\n" + + contentType + "\n" + + bodySHA256 + "\n" + + timestamp + "\n" + + nonce + "\n" + + accessKey + + signature := hmacSHA256Base64URL(secretKey, stringToSign) + + requestURL := host + path + if canonicalQuery != "" { + requestURL += "?" + canonicalQuery + } + + req, err := http.NewRequest(method, requestURL, bytes.NewReader(body)) + if err != nil { + panic(err) + } + req.Header.Set("Content-Type", contentType) + req.Header.Set("X-Agent-Access-Key", accessKey) + req.Header.Set("X-Agent-Timestamp", timestamp) + req.Header.Set("X-Agent-Nonce", nonce) + req.Header.Set("X-Agent-Body-SHA256", bodySHA256) + req.Header.Set("X-Agent-Signature-Method", "HMAC-SHA256") + req.Header.Set("X-Agent-Version", "v1") + req.Header.Set("X-Agent-Signature", signature) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + + rspBody, err := io.ReadAll(resp.Body) + if err != nil { + panic(err) + } + fmt.Println("status:", resp.StatusCode) + fmt.Println(string(rspBody)) +} + +func canonicalQueryString(query url.Values) string { + keys := make([]string, 0, len(query)) + for key := range query { + keys = append(keys, key) + } + sort.Strings(keys) + + parts := make([]string, 0) + for _, key := range keys { + values := append([]string(nil), query[key]...) + sort.Strings(values) + for _, value := range values { + parts = append(parts, urlEncode(key)+"="+urlEncode(value)) + } + } + return strings.Join(parts, "&") +} + +func urlEncode(value string) string { + encoded := url.QueryEscape(value) + encoded = strings.ReplaceAll(encoded, "+", "%20") + encoded = strings.ReplaceAll(encoded, "%7E", "~") + return encoded +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func hmacSHA256Base64URL(secret, payload string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func randomNonce() (string, error) { + buf := make([]byte, 16) + _, err := rand.Read(buf) + if err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} +``` + +### 1.4 常见签名失败原因(报错 `10006` 时逐条排查) + +- 本地时钟偏差超过 300 秒。 +- `X-Agent-Body-SHA256` 不是小写十六进制。 +- Base64URL 带了 `=` padding。 +- 多个同名 query 参数未分别 encode 后再字典序拼接。 +- 计算时用的 `Content-Type` 与实际发送的不一致。 +- 同一 AK 的 nonce 在 300 秒窗口内重复。 + +--- + +## 2. 通用请求 / 响应结构 + +### 2.1 提交任务请求体通参 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `capability_key` | string | 是 | 能力标识,见「能力对照」 | +| `action_key` | string | 是 | 具体动作标识,须与 capability_key 匹配 | +| `run_id` | string | 否 | 业务侧幂等键。同一 AK 下重复提交同一 run_id 返回同一任务;未传由系统生成 | +| `input` | object | 是 | 能力相关业务入参,不同能力结构不同(见各能力章节) | + +> 注:营销视频/剧情营销 使用字段名 `avatar_marketing_input` 而非 `input`;短剧与短片创作使用 `input`。 + +### 2.2 通用响应结构 + +```json +{ "code": "0", "message": "success", "request_id": "20260708xxxxxxxx", "data": { ... } } +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `code` | string/int64 | 业务错误码,`0` 表示成功;非零见「错误码」 | +| `message` | string | 错误信息或成功描述 | +| `request_id` | string | 请求追踪 ID(LogID),**排障必须提供该字段** | +| `data` | object | 业务数据,不同接口结构不同 | + +提交接口 `data` 通常含:`task_id`(后续查询主键)、`run_id`、`thread_id`(短剧会话 ID,链路串联依赖)、`status`(一般为 `QUEUED`)、`capability_key`、`action_key`、`created_at`(Unix 秒)。 + +### 2.3 任务状态机 + +| status | 含义 | +|---|---| +| `CREATED` | 已创建,等待入队 | +| `QUEUED` | 已入队,等待调度 | +| `RUNNING` | Agent 任务执行中 | +| `SUCCESS` | 成功终态,从 `data` 读取产物 | +| `FAILED` | 失败终态,`err_code`/`err_msg` 给出原因 | +| `CANCELED` | 已取消终态 | + +### 2.4 计费字段(query 返回 `data.usage`) + +| 字段 | 说明 | +|---|---| +| `withhold_token` | 预扣 Token(执行时冻结) | +| `actual_token` | 实际结算 Token(成功终态生效) | +| `refund_token` | 返还 Token(失败或结算预扣差额返还) | +| `withhold_quota` / `actual_quota` | 短片/剧情营销额外返回:视频=预估/实际总时长,图片=张数 | + +--- + +## 3. 接口清单 + +Base:`https://jimeng.jianying.com`,全部 `POST` + 鉴权头。 + +| 用途 | 接口 | 能力 | +|---|---|---| +| 短剧提交 | `/agent_openapi/v1/novel/submit` | `pippit_novel_agent` | +| 短剧查询 | `/agent_openapi/v1/novel/query` | `pippit_novel_agent` | +| 视频提交 | `/agent_openapi/v1/video/submit` | 带货营销 / 剧情营销 / 短片创作 | +| 视频查询 | `/agent_openapi/v1/video/query` | 带货营销 / 剧情营销 / 短片创作 | + +--- + +## 4. 能力对照(capability_key / action_key) + +| 业务能力 | capability_key | 支持的 action_key | +|---|---|---| +| 短剧/漫剧 | `pippit_novel_agent` | `script_analysis`(剧本解析)、`narration_design`(旁白改编·可选)、`character_generate`(角色生成)、`scene_generate`(场景生成)、`storyboard_design`(分镜设计)、`shot_video_generate`(分镜短片 Seedance2.0)、`shot_video_generate_fast`(Seedance2.0 fast)、`shot_video_compose`(成片合成) | +| 带货营销视频 | `pippit_avatar_marketing_agent` | `generate_video`(另有 `avatar_marketing_agent_v2` 待开放) | +| 剧情营销视频 | `pippit_story_marketing_agent` | `generate_video` | +| 短片创作 | `pippit_video_part_agent` | `generate_video` | + +--- + +## 5. 短剧 Agent(novel,多阶段流水线) + +短剧是一串串联的阶段任务,用 `thread_id`(会话)+ `asset_id`(资产)把链路串起来: + +``` +script_analysis ─→ narration_design(可选) ─→ character_generate / scene_generate ─→ storyboard_design ─→ shot_video_generate(_fast) ─→ shot_video_compose +``` + +### 5.1 提交任务 `POST /agent_openapi/v1/novel/submit` + +Body: + +```json +{ + "capability_key": "pippit_novel_agent", + "action_key": "<各阶段动作>", + "run_id": "biz-novel-003", + "input": { ... } +} +``` + +`input` 字段(是否必填取决于 action_key): + +| 字段 | 类型 | 必填时机 | 说明 | +|---|---|---|---| +| `thread_id` | string | 除 `script_analysis` 外均需回填 | 剧本分析返回的会话 ID | +| `asset_id` | string | 除 `script_analysis` 外均需回填 | `narration_design/character_generate/scene_generate` 传 **OverviewAssetID**(蓝图资产);`storyboard_design/shot_video_generate/shot_video_generate_fast/shot_video_compose` 传 **StoryboardAssetID**(分镜资产) | +| `visual_style` | string | 是(script_analysis) | 视觉风格,如 `"2D,国风,平涂"`、`"3D,CG动画,写实都市"`、`"真人写实,电影风格,冷色调"` | +| `video_ratio` | string | 是(script_analysis) | `"16:9"` / `"9:16"` | +| `file_url` | string | 是(script_analysis) | 剧本文件 URL,需公网可访问 | +| `file_type` | string | 是(script_analysis) | `"txt"` / `"docx"` | +| `file_name` | string | 是(script_analysis) | 剧本文件名 | +| `shot_ids` | string[] | 是(shot_video_generate / _fast) | 分镜脚本编码列表,如 `["S1","S2"]` | +| `enable_watermark` | bool | 否 | 是否加水印,默认 false | + +### 5.2 查询任务 `POST /agent_openapi/v1/novel/query` + +请求 Body:`task_id` 或 `run_id` **二选一**。 + +响应关键字段(`data`): + +- `status` / `usage` / `thread_id` / `start_at` / `elapsed_seconds`:同通用结构。 +- `novel_data.overview_asset_id`:蓝图资产 ID,后续角色/场景/分镜/视频链路的关键入口。 +- `novel_data.resp_data`:**JSON 字符串,需再次反序列化**才能读取内部字段。 + +`resp_data` 二次反序列化后的关键字段(对接必须会用): + +| 字段 | 说明 | +|---|---| +| `OverviewAssetID` | 蓝图资产 ID → 旁白/角色/场景阶段回填为 `asset_id` | +| `StoryboardAssetID` | 分镜资产 ID(在 `StoryboardBriefs[]` 内)→ 分镜/短片/成片阶段回填为 `asset_id` | +| `Settings.VideoRatio` / `Settings.VisualStyle` | 比例与风格(与提交一致) | +| `EpisodeAssets[]` | 每集:`EpisodeID`、`EpisodeAssetID`、`StoryboardAssetID`、`CharacterAssetIDs`、`SceneAssetIDs` | +| `CharacterAssets[]` | 角色:`CharacterID/Name`、`CharacterAssetID`、`IsMainCharacter` | +| `SceneAssets[]` | 场景:`SceneAssetID`、`EpisodeAssetIDs` | +| `ScriptAssetID` / `CoreElement` | 剧本资产与核心要素 | + +> 链路取 `asset_id` 的规则:`narration_design`、`character_generate`、`scene_generate` 取 **OverviewAssetID**;`storyboard_design`、`shot_video_generate(_fast)`、`shot_video_compose` 取对应集的 **StoryboardAssetID**。 + +--- + +## 6. 视频类能力(video:带货营销 / 剧情营销 / 短片创作) + +三类共用 `POST /agent_openapi/v1/video/submit` 与 `/agent_openapi/v1/video/query`,仅 `capability_key` 与入参对象不同。 + +### 6.1 带货营销 `pippit_avatar_marketing_agent` + +Body 使用 `avatar_marketing_input`: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `prompt` | string | 否 | 自定义营销 prompt | +| `product_name` | string | 是 | 商品名称,用于文案/素材语义理解 | +| `product_image_url_list` | array | 是 | 商品图 1~3 张 | +| `model_image_url_list` | array | 否 | 模特图,最多 1 张 | +| `enable_watermark` | bool | 否 | 默认无水印 | + +### 6.2 剧情营销 `pippit_story_marketing_agent` + +Body 使用 `avatar_marketing_input`: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `prompt` | string | 否 | 自定义剧情营销 prompt | +| `model` | string | 否 | 默认 `seedance20_fast`;可选 `seedance25` / `seedance20` / `seedance20_fast` / `seedance20_mini` | +| `duration` | int | 否 | 秒,默认 15s;`seedance25` 最长 30s | +| `product_name` | string | 是 | 商品名称 | +| `product_image_url_list` | array | 是 | 商品图 1~9 张 | +| `model_image_url_list` | array | 否 | 模特图,最多 1 张 | +| `enable_watermark` | bool | 否 | 默认无水印 | + +### 6.3 短片创作 `pippit_video_part_agent` + +Body 使用 `input`: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `prompt` | string | 是 | 提示词 | +| `image_url_list` | string[] | 否 | 参考图,最多 9 张,公网可访问 | +| `video_url_list` | string[] | 否 | 参考视频,最多 3 条;`seedance25` 总时长 ≤30s,其他 ≤15s | +| `audio_url_list` | string[] | 否 | 参考音频,最多 3 条;时长限制同上 | +| `model` | string | 否 | 默认 `seedance20_fast`,可选同剧情营销 | +| `duration` | int32 | 否 | ≥5s,默认 15s;`seedance25` 最大 30s,其他 15s | +| `ratio` | string | 否 | `21:9` / `16:9` / `4:3` / `1:1` / `3:4` / `9:16` | +| `resolution` | string | 否 | 默认 720P;`seedance25`:480/720/1080;`seedance20`:480/720/1080/4K;fast/mini:480/720 | +| `seed` | int32 | 否 | 随机种子,不传由下游生成 | +| `enable_watermark` | bool | 否 | 默认 false | + +### 6.4 图片通用规格(所有引用图片,带货营销 ≤3 张,剧情/短片 ≤9 张) + +- 格式:JPEG、PNG、WebP、BMP、TIFF、GIF、HEIC、HEIF。 +- Seedance 2.0:宽高比 `(0.4, 2.5)` 不含边界;宽高各 `(300, 6000)` px 不含边界。 +- Seedance 2.5:宽高比 `[0.4, 2.5]` 含边界;宽高各 `[300, 6000]` px 含边界。 +- 单张 < 30 MB,URL 需公网可访问。 + +### 6.5 查询任务 `POST /agent_openapi/v1/video/query` + +请求 Body:`task_id` 或 `run_id` **二选一**。 + +响应关键字段(`data`): + +| 字段 | 说明 | +|---|---| +| `status` | 状态机取值 | +| `usage.*` | 计费明细(短片/剧情营销含 `withhold_quota` / `actual_quota`) | +| `video_artifact.url` | **成品视频下载 URL**(24h 有效) | +| `video_artifact.cover_url` | 封面图 | +| `video_artifact.duration` | 成片时长(秒) | +| `video_artifact.width` / `height` | 成片像素 | +| `start_at` / `elapsed_seconds` | 时间信息 | +| `err_code` / `err_msg` | 终态 `FAILED` 时才有 | + +--- + +## 7. 错误码(对接需处理的分段与关键码) + +分段:`1xxxx`=参数/鉴权/账号风控(可校对重试);`2xxxx`=业务规则(配额/审核/下游终态);`5xxxx`=系统内部(稍后重试)。 + +| code | 含义 | 建议 | +|---|---|---| +| `0` | 成功 | — | +| `10001` | 参数非法 | 校验请求体字段 | +| `10002` | AK 不存在 | 确认 AK,重新签发 | +| `10003` | AK 被禁用/冻结 | 联系管理员 | +| `10004` | 未授权/UserID 缺失 | 确认 AK 已开通 | +| `10005` | 未知能力/动作 | 核对 capability_key/action_key | +| `10006` | 签名校验失败 | 见 §1.4 逐项排查 | +| `10007` | 身份权限不满足 | 确认账号具备能力权限(如即梦 Ultra 会员) | +| `10008` | 请求频率超限 | 降并发(提交 10qps / 查询 20qps) | +| `10020` | 账号风控拦截 | 联系管理员,提供 request_id | +| `20001` | 任务不存在 | 核对 task_id / run_id | +| `20002` | Token 余额不足/预扣失败 | 充值或稍后重试 | +| `20004` | 计费失败 | 稍后重试,持续失败给 request_id | +| `20010`~`20016` | 输入/输出音频/文本/图片/视频审核未通过 | 更换合规素材 / 调整 prompt,可重试 | +| `20020` | 下游任务执行失败 | 确定性终态;重试仍失败给 request_id | +| `20021` | 下游任务已取消 | 确定性终态 | +| `50000` | 系统内部错误 | 稍后重试 | +| `50001` | 下游任务轮询超时 | 稍后重试 | + +--- + +## 8. Golang 对接骨架(提交 → 轮询) + +```go +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "yourmod/signer" // §1.3 的签名包 +) + +const ( + submitVideoPath = "/agent_openapi/v1/video/submit" + queryVideoPath = "/agent_openapi/v1/video/query" +) + +type Client struct { + signer *signer.Config + hc *http.Client +} + +func NewClient(ak, sk string) *Client { + return &Client{ + signer: &signer.Config{AccessKey: ak, SecretKey: sk, Host: "https://jimeng.jianying.com"}, + hc: &http.Client{Timeout: 30 * time.Second}, + } +} + +// post 统一发送带签名的 POST 请求 +func (c *Client) post(path string, body any, out any) error { + b, err := json.Marshal(body) + if err != nil { + return err + } + hdrs, err := c.signer.Headers(&signer.Request{ + Method: "POST", + Path: path, + Body: b, + ContentType: "application/json", + }) + if err != nil { + return err + } + req, err := http.NewRequest("POST", c.signer.Host+path, bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + for k, v := range hdrs { + req.Header.Set(k, v) + } + resp, err := c.hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + rb, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(rb, out); err != nil { + return fmt.Errorf("decode resp: %w, body=%s", err, string(rb)) + } + return nil +} + +// ---- 提交:带货营销视频 ---- +type SubmitResp struct { + Code string `json:"code"` + Message string `json:"message"` + Data struct { + TaskID string `json:"task_id"` + RunID string `json:"run_id"` + ThreadID string `json:"thread_id"` + Status string `json:"status"` + } `json:"data"` +} + +func (c *Client) SubmitMarketingVideo(productName string, productImages []string) (*SubmitResp, error) { + req := map[string]any{ + "capability_key": "pippit_avatar_marketing_agent", + "action_key": "generate_video", + "run_id": fmt.Sprintf("biz-%d", time.Now().UnixNano()), + "avatar_marketing_input": map[string]any{ + "product_name": productName, + "product_image_url_list": productImages, + "model_image_url_list": []string{}, + "enable_watermark": false, + }, + } + var out SubmitResp + if err := c.post(submitVideoPath, req, &out); err != nil { + return nil, err + } + if out.Code != "0" { + return nil, fmt.Errorf("submit failed code=%s msg=%s", out.Code, out.Message) + } + return &out, nil +} + +// ---- 查询 + 轮询 ---- +type QueryVideoResp struct { + Code string `json:"code"` + Message string `json:"message"` + Data struct { + Status string `json:"status"` + ErrCode string `json:"err_code"` + ErrMsg string `json:"err_msg"` + VideoArtifact struct { + URL string `json:"url"` + CoverURL string `json:"cover_url"` + Duration float64 `json:"duration"` + Width int64 `json:"width"` + Height int64 `json:"height"` + } `json:"video_artifact"` + } `json:"data"` +} + +// PollVideo 提交后轮询直到终态;SUCCESS 返回下载 URL,FAILED 返回错误 +func (c *Client) PollVideo(taskID, runID string, timeout time.Duration) (string, error) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + var out QueryVideoResp + body := map[string]string{} + if taskID != "" { + body["task_id"] = taskID + } else { + body["run_id"] = runID + } + if err := c.post(queryVideoPath, body, &out); err != nil { + return "", err + } + switch out.Data.Status { + case "SUCCESS": + return out.Data.VideoArtifact.URL, nil + case "FAILED", "CANCELED": + return "", fmt.Errorf("task %s err=%s msg=%s", out.Data.Status, out.Data.ErrCode, out.Data.ErrMsg) + } + time.Sleep(3 * time.Second) + } + return "", fmt.Errorf("timeout after %v", timeout) +} +``` + +### 8.1 对接注意点(代码里务必处理) + +1. **时钟同步**:本地时间偏差 >300s 会直接 `10006`;部署前校准服务器时钟。 +2. **幂等**:`run_id` 自行生成(建议 `biz-<毫秒/纳秒>` 或业务单号);重复提交同 run_id 返回同一任务。 +3. **产物时效**:`video_artifact.url` / `novel_data` 里的下载 URL **24h 过期**,成功终态后立即下载落库。 +4. **轮询**:提交返回 `QUEUED` 后开始轮询 query,间隔建议 3~5s,注意查询接口限流 20qps。 +5. **短剧多阶段**:每阶段是独立 submit+query,从上一阶段 `resp_data` 里取 `OverviewAssetID` / `StoryboardAssetID` 作为下一阶段 `asset_id`,`thread_id` 全程回填。 +6. **图片规格**:提交前校验宽高比、尺寸、格式,避免 `10001` 参数非法。 +7. **排障**:所有报错都把 `request_id` 记录下来,供反馈排查。