diff --git a/common/auth.go b/common/auth.go new file mode 100644 index 0000000..c6675bb --- /dev/null +++ b/common/auth.go @@ -0,0 +1,34 @@ +package common + +import ( + "errors" + + "github.com/golang-jwt/jwt/v5" +) + +const jwtSecret = "video-factory-jwt-secret-2024" + +type JwtClaims struct { + UserId int64 `json:"user_id"` + Role string `json:"role"` + AgentId int64 `json:"agent_id,omitempty"` + jwt.RegisteredClaims +} + +func GetJwtSecret() string { + return jwtSecret +} + +func ParseToken(tokenStr string) (*JwtClaims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &JwtClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(jwtSecret), nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*JwtClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid token") + } + return claims, nil +} diff --git a/common/auth_middleware.go b/common/auth_middleware.go index e644959..78b9803 100644 --- a/common/auth_middleware.go +++ b/common/auth_middleware.go @@ -1,47 +1,16 @@ package common import ( - "errors" "net/http" "strings" "github.com/gogf/gf/v2/net/ghttp" - "github.com/golang-jwt/jwt/v5" ) -const jwtSecret = "video-factory-jwt-secret-2024" - -func GetJwtSecret() string { - return jwtSecret +var publicPaths = []string{ + "/user/login", } -type JwtClaims struct { - UserId int64 `json:"user_id"` - Role string `json:"role"` - AgentId int64 `json:"agent_id,omitempty"` - jwt.RegisteredClaims -} - -func ParseToken(tokenStr string) (*JwtClaims, error) { - token, err := jwt.ParseWithClaims(tokenStr, &JwtClaims{}, func(token *jwt.Token) (interface{}, error) { - return []byte(jwtSecret), nil - }) - if err != nil { - return nil, err - } - claims, ok := token.Claims.(*JwtClaims) - if !ok || !token.Valid { - return nil, errors.New("invalid token") - } - return claims, nil -} - -var ( - publicPaths = []string{ - "/user/login", - } -) - func Auth(r *ghttp.Request) { path := r.URL.Path for _, p := range publicPaths { diff --git a/common/util.go b/common/util.go index 71f7d89..d521c06 100644 --- a/common/util.go +++ b/common/util.go @@ -2,6 +2,8 @@ package common import ( "encoding/base64" + "encoding/json" + "fmt" "os" "strings" ) @@ -34,3 +36,209 @@ func pathExt(path string) string { } return "" } + +// BuildSchemaRequest validates input values against a JSON schema definition, +// fills in default values for missing optional fields, +// and returns the result matching the schema's nested structure. +// +// The schema format follows test.json convention: +// +// { +// "section": { +// "field_name": { +// "type": "string|integer|number|boolean|array|object", +// "required": true|false, +// "default": value, +// "enum": [...], +// "min": number, +// "max": number, +// "max_chars": number, +// "min_items": number, +// "max_items": number +// } +// } +// } +// +// input is a flat map like {"prompt": "hello", "duration": 5}. +// Fields not present in input but with a "default" in the schema are filled automatically. +// Nodes without "type" are treated as grouping sections and recursed into. +func BuildSchemaRequest(schema map[string]any, input map[string]any) (map[string]any, error) { + result := make(map[string]any) + for key, val := range schema { + fieldDef, ok := val.(map[string]any) + if !ok { + result[key] = val + continue + } + if _, hasType := fieldDef["type"]; hasType { + processed, err := processField(key, fieldDef, input) + if err != nil { + return nil, err + } + if processed != nil { + result[key] = processed + } + continue + } + nested, err := BuildSchemaRequest(fieldDef, input) + if err != nil { + return nil, err + } + if len(nested) > 0 { + result[key] = nested + } + } + return result, nil +} + +func processField(name string, def map[string]any, input map[string]any) (any, error) { + fieldType, _ := def["type"].(string) + required, _ := def["required"].(bool) + + rawVal, exists := input[name] + if !exists { + if required { + return nil, fmt.Errorf("'%s' is required", name) + } + if dflt, ok := def["default"]; ok { + return convertDefault(dflt, fieldType), nil + } + return nil, nil + } + + switch fieldType { + case "string": + s, ok := rawVal.(string) + if !ok { + return nil, fmt.Errorf("'%s' must be a string", name) + } + if maxChars, ok := def["max_chars"].(float64); ok && len(s) > int(maxChars) { + return nil, fmt.Errorf("'%s' exceeds max length of %d", name, int(maxChars)) + } + if enum, ok := def["enum"].([]any); ok && len(enum) > 0 { + if !containsValue(enum, s) { + return nil, fmt.Errorf("'%s' must be one of %v", name, enum) + } + } + return s, nil + + case "integer": + v, err := toInt(rawVal) + if err != nil { + return nil, fmt.Errorf("'%s' must be an integer", name) + } + if minVal, ok := def["min"].(float64); ok && v < int(minVal) { + return nil, fmt.Errorf("'%s' must be >= %d", name, int(minVal)) + } + if maxVal, ok := def["max"].(float64); ok && v > int(maxVal) { + return nil, fmt.Errorf("'%s' must be <= %d", name, int(maxVal)) + } + return v, nil + + case "number": + v, ok := rawVal.(float64) + if !ok { + if iv, err := toInt(rawVal); err == nil { + v = float64(iv) + } else { + return nil, fmt.Errorf("'%s' must be a number", name) + } + } + if minVal, ok := def["min"].(float64); ok && v < minVal { + return nil, fmt.Errorf("'%s' must be >= %v", name, minVal) + } + if maxVal, ok := def["max"].(float64); ok && v > maxVal { + return nil, fmt.Errorf("'%s' must be <= %v", name, maxVal) + } + return v, nil + + case "boolean": + _, ok := rawVal.(bool) + if !ok { + return nil, fmt.Errorf("'%s' must be a boolean", name) + } + return rawVal, nil + + case "array": + arr, ok := rawVal.([]any) + if !ok { + return nil, fmt.Errorf("'%s' must be an array", name) + } + if minItems, ok := def["min_items"].(float64); ok && len(arr) < int(minItems) { + return nil, fmt.Errorf("'%s' must have at least %d items", name, int(minItems)) + } + if maxItems, ok := def["max_items"].(float64); ok && len(arr) > int(maxItems) { + return nil, fmt.Errorf("'%s' must have at most %d items", name, int(maxItems)) + } + if itemsDef, ok := def["items"].(map[string]any); ok { + items, err := processArrayItems(arr, itemsDef) + if err != nil { + return nil, fmt.Errorf("'%s': %w", name, err) + } + return items, nil + } + return arr, nil + } + + return rawVal, nil +} + +func processArrayItems(arr []any, itemsDef map[string]any) ([]any, error) { + itemType, _ := itemsDef["type"].(string) + if itemType != "object" { + return arr, nil + } + props, _ := itemsDef["properties"].(map[string]any) + if props == nil { + return arr, nil + } + + result := make([]any, len(arr)) + for i, item := range arr { + itemMap, ok := item.(map[string]any) + if !ok { + result[i] = item + continue + } + processed, err := BuildSchemaRequest(props, itemMap) + if err != nil { + return nil, fmt.Errorf("item[%d]: %w", i, err) + } + result[i] = processed + } + return result, nil +} + +func toInt(v any) (int, error) { + switch val := v.(type) { + case float64: + return int(val), nil + case int: + return val, nil + case int64: + return int(val), nil + case json.Number: + n, err := val.Int64() + return int(n), err + default: + return 0, fmt.Errorf("cannot convert %T to int", v) + } +} + +func convertDefault(dflt any, fieldType string) any { + if fieldType == "integer" { + if f, ok := dflt.(float64); ok { + return int(f) + } + } + return dflt +} + +func containsValue(arr []any, val any) bool { + for _, v := range arr { + if v == val { + return true + } + } + return false +} diff --git a/shortdrama/service/episode_service.go b/shortdrama/service/episode_service.go index af099eb..b9b8081 100644 --- a/shortdrama/service/episode_service.go +++ b/shortdrama/service/episode_service.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "video-factory/common" "video-factory/shortdrama/agent" consts "video-factory/shortdrama/consts" "video-factory/shortdrama/dao" @@ -403,59 +404,6 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, minSingle = effectiveMax } - // 从 video_schema 构建完整请求体结构(所有默认值) - bodyBase := map[string]any{} - if vs != nil { - if bodyDef, ok := nested(vs, "body").(map[string]any); ok { - for sectionKey, sectionVal := range bodyDef { - sectionFields, ok := sectionVal.(map[string]any) - if !ok { - continue - } - sectionOut := map[string]any{} - for fieldKey, fieldDef := range sectionFields { - fd, ok := fieldDef.(map[string]any) - if !ok { - continue - } - // parameters 支持 supported_models 过滤 - if sectionKey == "parameters" { - if sm, ok := fd["supported_models"]; ok { - if models, ok := sm.([]any); ok { - found := false - for _, m := range models { - if ms, ok := m.(string); ok && ms == modelCfg.ModelName { - found = true - break - } - } - if !found { - continue - } - } - } - } - // 始终包含 schema 中定义的所有字段(无 default 的字段留 nil,后续覆盖) - if def, ok := fd["default"]; ok { - sectionOut[fieldKey] = def - } else { - sectionOut[fieldKey] = nil - } - } - bodyBase[sectionKey] = sectionOut - } - } - // 尺寸映射:从 drama 的 Resolution/AspectRatio 查 video_schema - if sizes := nested(vs, "body", "parameters", "size", "sizes"); sizes != nil { - if sm, ok := sizes.(map[string]any); ok { - if v := resolveSizeFromSchema(sm, d.Resolution, d.AspectRatio); v != "" { - if params, ok := bodyBase["parameters"].(map[string]any); ok { - params["size"] = v - } - } - } - } - } // ============ 加载参考素材 ============ type _namedRef struct { path string @@ -596,38 +544,39 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, } // ============ 插入任务 ============ + bodyDef, _ := nested(vs, "body").(map[string]any) for i, tg := range taskGroups { - // 深拷贝 bodyBase,覆盖动态字段 - body := make(map[string]any, len(bodyBase)) - for bk, bv := range bodyBase { - if section, ok := bv.(map[string]any); ok { - nested := make(map[string]any, len(section)) - for sk, sv := range section { - nested[sk] = sv - } - body[bk] = nested - } else { - body[bk] = bv - } + segInput := map[string]any{ + "prompt": buildFinalPrompt(tg.promptText), + "duration": tg.dur, + "seed": seed, } - body["model"] = modelCfg.ModelName - setSchemaField(body, "prompt", buildFinalPrompt(tg.promptText)) if negativePrompt != "" { - setSchemaField(body, "negative_prompt", negativePrompt) + segInput["negative_prompt"] = negativePrompt } - setSchemaField(body, "duration", tg.dur) - // 参考素材 if len(refURLs) > 0 { _, mediaTypeField, mediaURLField, _ := resolveMediaFields(modelCfg.Schema, modelCfg.FirstFrameMapping) - setSchemaField(body, "media", buildSchemaMedia(refURLs, mediaTypeField, mediaURLField)) - setSchemaField(body, "reference_urls", refURLs) - } else if getSchemaField(body, "media") == nil { - setSchemaField(body, "media", []any{}) + segInput["media"] = buildSchemaMedia(refURLs, mediaTypeField, mediaURLField) + refsAny := make([]any, len(refURLs)) + for j, u := range refURLs { + refsAny[j] = u + } + segInput["reference_urls"] = refsAny } - if seed > 0 { - setSchemaField(body, "seed", seed) + body, _ := common.BuildSchemaRequest(bodyDef, segInput) + if body == nil { + body = map[string]any{} + } + body["model"] = modelCfg.ModelName + if sizes := nested(vs, "body", "parameters", "size", "sizes"); sizes != nil { + if sm, ok := sizes.(map[string]any); ok { + if v := resolveSizeFromSchema(sm, d.Resolution, d.AspectRatio); v != "" { + if params, ok := body["parameters"].(map[string]any); ok { + params["size"] = v + } + } + } } - // 非第0段添加首帧占位(URL 为空),第0段不需要首帧参考 if i > 0 { injectFirstFrame(body, "", modelCfg.FirstFrameMapping, modelCfg.Schema) } diff --git a/shortdrama/service/generation_service.go b/shortdrama/service/generation_service.go index 5835a34..6e084ee 100644 --- a/shortdrama/service/generation_service.go +++ b/shortdrama/service/generation_service.go @@ -783,7 +783,7 @@ func (s *generationService) ListEpisodeTasks(ctx context.Context, epId int64) ([ return dao.GenerationTask.ListByEpisode(ctx, epId) } -// createVideoTask 调用视频生成API提交任务,返回 (taskID, requestBodyJSON, error) +// submitVideoGenRequest 构建请求体并调用视频生成API,返回 (taskID, requestBodyJSON, error) func (s *generationService) mergeEpisodeVideo(ctx context.Context, epId int64, lastTaskId int64) error { tasks, err := dao.GenerationTask.ListByEpisode(ctx, epId) @@ -2118,7 +2118,7 @@ func (s *generationService) submitVideoTask(ctx context.Context, d *entity.Drama // 固定 seed 确保各段画风/人物形象一致(基于短剧ID+剧集序号) seed := int(d.Id)*1000 + ep.Index*10 - taskId, requestJSON, err := createVideoTask(ctx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.ModelName, + taskId, requestJSON, err := submitVideoGenRequest(ctx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.ModelName, prompt, negativePrompt, refURLs, effectiveDur, d.Resolution, d.AspectRatio, modelCfg.Schema, seed, segIdx, genTaskId, ep.Id, d.Title, ep.Title, modelCfg.FirstFrameMapping) if err != nil { return "", "", fmt.Errorf("video composition request failed: %w", err) @@ -2178,78 +2178,53 @@ func callVideoAPI(ctx context.Context, apiKey, baseURL string, payload []byte, s // // params: 额外参数(如 audio/shot_type/watermark 等) // sizes: 分辨率→尺寸字符串映射表 -func createVideoTask(ctx context.Context, apiKey, baseURL, modelName, prompt, negativePrompt string, refURLs []string, duration int, resolution, aspectRatio, videoSchema string, seed int, segIdx int, genTaskId int64, episodeId int64, dramaTitle, epTitle, firstFrameMapping string) (string, string, error) { - // 从 schema body 构建请求体基础结构(vs 只解析一次) - body := map[string]any{} +func submitVideoGenRequest(ctx context.Context, apiKey, baseURL, modelName, prompt, negativePrompt string, refURLs []string, duration int, resolution, aspectRatio, videoSchema string, seed int, segIdx int, genTaskId int64, episodeId int64, dramaTitle, epTitle, firstFrameMapping string) (string, string, error) { var vs map[string]any if videoSchema != "" { json.Unmarshal([]byte(videoSchema), &vs) } - if bodyDef, ok := nested(vs, "body").(map[string]any); ok { - for sectionKey, sectionVal := range bodyDef { - sectionFields, ok := sectionVal.(map[string]any) - if !ok { - continue - } - sectionOut := map[string]any{} - for fieldKey, fieldDef := range sectionFields { - fd, ok := fieldDef.(map[string]any) - if !ok { - continue - } - // parameters 支持 supported_models 过滤 - if sectionKey == "parameters" { - if sm, ok := fd["supported_models"]; ok { - if models, ok := sm.([]any); ok { - found := false - for _, m := range models { - if ms, ok := m.(string); ok && ms == modelName { - found = true - break - } - } - if !found { - continue - } - } - } - } - // 始终包含 schema 中定义的所有字段(无 default 的字段留 nil,后续覆盖) - if def, ok := fd["default"]; ok { - sectionOut[fieldKey] = def - } else { - sectionOut[fieldKey] = nil - } - } - body[sectionKey] = sectionOut - } - } - // 动态字段覆盖 — setSchemaField 按字段名自动匹配 section - body["model"] = modelName - setSchemaField(body, "prompt", prompt) - if negativePrompt != "" { - setSchemaField(body, "negative_prompt", negativePrompt) - } + _, mediaTypeField, mediaURLField, _ := resolveMediaFields(videoSchema, firstFrameMapping) - if len(refURLs) > 0 { - setSchemaField(body, "media", buildSchemaMedia(refURLs, mediaTypeField, mediaURLField)) - setSchemaField(body, "reference_urls", refURLs) + // 通过 BuildSchemaRequest 构建请求体:flat input → schema 校验/填充 → 嵌套结构 + input := map[string]any{ + "prompt": prompt, + "seed": seed, + } + if negativePrompt != "" { + input["negative_prompt"] = negativePrompt } if duration > 0 { - setSchemaField(body, "duration", duration) + input["duration"] = duration } - if seed > 0 { - setSchemaField(body, "seed", seed) + if len(refURLs) > 0 { + input["media"] = buildSchemaMedia(refURLs, mediaTypeField, mediaURLField) + refsAny := make([]any, len(refURLs)) + for i, u := range refURLs { + refsAny[i] = u + } + input["reference_urls"] = refsAny } + bodyDef, _ := nested(vs, "body").(map[string]any) + body, err := common.BuildSchemaRequest(bodyDef, input) + if err != nil { + return "", "", fmt.Errorf("build request body failed: %w", err) + } + if body == nil { + body = map[string]any{} + } + body["model"] = modelName + // 统一处理首帧(segIdx==0 删除,>0 解析URL) prepareFirstFrame(ctx, body, segIdx, genTaskId, episodeId, dramaTitle, epTitle, firstFrameMapping, videoSchema) // 尺寸映射:从 drama 的 Resolution/AspectRatio 查 video_schema if sizes := nested(vs, "body", "parameters", "size", "sizes"); sizes != nil { if sm, ok := sizes.(map[string]any); ok { if v := resolveSizeFromSchema(sm, resolution, aspectRatio); v != "" { - setSchemaField(body, "size", v) + if params, ok := body["parameters"].(map[string]any); ok { + params["size"] = v + } } } }