diff --git a/short_drama.db b/short_drama.db index d14d815..8f22ab2 100644 Binary files a/short_drama.db and b/short_drama.db differ diff --git a/shortdrama/consts/public/table_name.go b/shortdrama/consts/public/table_name.go index 2389884..160363e 100644 --- a/shortdrama/consts/public/table_name.go +++ b/shortdrama/consts/public/table_name.go @@ -17,4 +17,5 @@ const ( TableNamePaymentChannelTrade = "payment_channel_trade" TableNamePaymentConfig = "payment_config" TableNameRegionPricing = "region_pricing" + TableNameUserModelConfig = "user_model_config" ) diff --git a/shortdrama/controller/config_controller.go b/shortdrama/controller/config_controller.go index 8f8a0fa..79fe07c 100644 --- a/shortdrama/controller/config_controller.go +++ b/shortdrama/controller/config_controller.go @@ -2,22 +2,26 @@ package controller import ( "context" + "fmt" + "video-factory/shortdrama/middleware" "video-factory/shortdrama/model/dto" "video-factory/shortdrama/model/entity" "video-factory/shortdrama/service" + + "github.com/gogf/gf/v2/frame/g" ) type config struct{} var Config = new(config) -func (c *config) Get(ctx context.Context, req *dto.GetModelConfigReq) (res *dto.GetModelConfigRes, err error) { - return service.ConfigService.GetResponse(ctx), nil +func (c *config) GetModelList(ctx context.Context, req *dto.GetModelConfigListReq) (res *dto.GetModelConfigListRes, err error) { + return service.ConfigService.GetModelListResponse(ctx), nil } -func (c *config) Save(ctx context.Context, req *dto.SaveModelConfigReq) (res *struct{}, err error) { - return nil, service.ConfigService.Save(ctx, req) +func (c *config) SaveModelConfig(ctx context.Context, req *dto.SaveModelConfigReq) (res *struct{}, err error) { + return nil, service.ConfigService.SaveModelConfig(ctx, req) } func (c *config) GetPayment(ctx context.Context, req *dto.GetPaymentConfigReq) (res *dto.GetPaymentConfigRes, err error) { @@ -39,8 +43,6 @@ func (c *config) GetPayment(ctx context.Context, req *dto.GetPaymentConfigReq) ( res.Wechat.H5 = cfg case "app": res.Wechat.App = cfg - case "native": - res.Wechat.Native = cfg default: res.Wechat.Jsapi = cfg } @@ -67,6 +69,38 @@ func (c *config) GetPayment(ctx context.Context, req *dto.GetPaymentConfigReq) ( return res, nil } +func (c *config) GetUserConfig(ctx context.Context, req *dto.GetUserModelConfigReq) (res *dto.GetUserModelConfigRes, err error) { + r := g.RequestFromCtx(ctx) + userId := middleware.GetUserId(r) + if userId <= 0 { + return nil, fmt.Errorf("未登录") + } + cfg := service.ConfigService.GetUserConfig(ctx, userId) + return &dto.GetUserModelConfigRes{UserModelConfig: cfg}, nil +} + +func (c *config) SaveUserConfig(ctx context.Context, req *dto.SaveUserModelConfigReq) (res *struct{}, err error) { + r := g.RequestFromCtx(ctx) + userId := middleware.GetUserId(r) + if userId <= 0 { + return nil, fmt.Errorf("未登录") + } + cfg := &entity.UserModelConfig{ + ModelConfigId: req.ModelConfigId, + ApiKey: req.ApiKey, + Temperature: req.Temperature, + MaxTokens: req.MaxTokens, + IsActive: req.IsActive, + } + return nil, service.ConfigService.SaveUserConfig(ctx, userId, cfg) +} + +func (c *config) GetUserModelList(ctx context.Context, req *dto.GetUserModelListReq) (res *dto.GetUserModelListRes, err error) { + r := g.RequestFromCtx(ctx) + userId := middleware.GetUserId(r) + return service.ConfigService.GetUserModelList(ctx, userId), nil +} + func (c *config) SavePayment(ctx context.Context, req *dto.SavePaymentConfigReq) (res *struct{}, err error) { return nil, service.ConfigService.SavePaymentConfig(ctx, &entity.PaymentConfig{ Channel: req.Channel, diff --git a/shortdrama/dao/model_config_dao.go b/shortdrama/dao/model_config_dao.go index fc09254..ed9610d 100644 --- a/shortdrama/dao/model_config_dao.go +++ b/shortdrama/dao/model_config_dao.go @@ -16,86 +16,120 @@ type modelConfigDao struct{} func init() { ctx := context.Background() - if _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+public.TableNameModelConfig+` ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - chat_api_key TEXT NOT NULL DEFAULT '', - chat_base_url TEXT NOT NULL DEFAULT '', - chat_model_name TEXT NOT NULL DEFAULT '', - max_tokens INTEGER NOT NULL DEFAULT 4096, - temperature REAL NOT NULL DEFAULT 0.8, - chat_schema TEXT NOT NULL DEFAULT '', - video_api_key TEXT NOT NULL DEFAULT '', - video_base_url TEXT NOT NULL DEFAULT '', - video_model_name TEXT NOT NULL DEFAULT '', - video_schema TEXT NOT NULL DEFAULT '', - video_task_callback_url TEXT NOT NULL DEFAULT '', - price_per_second INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT (datetime('now','localtime')), - updated_at DATETIME DEFAULT (datetime('now','localtime')) - )`); err != nil { + _, err := g.DB().Exec(ctx, + "CREATE TABLE IF NOT EXISTS "+public.TableNameModelConfig+" ("+ + "id INTEGER PRIMARY KEY AUTOINCREMENT,"+ + "model_type TEXT NOT NULL DEFAULT '',"+ + "model_name TEXT NOT NULL DEFAULT '',"+ + "base_url TEXT NOT NULL DEFAULT '',"+ + "schema TEXT NOT NULL DEFAULT '',"+ + "price INTEGER NOT NULL DEFAULT 0,"+ + "price_unit TEXT NOT NULL DEFAULT 'second',"+ + "task_callback_url TEXT NOT NULL DEFAULT '',"+ + "created_at DATETIME DEFAULT (datetime('now','localtime')),"+ + "updated_at DATETIME DEFAULT (datetime('now','localtime'))"+ + ")") + if err != nil { g.Log().Warningf(ctx, "创建模型配置表失败: %v", err) } - // 清理旧字段 - for _, col := range []string{ - "max_single_duration", "min_single_duration", - "chat_provider", "video_provider", - "video_no_duration_support", - "video_model_category", "image_model_name", - "chat_params", "video_params", - "max_reference_images", - "max_ref_video_count", "max_ref_video_file_size", "max_ref_video_duration", "ref_video_formats", - "max_ref_audio_count", "max_ref_audio_file_size", "max_ref_audio_duration", "ref_audio_formats", - "max_ref_image_file_size", "ref_image_formats", - "video_query_url", - "payment_config", - } { - if _, err := g.DB().Exec(ctx, `ALTER TABLE `+public.TableNameModelConfig+` DROP COLUMN `+col); err != nil { - g.Log().Debugf(ctx, "删除孤儿列 %s 失败(可能已删除): %v", col, err) + // 检测旧表结构:含 api_key / chat_api_key / is_active 等旧列 → 迁移 + hasOldColumns := false + if r, _ := g.DB().GetAll(ctx, "PRAGMA table_info("+public.TableNameModelConfig+")"); r != nil { + for _, col := range r { + name := col["name"].String() + if name == "api_key" || name == "chat_api_key" || name == "is_active" || name == "price_per_second" || name == "max_tokens" { + hasOldColumns = true + break + } } } - // 补充新字段 - for _, col := range []string{ - "chat_schema", - "video_schema", - "video_task_callback_url", - "price_per_second", - } { - if _, err := g.DB().Exec(ctx, `ALTER TABLE `+public.TableNameModelConfig+` ADD COLUMN `+col+` TEXT NOT NULL DEFAULT ''`); err != nil { - g.Log().Debugf(ctx, "添加列 %s 失败(可能已存在): %v", col, err) + + if hasOldColumns { + g.Log().Info(ctx, "检测到旧表结构,开始迁移 model_config...") + newTable := public.TableNameModelConfig + "_new" + _, _ = g.DB().Exec(ctx, + "CREATE TABLE IF NOT EXISTS "+newTable+" ("+ + "id INTEGER PRIMARY KEY AUTOINCREMENT,"+ + "model_type TEXT NOT NULL DEFAULT '',"+ + "model_name TEXT NOT NULL DEFAULT '',"+ + "base_url TEXT NOT NULL DEFAULT '',"+ + "schema TEXT NOT NULL DEFAULT '',"+ + "price INTEGER NOT NULL DEFAULT 0,"+ + "price_unit TEXT NOT NULL DEFAULT 'second',"+ + "task_callback_url TEXT NOT NULL DEFAULT '',"+ + "created_at DATETIME DEFAULT (datetime('now','localtime')),"+ + "updated_at DATETIME DEFAULT (datetime('now','localtime'))"+ + ")") + + hasChatApiKey := false + if _, err := g.DB().Exec(ctx, "SELECT chat_api_key FROM "+public.TableNameModelConfig+" LIMIT 1"); err == nil { + hasChatApiKey = true } - } - // price_per_second 是 INTEGER,补一个独立的 ALTER - if _, err := g.DB().Exec(ctx, `ALTER TABLE `+public.TableNameModelConfig+` ADD COLUMN price_per_second INTEGER NOT NULL DEFAULT 0`); err != nil { - g.Log().Debugf(ctx, "添加列 price_per_second 失败(可能已存在): %v", err) + now := gtime.Now().Format("Y-m-d H:i:s") + + if hasChatApiKey { + oldRow, _ := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Limit(1).One() + if oldRow != nil && !oldRow.IsEmpty() { + _, _ = g.DB().Model(newTable).Ctx(ctx).Data(g.Map{ + "model_type": "chat", "model_name": oldRow["chat_model_name"], + "base_url": oldRow["chat_base_url"], + "schema": oldRow["chat_schema"], + "price": 0, "price_unit": "video", + "created_at": now, "updated_at": now, + }).Insert() + _, _ = g.DB().Model(newTable).Ctx(ctx).Data(g.Map{ + "model_type": "video", "model_name": oldRow["video_model_name"], + "base_url": oldRow["video_base_url"], + "schema": oldRow["video_schema"], "task_callback_url": oldRow["video_task_callback_url"], + "price": gconv.Int(oldRow["price_per_second"]), "price_unit": "second", + "created_at": now, "updated_at": now, + }).Insert() + } + } else { + oldRows, _ := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).OrderAsc("id").All() + for _, or := range oldRows { + mt := or["model_type"].String() + priceVal := gconv.Int(or["price_per_second"]) + if priceVal <= 0 { + priceVal = gconv.Int(or["price"]) + } + unit := "second" + if mt == "chat" { + unit = "video" + } + _, _ = g.DB().Model(newTable).Ctx(ctx).Data(g.Map{ + "model_type": mt, "model_name": or["model_name"], + "base_url": or["base_url"], + "schema": or["schema"], + "price": priceVal, "price_unit": unit, + "task_callback_url": or["task_callback_url"], + "created_at": now, "updated_at": now, + }).Insert() + } + } + _, _ = g.DB().Exec(ctx, "DROP TABLE "+public.TableNameModelConfig) + _, _ = g.DB().Exec(ctx, "ALTER TABLE "+newTable+" RENAME TO "+public.TableNameModelConfig) + g.Log().Info(ctx, "model_config 表结构迁移完成") } // 初始化默认模型配置(仅当表为空时) count, _ := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Count() if count == 0 { - _, err := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Data(g.Map{ - "chat_api_key": "", - "chat_base_url": "", - "chat_model_name": "", - "max_tokens": 4096, - "temperature": 0.85, - "video_api_key": "", - "video_base_url": "", - "video_task_callback_url": "", - "video_model_name": "", - "price_per_second": 10, - "created_at": gtime.Now().Format("Y-m-d H:i:s"), - "updated_at": gtime.Now().Format("Y-m-d H:i:s"), - }).Insert() - if err != nil { - g.Log().Warningf(ctx, "初始化默认模型配置失败: %v", err) - } else { - g.Log().Info(ctx, "已初始化默认模型配置") + now := gtime.Now().Format("Y-m-d H:i:s") + defaults := []g.Map{ + {"model_type": "chat", "model_name": "", "base_url": "", "schema": "{\"properties\":{\"parameters\":{\"properties\":{\"max_tokens\":{\"type\":\"integer\",\"description\":\"最大Token数\",\"default\":4096,\"maximum\":65535}}}}}}", "price": 0, "price_unit": "video", "created_at": now, "updated_at": now}, + {"model_type": "video", "model_name": "", "base_url": "", "schema": "", "price": 10, "price_unit": "second", "created_at": now, "updated_at": now}, } + for _, m := range defaults { + if _, e := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Data(m).Insert(); e != nil { + g.Log().Warningf(ctx, "初始化默认模型配置失败: %v", e) + } + } + g.Log().Info(ctx, "已初始化默认模型配置(chat + video)") } } -// GetFirst 获取第一条配置行 func (d *modelConfigDao) GetFirst(ctx context.Context) (res *entity.ModelConfig, err error) { r, err := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).OrderAsc("id").Limit(1).One() if err != nil { @@ -109,34 +143,47 @@ func (d *modelConfigDao) GetFirst(ctx context.Context) (res *entity.ModelConfig, return } -// Save 保存配置:存在则更新,不存在则插入 -func (d *modelConfigDao) Save(ctx context.Context, data *entity.ModelConfig) error { - existing, err := d.GetFirst(ctx) +func (d *modelConfigDao) GetActiveModelByType(ctx context.Context, modelType string) (res *entity.ModelConfig, err error) { + r, err := g.DB().Model(public.TableNameModelConfig).Ctx(ctx). + Where("model_type", modelType).Limit(1).One() if err != nil { - return err + return nil, err } - if existing != nil { - data.Id = existing.Id - _, err = g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Data(data).Where("id", existing.Id).Update() - return err + if r == nil { + return nil, nil } - m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}}) - delete(m, "id") - m["created_at"] = gtime.Now().Format("Y-m-d H:i:s") - m["updated_at"] = gtime.Now().Format("Y-m-d H:i:s") - _, err = g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Data(m).Insert() - return err + res = new(entity.ModelConfig) + err = r.Struct(&res) + return } -// UpdateField 更新指定字段 -func (d *modelConfigDao) UpdateField(ctx context.Context, field string, value interface{}) error { - existing, err := d.GetFirst(ctx) +func (d *modelConfigDao) GetAll(ctx context.Context) (res []*entity.ModelConfig, err error) { + r, err := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).OrderAsc("id").All() if err != nil { - return err + return nil, err } - if existing == nil { - return nil + res = make([]*entity.ModelConfig, 0) + if r != nil { + err = r.Structs(&res) } - _, err = g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Data(g.Map{field: value}).Where("id", existing.Id).Update() - return err + return +} + +func (d *modelConfigDao) Save(ctx context.Context, data *entity.ModelConfig) error { + if data.Id > 0 { + _, err := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Data(data).Where("id", data.Id).Update() + if err != nil { + return err + } + } else { + m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}}) + delete(m, "id") + m["created_at"] = gtime.Now().Format("Y-m-d H:i:s") + m["updated_at"] = gtime.Now().Format("Y-m-d H:i:s") + _, err := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Data(m).Insert() + if err != nil { + return err + } + } + return nil } diff --git a/shortdrama/dao/user_model_config_dao.go b/shortdrama/dao/user_model_config_dao.go new file mode 100644 index 0000000..6968475 --- /dev/null +++ b/shortdrama/dao/user_model_config_dao.go @@ -0,0 +1,160 @@ +package dao + +import ( + "context" + + "video-factory/shortdrama/consts/public" + "video-factory/shortdrama/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" +) + +var UserModelConfig = &userModelConfigDao{} + +type userModelConfigDao struct{} + +func init() { + ctx := context.Background() + // 检测表是否已存在 + var tableExists bool + if r, _ := g.DB().Exec(ctx, "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='"+public.TableNameUserModelConfig+"'"); r != nil { + tableExists = true + } + + if !tableExists { + _, err := g.DB().Exec(ctx, `CREATE TABLE `+public.TableNameUserModelConfig+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + model_config_id INTEGER NOT NULL DEFAULT 0, + api_key TEXT NOT NULL DEFAULT '', + temperature REAL NOT NULL DEFAULT 0, + max_tokens INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "创建用户模型配置表失败: %v", err) + } + return + } + + // 表已存在,检测列类型是否完整 + r, _ := g.DB().GetAll(ctx, "PRAGMA table_info("+public.TableNameUserModelConfig+")") + var needsMigrate bool + for _, col := range r { + name := col["name"].String() + if name == "api_key" && col["notnull"].Int() == 0 { + needsMigrate = true + break + } + } + if needsMigrate { + g.Log().Info(ctx, "检测到 user_model_config 列类型异常,开始迁移...") + newTable := public.TableNameUserModelConfig + "_new" + _, _ = g.DB().Exec(ctx, `CREATE TABLE `+newTable+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + model_config_id INTEGER NOT NULL DEFAULT 0, + api_key TEXT NOT NULL DEFAULT '', + temperature REAL NOT NULL DEFAULT 0, + max_tokens INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + _, _ = g.DB().Exec(ctx, + `INSERT INTO `+newTable+`(user_id,model_config_id,api_key,temperature,max_tokens,is_active,created_at,updated_at) + SELECT user_id,COALESCE(model_config_id,0), + COALESCE(api_key,''), + COALESCE(temperature,0), + COALESCE(max_tokens,0), + COALESCE(is_active,0), + COALESCE(created_at,datetime('now','localtime')), + COALESCE(updated_at,datetime('now','localtime')) + FROM `+public.TableNameUserModelConfig) + _, _ = g.DB().Exec(ctx, "DROP TABLE "+public.TableNameUserModelConfig) + _, _ = g.DB().Exec(ctx, "ALTER TABLE "+newTable+" RENAME TO "+public.TableNameUserModelConfig) + g.Log().Info(ctx, "user_model_config 表结构迁移完成") + } +} + +// GetActiveByUserId 获取指定用户当前使用中的模型配置 +func (d *userModelConfigDao) GetActiveByUserId(ctx context.Context, userId int64) (res *entity.UserModelConfig, err error) { + r, err := g.DB().Model(public.TableNameUserModelConfig).Ctx(ctx). + Where("user_id", userId).Where("is_active", 1).Limit(1).One() + if err != nil { + return nil, err + } + if r == nil { + return nil, nil + } + res = new(entity.UserModelConfig) + err = r.Struct(&res) + return +} + +// GetByUserId 获取指定用户所有模型配置(降序排列) +func (d *userModelConfigDao) GetByUserId(ctx context.Context, userId int64) (res []*entity.UserModelConfig, err error) { + r, err := g.DB().Model(public.TableNameUserModelConfig).Ctx(ctx). + Where("user_id", userId).OrderDesc("id").All() + if err != nil { + return nil, err + } + if r == nil { + return nil, nil + } + res = make([]*entity.UserModelConfig, 0) + err = r.Structs(&res) + return +} + +// GetByModelConfigId 获取指定用户对指定系统模型的配置 +func (d *userModelConfigDao) GetByModelConfigId(ctx context.Context, userId, modelConfigId int64) (res *entity.UserModelConfig, err error) { + r, err := g.DB().Model(public.TableNameUserModelConfig).Ctx(ctx). + Where("user_id", userId).Where("model_config_id", modelConfigId).Limit(1).One() + if err != nil { + return nil, err + } + if r == nil { + return nil, nil + } + res = new(entity.UserModelConfig) + err = r.Struct(&res) + return +} + +// Save 保存用户模型配置:存在则更新,不存在则插入 +// 如果标记为 is_active=1,会自动将同用户同模型配置的其他记录 is_active 置为 0 +func (d *userModelConfigDao) Save(ctx context.Context, data *entity.UserModelConfig) error { + if data.Id > 0 { + data.UpdatedAt = gtime.Now() + _, err := g.DB().Model(public.TableNameUserModelConfig).Ctx(ctx).Data(data).Where("id", data.Id).Update() + if err != nil { + return err + } + } else { + m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}}) + delete(m, "id") + m["created_at"] = gtime.Now().Format("Y-m-d H:i:s") + m["updated_at"] = gtime.Now().Format("Y-m-d H:i:s") + r, err := g.DB().Model(public.TableNameUserModelConfig).Ctx(ctx).Data(m).Insert() + if err != nil { + return err + } + // 获取新插入记录的 ID,供后续 is_active 清理使用 + if lid, _ := r.LastInsertId(); lid > 0 { + data.Id = lid + } + } + // 如果标记为使用中,将同用户其他同模型类型的配置设为非使用中 + if data.IsActive == 1 && data.UserId > 0 && data.ModelConfigId > 0 { + _, _ = g.DB().Model(public.TableNameUserModelConfig).Ctx(ctx). + Where("user_id", data.UserId).Where("model_config_id", data.ModelConfigId). + Where("id != ?", data.Id). + Data(g.Map{"is_active": 0, "updated_at": gtime.Now().Format("Y-m-d H:i:s")}).Update() + } + return nil +} diff --git a/shortdrama/model/dto/config_dto.go b/shortdrama/model/dto/config_dto.go index 0aa3bc3..9554a56 100644 --- a/shortdrama/model/dto/config_dto.go +++ b/shortdrama/model/dto/config_dto.go @@ -7,33 +7,65 @@ import ( "github.com/gogf/gf/v2/frame/g" ) -type GetModelConfigReq struct { - g.Meta `path:"/model" method:"get" tags:"模型配置" summary:"获取模型配置"` - *entity.ModelConfig - ChatSchema *gjson.Json `json:"chatSchema"` - VideoSchema *gjson.Json `json:"videoSchema"` +// GetModelConfigListReq 获取所有模型配置列表 +type GetModelConfigListReq struct { + g.Meta `path:"/model-list" method:"get" tags:"模型配置" summary:"获取模型配置列表"` } -type GetModelConfigRes struct { - *entity.ModelConfig - ChatSchema *gjson.Json `json:"chatSchema"` - VideoSchema *gjson.Json `json:"videoSchema"` +type GetModelConfigListRes struct { + List []*entity.ModelConfig `json:"list"` } +// SaveModelConfigReq 保存单个模型配置 type SaveModelConfigReq struct { - g.Meta `path:"/model" method:"post" tags:"模型配置" summary:"保存模型配置"` - ChatApiKey string `v:"required" json:"chatApiKey" dc:"对话模型API密钥"` - ChatBaseUrl string `v:"required|url" json:"chatBaseUrl" dc:"对话模型API接口地址"` - ChatModelName string `v:"required" json:"chatModelName" dc:"对话模型名称"` - MaxTokens int `v:"required" json:"maxTokens" dc:"最大Token数"` - Temperature float64 `v:"required" json:"temperature" dc:"温度参数"` - ChatSchema *gjson.Json `json:"chatSchema" dc:"对话模型schema"` - VideoApiKey string `v:"required" json:"videoApiKey" dc:"视频模型API密钥"` - VideoBaseUrl string `v:"required|url" json:"videoBaseUrl" dc:"视频模型API接口地址"` - VideoModelName string `v:"required" json:"videoModelName" dc:"视频模型名称"` - VideoSchema *gjson.Json `json:"videoSchema" dc:"视频生成模型schema(JSON, 含duration/ref/params/sizes)"` - PricePerSecond int `v:"required|min:1" json:"pricePerSecond" dc:"每秒价格(分)"` - VideoTaskCallbackUrl string `json:"videoTaskCallbackUrl" dc:"视频生成任务回调地址"` + g.Meta `path:"/model" method:"post" tags:"模型配置" summary:"保存模型配置"` + Id int64 `json:"id"` + ModelType string `json:"modelType" v:"required" dc:"chat=对话模型 video=视频模型"` + ModelName string `json:"modelName" v:"required" dc:"模型名称"` + BaseUrl string `json:"baseUrl" v:"required" dc:"API接口地址"` + Schema *gjson.Json `json:"schema" dc:"请求体JSON Schema"` + Price int `json:"price" dc:"价格(分)"` + PriceUnit string `json:"priceUnit" dc:"价格单位(second=每秒/video=每次视频)"` + TaskCallbackUrl string `json:"taskCallbackUrl" dc:"回调地址(仅视频模型)"` +} + +type GetUserModelConfigReq struct { + g.Meta `path:"/user-model" method:"get" tags:"模型配置" summary:"获取用户模型配置"` +} + +type GetUserModelConfigRes struct { + *entity.UserModelConfig +} + +type SaveUserModelConfigReq struct { + g.Meta `path:"/user-model" method:"post" tags:"模型配置" summary:"保存用户模型配置"` + ModelConfigId int64 `json:"modelConfigId" dc:"关联模型配置ID"` + ApiKey string `json:"apiKey" dc:"用户API密钥"` + Temperature float64 `json:"temperature" dc:"温度参数"` + MaxTokens int `json:"maxTokens" dc:"最大Token数(不超过系统配置)"` + IsActive int `json:"isActive" dc:"是否使用中(0=否 1=是)"` +} + +// GetUserModelListReq 获取用户模型配置列表 +type GetUserModelListReq struct { + g.Meta `path:"/user-model-list" method:"get" tags:"模型配置" summary:"获取系统模型列表及用户配置状态"` +} + +// UserModelItem 系统模型项及用户配置状态 +type UserModelItem struct { + ModelConfigId int64 `json:"modelConfigId" dc:"系统模型配置ID"` + ModelType string `json:"modelType" dc:"chat=对话模型 video=视频模型"` + ModelName string `json:"modelName" dc:"模型名称"` + Configured bool `json:"configured" dc:"用户是否已配置"` + UserApiKey string `json:"userApiKey,omitempty" dc:"用户配置的API Key"` + Temperature float64 `json:"temperature,omitempty" dc:"用户配置的温度"` + UserMaxTokens int `json:"userMaxTokens,omitempty" dc:"用户配置的max_tokens"` + SystemMaxTokens int `json:"systemMaxTokens,omitempty" dc:"系统max_tokens上限"` +} + +// GetUserModelListRes 用户模型配置列表响应 +type GetUserModelListRes struct { + List []*UserModelItem `json:"list"` } type GetPaymentConfigReq struct { diff --git a/shortdrama/model/entity/model_config.go b/shortdrama/model/entity/model_config.go index c44954d..f612f92 100644 --- a/shortdrama/model/entity/model_config.go +++ b/shortdrama/model/entity/model_config.go @@ -4,20 +4,19 @@ import ( "github.com/gogf/gf/v2/os/gtime" ) +// ModelConfig 模型配置(每行一个模型,chat/video 分开存储) type ModelConfig struct { - Id int64 `orm:"id" json:"id" dc:"配置ID"` - ChatApiKey string `orm:"chat_api_key" json:"chatApiKey" dc:"对话模型API密钥"` - ChatBaseUrl string `orm:"chat_base_url" json:"chatBaseUrl" dc:"对话模型接口地址"` - ChatModelName string `orm:"chat_model_name" json:"chatModelName" dc:"对话模型名称"` - MaxTokens int `orm:"max_tokens" json:"maxTokens" dc:"最大Token数"` - Temperature float64 `orm:"temperature" json:"temperature" dc:"温度参数"` - ChatSchema string `orm:"chat_schema" json:"chatSchema" dc:"对话模型schema"` - VideoApiKey string `orm:"video_api_key" json:"videoApiKey" dc:"视频模型API密钥"` - VideoBaseUrl string `orm:"video_base_url" json:"videoBaseUrl" dc:"视频模型接口地址"` - VideoModelName string `orm:"video_model_name" json:"videoModelName" dc:"视频模型名称"` - VideoSchema string `orm:"video_schema" json:"videoSchema" dc:"视频生成模型schema(JSON, 含duration/ref/prams/sizes)"` - PricePerSecond int `orm:"price_per_second" json:"pricePerSecond" dc:"每秒价格(分)"` - VideoTaskCallbackUrl string `orm:"video_task_callback_url" json:"videoTaskCallbackUrl" dc:"视频生成任务回调地址"` - CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间"` - UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间"` + Id int64 `orm:"id" json:"id" dc:"配置ID"` + ModelType string `orm:"model_type" json:"modelType" dc:"chat=对话模型 video=视频模型"` + ModelName string `orm:"model_name" json:"modelName" dc:"模型名称"` + ApiKey string `json:"apiKey" dc:"API密钥"` + BaseUrl string `orm:"base_url" json:"baseUrl" dc:"API接口地址"` + Schema string `orm:"schema" json:"schema" dc:"请求体JSON Schema"` + MaxTokens int `orm:"max_tokens" json:"maxTokens" dc:"最大Token数(仅对话模型)"` + Price int `orm:"price" json:"price" dc:"价格(分)"` + PriceUnit string `orm:"price_unit" json:"priceUnit" dc:"价格单位(second=每秒/video=每次视频)"` + TaskCallbackUrl string `orm:"task_callback_url" json:"taskCallbackUrl" dc:"回调地址(仅视频模型)"` + Temperature float64 `json:"temperature" dc:"温度参数(仅运行时合并用户配置用,不持久化)"` + CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间"` } diff --git a/shortdrama/model/entity/user_model_config.go b/shortdrama/model/entity/user_model_config.go new file mode 100644 index 0000000..e9c874e --- /dev/null +++ b/shortdrama/model/entity/user_model_config.go @@ -0,0 +1,17 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// UserModelConfig 用户级别模型配置 +// 每行对应一个用户对一个系统模型的 API Key 等私有配置。 +type UserModelConfig struct { + Id int64 `orm:"id" json:"id" dc:"配置ID"` + UserId int64 `orm:"user_id" json:"userId" dc:"用户ID"` + ModelConfigId int64 `orm:"model_config_id" json:"modelConfigId" dc:"关联模型配置ID"` + ApiKey string `orm:"api_key" json:"apiKey" dc:"用户API密钥"` + Temperature float64 `orm:"temperature" json:"temperature" dc:"温度参数"` + MaxTokens int `orm:"max_tokens" json:"maxTokens" dc:"最大Token数(不超过系统配置)"` + IsActive int `orm:"is_active" json:"isActive" dc:"是否使用中(0=否 1=是)"` + CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间"` +} diff --git a/shortdrama/service/auth_service.go b/shortdrama/service/auth_service.go index b45cc4b..8533b14 100644 --- a/shortdrama/service/auth_service.go +++ b/shortdrama/service/auth_service.go @@ -356,8 +356,8 @@ func (s *authService) CheckBalance(ctx context.Context, customerId int64, durati if cp == nil { return false, 0, errors.New("客户不存在") } - cfg := ConfigService.Get(ctx) - cost := durationSec * int64(cfg.PricePerSecond) + cfg := ConfigService.GetActiveModel(ctx, "video") + cost := durationSec * int64(cfg.Price) return cp.Balance >= cost, cost, nil } diff --git a/shortdrama/service/config_service.go b/shortdrama/service/config_service.go index b1e4a90..e1f23ed 100644 --- a/shortdrama/service/config_service.go +++ b/shortdrama/service/config_service.go @@ -2,78 +2,221 @@ package service import ( "context" + "encoding/json" "video-factory/shortdrama/dao" "video-factory/shortdrama/model/dto" "video-factory/shortdrama/model/entity" - "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gcache" "github.com/gogf/gf/v2/util/gconv" ) -const cacheKeyConfig = "model_config" +const cacheKeyUserConfigPrefix = "user_model_config_" + +const cacheKeyModelList = "model_config_list" // ConfigService 模型配置服务 type configService struct{} var ConfigService = new(configService) -// Get 获取模型配置实体(供内部调用,schema 为 string 类型) -func (s *configService) Get(ctx context.Context) *entity.ModelConfig { - if v, err := gcache.Get(ctx, cacheKeyConfig); err == nil && !v.IsNil() { - return v.Val().(*entity.ModelConfig) +// schemaMaxTokens 从 schema JSON 中解析 max_tokens 值 +// 查找路径:properties.parameters.properties.max_tokens.default(JSON Schema 格式) +func schemaMaxTokens(schemaStr string) int { + if schemaStr == "" { + return 4096 } - - cfg, err := dao.ModelConfig.GetFirst(ctx) - if err == nil && cfg != nil { - _ = gcache.Set(ctx, cacheKeyConfig, cfg, 0) - return cfg + var doc map[string]any + if err := json.Unmarshal([]byte(schemaStr), &doc); err != nil { + return 4096 } - - m := &entity.ModelConfig{} - _ = gcache.Set(ctx, cacheKeyConfig, m, 0) - return m + // 尝试 parameters.properties.max_tokens.default + if params, _ := doc["properties"].(map[string]any); params != nil { + if p, _ := params["parameters"].(map[string]any); p != nil { + if props, _ := p["properties"].(map[string]any); props != nil { + if mt, _ := props["max_tokens"].(map[string]any); mt != nil { + if def, ok := mt["default"].(float64); ok { + return int(def) + } + } + } + } + } + return 4096 } -// GetResponse 获取模型配置 DTO(给 Controller 返回前端,schema 为 *json.RawMessage) -func (s *configService) GetResponse(ctx context.Context) *dto.GetModelConfigRes { - cfg := s.Get(ctx) - res := &dto.GetModelConfigRes{ModelConfig: cfg} - gconv.Struct(cfg.ChatSchema, &res.ChatSchema) - gconv.Struct(cfg.VideoSchema, &res.VideoSchema) - return res +// getModelList 获取模型配置列表(缓存) +func (s *configService) getModelList(ctx context.Context) []*entity.ModelConfig { + if v, err := gcache.Get(ctx, cacheKeyModelList); err == nil && !v.IsNil() { + return v.Val().([]*entity.ModelConfig) + } + list, err := dao.ModelConfig.GetAll(ctx) + if err != nil || len(list) == 0 { + return make([]*entity.ModelConfig, 0) + } + _ = gcache.Set(ctx, cacheKeyModelList, list, 0) + return list } -// Save 保存模型配置(接收 DTO,内部用 gconv.Struct 转换) -func (s *configService) Save(ctx context.Context, req *dto.SaveModelConfigReq) error { - existing, _ := dao.ModelConfig.GetFirst(ctx) +// clearModelListCache 清除模型列表缓存 +func (s *configService) ClearModelListCache(ctx context.Context) { + _, _ = gcache.Remove(ctx, cacheKeyModelList) +} +// GetActiveModel 获取指定类型已启用的系统模型配置 +func (s *configService) GetActiveModel(ctx context.Context, modelType string) *entity.ModelConfig { + list := s.getModelList(ctx) + for _, m := range list { + if m.ModelType == modelType { + return m + } + } + return &entity.ModelConfig{} +} + +// GetModelList 获取所有模型配置列表(供管理端) +func (s *configService) GetModelList(ctx context.Context) []*entity.ModelConfig { + return s.getModelList(ctx) +} + +// GetModelListResponse 获取模型配置列表 DTO +func (s *configService) GetModelListResponse(ctx context.Context) *dto.GetModelConfigListRes { + list := s.getModelList(ctx) + return &dto.GetModelConfigListRes{List: list} +} + +// SaveModelConfig 保存单个模型配置 +func (s *configService) SaveModelConfig(ctx context.Context, req *dto.SaveModelConfigReq) error { cfg := new(entity.ModelConfig) - if existing != nil { - gconv.Struct(existing, cfg) + if req.Id > 0 { + existing, _ := dao.ModelConfig.GetAll(ctx) + for _, m := range existing { + if m.Id == req.Id { + gconv.Struct(m, cfg) + break + } + } } - // DTO → Entity(同名按字段名映射,schema 除外) gconv.Struct(req, cfg) - // 特殊处理:*json.RawMessage → string - if req.ChatSchema != nil { - cfg.ChatSchema = req.ChatSchema.String() + // 处理 Schema: *gjson.Json → string + if req.Schema != nil { + cfg.Schema = req.Schema.String() } - if req.VideoSchema != nil { - cfg.VideoSchema = req.VideoSchema.String() - } - - syncModelDurationFromAPI(ctx, cfg) if err := dao.ModelConfig.Save(ctx, cfg); err != nil { return err } - - _ = gcache.Set(ctx, cacheKeyConfig, cfg, 0) + s.ClearModelListCache(ctx) return nil } +// GetUserConfig 获取指定用户对指定模型的私有配置 +func (s *configService) GetUserConfig(ctx context.Context, userId int64) *entity.UserModelConfig { + if userId <= 0 { + return &entity.UserModelConfig{} + } + cacheKey := cacheKeyUserConfigPrefix + gconv.String(userId) + if v, err := gcache.Get(ctx, cacheKey); err == nil && !v.IsNil() { + return v.Val().(*entity.UserModelConfig) + } + + cfg, err := dao.UserModelConfig.GetActiveByUserId(ctx, userId) + if err != nil || cfg == nil { + m := &entity.UserModelConfig{UserId: userId} + _ = gcache.Set(ctx, cacheKey, m, 0) + return m + } + _ = gcache.Set(ctx, cacheKey, cfg, 0) + return cfg +} + +// SaveUserConfig 保存用户私有模型配置 +// 如果已存在 user_id + model_config_id 的记录则更新,否则新增 +func (s *configService) SaveUserConfig(ctx context.Context, userId int64, cfg *entity.UserModelConfig) error { + cfg.UserId = userId + + // 查询是否已存在该用户的同模型配置 + existing, _ := dao.UserModelConfig.GetByModelConfigId(ctx, userId, cfg.ModelConfigId) + if existing != nil { + cfg.Id = existing.Id + cfg.CreatedAt = existing.CreatedAt + } + + if err := dao.UserModelConfig.Save(ctx, cfg); err != nil { + return err + } + _, _ = gcache.Remove(ctx, cacheKeyUserConfigPrefix+gconv.String(userId)) + return nil +} + +// GetMergedConfig 获取系统配置合并用户私有配置的结果 +// modelType: "chat" 或 "video" +func (s *configService) GetMergedConfig(ctx context.Context, userId int64, modelType string) *entity.ModelConfig { + sysCfg := s.GetActiveModel(ctx, modelType) + merged := *sysCfg + + if userId <= 0 { + return &merged + } + userCfg := s.GetUserConfig(ctx, userId) + if userCfg == nil || userCfg.Id <= 0 { + return &merged + } + + // 用户 API Key 覆盖系统 API Key + if userCfg.ApiKey != "" { + merged.ApiKey = userCfg.ApiKey + } + // Temperature 从用户配置读取(仅 chat 模型) + if userCfg.Temperature > 0 { + merged.Temperature = userCfg.Temperature + } else if modelType == "chat" { + merged.Temperature = 0.85 + } + // UserMaxTokens 仅 chat 模型有效(不超过系统上限,系统上限从 schema 解析) + if modelType == "chat" && userCfg.MaxTokens > 0 { + merged.MaxTokens = userCfg.MaxTokens + sysMax := schemaMaxTokens(sysCfg.Schema) + if merged.MaxTokens > sysMax && sysMax > 0 { + merged.MaxTokens = sysMax + } + } + return &merged +} + +// GetUserModelList 获取系统模型列表及当前用户的配置状态 +func (s *configService) GetUserModelList(ctx context.Context, userId int64) *dto.GetUserModelListRes { + models := s.getModelList(ctx) + list := make([]*dto.UserModelItem, 0, len(models)) + + // 加载该用户所有模型配置(不分是否激活),逐模型匹配展示 + userCfgs, _ := dao.UserModelConfig.GetByUserId(ctx, userId) + cfgMap := make(map[int64]*entity.UserModelConfig) + for _, uc := range userCfgs { + cfgMap[uc.ModelConfigId] = uc + } + + for _, m := range models { + sysMax := schemaMaxTokens(m.Schema) + item := &dto.UserModelItem{ + ModelConfigId: m.Id, + ModelType: m.ModelType, + ModelName: m.ModelName, + SystemMaxTokens: sysMax, + } + if uc, ok := cfgMap[m.Id]; ok { + item.Configured = uc.ApiKey != "" + item.UserApiKey = uc.ApiKey + item.Temperature = uc.Temperature + item.UserMaxTokens = uc.MaxTokens + } + list = append(list, item) + } + return &dto.GetUserModelListRes{List: list} +} + // GetPaymentConfigs 获取所有支付配置 func (s *configService) GetPaymentConfigs(ctx context.Context) ([]*entity.PaymentConfig, error) { return dao.PaymentConfigDao.GetAll(ctx) @@ -83,15 +226,3 @@ func (s *configService) GetPaymentConfigs(ctx context.Context) ([]*entity.Paymen func (s *configService) SavePaymentConfig(ctx context.Context, cfg *entity.PaymentConfig) error { return dao.PaymentConfigDao.Save(ctx, cfg) } - -// syncModelDurationFromAPI 保存前校验配置完整性,设置默认值 -func syncModelDurationFromAPI(ctx context.Context, cfg *entity.ModelConfig) { - if cfg.Temperature <= 0 { - cfg.Temperature = 0.85 - g.Log().Infof(ctx, "使用默认 Temperature: 0.85") - } - if cfg.MaxTokens <= 0 { - cfg.MaxTokens = 4096 - g.Log().Infof(ctx, "使用默认 MaxTokens: 4096") - } -} diff --git a/shortdrama/service/drama_service.go b/shortdrama/service/drama_service.go index 1f7f964..ec5f907 100644 --- a/shortdrama/service/drama_service.go +++ b/shortdrama/service/drama_service.go @@ -235,11 +235,12 @@ func (s *dramaService) GenerateEpisode(ctx context.Context, dramaId, epId int64, return fmt.Errorf("剧集不存在") } - modelCfg := ConfigService.Get(ctx) - if modelCfg.ChatApiKey == "" || modelCfg.ChatModelName == "" { - return fmt.Errorf("模型未配置") + chatCfg := ConfigService.GetMergedConfig(ctx, d.UserId, "chat") + if chatCfg.ApiKey == "" || chatCfg.ModelName == "" { + return fmt.Errorf("聊天模型未配置") } - if modelCfg.VideoApiKey == "" || modelCfg.VideoBaseUrl == "" || modelCfg.VideoModelName == "" { + videoCfg := ConfigService.GetMergedConfig(ctx, d.UserId, "video") + if videoCfg.ApiKey == "" || videoCfg.BaseUrl == "" || videoCfg.ModelName == "" { return fmt.Errorf("视频模型未配置") } // 客户身份:余额检查并预扣费 @@ -273,7 +274,7 @@ func (s *dramaService) GenerateEpisode(ctx context.Context, dramaId, epId int64, genCtx := agent.WithDramaID(context.Background(), d.Id) - segDurs := calcSegDurs(d.EpisodeDuration, modelCfg) + segDurs := calcSegDurs(d.EpisodeDuration, videoCfg) numSegments := len(segDurs) g.Log().Infof(ctx, "第%d集 总时长=%ds 拆分为%d段: %v", @@ -376,13 +377,14 @@ func (s *dramaService) GenerateEpisode(ctx context.Context, dramaId, epId int64, // generateOneSegment 生成一段内容:Agent → 保存演员/场景 → 保存脚本 → 提交视频 func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama, ep *entity.Episode, taskId int64, segIdx, segDur int, feedback string, genCtx *GenerationContext) error { - modelCfg := ConfigService.Get(ctx) + chatCfg := ConfigService.GetMergedConfig(ctx, d.UserId, "chat") + videoCfg := ConfigService.GetMergedConfig(ctx, d.UserId, "video") // 使用预加载的场景/道具/演员 characters := genCtx.Characters // 计算本段在整集中的起始时间(累计前几段时长) - segDurs := calcSegDurs(d.EpisodeDuration, modelCfg) + segDurs := calcSegDurs(d.EpisodeDuration, videoCfg) segStartTime := 0 for j := 0; j < segIdx; j++ { segStartTime += segDurs[j] @@ -395,7 +397,7 @@ func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama, segOutput = buildSegOutputFromShots(ep.Script, segIdx, segStartTime, segDur, genCtx) g.Log().Infof(ctx, "第%d集第%d段 JSON镜头直接提交: %d个镜头", ep.Index, segIdx+1, len(segOutput.Scenes)) } else { - result, err := s.generateSegment(ctx, d, ep, segIdx, segDur, segStartTime, totalSegs, modelCfg, feedback, genCtx) + result, err := s.generateSegment(ctx, d, ep, segIdx, segDur, segStartTime, totalSegs, chatCfg, feedback, genCtx) if err != nil { return err } @@ -462,76 +464,51 @@ func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama, numSegments = task.NumSegments } - // 提交视频合成 - // 构建引用列表:从Agent输出的角色名匹配预加载的参考图片 + // 角色参考:从 segOutput 中选取本段涉及的角色 var videoRefs []model.VideoRef - - // 1. 角色参考 for _, ch := range segOutput.Characters { - mediaURL := genCtx.LookupRef("演员", ch.Name) - if mediaURL == "" { - continue + if url := genCtx.LookupRef("演员", ch.Name); url != "" { + videoRefs = append(videoRefs, model.VideoRef{ + Type: "character", Name: ch.Name, MediaURL: url, + }) } - videoRefs = append(videoRefs, model.VideoRef{ - Type: "character", Name: ch.Name, MediaURL: mediaURL, - }) } - - // 2. 场景和道具参考:从脚本镜头中解析本段时间范围内涉及的场景和道具 + // 场景和道具参考:从脚本镜头中解析本段涉及的场景和道具(按 StartTime 归属,不跨段重复) segEndTime := segStartTime + segDur - var coveredScenes []string - var coveredProps []string + seenScene := make(map[string]bool) + seenProp := make(map[string]bool) if domain.IsShotsJSON(ep.Script) { var shots []domain.Shot if err := json.Unmarshal([]byte(ep.Script), &shots); err == nil { for _, sh := range shots { shStart := parseMMSSToSeconds(sh.StartTime) - shEnd := parseMMSSToSeconds(sh.EndTime) - // 检查镜头是否与本段时间范围重叠 - if shEnd <= segStartTime || shStart >= segEndTime { + if shStart < segStartTime || shStart >= segEndTime { continue } - if sh.Scene != "" { - coveredScenes = append(coveredScenes, sh.Scene) + if sh.Scene != "" && !seenScene[sh.Scene] { + seenScene[sh.Scene] = true + if url := genCtx.LookupRef("场景", sh.Scene); url != "" { + videoRefs = append(videoRefs, model.VideoRef{ + Type: "scene", Name: sh.Scene, MediaURL: url, + }) + } } for _, p := range sh.Props { - if p != "" { - coveredProps = append(coveredProps, p) + if p != "" && !seenProp[p] { + seenProp[p] = true + if url := genCtx.LookupRef("道具", p); url != "" { + videoRefs = append(videoRefs, model.VideoRef{ + Type: "prop", Name: p, MediaURL: url, + }) + } } } } } } - // 去重后添加场景参考 - seenScene := make(map[string]bool) - for _, name := range coveredScenes { - if seenScene[name] { - continue - } - seenScene[name] = true - if url := genCtx.LookupRef("场景", name); url != "" { - videoRefs = append(videoRefs, model.VideoRef{ - Type: "scene", Name: name, MediaURL: url, - }) - } - } - // 去重后添加道具参考 - seenProp := make(map[string]bool) - for _, name := range coveredProps { - if seenProp[name] { - continue - } - seenProp[name] = true - if url := genCtx.LookupRef("道具", name); url != "" { - videoRefs = append(videoRefs, model.VideoRef{ - Type: "prop", Name: name, MediaURL: url, - }) - } - } - g.Log().Infof(ctx, "第%d段视频参考素材: %d个(角色%d 场景%d 道具%d)", - segIdx+1, len(videoRefs), len(segOutput.Characters), len(coveredScenes), len(coveredProps)) + segIdx+1, len(videoRefs), len(segOutput.Characters), len(seenScene), len(seenProp)) taskID, _, submitErr := s.submitVideoTask(ctx, d, ep, segIdx, segDur, segOutput.Scenes, videoRefs) if submitErr != nil { @@ -567,8 +544,8 @@ func buildSegOutputFromShots(script string, segIdx, segStartTime, segDur int, ge charSet := make(map[string]bool) for _, sh := range allShots { shStart := parseMMSSToSeconds(sh.StartTime) - shEnd := parseMMSSToSeconds(sh.EndTime) - if shEnd <= segStartTime || shStart >= segEndTime { + // 按 StartTime 归属:每个镜头只归入其起始时间所在的段,确保不跨段重复 + if shStart < segStartTime || shStart >= segEndTime { continue } segShots = append(segShots, sh) @@ -727,9 +704,9 @@ func (s *dramaService) generateSegment(ctx context.Context, d *entity.Drama, ep genCtx *GenerationContext) (string, error) { chatCfg := &agent.ModelConfig{ - ModelName: modelCfg.ChatModelName, - APIKey: modelCfg.ChatApiKey, - BaseURL: modelCfg.ChatBaseUrl, + ModelName: modelCfg.ModelName, + APIKey: modelCfg.ApiKey, + BaseURL: modelCfg.BaseUrl, MaxTokens: modelCfg.MaxTokens, Temperature: float32(modelCfg.Temperature), } @@ -1017,15 +994,15 @@ func (s *dramaService) FeedbackSegment(ctx context.Context, taskId int64, feedba return err } - modelCfg := ConfigService.Get(ctx) - if modelCfg.VideoApiKey == "" || modelCfg.VideoBaseUrl == "" { - return fmt.Errorf("视频模型未配置") - } - d, err := dao.Drama.GetOne(ctx, task.DramaId) if err != nil || d == nil { return fmt.Errorf("短剧不存在") } + + modelCfg := ConfigService.GetMergedConfig(ctx, d.UserId, "video") + if modelCfg.ApiKey == "" || modelCfg.BaseUrl == "" { + return fmt.Errorf("视频模型未配置") + } ep, err := dao.Episode.GetOne(ctx, task.EpisodeId) if err != nil || ep == nil { return fmt.Errorf("剧集不存在") @@ -1048,12 +1025,12 @@ func (s *dramaService) FeedbackSegment(ctx context.Context, taskId int64, feedba } } // 用当前配置的模型名覆盖(兼容数据库配置变更后重做) - bodyMap["model"] = modelCfg.VideoModelName + bodyMap["model"] = modelCfg.ModelName bodyBytes, _ = json.Marshal(bodyMap) } } - newTaskID, resolvedBody, submitErr := resubmitVideoTask(genCtx, modelCfg.VideoApiKey, modelCfg.VideoBaseUrl, bodyBytes) + newTaskID, resolvedBody, submitErr := resubmitVideoTask(genCtx, modelCfg.ApiKey, modelCfg.BaseUrl, bodyBytes) if submitErr != nil { g.Log().Errorf(genCtx, "第%d集第%d段重新提交视频失败: %v", ep.Index, task.SegmentIdx+1, submitErr) _ = dao.GenerationTask.UpdateFailed(genCtx, taskId, submitErr.Error()) @@ -1158,11 +1135,13 @@ func (s *dramaService) StartVideoPoller(ctx context.Context) { // pollPendingVideos 扫描所有 generating 任务,轮询或重试视频合成 func (s *dramaService) pollPendingVideos(ctx context.Context) { - modelCfg := ConfigService.Get(ctx) - if modelCfg.VideoApiKey == "" || modelCfg.VideoBaseUrl == "" { + modelCfg := ConfigService.GetActiveModel(ctx, "video") + if modelCfg.ApiKey == "" || modelCfg.BaseUrl == "" { return } + // 注意:各任务所属用户不同,轮询时使用 per-task 的 merged config + allTasks, err := dao.GenerationTask.ListByStatuses(ctx, []string{consts.TaskStatusGenerating, consts.TaskStatusReview}) if err != nil || len(allTasks) == 0 { return @@ -1198,7 +1177,7 @@ func (s *dramaService) pollPendingVideos(ctx context.Context) { g.Log().Warningf(ctx, "轮询器: 任务 %d 第%d段视频不支持自定义时长,清除 task_id 准备不携带 duration 重试", task.Id, task.SegmentIdx+1) _ = dao.GenerationTask.UpdateFields(ctx, task.Id, g.Map{"video_task_id": ""}) // 持久化探测结果,让后续 calcSegDurs 用保守值切段 - gcache.Remove(ctx, cacheKeyConfig) + ConfigService.ClearModelListCache(ctx) epUpdates[task.EpisodeId] = true } else { g.Log().Warningf(ctx, "轮询器: 任务 %d 第%d段视频任务失败: %v", task.Id, task.SegmentIdx+1, err) @@ -1341,13 +1320,13 @@ func buildPollURL(baseURL, taskId string) string { // pollVideoTaskOnce 单次查询视频生成任务状态 func (s *dramaService) pollVideoTaskOnce(ctx context.Context, modelCfg *entity.ModelConfig, taskId string) (string, error) { - queryURL := buildPollURL(modelCfg.VideoBaseUrl, taskId) + queryURL := buildPollURL(modelCfg.BaseUrl, taskId) req, err := http.NewRequestWithContext(ctx, "GET", queryURL, nil) if err != nil { return "", fmt.Errorf("创建查询请求失败: %w", err) } - req.Header.Set("Authorization", "Bearer "+modelCfg.VideoApiKey) + req.Header.Set("Authorization", "Bearer "+modelCfg.ApiKey) req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} @@ -1430,8 +1409,8 @@ func mapVideoTaskStatus(s string) videoTaskStatus { // submitVideoTask 提交视频合成任务,返回 (taskID, requestBodyJSON, error) // requestBodyJSON 是调用视频模型 API 时发送的完整请求体 JSON 字符串 func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep *entity.Episode, segIdx, segDur int, scenes []model.SegmentScene, refs []model.VideoRef) (string, string, error) { - modelCfg := ConfigService.Get(ctx) - if modelCfg.VideoApiKey == "" || modelCfg.VideoBaseUrl == "" || modelCfg.VideoModelName == "" { + modelCfg := ConfigService.GetMergedConfig(ctx, d.UserId, "video") + if modelCfg.ApiKey == "" || modelCfg.BaseUrl == "" || modelCfg.ModelName == "" { return "", "", fmt.Errorf("视频模型未配置") } @@ -1448,9 +1427,9 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep // 从 video_schema 读取模型特定配置(结构按 API 请求格式: input / parameters) refMaxCount := 5 // 默认最多5个 promptMaxChars := 0 - if modelCfg.VideoSchema != "" { + if modelCfg.Schema != "" { var vs map[string]any - if err := json.Unmarshal([]byte(modelCfg.VideoSchema), &vs); err == nil { + if err := json.Unmarshal([]byte(modelCfg.Schema), &vs); err == nil { refMaxCount = intVal(nested(vs, "input", "reference_urls", "total_max"), refMaxCount) promptMaxChars = intVal(nested(vs, "input", "prompt", "max_chars"), 0) } @@ -1526,7 +1505,7 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep } promptRunes := []rune(prompt) if keepLen < len(promptRunes) { - prompt = "..." + string(promptRunes[len(promptRunes)-keepLen+3:]) + prompt = string(promptRunes[:keepLen-3]) + "..." } g.Log().Infof(ctx, "prompt超长已截断至%d字符(原%d字符)", promptMaxChars, len([]rune(prompt))) } @@ -1541,16 +1520,16 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep // 从 video_schema 判断当前模型是否支持 duration 参数 effectiveDur := segDur - if modelCfg.VideoSchema != "" { + if modelCfg.Schema != "" { var vs map[string]any - if err := json.Unmarshal([]byte(modelCfg.VideoSchema), &vs); err == nil { + if err := json.Unmarshal([]byte(modelCfg.Schema), &vs); err == nil { if durDef := nested(vs, "parameters", "duration"); durDef != nil { if durMap, ok := durDef.(map[string]any); ok { if sm, ok := durMap["supported_models"]; ok { if models, ok := sm.([]any); ok { found := false for _, m := range models { - if ms, ok := m.(string); ok && ms == modelCfg.VideoModelName { + if ms, ok := m.(string); ok && ms == modelCfg.ModelName { found = true break } @@ -1569,9 +1548,9 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep } // 固定 seed 确保各段画风/人物形象一致(基于短剧ID+剧集序号) - seed := int(d.Id)*1000 + ep.Index*10 + segIdx - taskId, requestJSON, err := createVideoTask(ctx, modelCfg.VideoApiKey, modelCfg.VideoBaseUrl, modelCfg.VideoModelName, - prompt, negativePrompt, refURLs, effectiveDur, d.Resolution, d.AspectRatio, modelCfg.VideoSchema, seed) + seed := int(d.Id)*1000 + ep.Index*10 + taskId, requestJSON, err := createVideoTask(ctx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.ModelName, + prompt, negativePrompt, refURLs, effectiveDur, d.Resolution, d.AspectRatio, modelCfg.Schema, seed) if err != nil { return "", "", fmt.Errorf("视频合成请求失败: %w", err) } @@ -1975,8 +1954,8 @@ func (s *dramaService) overlayBackgroundMusic(ctx context.Context, dramaId int64 // 轮询视频 API 直到视频就绪,下载到本地,更新 DB 的 video_url。 // 用于串行模式中让下一段能提取上一段的尾帧作为首帧。 func (s *dramaService) waitForSegmentVideo(ctx context.Context, d *entity.Drama, ep *entity.Episode, taskId int64) error { - modelCfg := ConfigService.Get(ctx) - if modelCfg.VideoApiKey == "" || modelCfg.VideoBaseUrl == "" { + modelCfg := ConfigService.GetMergedConfig(ctx, d.UserId, "video") + if modelCfg.ApiKey == "" || modelCfg.BaseUrl == "" { return fmt.Errorf("视频模型未配置或查询地址为空") } @@ -2141,9 +2120,9 @@ func calcSegDurs(episodeDuration int64, cfg *entity.ModelConfig) []int { // 从 video_schema.duration 读取模型单段时长约束 effectiveMax := 15 // 默认值 minSingle := 5 - if cfg.VideoSchema != "" { + if cfg.Schema != "" { var vs map[string]any - if err := json.Unmarshal([]byte(cfg.VideoSchema), &vs); err == nil { + if err := json.Unmarshal([]byte(cfg.Schema), &vs); err == nil { if v := intVal(nested(vs, "parameters", "duration", "max"), 0); v > 0 { effectiveMax = v } diff --git a/shortdrama/service/episode_service.go b/shortdrama/service/episode_service.go index bb54180..e4bd1a0 100644 --- a/shortdrama/service/episode_service.go +++ b/shortdrama/service/episode_service.go @@ -205,8 +205,8 @@ func (s *dramaService) GenerateScript(ctx context.Context, dramaId int64, episod return } - modelCfg := ConfigService.Get(ctx) - if modelCfg.ChatApiKey == "" || modelCfg.ChatModelName == "" { + modelCfg := ConfigService.GetMergedConfig(ctx, d.UserId, "chat") + if modelCfg.ApiKey == "" || modelCfg.ModelName == "" { err = fmt.Errorf("模型未配置") return } @@ -222,9 +222,9 @@ func (s *dramaService) GenerateScript(ctx context.Context, dramaId int64, episod userInput := s.buildScriptGenUserInput(d, episodeTitle, description, genCtx) chatCfg := &agent.ModelConfig{ - ModelName: modelCfg.ChatModelName, - APIKey: modelCfg.ChatApiKey, - BaseURL: modelCfg.ChatBaseUrl, + ModelName: modelCfg.ModelName, + APIKey: modelCfg.ApiKey, + BaseURL: modelCfg.BaseUrl, MaxTokens: modelCfg.MaxTokens, Temperature: float32(modelCfg.Temperature), Timeout: time.Duration(g.Cfg().MustGet(ctx, "chat.timeout", 180).Int()) * time.Second, @@ -349,7 +349,7 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, if err != nil || d == nil { return fmt.Errorf("短剧不存在: %d", dramaId) } - modelCfg := ConfigService.Get(ctx) + modelCfg := ConfigService.GetActiveModel(ctx, "video") // 预加载参考素材(演员优先,场景其次,道具最后) chars, _, _ := dao.Character.ListPageByDrama(ctx, dramaId, 1, -1) @@ -365,8 +365,8 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, // 从 video_schema 读取参考素材上限和默认参数 refMax := 5 var vs map[string]any - if modelCfg.VideoSchema != "" { - json.Unmarshal([]byte(modelCfg.VideoSchema), &vs) + if modelCfg.Schema != "" { + json.Unmarshal([]byte(modelCfg.Schema), &vs) } if vs != nil { if v := nested(vs, "input", "reference_urls", "total_max"); v != nil { @@ -446,7 +446,7 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, if models, ok := sm.([]any); ok { found := false for _, m := range models { - if ms, ok := m.(string); ok && ms == modelCfg.VideoModelName { + if ms, ok := m.(string); ok && ms == modelCfg.ModelName { found = true break } @@ -601,7 +601,7 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, segParams["duration"] = tg.dur bodyJSON, _ := json.Marshal(map[string]any{ - "model": modelCfg.VideoModelName, + "model": modelCfg.ModelName, "input": map[string]any{ "prompt": tg.promptText, "negative_prompt": negativePrompt, diff --git a/test.json b/test.json index 076c728..35471e2 100644 --- a/test.json +++ b/test.json @@ -1 +1,97 @@ -{"model": "wan2.6-r2v", "input": {"prompt": {"required": true, "type": "string", "max_chars": 1500, "description": "文本提示词。支持中英文,每个汉字/字母/标点占一个字符,超过部分自动截断。通过 character1、character2 引用参考角色,每个参考仅包含单一角色。模型仅通过此方式识别参考中的角色"}, "negative_prompt": {"required": false, "type": "string", "max_chars": 500, "description": "反向提示词,用来描述不希望在视频画面中出现的内容,可以对视频画面进行限制"}, "reference_urls": {"required": true, "type": "array", "items": "string", "ref_ordering": "按数组顺序定义角色顺序,第1个URL对应character1,第2个对应character2,依此类推。每个参考文件仅包含一个主体角色", "total_max": 5, "image_max": 5, "video_max": 3, "video_formats": ["MP4", "MOV"], "video_duration_sec": {"min": 1, "max": 30}, "video_max_size_mb": 100, "image_formats": ["JPEG", "JPG", "PNG", "BMP", "WEBP"], "image_note": "PNG不支持透明通道", "image_resolution_px": {"min": 240, "max": 8000}, "image_max_size_mb": 20}}, "parameters": {"size": {"required": false, "type": "string", "format": "{width}*{height}", "default": "1920*1080", "description": "视频分辨率,格式为宽*高。必须设置为具体数值(如1280*720),而不是1:1或720P", "sizes": {"720P": {"9:16": "720*1280", "16:9": "1280*720", "1:1": "960*960", "4:3": "1088*832", "3:4": "832*1088"}, "1080P": {"9:16": "1080*1920", "16:9": "1920*1080", "1:1": "1440*1440", "4:3": "1632*1248", "3:4": "1248*1632"}}}, "duration": {"required": false, "type": "integer", "min": 2, "max": 10, "default": 5, "description": "生成视频的时长,单位为秒"}, "shot_type": {"required": false, "type": "string", "enum": ["single", "multi"], "default": "single", "description": "镜头类型。参数优先级:shot_type > prompt。single=单镜头视频,multi=多镜头视频"}, "audio": {"required": false, "type": "boolean", "default": true, "supported_models": ["wan2.6-r2v-flash"], "description": "是否生成有声视频"}, "watermark": {"required": false, "type": "boolean", "default": false}, "seed": {"required": false, "type": "integer", "min": 0, "max": 2147483647}}} \ No newline at end of file +{ + "input": { + "negative_prompt": { + "description": "反向提示词,用来描述不希望在视频画面中出现的内容,可以对视频画面进行限制", + "max_chars": 500, + "required": false, + "type": "string" + }, + "prompt": { + "description": "文本提示词。支持中英文,每个汉字/字母/标点占一个字符,超过部分自动截断。通过 character1、character2 引用参考角色,每个参考仅包含单一角色。模型仅通过此方式识别参考中的角色", + "max_chars": 1500, + "required": true, + "type": "string" + }, + "reference_urls": { + "image_formats": ["JPEG", "JPG", "PNG", "BMP", "WEBP"], + "image_max": 5, + "image_max_size_mb": 20, + "image_note": "PNG不支持透明通道", + "image_resolution_px": { + "max": 8000, + "min": 240 + }, + "items": "string", + "ref_ordering": "按数组顺序定义角色顺序,第1个URL对应character1,第2个对应character2,依此类推。每个参考文件仅包含一个主体角色", + "required": true, + "total_max": 5, + "type": "array", + "video_duration_sec": { + "max": 30, + "min": 1 + }, + "video_formats": ["MP4", "MOV"], + "video_max": 3, + "video_max_size_mb": 100 + } + }, + "model": "wan2.6-r2v-flash", + "parameters": { + "audio": { + "default": true, + "description": "是否生成有声视频", + "required": false, + "supported_models": ["wan2.6-r2v-flash"], + "type": "boolean" + }, + "duration": { + "default": 5, + "description": "生成视频的时长,单位为秒", + "max": 10, + "min": 2, + "required": false, + "type": "integer" + }, + "seed": { + "max": 2147483647, + "min": 0, + "required": false, + "type": "integer" + }, + "shot_type": { + "default": "single", + "description": "镜头类型。参数优先级:shot_type \u003e prompt。single=单镜头视频,multi=多镜头视频", + "enum": ["single", "multi"], + "required": false, + "type": "string" + }, + "size": { + "default": "1920*1080", + "description": "视频分辨率,格式为宽*高。必须设置为具体数值(如1280*720),而不是1:1或720P", + "format": "{width}*{height}", + "required": false, + "sizes": { + "1080P": { + "16:9": "1920*1080", + "1:1": "1440*1440", + "3:4": "1248*1632", + "4:3": "1632*1248", + "9:16": "1080*1920" + }, + "720P": { + "16:9": "1280*720", + "1:1": "960*960", + "3:4": "832*1088", + "4:3": "1088*832", + "9:16": "720*1280" + } + }, + "type": "string" + }, + "watermark": { + "default": false, + "required": false, + "type": "boolean" + } + } +} \ No newline at end of file