This commit is contained in:
2026-07-17 12:48:49 +08:00
parent c8feaab3d3
commit e7c581ed5b
13 changed files with 778 additions and 282 deletions
BIN
View File
Binary file not shown.
+1
View File
@@ -17,4 +17,5 @@ const (
TableNamePaymentChannelTrade = "payment_channel_trade"
TableNamePaymentConfig = "payment_config"
TableNameRegionPricing = "region_pricing"
TableNameUserModelConfig = "user_model_config"
)
+40 -6
View File
@@ -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,
+134 -87
View File
@@ -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
}
+160
View File
@@ -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
}
+54 -22
View File
@@ -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 {
+14 -15
View File
@@ -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:"更新时间"`
}
@@ -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:"更新时间"`
}
+2 -2
View File
@@ -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
}
+181 -50
View File
@@ -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.defaultJSON 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")
}
}
+68 -89
View File
@@ -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
}
+10 -10
View File
@@ -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,
+97 -1
View File
@@ -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}}}
{
"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"
}
}
}