From 23c8a4c1ba0b9ce7e74dbcb2c640fdfd95689d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Fri, 24 Jul 2026 13:05:57 +0800 Subject: [PATCH] 1 --- common/auth.go | 34 ++ .../middleware => common}/auth_middleware.go | 6 +- common/base_dao.go | 83 +++ common/{http => }/http.go | 6 +- common/util.go | 36 ++ docs/项目文档.md | 485 +++++++++++++++++ docs/项目缺陷与优化说明.md | 251 +++++++++ go.mod | 1 - go.sum | 4 - main.go | 41 +- short_drama.db | Bin 462848 -> 462848 bytes shortdrama/agent/chat_model.go | 37 +- shortdrama/agent/react_agent.go | 14 +- shortdrama/agent/tools.go | 32 +- shortdrama/config/shot_duration.go | 8 +- shortdrama/controller/customer_controller.go | 14 +- shortdrama/controller/drama_controller.go | 24 +- .../controller/payment_order_controller.go | 14 +- .../controller/transaction_controller.go | 4 +- .../user_model_config_controller.go | 12 +- shortdrama/dao/account_transaction_dao.go | 23 +- shortdrama/dao/agent_profile_dao.go | 36 +- shortdrama/dao/background_music_dao.go | 69 +-- shortdrama/dao/character_dao.go | 68 +-- shortdrama/dao/customer_profile_dao.go | 24 +- shortdrama/dao/drama_dao.go | 42 +- shortdrama/dao/episode_dao.go | 56 +- shortdrama/dao/generation_task_dao.go | 11 +- shortdrama/dao/model_config_dao.go | 44 +- shortdrama/dao/payment_channel_trade_dao.go | 6 +- shortdrama/dao/payment_config_dao.go | 2 +- shortdrama/dao/payment_order_dao.go | 44 +- shortdrama/dao/prop_dao.go | 69 +-- shortdrama/dao/region_pricing_dao.go | 25 +- shortdrama/dao/scene_dao.go | 69 +-- shortdrama/dao/user_dao.go | 72 ++- shortdrama/dao/user_model_config_dao.go | 21 +- .../model/dto/account_transaction_dto.go | 12 +- shortdrama/model/dto/agent_profile_dto.go | 18 +- shortdrama/model/dto/background_music_dto.go | 16 +- shortdrama/model/dto/character_dto.go | 20 +- shortdrama/model/dto/customer_profile_dto.go | 30 +- shortdrama/model/dto/drama_dto.go | 32 +- shortdrama/model/dto/episode_dto.go | 36 +- shortdrama/model/dto/generation_task_dto.go | 8 +- shortdrama/model/dto/model_config_dto.go | 16 +- .../model/dto/payment_channel_trade_dto.go | 2 +- shortdrama/model/dto/payment_config_dto.go | 16 +- shortdrama/model/dto/payment_order_dto.go | 23 +- shortdrama/model/dto/prop_dto.go | 20 +- shortdrama/model/dto/region_pricing_dto.go | 14 +- shortdrama/model/dto/scene_dto.go | 20 +- shortdrama/model/dto/user_dto.go | 4 +- shortdrama/model/dto/user_model_config_dto.go | 24 +- shortdrama/model/entity/character.go | 6 +- shortdrama/model/entity/drama.go | 22 +- shortdrama/model/entity/episode.go | 10 +- shortdrama/model/entity/prop.go | 6 +- shortdrama/model/entity/scene.go | 6 +- shortdrama/model/entity/user.go | 2 +- shortdrama/service/agent_service.go | 18 +- .../service/background_music_service.go | 2 +- shortdrama/service/character_service.go | 16 +- shortdrama/service/customer_service.go | 16 +- shortdrama/service/drama_service.go | 55 +- shortdrama/service/episode_service.go | 101 ++-- shortdrama/service/generation_service.go | 506 ++++++++++-------- shortdrama/service/model_config_service.go | 2 +- shortdrama/service/payment_config_service.go | 2 +- shortdrama/service/payment_order_service.go | 192 +++---- shortdrama/service/prop_service.go | 2 +- shortdrama/service/region_pricing_service.go | 2 +- shortdrama/service/scene_service.go | 2 +- shortdrama/service/transaction_service.go | 4 +- .../service/user_model_config_service.go | 4 +- shortdrama/service/user_service.go | 58 +- tools/split_merged/split_merged.go | 263 --------- 77 files changed, 1987 insertions(+), 1408 deletions(-) create mode 100644 common/auth.go rename {shortdrama/middleware => common}/auth_middleware.go (92%) create mode 100644 common/base_dao.go rename common/{http => }/http.go (92%) create mode 100644 common/util.go create mode 100644 docs/项目文档.md create mode 100644 docs/项目缺陷与优化说明.md delete mode 100644 tools/split_merged/split_merged.go diff --git a/common/auth.go b/common/auth.go new file mode 100644 index 0000000..40d9eee --- /dev/null +++ b/common/auth.go @@ -0,0 +1,34 @@ +package common + +import ( + "errors" + + "github.com/golang-jwt/jwt/v5" +) + +const jwtSecret = "video-factory-jwt-secret-2024" + +func GetJwtSecret() string { + return jwtSecret +} + +type JwtClaims struct { + UserId int64 `json:"user_id"` + Role string `json:"role"` + AgentId int64 `json:"agent_id,omitempty"` + jwt.RegisteredClaims +} + +func ParseToken(tokenStr string) (*JwtClaims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &JwtClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(jwtSecret), nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*JwtClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid token") + } + return claims, nil +} diff --git a/shortdrama/middleware/auth_middleware.go b/common/auth_middleware.go similarity index 92% rename from shortdrama/middleware/auth_middleware.go rename to common/auth_middleware.go index d2f7f52..aebc651 100644 --- a/shortdrama/middleware/auth_middleware.go +++ b/common/auth_middleware.go @@ -1,11 +1,9 @@ -package middleware +package common import ( "net/http" "strings" - "video-factory/shortdrama/service" - "github.com/gogf/gf/v2/net/ghttp" ) @@ -34,7 +32,7 @@ func Auth(r *ghttp.Request) { return } - claims, err := service.UserService.ParseToken(auth[7:]) + claims, err := ParseToken(auth[7:]) if err != nil { r.Response.WriteJson(ghttp.DefaultHandlerResponse{ Code: http.StatusUnauthorized, diff --git a/common/base_dao.go b/common/base_dao.go new file mode 100644 index 0000000..92706bc --- /dev/null +++ b/common/base_dao.go @@ -0,0 +1,83 @@ +package common + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" +) + +func prepareInsertData(data any) map[string]any { + 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") + delete(m, "deleted_at") + return m +} + +func InsertAndReturnId(ctx context.Context, table string, data any) (id int64, err error) { + m := prepareInsertData(data) + r, err := g.DB().Model(table).Ctx(ctx).Data(m).Insert() + if err != nil { + return 0, err + } + if r == nil { + return 0, nil + } + return r.LastInsertId() +} + +func GetOneByPk[T any](ctx context.Context, table string, pk int64) (res *T, err error) { + r, err := g.DB().Model(table).Ctx(ctx).Where("id", pk).One() + if err != nil { + return nil, err + } + if r == nil { + return nil, nil + } + err = r.Struct(&res) + return +} + +func UpdateByPk(ctx context.Context, table string, pk int64, data any) error { + _, err := g.DB().Model(table).Ctx(ctx).Data(data).Where("id", pk).Update() + return err +} + +func DeleteByPk(ctx context.Context, table string, pk int64) error { + _, err := g.DB().Model(table).Ctx(ctx).Unscoped().Where("id", pk).Delete() + return err +} + +func DeleteByDrama(ctx context.Context, table string, dramaId int64) error { + _, err := g.DB().Model(table).Ctx(ctx).Unscoped().Where("drama_id", dramaId).Delete() + return err +} + +func ListPageByDrama[T any](ctx context.Context, table string, dramaId int64, page, pageSize int) (res []*T, total int, err error) { + m := g.DB().Model(table).Ctx(ctx).Where("drama_id", dramaId) + if pageSize == -1 { + r, err := m.OrderAsc("id").All() + if err != nil { + return nil, 0, err + } + res = make([]*T, 0) + err = r.Structs(&res) + return res, len(res), err + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + r, total, err := m.OrderAsc("id").Limit(pageSize).Offset((page - 1) * pageSize).AllAndCount(false) + if err != nil { + return + } + res = make([]*T, 0) + err = r.Structs(&res) + return +} diff --git a/common/http/http.go b/common/http.go similarity index 92% rename from common/http/http.go rename to common/http.go index b5d9217..4a2c811 100644 --- a/common/http/http.go +++ b/common/http.go @@ -1,4 +1,4 @@ -package http +package common import ( "fmt" @@ -6,8 +6,6 @@ import ( "regexp" "strings" - "video-factory/shortdrama/middleware" - "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/gtime" @@ -29,7 +27,7 @@ func init() { r.Middleware.Next() }) // JWT 鉴权 - Httpserver.BindMiddlewareDefault(middleware.Auth) + Httpserver.BindMiddlewareDefault(Auth) } // RouteRegister 根据控制器结构体名称自动注册路由 diff --git a/common/util.go b/common/util.go new file mode 100644 index 0000000..71f7d89 --- /dev/null +++ b/common/util.go @@ -0,0 +1,36 @@ +package common + +import ( + "encoding/base64" + "os" + "strings" +) + +// ImageFileToBase64 reads an image file and returns a data:image/...;base64 string. +func ImageFileToBase64(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + ext := strings.ToLower(pathExt(path)) + mime := "image/png" + switch ext { + case ".jpg", ".jpeg": + mime = "image/jpeg" + case ".gif": + mime = "image/gif" + case ".webp": + mime = "image/webp" + } + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil +} + +// pathExt extracts the extension from a path. +func pathExt(path string) string { + for i := len(path) - 1; i >= 0 && path[i] != '/'; i-- { + if path[i] == '.' { + return path[i:] + } + } + return "" +} diff --git a/docs/项目文档.md b/docs/项目文档.md new file mode 100644 index 0000000..01897f3 --- /dev/null +++ b/docs/项目文档.md @@ -0,0 +1,485 @@ +# Video Factory 项目文档 + +## 概述 + +Video Factory 是一个 AI 驱动的短视频自动生成平台。用户创建短剧项目,配置演员/场景/道具等素材,通过 AI 生成剧集脚本并调用视频生成 API 自动产出视频。 + +--- + +## 技术栈 + +| 层 | 技术 | +|---|------| +| 语言 | Go 1.22+ | +| Web 框架 | GoFrame v2 (github.com/gogf/gf/v2) | +| 数据库 | SQLite(通过 GoFrame ORM) | +| 认证 | JWT (golang-jwt/jwt/v5) | +| 密码 | bcrypt (golang.org/x/crypto/bcrypt) | +| AI 模型 | OpenAI 兼容 API(可对接 DeepSeek/Kimi/Qwen 等) | +| 前端 | Vue 3 + Vite(video-factory-ui) | + +--- + +## 项目结构 + +``` +video-factory/ +├── main.go # 入口:路由注册、静态文件、启动轮询 +├── common/ +│ └── http/ +│ └── http.go # RouteRegister — 自动注册路由 +├── shortdrama/ +│ ├── controller/ # HTTP 控制器层(17 个) +│ │ ├── drama_controller.go # 短剧 CRUD +│ │ ├── scene_controller.go # 场景 CRUD +│ │ ├── character_controller.go # 演员 CRUD +│ │ ├── prop_controller.go # 道具 CRUD +│ │ ├── bgm_controller.go # 背景音 CRUD +│ │ ├── episode_controller.go # 剧集 CRUD + 脚本生成 +│ │ ├── generation_controller.go # 视频生成/轮询/分段 +│ │ ├── user_controller.go # 用户登录/改密 +│ │ ├── agent_controller.go # 代理商管理 +│ │ ├── customer_controller.go # 客户管理 +│ │ ├── transaction_controller.go # 交易记录 +│ │ ├── payment_order_controller.go # 支付订单 +│ │ ├── payment_channel_trade_controller.go # 支付渠道流水 +│ │ ├── payment_config_controller.go # 支付配置 +│ │ ├── model_config_controller.go # 模型配置 +│ │ ├── user_model_config_controller.go # 用户模型配置 +│ │ └── region_pricing_controller.go # 区域定价 +│ ├── service/ # 业务逻辑层(17 个) +│ │ ├── drama_service.go # 短剧 CRUD + 工作空间管理 +│ │ ├── scene_service.go # 场景 +│ │ ├── character_service.go # 演员(含文件保存) +│ │ ├── prop_service.go # 道具 +│ │ ├── background_music_service.go # 背景音 +│ │ ├── episode_service.go # 剧集 + AI 脚本生成 +│ │ ├── generation_service.go # 视频生成核心流水线 +│ │ ├── user_service.go # 登录/JWT +│ │ ├── agent_service.go # 代理商 +│ │ ├── customer_service.go # 客户 +│ │ ├── transaction_service.go # 交易 +│ │ ├── payment_order_service.go # 支付(微信/支付宝/线下) +│ │ ├── payment_channel_trade_service.go # 渠道流水 +│ │ ├── payment_config_service.go # 支付配置 +│ │ ├── model_config_service.go # 模型配置(缓存) +│ │ ├── user_model_config_service.go # 用户模型配置(合并系统+用户) +│ │ └── region_pricing_service.go # 区域定价(缓存) +│ ├── dao/ # 数据访问层(17 个,每表一个) +│ │ ├── drama_dao.go +│ │ ├── scene_dao.go +│ │ ├── character_dao.go +│ │ ├── episode_dao.go +│ │ ├── prop_dao.go +│ │ ├── background_music_dao.go +│ │ ├── generation_task_dao.go +│ │ ├── user_dao.go +│ │ ├── agent_profile_dao.go +│ │ ├── customer_profile_dao.go +│ │ ├── account_transaction_dao.go +│ │ ├── payment_order_dao.go +│ │ ├── payment_channel_trade_dao.go +│ │ ├── payment_config_dao.go +│ │ ├── model_config_dao.go +│ │ ├── user_model_config_dao.go +│ │ └── region_pricing_dao.go +│ ├── model/ +│ │ ├── entity/ # 数据库实体(每表一个) +│ │ ├── dto/ # 请求/响应结构体(含 g.Meta 路由信息) +│ │ ├── domain/ # 领域模型 +│ │ │ └── shot.go # 镜头模型 + 镜头转文本方法 +│ │ ├── agent_output.go # AI Agent 输出解析 +│ │ └── segment_output.go # 分段生成输出类型 +│ ├── agent/ # AI Agent 模块 +│ │ ├── types.go # 类型定义 +│ │ ├── chat_model.go # LLM API 调用(OpenAI 兼容) +│ │ ├── react_agent.go # ReAct 智能体引擎 +│ │ ├── tools.go # Agent 工具集 +│ │ └── context.go # Agent 上下文 +│ ├── middleware/ +│ │ └── auth_middleware.go # JWT 鉴权中间件 +│ ├── config/ +│ │ └── shot_duration.go # 镜头时长提示词 +│ └── consts/ +│ ├── public/ +│ │ ├── table_name.go # 数据库表名常量 +│ │ └── content_type.go # 内容类型/字段定义 +│ └── status.go # 剧集/任务状态常量 +└── workspace/ # 上传文件存储目录 +``` + +--- + +## 架构模式 + +分层结构:**Controller → Service → DAO → SQLite** + +``` +HTTP 请求 + ↓ +Controller(接收请求、参数校验、返回响应) + ↓ +Service(业务逻辑、事务管理、AI 调用) + ↓ +DAO(数据访问、ORM 操作) + ↓ +SQLite +``` + +- 每一层都是独立的包,通过包级变量暴露单例(如 `var DramaService = new(dramaService)`) +- 每张数据库表对应一个 DAO、一个 Service、一个 Controller(17×17 一对一映射) + +### 路由注册机制 + +`RouteRegister`(`common/http/http.go`)通过反射获取结构体名,转为 kebab-case 作为 URL 前缀: + +```go +type scene struct{} // → 前缀 /scene +type modelConfig struct{} // → 前缀 /model-config +``` + +GoFrame v2 将 Controller 方法名转为 kebab-case 拼接到前缀后: + +```go +// scene_controller.go +func (c *scene) List(ctx, req) → GET /scene/list +func (c *scene) Add(ctx, req) → POST /scene/add +``` + +完整路由表(详见下方 API 章节)。 + +--- + +## 数据库 + +使用 SQLite,`init()` 函数自动建表,支持旧表迁移(ALTER TABLE ADD COLUMN 兼容)。 + +### 短剧相关表(6 张) + +| 表名 | 实体 | 说明 | +|------|------|------| +| `short_drama` | Drama | 短剧项目(标题/类型/时长/分辨率/配置) | +| `short_drama_episode` | Episode | 剧集(序号/标题/脚本/状态/视频URL) | +| `short_drama_character` | Character | 演员(名称/描述/声音文件/形象文件) | +| `short_drama_scene` | Scene | 场景(名称/描述/图片) | +| `short_drama_prop` | Prop | 道具(名称/描述/图片) | +| `short_drama_background_music` | BackgroundMusic | 背景音乐(名称/文件) | + +### 生成任务表(1 张) + +| 表名 | 说明 | +|------|------| +| `short_drama_generation_task` | 视频生成任务(剧集/分段/状态/任务ID/脚本/模型名) | + +### 用户/代理商/客户表(3 张) + +| 表名 | 说明 | +|------|------| +| `user` | 用户(角色:admin/agent/customer) | +| `agent_profile` | 代理商资料(最大客户数/到期时间/区域保护) | +| `customer_profile` | 客户资料(关联代理商/余额) | + +### 支付相关表(3 张) + +| 表名 | 说明 | +|------|------| +| `payment_order` | 支付订单(用户/金额/渠道/状态/类型) | +| `payment_channel_trade` | 渠道流水(支付单号/渠道单号/状态) | +| `payment_config` | 支付配置(渠道/商户ID/密钥) | + +### 其他表(4 张) + +| 表名 | 说明 | +|------|------| +| `short_drama_model_config` | 模型配置(系统级,模型列表/参数) | +| `user_model_config` | 用户模型配置(用户自定义 API Key/模型选择) | +| `account_transaction` | 账户交易流水(充值/扣费) | +| `region_pricing` | 区域定价(省份/地区/套餐/价格) | + +--- + +## API 路由表 + +所有请求均需 JWT 鉴权(除 `/user/login`),统一 JSON 响应格式 `{"code":0,"message":"OK","data":...}`。 + +### 短剧管理 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/drama/list` | 短剧列表(分页) | +| POST | `/drama/create` | 创建短剧 | +| GET | `/drama/get` | 短剧详情(含关联演员/场景/道具/背景音) | +| POST | `/drama/update` | 更新短剧 | +| POST | `/drama/delete` | 删除短剧(级联删除关联数据) | +| GET | `/drama/field-definitions` | 获取内容类型字段定义 | + +### 场景 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/scene/list` | 场景列表 | +| POST | `/scene/add` | 添加场景(支持上传图片) | +| POST | `/scene/update` | 更新场景 | +| POST | `/scene/delete` | 删除场景 | + +### 演员 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/character/list` | 演员列表 | +| POST | `/character/add` | 添加演员(支持上传声音/形象) | +| POST | `/character/update` | 更新演员 | +| POST | `/character/delete` | 删除演员 | + +### 道具 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/prop/list` | 道具列表 | +| POST | `/prop/add` | 添加道具 | +| POST | `/prop/update` | 更新道具 | +| POST | `/prop/delete` | 删除道具 | + +### 背景音乐 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/bgm/list` | 背景音乐列表 | +| POST | `/bgm/add` | 添加背景音乐 | +| POST | `/bgm/update` | 更新背景音乐 | +| POST | `/bgm/delete` | 删除背景音乐 | + +### 剧集 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/episode/list` | 剧集列表(分页) | +| POST | `/episode/add` | 添加剧集 | +| POST | `/episode/update` | 更新剧集 | +| POST | `/episode/delete` | 删除剧集 | +| POST | `/episode/generate-script` | AI 生成脚本 | + +### 视频生成 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/generation/generate` | 提交一集视频生成任务 | +| GET | `/generation/poll` | 轮询生成状态 | +| GET | `/generation/episode/task` | 获取剧集所有生成任务 | +| POST | `/generation/segment/continue` | 继续分段生成(含反馈) | +| POST | `/generation/segment/feedback` | 分段反馈 | + +### 用户 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/user/login` | 登录(公开路径) | +| POST | `/user/change-password` | 修改密码 | + +### 模型配置 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/model-config/get-model-list` | 模型列表 | +| POST | `/model-config/save-model-config` | 保存模型配置 | +| GET | `/user-model-config/get-user-config` | 获取用户模型配置 | +| POST | `/user-model-config/save-user-config` | 保存用户模型配置 | +| GET | `/user-model-config/get-user-model-list` | 用户可用模型列表 | + +### 支付 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/payment-order/prepay` | 创建支付订单 | +| GET | `/payment-order/status` | 查询支付状态 | +| POST | `/payment-order/confirm-offline` | 线下确认 | +| ALL | `/payment-order/notify-wechat` | 微信回调 | +| ALL | `/payment-order/notify-alipay` | 支付宝回调 | +| GET | `/payment-channel-trade/list-by-order` | 订单渠道流水 | +| GET | `/payment-config/get` | 支付配置 | +| POST | `/payment-config/save` | 保存支付配置 | + +### 代理商/客户/交易 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/agent/list` | 代理商列表 | +| POST | `/agent/create` | 创建代理商 | +| POST | `/agent/update` | 更新代理商 | +| ALL | `/agent/get` | 代理商详情 | +| POST | `/agent/create-renew-order` | 创建续费订单 | +| GET | `/agent/list-renewals` | 续费记录 | +| GET | `/customer/list` | 客户列表 | +| POST | `/customer/create` | 创建客户 | +| POST | `/customer/update` | 更新客户 | +| ALL | `/customer/detail` | 客户详情 | +| GET | `/transaction/list` | 交易流水 | +| POST | `/transaction/recharge` | 充值 | + +### 区域定价 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/region-pricing/list` | 定价列表 | +| POST | `/region-pricing/save` | 保存定价 | +| ALL | `/region-pricing/delete` | 删除定价 | +| ALL | `/region-pricing/regions` | 区域列表 | +| ALL | `/region-pricing/cascades` | 级联数据 | + +--- + +## 核心业务流程 + +### 1. 创建短剧 + +``` +用户创建短剧 → 填写标题/类型/时长 → 添加演员/场景/道具/背景音 → 添加剧集 +``` + +- 短剧内容类型:短剧、漫剧、广告视频 +- 创建时自动创建 workspace 目录结构 + +### 2. 生成脚本 + +``` +用户选择剧集 → 点击"生成脚本" + → 系统加载短剧上下文(演员/场景/道具列表) + → 调用 LLM chat completion(简单对话,非 ReAct) + → 模型返回 JSON 格式的镜头数组(Shot[]) + → 解析验证,失败则重试修正 +``` + +每个镜头包含:时间起止、画面描述、台词、旁白、运镜、景别、出演人物、场景、道具。 + +### 3. 生成视频(核心流程) + +``` +用户选择剧集 → 点击"生成视频" + ┌─ 1. 余额检查(客户角色)并预扣费 + ├─ 2. 按视频模型约束将剧集时长拆分为多段(15-60s/段) + ├─ 3. 并行或串行生成每段: + │ ├─ a. 如果脚本是 JSON 镜头格式 → 直接按时间提取段内容 + │ ├─ b. 否则 → ReAct Agent 循环(最多 15 步): + │ │ 思考 → 工具调用 → 观察结果 → 最终输出 + │ │ 工具:parse_script / analyze_script_for_episode / generate_scene_image + │ ├─ c. 解析 Agent 输出为 SegmentOutput + │ ├─ d. 提取角色/场景参考图片 + │ ├─ e. 构建视频 API 请求体并提交 + │ └─ f. 保存 video_task_id + ├─ 4. 串行模式下等待前段完成(用于首帧参考) + └─ 5. 后台轮询(每 15s)检查视频生成状态 +``` + +#### 分段策略(`calcSegDurs`) + +根据视频模型的最大时长限制,将整集拆分为若干段: +- 短剧(默认):每段 15-60 秒 +- 漫剧:每段 15-30 秒 +- 广告视频:整段 15-60 秒 + +#### ReAct Agent 工具 + +| 工具 | 功能 | +|------|------| +| `parse_script` | 将原始剧本按 `---` 拆分为多集,提取标题和内容 | +| `analyze_script_for_episode` | 分析单集剧本,按空行拆分为场景,识别出场演员 | +| `generate_scene_image` | 从场景库中匹配图片返回 base64 | + +### 4. 视频模型对接 + +项目通过 OpenAI 兼容 API 对接视频生成模型。流程: + +1. 构建请求体(含角色参考图URL、场景图片、脚本描述) +2. POST 提交视频生成任务 +3. 轮询任务状态(pending → generating → completed/failed) +4. 完成后拼接各段视频为最终输出 + +--- + +## 权限与角色 + +| 角色 | 说明 | +|------|------| +| `admin` | 管理员,查看所有数据 | +| `agent` | 代理商,管理名下客户,查看区域客户数据 | +| `customer` | 客户,仅查看自己的数据,生成视频扣费 | + +登录默认账号: +- admin / Tongli686^*^(管理员) +- test1 / Tongli686^*^(代理商) + +--- + +## AI Agent 模块 + +### 架构 + +``` +agent/ +├── types.go # ChatMessage/ToolInfo/ToolCall 类型定义 +├── chat_model.go # OpenAI 兼容 API 调用(含重试/限流处理) +├── react_agent.go # ReAct 循环引擎(思考→行动→观察→重复) +├── tools.go # 工具注册与实现 +└── context.go # 上下文(DramaID 注入) +``` + +### 调用模式 + +**简单 Chat Completion**(脚本生成用): +``` +System Prompt + User Input → LLM → JSON 输出 +``` + +**ReAct Agent**(视频生成用): +``` +System Prompt + User Input + → LLM 思考 → 工具调用(parse_script) → 观察结果 + → LLM 思考 → 工具调用(analyze_script_for_episode) → 观察结果 + → LLM 思考 → 工具调用(generate_scene_image) → 观察结果 + → LLM 最终回答 → 输出 JSON +``` + +最大 15 步循环,支持指数退避重试。 + +--- + +## JWT 认证 + +- Token 在 `/user/login` 获取 +- 所有请求(除 `/user/login`)需在 Header 携带 `Authorization: Bearer ` +- 中间件从 token 解析 `userId`、`role`、`agentId` 注入请求上下文 +- 过期时间默认 7 天 + +--- + +## 文件存储 + +上传文件统一存储在 `workspace/{短剧标题}/` 目录下: + +``` +workspace/{短剧标题}/ +├── 产出视频/ +├── 演员形象/ +├── 演员声音/ +├── 场景/ +├── 道具/ +└── 背景音乐/ +``` + +--- + +## 配置 + +- `config.yml`:镜头时长规则(按内容类型)、画风约束 +- `.env`:开发环境代理目标(前端项目) +- 用户模型配置存数据库 `short_drama_model_config` 和 `user_model_config` + +### 模型配置继承 + +``` +系统模型配置(short_drama_model_config) + └── 用户模型配置(user_model_config,可选覆盖 API Key/模型选择) + └── GetMergedConfig() 合并两者,用户配置优先 +``` + +系统配置 + 用户配置 → `MergedModelConfig`(运行时最终配置) diff --git a/docs/项目缺陷与优化说明.md b/docs/项目缺陷与优化说明.md new file mode 100644 index 0000000..4bb2649 --- /dev/null +++ b/docs/项目缺陷与优化说明.md @@ -0,0 +1,251 @@ +# Video Factory 后端项目缺陷与优化说明 + +> 基于当前代码实际扫描(2026-07-24 最新)。已在此会话中修复的项不重复列出。 + +--- + +## 一、安全缺陷 + +### 1.1 JWT 密钥硬编码 + +**文件**: `shortdrama/service/user_service.go:15` + +```go +const jwtSecret = "video-factory-jwt-secret-2024" +``` + +密钥硬编码源码中,所有部署共用同一密钥。泄露后可用任意用户 Token 伪造身份。 + +**修复**: 从环境变量 `JWT_SECRET` 读取。 + +**严重程度**: 🔴 严重 + +--- + +### 1.2 支付签名使用 MD5 + +**文件**: `shortdrama/service/payment_order_service.go:362,447` + +- 微信支付使用 MD5 签名(微信推荐 HMAC-SHA256) +- 支付宝使用 `md5.Sum([]byte(raw + privateKey))` 而非标准 RSA2 + +**严重程度**: 🔴 严重 + +--- + +### 1.3 支付通知 URL 为空 + +**文件**: `shortdrama/service/payment_order_service.go:309,384` + +```go +"notify_url": "", // 需要实际外网可访问地址 +``` + +微信和支付宝通知地址均为空,支付渠道无法回调通知订单状态变更。 + +**严重程度**: 🔴 严重 + +--- + +### 1.4 支付回调错误被静默忽略 + +**文件**: `shortdrama/controller/payment_order_controller.go:67,76` + +```go +_ = service.PaymentService.HandleNotify(ctx, "wechat", body) +``` + +`HandleNotify` 返回值被忽略,即使处理失败也返回 `SUCCESS`,支付渠道不会重试,可能导致资金损失。 + +**严重程度**: 🔴 严重 + +--- + +### 1.5 微信支付 IP 写死 + +**文件**: `shortdrama/service/payment_order_service.go:308` + +```go +"spbill_create_ip": "127.0.0.1", +``` + +全部走 localhost 可能触发微信风控。 + +**严重程度**: 🟠 重要 + +--- + +### 1.6 API Key 明文写入日志 + +**文件**: `shortdrama/service/episode_service.go:216` + +```go +g.Log().Warningf(ctx, "... ApiKey=%q ...", modelCfg.ApiKey) +``` + +模型 API Key 在日志中明文输出。 + +**严重程度**: 🟠 重要 + +--- + +### 1.7 订单号随机性弱 + +**文件**: `shortdrama/service/payment_order_service.go:290` + +```go +r := rand.Intn(10000) // 仅 10000 种 +return fmt.Sprintf("PAY%s%04d", now.Format("20060102150405"), r) +``` + +使用 `math/rand`(非加密安全),同一秒内仅 10000 种订单号,高并发可重复。 + +**严重程度**: 🟠 重要 + +--- + +### 1.8 支付 nonce 使用 math/rand + +**文件**: `shortdrama/service/payment_order_service.go:457` + +```go +b[i] = letters[rand.Intn(len(letters))] +``` + +nonce 可被预测。 + +**严重程度**: 🟠 重要 + +--- + +### 1.9 CORS 未限制来源 + +**文件**: `common/http/http.go:28` + +```go +r.Response.CORS(r.Response.DefaultCORSOptions()) +``` + +允许所有来源跨域访问。 + +**严重程度**: 🟠 重要 + +--- + +### 1.10 客户默认密码为手机号后 6 位 + +**文件**: `shortdrama/service/customer_service.go:57-61` + +默认密码 = 手机号后 6 位,且 `bcryptGenerate` 错误被忽略(失败时密码为空)。 + +**严重程度**: 🟠 重要 + +--- + +### 1.11 API Key 明文存储 + +**文件**: `shortdrama/dao/payment_config_dao.go:25`, `dao/user_model_config_dao.go:34,71` + +支付 API Key、AppSecret、PrivateKey 及用户模型 API Key 在 SQLite 中明文存储。 + +**严重程度**: 🟠 重要 + +--- + +## 二、严重逻辑缺陷 + +### 2.1 并行模式永久死代码 + +**文件**: `shortdrama/service/generation_service.go:95` + +```go +// 注释说"默认使用并行生成模式" +mode = "serial" +``` + +`mode` 参数被无条件覆盖为 `"serial"`,下方完整的并行 goroutine 代码行(约 30 行)完全不可达。注释与行为矛盾。 + +**严重程度**: 🔴 严重 + +--- + +## 三、代码质量问题 + +### 3.1 generation_service.go 文件过大 + +**文件**: `shortdrama/service/generation_service.go`(2688 行,60+ 个函数) + +职责涵盖:Agent 调用、视频提交/合并、ffmpeg 操作、轮询引擎、缓存、prompt 构建、上下文构建、文件操作等。 + +**建议**: 拆分为 `generation.go`、`poller.go`、`prompt.go`、`fileops.go`、`ffmpeg.go`。 + +**严重程度**: 🟡 中等 + +--- + +## 四、架构与设计问题 + +### 4.1 DAO 用 init() 执行 DDL 迁移 + +**文件**: `shortdrama/dao/*dao.go`(17 个文件) + +DDL 散落在 `init()` 中,执行顺序依赖包导入顺序,SQLite `DROP COLUMN` 被静默忽略,无回滚,无超时,不可测试。 + +**建议**: 使用 migrate/sqlite 等迁移工具统一管理。 + +**严重程度**: 🟠 重要 + +--- + +### 4.2 缺少审计日志 + +所有 CRUD 操作(短剧、演员、场景、代理商、充值等)均无审计记录。充值无操作人身份记录。 + +**建议**: 关键操作记录 `(actor_id, action, target_type, target_id, detail)`。 + +**严重程度**: 🟡 中等 + +--- + +### 4.3 视频轮询器无指数退避 + +**文件**: `shortdrama/service/generation_service.go:1630` + +```go +ticker := time.NewTicker(15 * time.Second) +``` + +恒定 15s 轮询,任务长时间 RUNNING 时无谓消耗 DB。 + +**建议**: 实现指数退避(15s → 30s → 60s → 120s)。 + +**严重程度**: 🟡 中等 + +--- + +## 五、测试覆盖 + +### 5.1 无单元测试 + +约 100+ `.go` 文件,`_test.go` 数量为 **0**。Agent 调用、分段算法、支付签名、权限校验、DAO 操作等核心逻辑均无覆盖。 + +**严重程度**: 🟠 重要 + +--- + +### 5.2 无集成测试 + +API 端点无集成测试,无法验证路由、参数解析、响应格式。DDL 迁移完全不可测试。 + +**严重程度**: 🟡 中等 + +--- + +## 严重程度汇总 + +| 级别 | 含义 | 数量 | +|------|------|------| +| 🔴 严重 | 安全性/功能性问题 | 5 | +| 🟠 重要 | 有实际风险 | 12 | +| 🟡 中等 | 代码质量/架构 | 4 | +| 🟢 轻微 | 代码整洁性 | 0 | diff --git a/go.mod b/go.mod index f3c7552..5ea46cb 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module video-factory go 1.26.1 require ( - github.com/Eyevinn/mp4ff v0.53.0 github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 github.com/gogf/gf/v2 v2.10.2 github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/go.sum b/go.sum index e0ec07d..099d188 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/Eyevinn/mp4ff v0.53.0 h1:aK4OF9gFwjrqvHRf0znEMCUVupgRFR1LB8OVpw9z1RA= -github.com/Eyevinn/mp4ff v0.53.0/go.mod h1:AhC+bOI7GSZmzuN4zFY9U76qMedbHI+8BdQXWrC9+8U= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= @@ -27,8 +25,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= -github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 h1:KLS68SWS2W749x7e+eCCOO3UD2Sbw+bIbLEPR8o1FXw= diff --git a/main.go b/main.go index 3410cf9..01b43b1 100644 --- a/main.go +++ b/main.go @@ -3,11 +3,20 @@ package main import ( "context" "errors" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + commonHttp "video-factory/common" - commonHttp "video-factory/common/http" "video-factory/shortdrama/controller" "video-factory/shortdrama/service" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/util/gvalid" _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" @@ -67,12 +76,32 @@ func main() { controller.RegionPricing, }) - // ==================== 静态文件服务(workspace 目录) ==================== - commonHttp.Httpserver.AddStaticPath("/workspace", "workspace") + // ==================== Workspace 文件服务(鉴权保护) ==================== + // 通过 BindHandler 代替 AddStaticPath,确保经过 JWT 中间件鉴权 + commonHttp.Httpserver.BindHandler("/workspace/*", func(r *ghttp.Request) { + relPath := strings.TrimPrefix(r.URL.Path, "/workspace/") + if relPath == "" || strings.Contains(relPath, "..") { + r.Response.WriteStatus(http.StatusForbidden) + return + } + filePath := filepath.Join("workspace", relPath) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + r.Response.WriteStatus(http.StatusNotFound) + return + } + r.Response.ServeFile(filePath) + }) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() // 启动时恢复未完成的视频生成轮询 - service.GenerationService.StartVideoPoller(context.Background()) + service.GenerationService.StartVideoPoller(ctx) - // 保持运行 - select {} + g.Log().Info(ctx, "service started on :3006") + + <-ctx.Done() + g.Log().Info(ctx, "shutting down...") + time.Sleep(3 * time.Second) // 等待当前任务完成 + g.Log().Info(ctx, "bye") } diff --git a/short_drama.db b/short_drama.db index 0d466a8e3a056c5160315941fa1508a369485a11..59a4dc98c67a84964fe74a841403b78c65aa1ccb 100644 GIT binary patch delta 6465 zcmeHLdvH@_7Ef;SrH}MMleVDHQcF>aElrxVJk$kr@i8u9m9W))($5o#^R0XAI_M{|8#g^gef&6 zb^X@$8@GV5OO?Q%J*(rphm*GpHhXZ7g*NS^6+44%ou>z^w5Up|YoX4T;Qn@@qbFEf zAi6-x;{%?uVEGQ(T#e7*eX*>Q*3}8#!VfGv0$!_d$VChDX<3Uf&?+@n3JnAJnAlqv ztg;7f^@68M*vsRcpw}<#aS5J6VRr}qF%^!e!&2`Ic#827x?DnjH!bPF=K>xJS`>D- zNtRmlK&b6OpCn73)MRH?TK9@=2LisG!2^D=uPop#f|Nqd6j(i^BK)4?T7KQ(do;8O zL$|Q&9d$}L7yDoQtazw9>R%^_u3A?R9;b*%t@Bc5}U`{a_7MM*7 za&qS6n&)Nb&cD?#Z-K#(ZOG2eG|bJ+zU=q8{GUF3*Zu5gnxh=7DkE`96Xa!bQ|`U~ zys+z#L1a@Vm|>hnX@Bh6RQkd7HP;qAt;2Co~U;CH=IrMJVwL z)%}vC0h2}B>ZOBL+SY`b;*GB++=;b>xe~pt)Lt31)&&dtMEjsroyRH%?^|2(4s~_} zE88X45Ed16wBTQ9yH6+`!h{PhD=4+A2LoO&wbx_0u~Mq`NqhE* z`&#LMmll>%SF7OTpBqVnRheA!lDW*f(J;}v;OwNYKxpg1>MmiAACCr_5}Yk!4~vB4 z>JtY$aLwSpO0jVV|MAFlom5-nV@lHh?% zE(U|G=gTi>l?L8^KAt~uJ`H}>tBSeHA~;LYb#zy9b_w}qLO~6#D%ILBtkR&JmblsF z><~)J1#2lSu?Tw$*beL3*v5*jw6c_6bz$1L2);9oM1~u)kx zFX$!yn^q0t@uIdyT0JCmwc?$Cr(7toiS0!J&yd*b#?_=^D@*%03=s=F7!$$TM2o$& zqlb1J!Uuwf9JH-}Tq=)$L^SY}U!4uMSn^;Lp3qqZh+v8t1>ptC9*(3Ca!Nr?bsJMo z{&;Gy30HqSwLhNP@8#64bbpb>DHlaX=)Yc)k zS1|amizG>W+Z)LS#7Y4gL|2QDUx>)aRE4#f+PV?67&10_5y|i~LYnBZqIj7IPI~1$ zs69ibgDsPboK0;N)LSjo=TUzM1}#EanNU{1FwljlSjKe9fQ^=W!KEb$3Bt~PYIh*w zBYukhVp{1#z5~}ZatVPG!j1D8_+}d!sot=9@DL-(vIC;q!QAuq;#c%pbmueN^&u)> z&O8iO4}wtQ4mR}Q2hn0h#Kni1oxx6_uY@*rGDJTJ2+v@MCDRzYKx;IK3Ny+}o8_f% zv=8EAVsD37;t6ru7@fmsFL5^|qtaqJ(bgn37BaAJFOuqtkd1;vO-K}w;Zvo-_Gog0 zZ2rXXSR7=#Q9 zZZ|D#4*ANj{9|g|U?pwp5v&fOyfNT$ip_rbccgkoNV7bCn#^Myw^FGpvT%JCm1>Q0 z5#N9KmiuK@zH!~d8=hI8x*!#oGUSZvbo?&v9t!yKzF(=u=N&PFJzo5$c_GQr&|= zjfT-Uz~WCd3CUNMSa70CHT7yV*7Ha1;?1weVS|tkv0-6R@Zcw!IGE&CP3CVrsz=4u z@p{s?tGM!xEMY-Rgh@LzQSg{oH4Bywt95+FQT_I>$n|$Gzz~HnwCgskeVX?jeE?23 zt8N8bugZYAO@wDY(dd;qaGP5t1L)#w1<*^zpMCNLR?>Xpo42twW?$Mrf9m&oe(24# zYeiH|2vOl59@TxY`j9GBb{p39tCp;n4~qC5<0qf@){+U8lf*xLEFN}y@nFpTgKjEz zHP;KGi}{q-t+ro5MNp_cie(fjT0FrSlUmEcB3f3J{A@)C%Lg~sk$M- zRU)>#=)ew!t1H=In~+dAm9Norrd(J%tWJV$%SmdAb0FAUAFMn;9o=XmFyN8<#$r?} zNVY6OFKywrjvd{J*2=bWdU*CfD*bljcn^IJBvRx=M&unk=};R=DzqKSK`^vn{UHf@ zguX_hrjdD$t?RxL+>Ye+u_saQ4D#Y&d(hX57fLo11hlk*TB=Y#;R9#{zlNH2YTG4o zJ_iOi2j*<$Zl6#X%11~%tk|JRB%z_#;T0rd_L!zY`GWHaT2#nfC@f`Dsa-aq77)Hd z&O+jOayw+rBsaz21{qjh#4|X4p5(%Zzu{)U7oVzUF$hYAcUItFLME`?kK(cy@->NY z>mwv%A`?N?LQeDC>~gk*49U%SDau?l4M|xfHKyDp`Pi@~lodFv z4!b3%sF3Axfvti?t?!Ro~G6>YKChNNwrTj`+wTAQ(wdSO9IYgaQKcQR=n@*~E z<>LM7_tn$GIYqic^O7PRPDZMeeqmi!pk*zCa%%MbtYv`B%4lVx>Z0YZp32QPUvX%M zKO4t&5T$cQRjPTetnZ-t0%q@lM@Yo=CT^^cNQ=%kj)_TJX4B0ic{|qKHwZM=CSFfG zwf6b7Pi$HLyz%NYP6unGz1>oE8_Qv*4KlKbK76zQp!F=7#ilSuWiHsWxJOrLRJ@`f z@?z}ecCBeSi#>Pc*qaP&o566glcx!}tCO0ak`7r$)`8j+Xtq#8fmHnkn(yIM*7mg<_I48pmUxdpm;Pa)K zsO<6fnMAwYB=?hM<7FOy4KQC?{96KN{X}&3Ij~|wEw3DhXEI0IPO}+?&y!_v(+Q=1 t#@OKa>rok^&XcF81B9IoMO3j{Qz-Z@Zx(Rd-XNP?Xb_Pg|Zs5f|8$X=QF6D4dza#C5i0l{BanISTqs z(+rVv?G*l(S*j-#D%J7aWKHMH&<|=D>E?{q{;rLY<7=Y6ZQ&gd-nu)o_KR@vK;-D2 zo+In2D_XO!r#ld--xXf7E?n0UZQ2lNZ6h^j=Rja7J8e>=t){Q5IZBS2GEHUP(z2x{ z*8)>t2ZWFKBdvSj{cbLON)Gk8JqzbATH-djOoHIDit@~Ix2J-RBcFcRbF2n7#-kOV z2ZWe*kQa`HhV)`B&%^DrQ`&dhR^6nrSO# z)J$?2&9TT+A_yqG%+gyn3KXf=w0OR&4L9Tv%+Ejh#)g3~1kk|5d5g2OD0 z^-JX9U`i};Xa0bZ32yJhGg&BDk98Vbf7iMKPtVZ}J;x6BcWoho&=j)bt_kV_jFwyt z%?4W~n+XZ^m}hK6xR*pmzIrmg_`M=;QE`RaD-y$rW!k@n;f9vpj#fBu7aL&fZ+IMB zuVQ(l^_p)}f7eI!yl8Q<%^8!l?4&?)hYjS&$+@I?I0Ya9C6eepJ}yUIE=r)ehf~O< zulG|gPZp0n0gIo)iR9a1K2D0Qu#s&e&yiQZc^43mOpup>lX>%lvW2PzE(+XLM^6NSu4Y&^jA|m+rOIHSNv7fPWD&UG6z{~#L9Aovb<9?mMC3>_gCs= zw9?cRj+zvvEsJq5$;G8h$`-pT+zbEl{6#1<;v~H&xb=|?;yJC~%%a4gH-tDozQpZudy6Wln!u`aC_O>4xkRf) z5=EQM7Ti6A3eX{h;;zu(ge$0+$%C>hh%oQ$LRV3mlEEPb4#DMkH1&{xs$)RtCN$;x zt7I0a{$#;Uoy-C_jnPDFnN(P`lI6*^(}gm>4KemJ4j5X=P9wjbDTP)8)f-tf;4WP^KBgLy zG)81n&t{AkuIJC@%G#C!?W@^DxHGKOftL=+3+L)VRYm9N|B{TtqvE7kanji0xbx}! zX#H&Y?oCoOk|LLw#E7tJ1WzYFpSQ;zDB-_8P|&cNO#@d3HaxCoZCPx=_z#LalEq>6 RO3O#7-P-#aOq-H1{sBwm)nxzx diff --git a/shortdrama/agent/chat_model.go b/shortdrama/agent/chat_model.go index 110ac84..41ead60 100644 --- a/shortdrama/agent/chat_model.go +++ b/shortdrama/agent/chat_model.go @@ -27,16 +27,16 @@ type ModelConfig struct { // CallChatModel 调用大模型聊天接口(OpenAI 兼容格式) func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*ChatResponse, error) { if cfg == nil { - return nil, fmt.Errorf("模型配置不能为空") + return nil, fmt.Errorf("model config cannot be empty") } if cfg.APIKey == "" { - return nil, fmt.Errorf("APIKey 未配置") + return nil, fmt.Errorf("APIKey not configured") } if cfg.ModelName == "" { - return nil, fmt.Errorf("模型名称未配置") + return nil, fmt.Errorf("model name not configured") } if cfg.BaseURL == "" { - return nil, fmt.Errorf("API 地址未配置") + return nil, fmt.Errorf("API address not configured") } timeout := cfg.Timeout @@ -56,12 +56,12 @@ func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*Ch if maxRetries <= 0 { maxRetries = 3 } - g.Log().Infof(ctx, "ChatAPI 开始调用 model=%s timeout=%v max_retries=%d body_size=%d", cfg.ModelName, timeout, maxRetries, len(body)) + g.Log().Debugf(ctx, "ChatAPI 开始调用 model=%s timeout=%v max_retries=%d body_size=%d", cfg.ModelName, timeout, maxRetries, len(body)) for attempt := 0; attempt <= maxRetries; attempt++ { if attempt > 0 { wait := time.Duration(1<<(attempt-1)) * time.Second - g.Log().Infof(ctx, "ChatAPI 限流重试第%d次(等待%v)", attempt, wait) + g.Log().Infof(ctx, "ChatAPI 重试第%d次(等待%v)", attempt, wait) select { case <-ctx.Done(): return nil, ctx.Err() @@ -71,14 +71,13 @@ func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*Ch result, doErr := doChatRequest(ctx, url, cfg.APIKey, body, timeout) if doErr == nil { - g.Log().Infof(ctx, "ChatAPI 调用成功 url=%s tool_calls=%d content_len=%d", + g.Log().Debugf(ctx, "ChatAPI 调用成功 url=%s tool_calls=%d content_len=%d", url, len(result.ToolCalls), len(result.Content)) - g.Log().Printf(ctx, "ChatAPI 返回内容:\n%s", result.Content) return result, nil } lastErr = doErr - g.Log().Warningf(ctx, "ChatAPI 请求失败(attempt=%d/%d): %v", attempt+1, maxRetries+1, doErr) + g.Log().Warningf(ctx, "ChatAPI request failed (attempt=%d/%d): %v", attempt+1, maxRetries+1, doErr) // 只有限流或服务端错误才重试 errStr := lastErr.Error() if !strings.Contains(errStr, "limit_requests") && @@ -90,14 +89,14 @@ func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*Ch } } - g.Log().Errorf(ctx, "ChatAPI %d次重试后最终失败: %v", maxRetries+1, lastErr) + g.Log().Errorf(ctx, "ChatAPI failed after %d retries: %v", maxRetries+1, lastErr) return nil, lastErr } func doChatRequest(ctx context.Context, url, apiKey string, body []byte, timeout time.Duration) (*ChatResponse, error) { httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(body)) if err != nil { - return nil, fmt.Errorf("创建请求失败: %w", err) + return nil, fmt.Errorf("create request failed: %w", err) } httpReq.Header.Set("Authorization", "Bearer "+apiKey) httpReq.Header.Set("Content-Type", "application/json") @@ -107,20 +106,20 @@ func doChatRequest(ctx context.Context, url, apiKey string, body []byte, timeout resp, err := client.Do(httpReq) elapsed := time.Since(start) if err != nil { - return nil, fmt.Errorf("请求失败(耗时%v): %w", elapsed, err) + return nil, fmt.Errorf("request failed (elapsed %v): %w", elapsed, err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("读取响应失败(状态码=%d): %w", resp.StatusCode, err) + return nil, fmt.Errorf("read response failed (status=%d): %w", resp.StatusCode, err) } if resp.StatusCode != 200 { - return nil, fmt.Errorf("API返回错误状态码=%d body=%s", resp.StatusCode, string(respBody)) + return nil, fmt.Errorf("API response error status=%d body=%s", resp.StatusCode, string(respBody)) } - g.Log().Infof(ctx, "ChatAPI 响应完成 status=%d body_len=%d elapsed=%v", + g.Log().Debugf(ctx, "ChatAPI 响应完成 status=%d body_len=%d elapsed=%v", resp.StatusCode, len(respBody), elapsed) return parseRespBody(ctx, respBody) @@ -258,13 +257,13 @@ func toAPIMessages(msgs []*ChatMessage) []apiMessage { func parseRespBody(ctx context.Context, data []byte) (*ChatResponse, error) { var resp apiRespBody if err := json.Unmarshal(data, &resp); err != nil { - return nil, fmt.Errorf("解析响应失败: %s", string(data)) + return nil, fmt.Errorf("parse response failed: %s", string(data)) } if resp.Error != nil { - return nil, fmt.Errorf("API错误(code=%s): %s", resp.Error.Code, resp.Error.Message) + return nil, fmt.Errorf("API error(code=%s): %s", resp.Error.Code, resp.Error.Message) } if len(resp.Choices) == 0 { - return nil, fmt.Errorf("响应为空") + return nil, fmt.Errorf("empty response") } msg := resp.Choices[0].Message @@ -272,7 +271,7 @@ func parseRespBody(ctx context.Context, data []byte) (*ChatResponse, error) { // 检测 finish_reason 是否为 length(被 max_tokens 截断) if resp.Choices[0].FinishReason == "length" { - g.Log().Warningf(ctx, "ChatAPI 响应被截断(finish_reason=length), 当前content_len=%d, 请考虑增大max_tokens", len(msg.Content)) + g.Log().Warningf(ctx, "ChatAPI response truncated (finish_reason=length), content_len=%d, consider increasing max_tokens", len(msg.Content)) } if len(msg.ToolCalls) > 0 { diff --git a/shortdrama/agent/react_agent.go b/shortdrama/agent/react_agent.go index fc23f5d..c0f2137 100644 --- a/shortdrama/agent/react_agent.go +++ b/shortdrama/agent/react_agent.go @@ -49,7 +49,7 @@ func (a *ReActAgent) Run(ctx context.Context, userInput string) (string, error) }) elapsed := time.Since(startTime) if err != nil { - return "", fmt.Errorf("step %d: 模型调用失败: %w", step, err) + return "", fmt.Errorf("step %d: model call failed: %w", step, err) } g.Log().Infof(ctx, "ReAct step %d/%d: 模型返回 (耗时 %v), content_len=%d, ToolCalls=%d", @@ -71,7 +71,7 @@ func (a *ReActAgent) Run(ctx context.Context, userInput string) (string, error) for _, tc := range result.ToolCalls { tool := a.findTool(tc.Name) if tool == nil { - g.Log().Warningf(ctx, "ReAct step %d: 未知工具: %s", step+1, tc.Name) + g.Log().Warningf(ctx, "ReAct step %d: unknown tool: %s", step+1, tc.Name) messages = append(messages, &ChatMessage{ Role: RoleTool, Content: fmt.Sprintf("未知工具: %s", tc.Name), @@ -82,7 +82,7 @@ func (a *ReActAgent) Run(ctx context.Context, userInput string) (string, error) } if tc.Arguments == "" { - g.Log().Warningf(ctx, "ReAct step %d: 工具 %s 参数为空,跳过", step+1, tc.Name) + g.Log().Warningf(ctx, "ReAct step %d: tool %s arguments empty, skipping", step+1, tc.Name) messages = append(messages, &ChatMessage{ Role: RoleTool, Content: "工具参数为空", @@ -94,7 +94,7 @@ func (a *ReActAgent) Run(ctx context.Context, userInput string) (string, error) var args map[string]any if err := json.Unmarshal([]byte(tc.Arguments), &args); err != nil { - g.Log().Warningf(ctx, "ReAct step %d: 参数解析失败: %v", step+1, err) + g.Log().Warningf(ctx, "ReAct step %d: argument parse failed: %v", step+1, err) messages = append(messages, &ChatMessage{ Role: RoleTool, Content: fmt.Sprintf("参数解析失败: %v", err), @@ -111,7 +111,7 @@ func (a *ReActAgent) Run(ctx context.Context, userInput string) (string, error) toolElapsed := time.Since(toolStart) if err != nil { output = fmt.Sprintf("工具执行失败: %v", err) - g.Log().Warningf(ctx, "ReAct step %d: 工具 %s 执行失败 (耗时 %v): %v", step+1, tc.Name, toolElapsed, err) + g.Log().Warningf(ctx, "ReAct step %d: tool %s execution failed (elapsed %v): %v", step+1, tc.Name, toolElapsed, err) } else { truncated := output if len(truncated) > 200 { @@ -129,8 +129,8 @@ func (a *ReActAgent) Run(ctx context.Context, userInput string) (string, error) } } - g.Log().Errorf(ctx, "ReAct 达到最大步骤数 %d,生成未完成", a.maxStep) - return "", fmt.Errorf("达到最大步骤数 %d,生成未完成", a.maxStep) + g.Log().Errorf(ctx, "ReAct reached max steps %d, generation incomplete", a.maxStep) + return "", fmt.Errorf("max steps reached %d, generation incomplete", a.maxStep) } func (a *ReActAgent) findTool(name string) *ToolInfo { diff --git a/shortdrama/agent/tools.go b/shortdrama/agent/tools.go index ff83354..8fcf485 100644 --- a/shortdrama/agent/tools.go +++ b/shortdrama/agent/tools.go @@ -2,12 +2,10 @@ package agent import ( "context" - "encoding/base64" "encoding/json" "fmt" - "os" - "path/filepath" "strings" + "video-factory/common" "video-factory/shortdrama/dao" @@ -42,7 +40,7 @@ func parseScriptTool() *ToolInfo { Func: func(ctx context.Context, args map[string]any) (string, error) { rawScript, _ := args["raw_script"].(string) if rawScript == "" { - return "", fmt.Errorf("剧本内容不能为空") + return "", fmt.Errorf("script content cannot be empty") } episodeTexts := strings.Split(rawScript, "---") @@ -121,7 +119,7 @@ func analyzeScriptForEpisodeTool() *ToolInfo { duration := int(durationFloat) if scriptContent == "" { - return "", fmt.Errorf("剧本内容不能为空") + return "", fmt.Errorf("script content cannot be empty") } if duration <= 0 { duration = 60 @@ -233,19 +231,13 @@ func generateSceneImageTool() *ToolInfo { "type": "string", "description": "画面描述(场景设定、演员动作、镜头角度等)", }, - "style": map[string]any{ - "type": "string", - "description": "整体风格", - }, }, - "required": []string{"visual_description", "style"}, + "required": []string{"visual_description"}, }, Func: func(ctx context.Context, args map[string]any) (string, error) { visualDesc, _ := args["visual_description"].(string) - style, _ := args["style"].(string) episodeIdx, _ := args["episode_index"].(float64) sceneIdx, _ := args["scene_index"].(float64) - _ = style dramaId := GetDramaID(ctx) var imgBase64 string @@ -303,19 +295,5 @@ func cleanEpisodeTitle(title string) string { } func readImageFileAsBase64(path string) (string, error) { - data, err := os.ReadFile(path) - if err != nil { - return "", err - } - ext := strings.ToLower(filepath.Ext(path)) - mime := "image/png" - switch ext { - case ".jpg", ".jpeg": - mime = "image/jpeg" - case ".gif": - mime = "image/gif" - case ".webp": - mime = "image/webp" - } - return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil + return common.ImageFileToBase64(path) } diff --git a/shortdrama/config/shot_duration.go b/shortdrama/config/shot_duration.go index f8d7b7f..c6123f2 100644 --- a/shortdrama/config/shot_duration.go +++ b/shortdrama/config/shot_duration.go @@ -7,8 +7,8 @@ import ( ) // GetShotDurationPrompt 从 config.yml 读取镜头时长约束并生成提示词文本 -func GetShotDurationPrompt(contentType string) string { - typeMap := g.Cfg().MustGet(context.TODO(), "shotDuration."+contentType).Map() +func GetShotDurationPrompt(ctx context.Context, contentType string) string { + typeMap := g.Cfg().MustGet(ctx, "shotDuration."+contentType).Map() if len(typeMap) == 0 { return "" } @@ -37,8 +37,8 @@ func GetShotDurationPrompt(contentType string) string { } // GetStylePrompt 从 config.yml 读取画风/质量/防崩坏约束并生成提示词文本 -func GetStylePrompt(contentType string) string { - m := g.Cfg().MustGet(context.TODO(), "shotDuration."+contentType).Map() +func GetStylePrompt(ctx context.Context, contentType string) string { + m := g.Cfg().MustGet(ctx, "shotDuration."+contentType).Map() if len(m) == 0 { return "" } diff --git a/shortdrama/controller/customer_controller.go b/shortdrama/controller/customer_controller.go index bb1cf47..ba12d92 100644 --- a/shortdrama/controller/customer_controller.go +++ b/shortdrama/controller/customer_controller.go @@ -2,8 +2,10 @@ package controller import ( "context" + "errors" + "video-factory/common" - "video-factory/shortdrama/middleware" + "video-factory/shortdrama/dao" "video-factory/shortdrama/model/dto" "video-factory/shortdrama/service" @@ -22,9 +24,13 @@ func (c *customer) Create(ctx context.Context, req *dto.CreateCustomerReq) (res agentId := req.AgentId if agentId == 0 { r := g.RequestFromCtx(ctx) - agentId = middleware.GetUserId(r) + agentId = common.GetUserId(r) } - user, err := service.CustomerService.CreateCustomer(ctx, req.Phone, req.Name, req.Address, agentId) + user, err := dao.User.GetOne(ctx, agentId) + if err != nil || user == nil || user.Role != "agent" { + return nil, errors.New("agent not found") + } + user, err = service.CustomerService.CreateCustomer(ctx, req.Phone, req.Name, req.Address, agentId) if err != nil { return nil, err } @@ -41,6 +47,6 @@ func (c *customer) Detail(ctx context.Context, req *struct{ Id int64 }) (res *dt func (c *customer) Update(ctx context.Context, req *dto.UpdateCustomerReq) (res *struct{}, err error) { r := g.RequestFromCtx(ctx) - agentId := middleware.GetUserId(r) + agentId := common.GetUserId(r) return nil, service.CustomerService.UpdateCustomer(ctx, agentId, req) } diff --git a/shortdrama/controller/drama_controller.go b/shortdrama/controller/drama_controller.go index d5d1389..17e8d02 100644 --- a/shortdrama/controller/drama_controller.go +++ b/shortdrama/controller/drama_controller.go @@ -3,10 +3,10 @@ package controller import ( "context" "fmt" + "video-factory/common" "video-factory/shortdrama/consts/public" "video-factory/shortdrama/dao" - "video-factory/shortdrama/middleware" "video-factory/shortdrama/model/dto" "video-factory/shortdrama/service" @@ -20,20 +20,20 @@ var Drama = new(drama) // checkDramaAccess 校验客户只能操作自己的短剧 func checkDramaAccess(ctx context.Context, dramaId int64) error { r := g.RequestFromCtx(ctx) - role := middleware.GetRole(r) + role := common.GetRole(r) if role != "customer" { return nil } - userId := middleware.GetUserId(r) + userId := common.GetUserId(r) d, err := dao.Drama.GetOne(ctx, dramaId) if err != nil { return err } if d == nil { - return fmt.Errorf("短剧不存在") + return fmt.Errorf("drama not found") } if d.UserId != userId { - return fmt.Errorf("无权操作该短剧") + return fmt.Errorf("no permission to operate on this drama") } return nil } @@ -42,8 +42,8 @@ func checkDramaAccess(ctx context.Context, dramaId int64) error { func (c *drama) List(ctx context.Context, req *dto.ListDramaReq) (res *dto.ListDramaRes, err error) { r := g.RequestFromCtx(ctx) - role := middleware.GetRole(r) - userId := middleware.GetUserId(r) + role := common.GetRole(r) + userId := common.GetUserId(r) list, total, err := service.DramaService.List(ctx, req.Page, req.PageSize, role, userId, req.Keyword) if err != nil { return nil, err @@ -53,7 +53,7 @@ func (c *drama) List(ctx context.Context, req *dto.ListDramaReq) (res *dto.ListD func (c *drama) Create(ctx context.Context, req *dto.CreateDramaReq) (res *dto.CreateDramaRes, err error) { r := g.RequestFromCtx(ctx) - userId := middleware.GetUserId(r) + userId := common.GetUserId(r) id, err := service.DramaService.Create(ctx, req.Title, req.Type, req.Config, req.AspectRatio, int64(req.EpisodeDuration), req.Resolution, userId) if err != nil { return nil, err @@ -67,14 +67,14 @@ func (c *drama) Get(ctx context.Context, req *dto.GetDramaReq) (res *dto.GetDram return nil, err } if drama == nil { - return nil, fmt.Errorf("短剧不存在") + return nil, fmt.Errorf("drama not found") } // 权限校验 r := g.RequestFromCtx(ctx) - role := middleware.GetRole(r) - userId := middleware.GetUserId(r) + role := common.GetRole(r) + userId := common.GetUserId(r) if role == "customer" && drama.UserId != userId { - return nil, fmt.Errorf("无权操作该短剧") + return nil, fmt.Errorf("no permission to operate on this drama") } d, characters, scenes, props, backgroundMusic, err := service.DramaService.Get(ctx, drama) diff --git a/shortdrama/controller/payment_order_controller.go b/shortdrama/controller/payment_order_controller.go index bddbd78..f61f43b 100644 --- a/shortdrama/controller/payment_order_controller.go +++ b/shortdrama/controller/payment_order_controller.go @@ -2,8 +2,8 @@ package controller import ( "context" + "video-factory/common" - "video-factory/shortdrama/middleware" "video-factory/shortdrama/model/dto" "video-factory/shortdrama/service" @@ -16,7 +16,7 @@ var PaymentOrder = new(paymentOrder) func (c *paymentOrder) Prepay(ctx context.Context, req *dto.PrepayReq) (res *dto.PrepayRes, err error) { r := g.RequestFromCtx(ctx) - userId := middleware.GetUserId(r) + userId := common.GetUserId(r) orderType := req.OrderType if orderType == "" { @@ -43,6 +43,16 @@ func (c *paymentOrder) Status(ctx context.Context, req *dto.PaymentStatusReq) (r return &dto.PaymentStatusRes{OrderNo: req.OrderNo, Status: status, Amount: amount}, nil } +func (c *paymentOrder) List(ctx context.Context, req *dto.ListPaymentOrderReq) (res *dto.ListPaymentOrderRes, err error) { + r := g.RequestFromCtx(ctx) + userId := common.GetUserId(r) + list, total, err := service.PaymentService.ListOrders(ctx, userId, req.Page, req.PageSize) + if err != nil { + return nil, err + } + return &dto.ListPaymentOrderRes{List: list, Total: total}, nil +} + func (c *paymentOrder) ConfirmOffline(ctx context.Context, req *dto.ConfirmOfflineReq) (res *dto.ConfirmOfflineRes, err error) { if err := service.PaymentService.ConfirmOffline(ctx, req.OrderNo); err != nil { return nil, err diff --git a/shortdrama/controller/transaction_controller.go b/shortdrama/controller/transaction_controller.go index cf0dffb..6f48769 100644 --- a/shortdrama/controller/transaction_controller.go +++ b/shortdrama/controller/transaction_controller.go @@ -3,8 +3,8 @@ package controller import ( "context" "fmt" + "video-factory/common" - "video-factory/shortdrama/middleware" "video-factory/shortdrama/model/dto" "video-factory/shortdrama/service" @@ -19,7 +19,7 @@ func (c *transaction) List(ctx context.Context, req *dto.ListTransactionReq) (re r := g.RequestFromCtx(ctx) userId := req.UserId if userId == 0 { - userId = middleware.GetUserId(r) + userId = common.GetUserId(r) } list, total, err := service.TransactionService.ListByUser(ctx, userId, req.Type, req.Page, req.PageSize) if err != nil { diff --git a/shortdrama/controller/user_model_config_controller.go b/shortdrama/controller/user_model_config_controller.go index c2fc478..2d231b6 100644 --- a/shortdrama/controller/user_model_config_controller.go +++ b/shortdrama/controller/user_model_config_controller.go @@ -3,8 +3,8 @@ package controller import ( "context" "fmt" + "video-factory/common" - "video-factory/shortdrama/middleware" "video-factory/shortdrama/model/dto" "video-factory/shortdrama/service" @@ -17,9 +17,9 @@ var UserModelConfig = new(userModelConfig) func (c *userModelConfig) GetUserConfig(ctx context.Context, req *dto.GetUserModelConfigReq) (res *dto.GetUserModelConfigRes, err error) { r := g.RequestFromCtx(ctx) - userId := middleware.GetUserId(r) + userId := common.GetUserId(r) if userId <= 0 { - return nil, fmt.Errorf("未登录") + return nil, fmt.Errorf("not logged in") } cfg := service.UserModelConfigService.GetUserConfig(ctx, userId, req.ModelType) return &dto.GetUserModelConfigRes{UserModelConfig: cfg}, nil @@ -27,16 +27,16 @@ func (c *userModelConfig) GetUserConfig(ctx context.Context, req *dto.GetUserMod func (c *userModelConfig) SaveUserConfig(ctx context.Context, req *dto.SaveUserModelConfigReq) (res *struct{}, err error) { r := g.RequestFromCtx(ctx) - userId := middleware.GetUserId(r) + userId := common.GetUserId(r) if userId <= 0 { - return nil, fmt.Errorf("未登录") + return nil, fmt.Errorf("not logged in") } return nil, service.UserModelConfigService.SaveUserConfigs(ctx, userId, req.Configs) } func (c *userModelConfig) GetUserModelList(ctx context.Context, req *dto.GetUserModelListReq) (res *dto.GetUserModelListRes, err error) { r := g.RequestFromCtx(ctx) - userId := middleware.GetUserId(r) + userId := common.GetUserId(r) if req.Page <= 0 { req.Page = 1 } diff --git a/shortdrama/dao/account_transaction_dao.go b/shortdrama/dao/account_transaction_dao.go index 9f4d3f0..c96f23c 100644 --- a/shortdrama/dao/account_transaction_dao.go +++ b/shortdrama/dao/account_transaction_dao.go @@ -26,10 +26,14 @@ func init() { created_at DATETIME DEFAULT (datetime('now','localtime')) )`) if err != nil { - g.Log().Warningf(ctx, "创建 account_transaction 表失败: %v", err) + g.Log().Warningf(ctx, "create account_transaction table failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_at_user ON "+public.TableNameAccountTransaction+"(user_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_at_user failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameAccountTransaction+" ADD COLUMN order_no TEXT NOT NULL DEFAULT ''"); err != nil { + g.Log().Warningf(ctx, "add column order_no failed: %v", err) } - _, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_at_user ON "+public.TableNameAccountTransaction+"(user_id)") - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameAccountTransaction+" ADD COLUMN order_no TEXT NOT NULL DEFAULT ''") } func (d *accountTransactionDao) Insert(ctx context.Context, data *entity.AccountTransaction) (int64, error) { @@ -47,11 +51,18 @@ func (d *accountTransactionDao) ListByUser(ctx context.Context, userId int64, ty if typeFilter != "" { m = m.Where("type", typeFilter) } - count, err := m.Count() + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + r, total, err := m.OrderDesc("id").Limit(pageSize).Offset((page - 1) * pageSize).AllAndCount(false) if err != nil { return nil, 0, err } var list []*entity.AccountTransaction - err = m.Page(page, pageSize).OrderDesc("id").Scan(&list) - return list, count, err + list = make([]*entity.AccountTransaction, 0) + err = r.Structs(&list) + return list, total, err } diff --git a/shortdrama/dao/agent_profile_dao.go b/shortdrama/dao/agent_profile_dao.go index e24d880..baa96d7 100644 --- a/shortdrama/dao/agent_profile_dao.go +++ b/shortdrama/dao/agent_profile_dao.go @@ -49,23 +49,25 @@ func (d *agentProfileDao) ListAgentWithProfile(ctx context.Context, keyword, pho if expiredAtTo != "" { m = m.Where("ap.expired_at <= ?", expiredAtTo) } - total, err := m.Count() + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + m = m.Fields( + "u.id", "u.username", "u.phone", "u.name", "u.province", "u.region", + "ap.expired_at", "ap.region_protected", "ap.max_customers", + ) + r, total, err := m.OrderAsc("u.id").Limit(pageSize).Offset((page - 1) * pageSize).AllAndCount(false) if err != nil { return nil, 0, err } var rows []*agentListRow - err = m.Fields( - "u.id", "u.username", "u.phone", "u.name", "u.province", "u.region", - "ap.expired_at", "ap.region_protected", "ap.max_customers", - ).Page(page, pageSize).OrderAsc("u.id").Scan(&rows) - if err != nil { - return nil, 0, err - } - return rows, total, nil + err = r.Structs(&rows) + return rows, total, err } -// ListActiveByRegion 查询指定地区的未过期代理商列表 - func init() { ctx := context.Background() _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+public.TableNameAgentProfile+` ( @@ -78,9 +80,15 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create agent_profile table failed: %v", err) } - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameAgentProfile+" ADD COLUMN expired_at DATETIME") - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameAgentProfile+" ADD COLUMN region_protected INTEGER NOT NULL DEFAULT 0") - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameAgentProfile+" DROP COLUMN tier_id") + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameAgentProfile+" ADD COLUMN expired_at DATETIME"); err != nil { + g.Log().Warningf(ctx, "add column expired_at failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameAgentProfile+" ADD COLUMN region_protected INTEGER NOT NULL DEFAULT 0"); err != nil { + g.Log().Warningf(ctx, "add column region_protected failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameAgentProfile+" DROP COLUMN tier_id"); err != nil { + g.Log().Warningf(ctx, "drop column tier_id failed: %v", err) + } } diff --git a/shortdrama/dao/background_music_dao.go b/shortdrama/dao/background_music_dao.go index dd323f1..d7a93fe 100644 --- a/shortdrama/dao/background_music_dao.go +++ b/shortdrama/dao/background_music_dao.go @@ -2,12 +2,11 @@ package dao import ( "context" + "video-factory/common" "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 BackgroundMusic = &backgroundMusicDao{} @@ -25,82 +24,38 @@ func init() { updated_at DATETIME DEFAULT (datetime('now','localtime')), -- 更新时间 deleted_at DATETIME -- 删除时间 )`); err != nil { - g.Log().Warningf(ctx, "创建背景音表失败: %v", err) + g.Log().Warningf(ctx, "create background music table failed: %v", err) if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_bgm_drama_id ON short_drama_background_music(drama_id)"); err != nil { - g.Log().Warningf(ctx, "创建背景音表索引失败: %v", err) + g.Log().Warningf(ctx, "create background music table index failed: %v", err) } } } - -func (d *backgroundMusicDao) Insert(ctx context.Context, data *entity.BackgroundMusic) (id int64, err error) { - 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") - delete(m, "deleted_at") - r, err := g.DB().Model(public.TableNameBackgroundMusic).Ctx(ctx).Data(m).Insert() - if err != nil { - return - } - return r.LastInsertId() +func (d *backgroundMusicDao) Insert(ctx context.Context, data *entity.BackgroundMusic) (int64, error) { + return common.InsertAndReturnId(ctx, public.TableNameBackgroundMusic, data) } -func (d *backgroundMusicDao) GetOne(ctx context.Context, id int64) (res *entity.BackgroundMusic, err error) { - r, err := g.DB().Model(public.TableNameBackgroundMusic).Ctx(ctx).Where("id", id).One() - if err != nil { - return - } - if r == nil { - return nil, nil - } - err = r.Struct(&res) - return +func (d *backgroundMusicDao) GetOne(ctx context.Context, id int64) (*entity.BackgroundMusic, error) { + return common.GetOneByPk[entity.BackgroundMusic](ctx, public.TableNameBackgroundMusic, id) } func (d *backgroundMusicDao) Update(ctx context.Context, id int64, data *entity.BackgroundMusic) error { - _, err := g.DB().Model(public.TableNameBackgroundMusic).Ctx(ctx).Data(data).Where("id", id).Update() - return err + return common.UpdateByPk(ctx, public.TableNameBackgroundMusic, id, data) } func (d *backgroundMusicDao) Delete(ctx context.Context, id int64) error { - _, err := g.DB().Model(public.TableNameBackgroundMusic).Ctx(ctx).Unscoped().Where("id", id).Delete() - return err + return common.DeleteByPk(ctx, public.TableNameBackgroundMusic, id) } func (d *backgroundMusicDao) DeleteByDrama(ctx context.Context, dramaId int64) error { - _, err := g.DB().Model(public.TableNameBackgroundMusic).Ctx(ctx).Unscoped().Where("drama_id", dramaId).Delete() - return err + return common.DeleteByDrama(ctx, public.TableNameBackgroundMusic, dramaId) } func (d *backgroundMusicDao) ListPageByDrama(ctx context.Context, dramaId int64, page, pageSize int) (res []*entity.BackgroundMusic, total int, err error) { - m := g.DB().Model(public.TableNameBackgroundMusic).Ctx(ctx).Where("drama_id", dramaId) - if pageSize == -1 { - r, err := m.OrderAsc("id").All() - if err != nil { - return nil, 0, err - } - res = make([]*entity.BackgroundMusic, 0) - err = r.Structs(&res) - return res, len(res), err - } - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 20 - } - r, total, err := m.OrderAsc("id").Limit(pageSize).Offset((page - 1) * pageSize).AllAndCount(false) - if err != nil { - return - } - res = make([]*entity.BackgroundMusic, 0) - err = r.Structs(&res) - return + return common.ListPageByDrama[entity.BackgroundMusic](ctx, public.TableNameBackgroundMusic, dramaId, page, pageSize) } - func (d *backgroundMusicDao) ListByDrama(ctx context.Context, dramaId int64) (res []*entity.BackgroundMusic, err error) { - r, err := g.DB().Model(public.TableNameBackgroundMusic).Ctx(ctx).Where("drama_id", dramaId).OrderAsc("id").All() + r, err := g.DB().Model(public.TableNameBackgroundMusic).Ctx(ctx).Where("drama_id", dramaId).OrderAsc("id").Limit(200).All() if err != nil { return } diff --git a/shortdrama/dao/character_dao.go b/shortdrama/dao/character_dao.go index 98779d9..d88d83a 100644 --- a/shortdrama/dao/character_dao.go +++ b/shortdrama/dao/character_dao.go @@ -2,12 +2,11 @@ package dao import ( "context" + "video-factory/common" "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 Character = &characterDao{} @@ -27,82 +26,39 @@ func init() { updated_at DATETIME DEFAULT (datetime('now','localtime')), -- 更新时间 deleted_at DATETIME -- 删除时间 )`); err != nil { - g.Log().Warningf(ctx, "创建演员表失败: %v", err) + g.Log().Warningf(ctx, "create character table failed: %v", err) if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_character_drama_id ON short_drama_character(drama_id)"); err != nil { - g.Log().Warningf(ctx, "创建演员表索引失败: %v", err) + g.Log().Warningf(ctx, "create character table index failed: %v", err) } } // 迁移:清理旧字段 for _, col := range []string{"voice_type", "portrait_url", "image_base64"} { if _, err := g.DB().Exec(ctx, `ALTER TABLE `+public.TableNameCharacter+` DROP COLUMN `+col); err != nil { - g.Log().Warningf(ctx, "删除演员表旧字段 %s 失败(可能已删除): %v", col, err) + g.Log().Warningf(ctx, "drop character table old column %s failed (may already be deleted): %v", col, err) } } } - -func (d *characterDao) Insert(ctx context.Context, data *entity.Character) (id int64, err error) { - 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") - delete(m, "deleted_at") - r, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Data(m).Insert() - if err != nil { - return - } - return r.LastInsertId() +func (d *characterDao) Insert(ctx context.Context, data *entity.Character) (int64, error) { + return common.InsertAndReturnId(ctx, public.TableNameCharacter, data) } -func (d *characterDao) GetOne(ctx context.Context, id int64) (res *entity.Character, err error) { - r, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Where("id", id).One() - if err != nil { - return - } - if r == nil { - return nil, nil - } - err = r.Struct(&res) - return +func (d *characterDao) GetOne(ctx context.Context, id int64) (*entity.Character, error) { + return common.GetOneByPk[entity.Character](ctx, public.TableNameCharacter, id) } func (d *characterDao) Update(ctx context.Context, id int64, data *entity.Character) error { - _, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Data(data).Where("id", id).Update() - return err + return common.UpdateByPk(ctx, public.TableNameCharacter, id, data) } func (d *characterDao) Delete(ctx context.Context, id int64) error { - _, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Unscoped().Where("id", id).Delete() - return err + return common.DeleteByPk(ctx, public.TableNameCharacter, id) } func (d *characterDao) DeleteByDrama(ctx context.Context, dramaId int64) error { - _, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Unscoped().Where("drama_id", dramaId).Delete() - return err + return common.DeleteByDrama(ctx, public.TableNameCharacter, dramaId) } func (d *characterDao) ListPageByDrama(ctx context.Context, dramaId int64, page, pageSize int) (res []*entity.Character, total int, err error) { - m := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Where("drama_id", dramaId) - if pageSize == -1 { - r, err := m.OrderAsc("id").All() - if err != nil { - return nil, 0, err - } - res = make([]*entity.Character, 0) - err = r.Structs(&res) - return res, len(res), err - } - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 20 - } - r, total, err := m.OrderAsc("id").Limit(pageSize).Offset((page - 1) * pageSize).AllAndCount(false) - if err != nil { - return - } - res = make([]*entity.Character, 0) - err = r.Structs(&res) - return + return common.ListPageByDrama[entity.Character](ctx, public.TableNameCharacter, dramaId, page, pageSize) } diff --git a/shortdrama/dao/customer_profile_dao.go b/shortdrama/dao/customer_profile_dao.go index 72c4893..3a87f31 100644 --- a/shortdrama/dao/customer_profile_dao.go +++ b/shortdrama/dao/customer_profile_dao.go @@ -53,20 +53,24 @@ func (d *customerProfileDao) ListCustomerWithAgent(ctx context.Context, agentId m = m.Where("au.name LIKE ? OR au.username LIKE ? OR au.phone LIKE ?", "%"+agentName+"%", "%"+agentName+"%", "%"+agentName+"%") } - var rows []*customerListRow - total, err := m.Count() - if err != nil { - return nil, 0, err + if page < 1 { + page = 1 } - err = m.Fields( + if pageSize < 1 { + pageSize = 20 + } + m = m.Fields( "u.id", "u.phone", "u.name", "u.province", "u.region", "u.address", "u.created_at", "cp.agent_id", "cp.balance", "au.name agent_name", - ).Page(page, pageSize).OrderAsc("u.id").Scan(&rows) + ) + r, total, err := m.OrderAsc("u.id").Limit(pageSize).Offset((page - 1) * pageSize).AllAndCount(false) if err != nil { return nil, 0, err } - return rows, total, nil + var rows []*customerListRow + err = r.Structs(&rows) + return rows, total, err } func init() { @@ -77,9 +81,11 @@ func init() { balance INTEGER NOT NULL DEFAULT 0 )`) if err != nil { - g.Log().Warningf(ctx, "创建 customer_profile 表失败: %v", err) + g.Log().Warningf(ctx, "create customer_profile table failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cp_agent ON "+public.TableNameCustomerProfile+"(agent_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_cp_agent failed: %v", err) } - _, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cp_agent ON "+public.TableNameCustomerProfile+"(agent_id)") } func (d *customerProfileDao) Get(ctx context.Context, userId int64) (*entity.CustomerProfile, error) { diff --git a/shortdrama/dao/drama_dao.go b/shortdrama/dao/drama_dao.go index 9f51e76..6f1f6de 100644 --- a/shortdrama/dao/drama_dao.go +++ b/shortdrama/dao/drama_dao.go @@ -31,28 +31,43 @@ func init() { updated_at DATETIME DEFAULT (datetime('now','localtime')), -- 更新时间 deleted_at DATETIME -- 删除时间 )`); err != nil { - g.Log().Warningf(ctx, "创建短剧表失败: %v", err) + g.Log().Warningf(ctx, "create drama table failed: %v", err) } // 对已存在的旧表补充唯一索引 if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_drama_user_id ON short_drama(user_id)"); err != nil { - g.Log().Warningf(ctx, "创建短剧用户索引失败: %v", err) + g.Log().Warningf(ctx, "create drama user index failed: %v", err) } if _, err := g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_short_drama_title ON "+public.TableNameDrama+"(title)"); err != nil { - g.Log().Warningf(ctx, "创建短剧标题唯一索引失败: %v", err) + g.Log().Warningf(ctx, "create drama title unique index failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" ADD COLUMN type TEXT NOT NULL DEFAULT '短剧'"); err != nil { + g.Log().Warningf(ctx, "add column type failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0"); err != nil { + g.Log().Warningf(ctx, "add column user_id failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" ADD COLUMN config TEXT NOT NULL DEFAULT '{}'"); err != nil { + g.Log().Warningf(ctx, "add column config failed: %v", err) } - // 旧表迁移:补充新字段(忽略已存在的错误) - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" ADD COLUMN type TEXT NOT NULL DEFAULT '短剧'") - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0") - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" ADD COLUMN config TEXT NOT NULL DEFAULT '{}'") // 数据迁移:将旧 style 字段值写入 config(仅对 config 为空的旧记录生效) - _, _ = g.DB().Exec(ctx, "UPDATE "+public.TableNameDrama+" SET config = '{\"题材\":\"' || style || '\"}', type = '短剧' WHERE config = '{}' AND style != '' AND style IS NOT NULL") - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" ADD COLUMN aspect_ratio TEXT NOT NULL DEFAULT ''") - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" ADD COLUMN resolution TEXT NOT NULL DEFAULT '720P'") + if _, err := g.DB().Exec(ctx, "UPDATE "+public.TableNameDrama+" SET config = '{\"题材\":\"' || style || '\"}', type = '短剧' WHERE config = '{}' AND style != '' AND style IS NOT NULL"); err != nil { + g.Log().Warningf(ctx, "migrate style to config failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" ADD COLUMN aspect_ratio TEXT NOT NULL DEFAULT ''"); err != nil { + g.Log().Warningf(ctx, "add column aspect_ratio failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" ADD COLUMN resolution TEXT NOT NULL DEFAULT '720P'"); err != nil { + g.Log().Warningf(ctx, "add column resolution failed: %v", err) + } // 清理已废弃的 min_shot_duration / max_shot_duration 字段(之前 ALTER TABLE 加进去的旧列) - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" DROP COLUMN min_shot_duration") - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" DROP COLUMN max_shot_duration") + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" DROP COLUMN min_shot_duration"); err != nil { + g.Log().Warningf(ctx, "drop column min_shot_duration failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" DROP COLUMN max_shot_duration"); err != nil { + g.Log().Warningf(ctx, "drop column max_shot_duration failed: %v", err) + } } func (d *dramaDao) IncrementEpCount(ctx context.Context, dramaId int64) error { @@ -85,6 +100,9 @@ func (d *dramaDao) Insert(ctx context.Context, data *entity.Drama) (id int64, er if err != nil { return } + if r == nil { + return 0, nil + } return r.LastInsertId() } diff --git a/shortdrama/dao/episode_dao.go b/shortdrama/dao/episode_dao.go index 14bc1bf..295c1f1 100644 --- a/shortdrama/dao/episode_dao.go +++ b/shortdrama/dao/episode_dao.go @@ -2,12 +2,11 @@ package dao import ( "context" + "video-factory/common" "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 Episode = &episodeDao{} @@ -28,10 +27,10 @@ func init() { updated_at DATETIME DEFAULT (datetime('now','localtime')), -- 更新时间 deleted_at DATETIME -- 删除时间 )`); err != nil { - g.Log().Warningf(ctx, "创建剧集表失败: %v", err) + g.Log().Warningf(ctx, "create episode table failed: %v", err) if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_episode_drama_id ON short_drama_episode(drama_id)"); err != nil { - g.Log().Warningf(ctx, "创建剧集表索引失败: %v", err) + g.Log().Warningf(ctx, "create episode table index failed: %v", err) } } // 迁移:删除 generation_mode 列(已由模型配置动态决定) @@ -41,7 +40,7 @@ func init() { // 迁移:清理旧字段 for _, col := range []string{"tech_script", "script_path", "tech_script_path", "description_path", "duration"} { if _, err := g.DB().Exec(ctx, `ALTER TABLE `+public.TableNameEpisode+` DROP COLUMN `+col); err != nil { - g.Log().Warningf(ctx, "删除剧集表旧字段 %s 失败(可能已删除): %v", col, err) + g.Log().Warningf(ctx, "drop episode table old column %s failed (may already be deleted): %v", col, err) } } // 迁移:添加 description 列 @@ -50,35 +49,24 @@ func init() { g.Log().Debugf(ctx, "添加 description 列失败(可能已存在): %v", err) } } - -func (d *episodeDao) Insert(ctx context.Context, data *entity.Episode) (id int64, err error) { - 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") - delete(m, "deleted_at") - r, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Data(m).Insert() - if err != nil { - return - } - return r.LastInsertId() +func (d *episodeDao) Insert(ctx context.Context, data *entity.Episode) (int64, error) { + return common.InsertAndReturnId(ctx, public.TableNameEpisode, data) } -func (d *episodeDao) GetOne(ctx context.Context, id int64) (res *entity.Episode, err error) { - r, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Where("id", id).One() - if err != nil { - return - } - if r == nil { - return nil, nil - } - err = r.Struct(&res) - return +func (d *episodeDao) GetOne(ctx context.Context, id int64) (*entity.Episode, error) { + return common.GetOneByPk[entity.Episode](ctx, public.TableNameEpisode, id) } func (d *episodeDao) Update(ctx context.Context, id int64, data *entity.Episode) error { - _, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Data(data).Where("id", id).Update() - return err + return common.UpdateByPk(ctx, public.TableNameEpisode, id, data) +} + +func (d *episodeDao) Delete(ctx context.Context, id int64) error { + return common.DeleteByPk(ctx, public.TableNameEpisode, id) +} + +func (d *episodeDao) DeleteByDrama(ctx context.Context, dramaId int64) error { + return common.DeleteByDrama(ctx, public.TableNameEpisode, dramaId) } func (d *episodeDao) UpdateStatus(ctx context.Context, id int64, status string, videoUrl string) error { @@ -90,16 +78,6 @@ func (d *episodeDao) UpdateStatus(ctx context.Context, id int64, status string, return err } -func (d *episodeDao) Delete(ctx context.Context, id int64) error { - _, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Unscoped().Where("id", id).Delete() - return err -} - -func (d *episodeDao) DeleteByDrama(ctx context.Context, dramaId int64) error { - _, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Unscoped().Where("drama_id", dramaId).Delete() - return err -} - func (d *episodeDao) ListPageByDrama(ctx context.Context, dramaId int64, page, pageSize int, keyword ...string) (res []*entity.Episode, total int, err error) { m := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Where("drama_id", dramaId) if len(keyword) > 0 && keyword[0] != "" { diff --git a/shortdrama/dao/generation_task_dao.go b/shortdrama/dao/generation_task_dao.go index e8df8c1..81906bd 100644 --- a/shortdrama/dao/generation_task_dao.go +++ b/shortdrama/dao/generation_task_dao.go @@ -30,14 +30,14 @@ func init() { created_at DATETIME DEFAULT (datetime('now','localtime')), updated_at DATETIME DEFAULT (datetime('now','localtime')) )`); err != nil { - g.Log().Warningf(ctx, "创建生成任务表失败: %v", err) + g.Log().Warningf(ctx, "create generation task table failed: %v", err) if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_gt_episode_id ON short_drama_generation_task(episode_id)"); err != nil { - g.Log().Warningf(ctx, "创建生成任务剧集索引失败: %v", err) + g.Log().Warningf(ctx, "create generation task episode index failed: %v", err) } if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_gt_status ON short_drama_generation_task(status)"); err != nil { - g.Log().Warningf(ctx, "创建生成任务状态索引失败: %v", err) + g.Log().Warningf(ctx, "create generation task status index failed: %v", err) } } // 迁移:添加 segment_idx 列(旧表升级) @@ -111,6 +111,9 @@ func (d *generationTaskDao) Insert(ctx context.Context, data *entity.GenerationT if err != nil { return } + if r == nil { + return 0, nil + } return r.LastInsertId() } @@ -237,7 +240,7 @@ func (d *generationTaskDao) ListByStatuses(ctx context.Context, statuses []strin } r, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx). Where("status in (?)", statuses). - OrderDesc("id").All() + OrderDesc("id").Limit(200).All() if err != nil { return } diff --git a/shortdrama/dao/model_config_dao.go b/shortdrama/dao/model_config_dao.go index 74c0a80..54a64bd 100644 --- a/shortdrama/dao/model_config_dao.go +++ b/shortdrama/dao/model_config_dao.go @@ -29,7 +29,7 @@ func init() { "updated_at DATETIME DEFAULT (datetime('now','localtime'))"+ ")") if err != nil { - g.Log().Warningf(ctx, "创建模型配置表失败: %v", err) + g.Log().Warningf(ctx, "create model config table failed: %v", err) } // 检测旧表结构:含 base_url / api_key / chat_api_key / is_active / task_callback_url 等旧列 → 迁移 @@ -47,7 +47,7 @@ func init() { if hasOldColumns { g.Log().Info(ctx, "检测到旧表结构,开始迁移 model_config...") newTable := public.TableNameModelConfig + "_new" - _, _ = g.DB().Exec(ctx, + if _, err := g.DB().Exec(ctx, "CREATE TABLE IF NOT EXISTS "+newTable+" ("+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"+ "model_type TEXT NOT NULL DEFAULT '',"+ @@ -60,7 +60,9 @@ func init() { "reference_template TEXT NOT NULL DEFAULT '',"+ "created_at DATETIME DEFAULT (datetime('now','localtime')),"+ "updated_at DATETIME DEFAULT (datetime('now','localtime'))"+ - ")") + ")"); err != nil { + g.Log().Warningf(ctx, "create temp table for model_config migration failed: %v", err) + } hasChatApiKey := false if _, err := g.DB().Exec(ctx, "SELECT chat_api_key FROM "+public.TableNameModelConfig+" LIMIT 1"); err == nil { @@ -71,18 +73,22 @@ func init() { 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{ + if _, err := g.DB().Model(newTable).Ctx(ctx).Data(g.Map{ "model_type": "chat", "model_name": oldRow["chat_model_name"], "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{ + }).Insert(); err != nil { + g.Log().Warningf(ctx, "insert chat model config failed: %v", err) + } + if _, err := g.DB().Model(newTable).Ctx(ctx).Data(g.Map{ "model_type": "video", "model_name": oldRow["video_model_name"], "schema": oldRow["video_schema"], "price": gconv.Int(oldRow["price_per_second"]), "price_unit": "second", "created_at": now, "updated_at": now, - }).Insert() + }).Insert(); err != nil { + g.Log().Warningf(ctx, "insert video model config failed: %v", err) + } } } else { oldRows, _ := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).OrderAsc("id").All() @@ -96,16 +102,22 @@ func init() { if mt == "chat" { unit = "video" } - _, _ = g.DB().Model(newTable).Ctx(ctx).Data(g.Map{ + if _, err := g.DB().Model(newTable).Ctx(ctx).Data(g.Map{ "model_type": mt, "model_name": or["model_name"], "schema": or["schema"], "price": priceVal, "price_unit": unit, "created_at": now, "updated_at": now, - }).Insert() + }).Insert(); err != nil { + g.Log().Warningf(ctx, "insert model config for %s failed: %v", or["model_name"].String(), err) + } } } - _, _ = g.DB().Exec(ctx, "DROP TABLE "+public.TableNameModelConfig) - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+newTable+" RENAME TO "+public.TableNameModelConfig) + if _, err := g.DB().Exec(ctx, "DROP TABLE "+public.TableNameModelConfig); err != nil { + g.Log().Warningf(ctx, "drop old model_config table failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+newTable+" RENAME TO "+public.TableNameModelConfig); err != nil { + g.Log().Warningf(ctx, "rename temp table to model_config failed: %v", err) + } g.Log().Info(ctx, "model_config 表结构迁移完成") } @@ -121,7 +133,7 @@ func init() { if !hasCol { g.Log().Info(ctx, "检测到 model_config 缺少 concurrency_count 列,正在补充...") if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameModelConfig+" ADD COLUMN concurrency_count INTEGER NOT NULL DEFAULT 1"); err != nil { - g.Log().Warningf(ctx, "补充 concurrency_count 列失败: %v", err) + g.Log().Warningf(ctx, "add concurrency_count column failed: %v", err) } else { g.Log().Info(ctx, "concurrency_count 列补充完成") } @@ -140,7 +152,7 @@ func init() { if !hasCol { g.Log().Info(ctx, "检测到 model_config 缺少 first_frame_mapping 列,正在补充...") if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameModelConfig+" ADD COLUMN first_frame_mapping TEXT NOT NULL DEFAULT ''"); err != nil { - g.Log().Warningf(ctx, "补充 first_frame_mapping 列失败: %v", err) + g.Log().Warningf(ctx, "add first_frame_mapping column failed: %v", err) } else { g.Log().Info(ctx, "first_frame_mapping 列补充完成") } @@ -159,7 +171,7 @@ func init() { if !hasCol { g.Log().Info(ctx, "检测到 model_config 缺少 reference_template 列,正在补充...") if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameModelConfig+" ADD COLUMN reference_template TEXT NOT NULL DEFAULT ''"); err != nil { - g.Log().Warningf(ctx, "补充 reference_template 列失败: %v", err) + g.Log().Warningf(ctx, "add reference_template column failed: %v", err) } else { g.Log().Info(ctx, "reference_template 列补充完成") } @@ -176,7 +188,7 @@ func init() { } 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().Warningf(ctx, "init default model config failed: %v", e) } } g.Log().Info(ctx, "已初始化默认模型配置(chat + video)") @@ -243,7 +255,7 @@ func (d *modelConfigDao) GetActiveModelByType(ctx context.Context, modelType str } func (d *modelConfigDao) GetAll(ctx context.Context) (res []*entity.ModelConfig, err error) { - r, err := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).OrderAsc("id").All() + r, err := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).OrderAsc("id").Limit(200).All() if err != nil { return nil, err } diff --git a/shortdrama/dao/payment_channel_trade_dao.go b/shortdrama/dao/payment_channel_trade_dao.go index 1896544..7988e5d 100644 --- a/shortdrama/dao/payment_channel_trade_dao.go +++ b/shortdrama/dao/payment_channel_trade_dao.go @@ -25,9 +25,11 @@ func init() { created_at DATETIME DEFAULT (datetime('now','localtime')) )`) if err != nil { - g.Log().Warningf(ctx, "创建 payment_channel_trade 表失败: %v", err) + g.Log().Warningf(ctx, "create payment_channel_trade table failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_pct_order ON "+public.TableNamePaymentChannelTrade+"(order_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_pct_order failed: %v", err) } - _, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_pct_order ON "+public.TableNamePaymentChannelTrade+"(order_id)") } func (d *paymentChannelTradeDao) Insert(ctx context.Context, data *entity.PaymentChannelTrade) (int64, error) { diff --git a/shortdrama/dao/payment_config_dao.go b/shortdrama/dao/payment_config_dao.go index a1215f2..4a0d3c5 100644 --- a/shortdrama/dao/payment_config_dao.go +++ b/shortdrama/dao/payment_config_dao.go @@ -29,7 +29,7 @@ func init() { created_at DATETIME DEFAULT (datetime('now','localtime')), updated_at DATETIME DEFAULT (datetime('now','localtime')) )`); err != nil { - g.Log().Warningf(ctx, "创建支付配置表失败: %v", err) + g.Log().Warningf(ctx, "create payment config table failed: %v", err) } } diff --git a/shortdrama/dao/payment_order_dao.go b/shortdrama/dao/payment_order_dao.go index aeb4333..25879d5 100644 --- a/shortdrama/dao/payment_order_dao.go +++ b/shortdrama/dao/payment_order_dao.go @@ -31,19 +31,23 @@ func init() { updated_at DATETIME DEFAULT (datetime('now','localtime')) )`) if err != nil { - g.Log().Warningf(ctx, "创建 payment_order 表失败: %v", err) + g.Log().Warningf(ctx, "create payment_order table failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_po_user ON "+public.TableNamePaymentOrder+"(user_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_po_user failed: %v", err) } - _, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_po_user ON "+public.TableNamePaymentOrder+"(user_id)") if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_po_order_type ON payment_order(order_type)"); err != nil { - g.Log().Warningf(ctx, "创建订单类型索引失败: %v", err) + g.Log().Warningf(ctx, "create order type index failed: %v", err) } if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_po_status ON payment_order(status)"); err != nil { - g.Log().Warningf(ctx, "创建订单状态索引失败: %v", err) + g.Log().Warningf(ctx, "create order status index failed: %v", err) } - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNamePaymentOrder+" ADD COLUMN order_type TEXT NOT NULL DEFAULT 'recharge'") + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNamePaymentOrder+" ADD COLUMN order_type TEXT NOT NULL DEFAULT 'recharge'"); err != nil { + g.Log().Warningf(ctx, "add column order_type failed: %v", err) + } } func (d *paymentOrderDao) Insert(ctx context.Context, data *entity.PaymentOrder) (int64, error) { @@ -70,7 +74,7 @@ func (d *paymentOrderDao) GetByOrderNo(ctx context.Context, orderNo string) (*en func (d *paymentOrderDao) ListByStatus(ctx context.Context, status string) ([]*entity.PaymentOrder, error) { var list []*entity.PaymentOrder - err := g.DB().Model(public.TableNamePaymentOrder).Ctx(ctx).Where("status", status).OrderDesc("id").Scan(&list) + err := g.DB().Model(public.TableNamePaymentOrder).Ctx(ctx).Where("status", status).OrderDesc("id").Limit(200).Scan(&list) return list, err } @@ -93,6 +97,32 @@ func (d *paymentOrderDao) ListByUserAndType(ctx context.Context, userId int64, o err := g.DB().Model(public.TableNamePaymentOrder).Ctx(ctx). Where("user_id", userId). Where("order_type", orderType). - OrderDesc("id").Scan(&list) + OrderDesc("id").Limit(200).Scan(&list) return list, err } + +func (d *paymentOrderDao) ListPageByUser(ctx context.Context, userId int64, page, pageSize int) (res []*entity.PaymentOrder, total int, err error) { + m := g.DB().Model(public.TableNamePaymentOrder).Ctx(ctx).Where("user_id", userId) + if pageSize == -1 { + r, err := m.OrderDesc("id").All() + if err != nil { + return nil, 0, err + } + res = make([]*entity.PaymentOrder, 0) + err = r.Structs(&res) + return res, len(res), err + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + r, total, err := m.OrderDesc("id").Limit(pageSize).Offset((page - 1) * pageSize).AllAndCount(false) + if err != nil { + return + } + res = make([]*entity.PaymentOrder, 0) + err = r.Structs(&res) + return +} diff --git a/shortdrama/dao/prop_dao.go b/shortdrama/dao/prop_dao.go index 49b7b61..8a5f516 100644 --- a/shortdrama/dao/prop_dao.go +++ b/shortdrama/dao/prop_dao.go @@ -2,12 +2,11 @@ package dao import ( "context" + "video-factory/common" "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 Prop = &propDao{} @@ -26,82 +25,38 @@ func init() { updated_at DATETIME DEFAULT (datetime('now','localtime')), -- 更新时间 deleted_at DATETIME -- 删除时间 )`); err != nil { - g.Log().Warningf(ctx, "创建道具表失败: %v", err) + g.Log().Warningf(ctx, "create prop table failed: %v", err) if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_prop_drama_id ON short_drama_prop(drama_id)"); err != nil { - g.Log().Warningf(ctx, "创建道具表索引失败: %v", err) + g.Log().Warningf(ctx, "create prop table index failed: %v", err) } } } - -func (d *propDao) Insert(ctx context.Context, data *entity.Prop) (id int64, err error) { - 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") - delete(m, "deleted_at") - r, err := g.DB().Model(public.TableNameProp).Ctx(ctx).Data(m).Insert() - if err != nil { - return - } - return r.LastInsertId() +func (d *propDao) Insert(ctx context.Context, data *entity.Prop) (int64, error) { + return common.InsertAndReturnId(ctx, public.TableNameProp, data) } -func (d *propDao) GetOne(ctx context.Context, id int64) (res *entity.Prop, err error) { - r, err := g.DB().Model(public.TableNameProp).Ctx(ctx).Where("id", id).One() - if err != nil { - return - } - if r == nil { - return nil, nil - } - err = r.Struct(&res) - return +func (d *propDao) GetOne(ctx context.Context, id int64) (*entity.Prop, error) { + return common.GetOneByPk[entity.Prop](ctx, public.TableNameProp, id) } func (d *propDao) Update(ctx context.Context, id int64, data *entity.Prop) error { - _, err := g.DB().Model(public.TableNameProp).Ctx(ctx).Data(data).Where("id", id).Update() - return err + return common.UpdateByPk(ctx, public.TableNameProp, id, data) } func (d *propDao) Delete(ctx context.Context, id int64) error { - _, err := g.DB().Model(public.TableNameProp).Ctx(ctx).Unscoped().Where("id", id).Delete() - return err + return common.DeleteByPk(ctx, public.TableNameProp, id) } func (d *propDao) DeleteByDrama(ctx context.Context, dramaId int64) error { - _, err := g.DB().Model(public.TableNameProp).Ctx(ctx).Unscoped().Where("drama_id", dramaId).Delete() - return err + return common.DeleteByDrama(ctx, public.TableNameProp, dramaId) } func (d *propDao) ListPageByDrama(ctx context.Context, dramaId int64, page, pageSize int) (res []*entity.Prop, total int, err error) { - m := g.DB().Model(public.TableNameProp).Ctx(ctx).Where("drama_id", dramaId) - if pageSize == -1 { - r, err := m.OrderAsc("id").All() - if err != nil { - return nil, 0, err - } - res = make([]*entity.Prop, 0) - err = r.Structs(&res) - return res, len(res), err - } - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 20 - } - r, total, err := m.OrderAsc("id").Limit(pageSize).Offset((page - 1) * pageSize).AllAndCount(false) - if err != nil { - return - } - res = make([]*entity.Prop, 0) - err = r.Structs(&res) - return + return common.ListPageByDrama[entity.Prop](ctx, public.TableNameProp, dramaId, page, pageSize) } - func (d *propDao) ListByDrama(ctx context.Context, dramaId int64) (res []*entity.Prop, err error) { - r, err := g.DB().Model(public.TableNameProp).Ctx(ctx).Where("drama_id", dramaId).OrderAsc("id").All() + r, err := g.DB().Model(public.TableNameProp).Ctx(ctx).Where("drama_id", dramaId).OrderAsc("id").Limit(200).All() if err != nil { return } diff --git a/shortdrama/dao/region_pricing_dao.go b/shortdrama/dao/region_pricing_dao.go index a04d0b8..8b7a079 100644 --- a/shortdrama/dao/region_pricing_dao.go +++ b/shortdrama/dao/region_pricing_dao.go @@ -26,15 +26,26 @@ func init() { )`); err != nil { g.Log().Warningf(ctx, "create region_pricing table failed: %v", err) } - // add province column for existing databases - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameRegionPricing+" ADD COLUMN province TEXT NOT NULL DEFAULT ''") + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameRegionPricing+" ADD COLUMN province TEXT NOT NULL DEFAULT ''"); err != nil { + g.Log().Warningf(ctx, "add column province failed: %v", err) + } // add max_customers column for existing databases - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameRegionPricing+" ADD COLUMN max_customers INTEGER NOT NULL DEFAULT 0") + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameRegionPricing+" ADD COLUMN max_customers INTEGER NOT NULL DEFAULT 0"); err != nil { + g.Log().Warningf(ctx, "add column max_customers failed: %v", err) + } // drop old indexes, recreate with new constraints - _, _ = g.DB().Exec(ctx, `DROP INDEX IF EXISTS idx_region_pricing_uniq`) - _, _ = g.DB().Exec(ctx, `DROP INDEX IF EXISTS idx_rp_price`) - _, _ = g.DB().Exec(ctx, `DROP INDEX IF EXISTS idx_rp_max_customers`) - _, _ = g.DB().Exec(ctx, `DROP INDEX IF EXISTS idx_rp_single_protected`) + if _, err := g.DB().Exec(ctx, `DROP INDEX IF EXISTS idx_region_pricing_uniq`); err != nil { + g.Log().Warningf(ctx, "drop index idx_region_pricing_uniq failed: %v", err) + } + if _, err := g.DB().Exec(ctx, `DROP INDEX IF EXISTS idx_rp_price`); err != nil { + g.Log().Warningf(ctx, "drop index idx_rp_price failed: %v", err) + } + if _, err := g.DB().Exec(ctx, `DROP INDEX IF EXISTS idx_rp_max_customers`); err != nil { + g.Log().Warningf(ctx, "drop index idx_rp_max_customers failed: %v", err) + } + if _, err := g.DB().Exec(ctx, `DROP INDEX IF EXISTS idx_rp_single_protected`); err != nil { + g.Log().Warningf(ctx, "drop index idx_rp_single_protected failed: %v", err) + } if _, err := g.DB().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_rp_price ON `+public.TableNameRegionPricing+`(region, protected, price)`); err != nil { g.Log().Warningf(ctx, "create idx_rp_price failed: %v", err) } diff --git a/shortdrama/dao/scene_dao.go b/shortdrama/dao/scene_dao.go index 480336d..d5ca506 100644 --- a/shortdrama/dao/scene_dao.go +++ b/shortdrama/dao/scene_dao.go @@ -2,12 +2,11 @@ package dao import ( "context" + "video-factory/common" "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 Scene = &sceneDao{} @@ -26,82 +25,38 @@ func init() { updated_at DATETIME DEFAULT (datetime('now','localtime')), -- 更新时间 deleted_at DATETIME -- 删除时间 )`); err != nil { - g.Log().Warningf(ctx, "创建场景表失败: %v", err) + g.Log().Warningf(ctx, "create scene table failed: %v", err) if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_scene_drama_id ON short_drama_scene(drama_id)"); err != nil { - g.Log().Warningf(ctx, "创建场景表索引失败: %v", err) + g.Log().Warningf(ctx, "create scene table index failed: %v", err) } } } - -func (d *sceneDao) Insert(ctx context.Context, data *entity.Scene) (id int64, err error) { - 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") - delete(m, "deleted_at") - r, err := g.DB().Model(public.TableNameScene).Ctx(ctx).Data(m).Insert() - if err != nil { - return - } - return r.LastInsertId() +func (d *sceneDao) Insert(ctx context.Context, data *entity.Scene) (int64, error) { + return common.InsertAndReturnId(ctx, public.TableNameScene, data) } -func (d *sceneDao) GetOne(ctx context.Context, id int64) (res *entity.Scene, err error) { - r, err := g.DB().Model(public.TableNameScene).Ctx(ctx).Where("id", id).One() - if err != nil { - return - } - if r == nil { - return nil, nil - } - err = r.Struct(&res) - return +func (d *sceneDao) GetOne(ctx context.Context, id int64) (*entity.Scene, error) { + return common.GetOneByPk[entity.Scene](ctx, public.TableNameScene, id) } func (d *sceneDao) Update(ctx context.Context, id int64, data *entity.Scene) error { - _, err := g.DB().Model(public.TableNameScene).Ctx(ctx).Data(data).Where("id", id).Update() - return err + return common.UpdateByPk(ctx, public.TableNameScene, id, data) } func (d *sceneDao) Delete(ctx context.Context, id int64) error { - _, err := g.DB().Model(public.TableNameScene).Ctx(ctx).Unscoped().Where("id", id).Delete() - return err + return common.DeleteByPk(ctx, public.TableNameScene, id) } func (d *sceneDao) DeleteByDrama(ctx context.Context, dramaId int64) error { - _, err := g.DB().Model(public.TableNameScene).Ctx(ctx).Unscoped().Where("drama_id", dramaId).Delete() - return err + return common.DeleteByDrama(ctx, public.TableNameScene, dramaId) } func (d *sceneDao) ListPageByDrama(ctx context.Context, dramaId int64, page, pageSize int) (res []*entity.Scene, total int, err error) { - m := g.DB().Model(public.TableNameScene).Ctx(ctx).Where("drama_id", dramaId) - if pageSize == -1 { - r, err := m.OrderAsc("id").All() - if err != nil { - return nil, 0, err - } - res = make([]*entity.Scene, 0) - err = r.Structs(&res) - return res, len(res), err - } - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 20 - } - r, total, err := m.OrderAsc("id").Limit(pageSize).Offset((page - 1) * pageSize).AllAndCount(false) - if err != nil { - return - } - res = make([]*entity.Scene, 0) - err = r.Structs(&res) - return + return common.ListPageByDrama[entity.Scene](ctx, public.TableNameScene, dramaId, page, pageSize) } - func (d *sceneDao) ListByDrama(ctx context.Context, dramaId int64) (res []*entity.Scene, err error) { - r, err := g.DB().Model(public.TableNameScene).Ctx(ctx).Where("drama_id", dramaId).OrderAsc("id").All() + r, err := g.DB().Model(public.TableNameScene).Ctx(ctx).Where("drama_id", dramaId).OrderAsc("id").Limit(200).All() if err != nil { return } diff --git a/shortdrama/dao/user_dao.go b/shortdrama/dao/user_dao.go index 2f79b58..77ddda8 100644 --- a/shortdrama/dao/user_dao.go +++ b/shortdrama/dao/user_dao.go @@ -29,20 +29,42 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create user table failed: %v", err) } - _, _ = g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_user_username ON "+public.TableNameUser+"(username) WHERE username != ''") - _, _ = g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_user_phone ON "+public.TableNameUser+"(phone) WHERE phone != ''") + if _, err := g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_user_username ON "+public.TableNameUser+"(username) WHERE username != ''"); err != nil { + g.Log().Warningf(ctx, "create index idx_user_username failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_user_phone ON "+public.TableNameUser+"(phone) WHERE phone != ''"); err != nil { + g.Log().Warningf(ctx, "create index idx_user_phone failed: %v", err) + } // add columns for existing databases - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameUser+" ADD COLUMN province TEXT NOT NULL DEFAULT ''") - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameUser+" ADD COLUMN address TEXT NOT NULL DEFAULT ''") + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameUser+" ADD COLUMN province TEXT NOT NULL DEFAULT ''"); err != nil { + g.Log().Warningf(ctx, "add column province failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameUser+" ADD COLUMN address TEXT NOT NULL DEFAULT ''"); err != nil { + g.Log().Warningf(ctx, "add column address failed: %v", err) + } // cleanup legacy columns - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameUser+" DROP COLUMN status") - _, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameUser+" DROP COLUMN expired_at") + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameUser+" DROP COLUMN status"); err != nil { + g.Log().Warningf(ctx, "drop column status failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameUser+" DROP COLUMN expired_at"); err != nil { + g.Log().Warningf(ctx, "drop column expired_at failed: %v", err) + } // backfill created_at for legacy data - _, _ = g.DB().Exec(ctx, "UPDATE "+public.TableNameUser+" SET created_at = datetime('now','localtime') WHERE created_at IS NULL") - _, _ = g.DB().Exec(ctx, "UPDATE "+public.TableNameAccountTransaction+" SET created_at = datetime('now','localtime') WHERE created_at IS NULL") - _, _ = g.DB().Exec(ctx, "UPDATE "+public.TableNamePaymentOrder+" SET created_at = datetime('now','localtime') WHERE created_at IS NULL") - _, _ = g.DB().Exec(ctx, "UPDATE "+public.TableNamePaymentConfig+" SET created_at = datetime('now','localtime') WHERE created_at IS NULL") - _, _ = g.DB().Exec(ctx, "UPDATE "+public.TableNamePaymentChannelTrade+" SET created_at = datetime('now','localtime') WHERE created_at IS NULL") + if _, err := g.DB().Exec(ctx, "UPDATE "+public.TableNameUser+" SET created_at = datetime('now','localtime') WHERE created_at IS NULL"); err != nil { + g.Log().Warningf(ctx, "backfill user created_at failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "UPDATE "+public.TableNameAccountTransaction+" SET created_at = datetime('now','localtime') WHERE created_at IS NULL"); err != nil { + g.Log().Warningf(ctx, "backfill account_transaction created_at failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "UPDATE "+public.TableNamePaymentOrder+" SET created_at = datetime('now','localtime') WHERE created_at IS NULL"); err != nil { + g.Log().Warningf(ctx, "backfill payment_order created_at failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "UPDATE "+public.TableNamePaymentConfig+" SET created_at = datetime('now','localtime') WHERE created_at IS NULL"); err != nil { + g.Log().Warningf(ctx, "backfill payment_config created_at failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "UPDATE "+public.TableNamePaymentChannelTrade+" SET created_at = datetime('now','localtime') WHERE created_at IS NULL"); err != nil { + g.Log().Warningf(ctx, "backfill payment_channel_trade created_at failed: %v", err) + } // UTC to local timezone migration, run once via PRAGMA user_version var userVersion int @@ -52,16 +74,30 @@ func init() { } if userVersion == 0 { g.Log().Info(ctx, "migration: convert historical UTC time data to local timezone") - g.DB().Exec(ctx, "UPDATE "+public.TableNameUser+" SET created_at = datetime(created_at, '+8 hours'), updated_at = datetime(updated_at, '+8 hours') WHERE created_at LIKE '%-%T%:%.%Z' OR created_at LIKE '%-%T%:%s%z'") - g.DB().Exec(ctx, "UPDATE "+public.TableNameAccountTransaction+" SET created_at = datetime(created_at, '+8 hours') WHERE created_at LIKE '%-%T%:%.%Z' OR created_at LIKE '%-%T%:%s%z' AND created_at IS NOT NULL") - g.DB().Exec(ctx, "UPDATE "+public.TableNamePaymentOrder+" SET created_at = datetime(created_at, '+8 hours'), updated_at = datetime(updated_at, '+8 hours'), paid_at = datetime(paid_at, '+8 hours') WHERE created_at LIKE '%-%T%:%.%Z' OR created_at LIKE '%-%T%:%s%z'") - g.DB().Exec(ctx, "UPDATE "+public.TableNamePaymentConfig+" SET created_at = datetime(created_at, '+8 hours') WHERE created_at LIKE '%-%T%:%.%Z' OR created_at LIKE '%-%T%:%s%z'") - g.DB().Exec(ctx, "UPDATE "+public.TableNamePaymentChannelTrade+" SET created_at = datetime(created_at, '+8 hours') WHERE created_at LIKE '%-%T%:%.%Z' OR created_at LIKE '%-%T%:%s%z'") - g.DB().Exec(ctx, "PRAGMA user_version = 1") + if _, err := g.DB().Exec(ctx, "UPDATE "+public.TableNameUser+" SET created_at = datetime(created_at, '+8 hours'), updated_at = datetime(updated_at, '+8 hours') WHERE created_at LIKE '%-%T%:%.%Z' OR created_at LIKE '%-%T%:%s%z'"); err != nil { + g.Log().Warningf(ctx, "migrate user UTC to local failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "UPDATE "+public.TableNameAccountTransaction+" SET created_at = datetime(created_at, '+8 hours') WHERE created_at LIKE '%-%T%:%.%Z' OR created_at LIKE '%-%T%:%s%z' AND created_at IS NOT NULL"); err != nil { + g.Log().Warningf(ctx, "migrate account_transaction UTC to local failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "UPDATE "+public.TableNamePaymentOrder+" SET created_at = datetime(created_at, '+8 hours'), updated_at = datetime(updated_at, '+8 hours'), paid_at = datetime(paid_at, '+8 hours') WHERE created_at LIKE '%-%T%:%.%Z' OR created_at LIKE '%-%T%:%s%z'"); err != nil { + g.Log().Warningf(ctx, "migrate payment_order UTC to local failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "UPDATE "+public.TableNamePaymentConfig+" SET created_at = datetime(created_at, '+8 hours') WHERE created_at LIKE '%-%T%:%.%Z' OR created_at LIKE '%-%T%:%s%z'"); err != nil { + g.Log().Warningf(ctx, "migrate payment_config UTC to local failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "UPDATE "+public.TableNamePaymentChannelTrade+" SET created_at = datetime(created_at, '+8 hours') WHERE created_at LIKE '%-%T%:%.%Z' OR created_at LIKE '%-%T%:%s%z'"); err != nil { + g.Log().Warningf(ctx, "migrate payment_channel_trade UTC to local failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 1"); err != nil { + g.Log().Warningf(ctx, "set user_version failed: %v", err) + } } // 补充 province 字段到各表的 DDL(仅针对在 province 列存在前入库的老数据) - _, _ = g.DB().Exec(ctx, "UPDATE "+public.TableNameUser+" SET province = region WHERE province = '' AND region != ''") + if _, err := g.DB().Exec(ctx, "UPDATE "+public.TableNameUser+" SET province = region WHERE province = '' AND region != ''"); err != nil { + g.Log().Warningf(ctx, "backfill province from region failed: %v", err) + } } func (d *userDao) Insert(ctx context.Context, data *entity.User) (int64, error) { diff --git a/shortdrama/dao/user_model_config_dao.go b/shortdrama/dao/user_model_config_dao.go index bc783a1..f23105e 100644 --- a/shortdrama/dao/user_model_config_dao.go +++ b/shortdrama/dao/user_model_config_dao.go @@ -19,7 +19,9 @@ 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 { + if r, err := g.DB().Exec(ctx, "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='"+public.TableNameUserModelConfig+"'"); err != nil { + g.Log().Warningf(ctx, "check user_model_config table existence failed: %v", err) + } else if r != nil { tableExists = true } @@ -40,14 +42,17 @@ func init() { updated_at DATETIME DEFAULT (datetime('now','localtime')) )`) if err != nil { - g.Log().Warningf(ctx, "创建用户模型配置表失败: %v", err) + g.Log().Warningf(ctx, "create user model config table failed: %v", err) } return } // 表已存在,检测是否包含旧列(base_url),有则迁移到不含 base_url/task_callback_url 的新表 - r, _ := g.DB().GetAll(ctx, "PRAGMA table_info("+public.TableNameUserModelConfig+")") + r, err := g.DB().GetAll(ctx, "PRAGMA table_info("+public.TableNameUserModelConfig+")") var hasBaseUrl bool + if err != nil { + g.Log().Warningf(ctx, "check user_model_config table columns failed: %v", err) + } for _, col := range r { if col["name"].String() == "base_url" { hasBaseUrl = true @@ -73,7 +78,7 @@ func init() { created_at DATETIME DEFAULT (datetime('now','localtime')), updated_at DATETIME DEFAULT (datetime('now','localtime')) )`); err != nil { - g.Log().Warningf(ctx, "创建新表失败: %v", err) + g.Log().Warningf(ctx, "create new table failed: %v", err) return } @@ -97,7 +102,7 @@ func init() { } if _, err := g.DB().Exec(ctx, "DROP TABLE "+public.TableNameUserModelConfig); err != nil { - g.Log().Warningf(ctx, "删除旧表失败: %v", err) + g.Log().Warningf(ctx, "drop old table failed: %v", err) } if _, err := g.DB().Exec(ctx, "ALTER TABLE "+newTable+" RENAME TO "+public.TableNameUserModelConfig); err != nil { g.Log().Error(ctx, "重命名表失败:", err) @@ -107,7 +112,9 @@ func init() { } // 检测是否缺少 concurrency_count 列(已有新表结构但字段不全) - if r, _ := g.DB().GetAll(ctx, "PRAGMA table_info("+public.TableNameUserModelConfig+")"); r != nil { + if r, err := g.DB().GetAll(ctx, "PRAGMA table_info("+public.TableNameUserModelConfig+")"); err != nil { + g.Log().Warningf(ctx, "check user_model_config concurrency_count column failed: %v", err) + } else if r != nil { hasCol := false for _, col := range r { if col["name"].String() == "concurrency_count" { @@ -118,7 +125,7 @@ func init() { if !hasCol { g.Log().Info(ctx, "检测到 user_model_config 缺少 concurrency_count 列,正在补充...") if _, err := g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameUserModelConfig+" ADD COLUMN concurrency_count INTEGER NOT NULL DEFAULT 0"); err != nil { - g.Log().Warningf(ctx, "补充 concurrency_count 列失败: %v", err) + g.Log().Warningf(ctx, "add concurrency_count column failed: %v", err) } else { g.Log().Info(ctx, "concurrency_count 列补充完成") } diff --git a/shortdrama/model/dto/account_transaction_dto.go b/shortdrama/model/dto/account_transaction_dto.go index 34e391f..32a3d54 100644 --- a/shortdrama/model/dto/account_transaction_dto.go +++ b/shortdrama/model/dto/account_transaction_dto.go @@ -8,16 +8,16 @@ import ( type RechargeReq struct { g.Meta `path:"/recharge" method:"post" tags:"短剧管理" summary:"充值"` - UserId int64 `json:"user_id"` - Amount int64 `json:"amount"` + UserId int64 `v:"required" json:"user_id"` + Amount int64 `v:"required|min:1" json:"amount"` } type ListTransactionReq struct { g.Meta `path:"/list" method:"get" tags:"短剧管理" summary:"交易记录"` - UserId int64 `json:"user_id"` - Type string `json:"type"` // 筛选类型:recharge/deduct/空(全部) - Page int `json:"page"` - PageSize int `json:"page_size"` + UserId int64 `v:"required" json:"user_id"` + Type string `json:"type" v:"in:recharge,deduct" dc:"筛选类型:recharge/deduct"` + Page int `json:"page" d:"1" v:"min:1" dc:"页码"` + PageSize int `json:"page_size" d:"20" v:"min:1|max:100" dc:"每页条数"` } type ListTransactionRes struct { diff --git a/shortdrama/model/dto/agent_profile_dto.go b/shortdrama/model/dto/agent_profile_dto.go index fcfe76a..f9c708e 100644 --- a/shortdrama/model/dto/agent_profile_dto.go +++ b/shortdrama/model/dto/agent_profile_dto.go @@ -19,14 +19,14 @@ type CreateAgentReq struct { type ListAgentReq struct { g.Meta `path:"/list" method:"get" tags:"短剧管理" summary:"代理商列表"` - Page int `json:"page"` - PageSize int `json:"page_size"` - Keyword string `json:"keyword"` - Phone string `json:"phone"` - Province string `json:"province"` - Region string `json:"region"` - ExpiredAtFrom string `json:"expired_at_from"` - ExpiredAtTo string `json:"expired_at_to"` + Page int `json:"page" d:"1" v:"min:1" dc:"页码"` + PageSize int `json:"page_size" d:"20" v:"min:1|max:100" dc:"每页条数"` + Keyword string `json:"keyword" dc:"搜索关键词"` + Phone string `json:"phone" dc:"手机号"` + Province string `json:"province" dc:"省份"` + Region string `json:"region" dc:"地区"` + ExpiredAtFrom string `json:"expired_at_from" dc:"过期时间(起)"` + ExpiredAtTo string `json:"expired_at_to" dc:"过期时间(止)"` } type ListAgentItem struct { @@ -45,7 +45,7 @@ type ListAgentRes struct { type UpdateAgentReq struct { g.Meta `path:"/update" method:"post" tags:"短剧管理" summary:"更新代理商"` Id int64 `v:"required" json:"id"` - Phone string `json:"phone"` + Phone string `json:"phone" v:"phone" dc:"手机号"` Name string `v:"required" json:"name"` Province string `json:"province"` Region string `v:"required" json:"region"` diff --git a/shortdrama/model/dto/background_music_dto.go b/shortdrama/model/dto/background_music_dto.go index 1e67ddc..969c850 100644 --- a/shortdrama/model/dto/background_music_dto.go +++ b/shortdrama/model/dto/background_music_dto.go @@ -9,7 +9,7 @@ import ( type ListBackgroundMusicReq struct { g.Meta `path:"/list" method:"get" tags:"短剧管理" summary:"背景音列表"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` } type ListBackgroundMusicRes struct { @@ -18,21 +18,21 @@ type ListBackgroundMusicRes struct { type AddBackgroundMusicReq struct { g.Meta `path:"/add" method:"post" tags:"短剧管理" summary:"添加背景音"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - Name string `v:"required" json:"name" dc:"背景音名称"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + Name string `v:"required|min-length:1|max-length:100" json:"name" dc:"背景音名称"` AudioFile *ghttp.UploadFile `json:"audioFile" dc:"背景音文件"` } type UpdateBackgroundMusicReq struct { g.Meta `path:"/update" method:"post" tags:"短剧管理" summary:"更新背景音"` - Id int64 `json:"id" dc:"背景音ID"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - Name string `v:"required" json:"name" dc:"背景音名称"` + Id int64 `v:"required" json:"id" dc:"背景音ID"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + Name string `v:"required|min-length:1|max-length:100" json:"name" dc:"背景音名称"` AudioFile *ghttp.UploadFile `json:"audioFile" dc:"背景音文件"` } type DeleteBackgroundMusicReq struct { g.Meta `path:"/delete" method:"post" tags:"短剧管理" summary:"删除背景音"` - Id int64 `json:"id" dc:"背景音ID"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` + Id int64 `v:"required" json:"id" dc:"背景音ID"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` } diff --git a/shortdrama/model/dto/character_dto.go b/shortdrama/model/dto/character_dto.go index 5d2d438..86db622 100644 --- a/shortdrama/model/dto/character_dto.go +++ b/shortdrama/model/dto/character_dto.go @@ -9,7 +9,7 @@ import ( type ListCharacterReq struct { g.Meta `path:"/list" method:"get" tags:"短剧管理" summary:"演员列表"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` } type ListCharacterRes struct { @@ -18,25 +18,25 @@ type ListCharacterRes struct { type AddCharacterReq struct { g.Meta `path:"/add" method:"post" tags:"短剧管理" summary:"添加演员"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - Name string `v:"required" json:"name" dc:"演员名称"` - Description string `v:"required" json:"description" dc:"演员描述"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + Name string `v:"required|min-length:1|max-length:100" json:"name" dc:"演员名称"` + Description string `v:"required|min-length:1|max-length:2000" json:"description" dc:"演员描述"` VoiceFile *ghttp.UploadFile `json:"voiceFile" dc:"声音文件"` PortraitFile *ghttp.UploadFile `json:"portraitFile" dc:"形象文件"` } type UpdateCharacterReq struct { g.Meta `path:"/update" method:"post" tags:"短剧管理" summary:"更新演员"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - CharId int64 `json:"charId" dc:"演员ID"` - Name string `v:"required" json:"name" dc:"演员名称"` - Description string `v:"required" json:"description" dc:"演员描述"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + CharId int64 `v:"required" json:"charId" dc:"演员ID"` + Name string `v:"required|min-length:1|max-length:100" json:"name" dc:"演员名称"` + Description string `v:"required|min-length:1|max-length:2000" json:"description" dc:"演员描述"` VoiceFile *ghttp.UploadFile `json:"voiceFile" dc:"声音文件"` PortraitFile *ghttp.UploadFile `json:"portraitFile" dc:"形象文件"` } type DeleteCharacterReq struct { g.Meta `path:"/delete" method:"post" tags:"短剧管理" summary:"删除演员"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - CharId int64 `json:"charId" dc:"演员ID"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + CharId int64 `v:"required" json:"charId" dc:"演员ID"` } diff --git a/shortdrama/model/dto/customer_profile_dto.go b/shortdrama/model/dto/customer_profile_dto.go index f21108d..a21662e 100644 --- a/shortdrama/model/dto/customer_profile_dto.go +++ b/shortdrama/model/dto/customer_profile_dto.go @@ -8,22 +8,22 @@ import ( type CreateCustomerReq struct { g.Meta `path:"/create" method:"post" tags:"短剧管理" summary:"创建客户"` - Phone string `v:"required" json:"phone"` - Name string `v:"required" json:"name"` - Address string `v:"required" json:"address"` - AgentId int64 `json:"agent_id"` + Phone string `v:"required|phone" json:"phone"` + Name string `v:"required|min-length:1|max-length:100" json:"name"` + Address string `v:"required|min-length:1|max-length:500" json:"address"` + AgentId int64 `v:"required" json:"agent_id"` } type ListCustomerReq struct { g.Meta `path:"/list" method:"get" tags:"短剧管理" summary:"客户列表"` - AgentId int64 `json:"agent_id"` - Page int `json:"page"` - PageSize int `json:"page_size"` - Keyword string `json:"keyword"` - Phone string `json:"phone"` - Province string `json:"province"` - Region string `json:"region"` - AgentName string `json:"agent_name"` // 仅admin按代理商搜索 + AgentId int64 `json:"agent_id" dc:"代理商ID"` + Page int `json:"page" d:"1" v:"min:1" dc:"页码"` + PageSize int `json:"page_size" d:"20" v:"min:1|max:100" dc:"每页条数"` + Keyword string `json:"keyword" dc:"搜索关键词"` + Phone string `json:"phone" dc:"手机号"` + Province string `json:"province" dc:"省份"` + Region string `json:"region" dc:"地区"` + AgentName string `json:"agent_name" dc:"代理商名称"` } type ListCustomerItem struct { @@ -55,7 +55,7 @@ type CustomerDetail struct { type UpdateCustomerReq struct { g.Meta `path:"/update" method:"post" tags:"短剧管理" summary:"更新客户"` Id int64 `v:"required" json:"id"` - Phone string `json:"phone"` - Name string `json:"name"` - Address string `json:"address"` + Phone string `json:"phone" v:"phone" dc:"手机号"` + Name string `json:"name" dc:"客户名称"` + Address string `json:"address" dc:"地址"` } diff --git a/shortdrama/model/dto/drama_dto.go b/shortdrama/model/dto/drama_dto.go index 88ba6f6..14930b9 100644 --- a/shortdrama/model/dto/drama_dto.go +++ b/shortdrama/model/dto/drama_dto.go @@ -11,8 +11,8 @@ import ( type ListDramaReq struct { g.Meta `path:"/list" method:"get" tags:"短剧管理" summary:"短剧列表"` - Page int `json:"page" dc:"页码,从1开始"` - PageSize int `json:"pageSize" dc:"每页条数"` + Page int `json:"page" d:"1" v:"min:1" dc:"页码,从1开始"` + PageSize int `json:"pageSize" d:"20" v:"min:1|max:100" dc:"每页条数"` Keyword string `json:"keyword" dc:"搜索关键词"` } @@ -27,9 +27,9 @@ type ListDramaRes struct { type ListEpisodeReq struct { g.Meta `path:"/list" method:"get" tags:"短剧管理" summary:"剧集列表"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - Page int `json:"page" dc:"页码,从1开始"` - PageSize int `json:"pageSize" dc:"每页条数"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + Page int `json:"page" d:"1" v:"min:1" dc:"页码,从1开始"` + PageSize int `json:"pageSize" d:"20" v:"min:1|max:100" dc:"每页条数"` Keyword string `json:"keyword" dc:"搜索关键词"` } @@ -44,12 +44,12 @@ type ListEpisodeRes struct { type CreateDramaReq struct { g.Meta `path:"/create" method:"post" tags:"短剧管理" summary:"创建短剧"` - Title string `v:"required" json:"title" dc:"标题"` - Type string `v:"required" json:"type" dc:"内容类型(短剧/漫剧/广告视频)"` + Title string `v:"required|min-length:1|max-length:200" json:"title" dc:"标题"` + Type string `v:"required|in:short_drama,comic_drama,ad_video" json:"type" dc:"内容类型(short_drama/comic_drama/ad_video)"` Config string `json:"config" dc:"类型专属配置(JSON格式)"` AspectRatio string `json:"aspectRatio" dc:"画面比例"` - Resolution string `json:"resolution" dc:"分辨率(720P/1080P)"` - EpisodeDuration int `json:"episodeDuration" dc:"时长(秒)"` + Resolution string `json:"resolution" v:"in:720P,1080P" dc:"分辨率(720P/1080P)"` + EpisodeDuration int `json:"episodeDuration" v:"min:1|max:3600" dc:"时长(秒)"` } type CreateDramaRes struct { @@ -60,7 +60,7 @@ type CreateDramaRes struct { type GetDramaReq struct { g.Meta `path:"/get" method:"get" tags:"短剧管理" summary:"获取短剧详情"` - Id int64 `json:"id" dc:"短剧ID"` + Id int64 `v:"required" json:"id" dc:"短剧ID"` } // GetDramaRes 包含短剧及其关联的演员、剧集、场景、道具、背景音和生成任务 @@ -77,20 +77,20 @@ type GetDramaRes struct { // ==================== Update ==================== type UpdateDramaReq struct { - Id int64 `json:"id" dc:"短剧ID"` - Title string `v:"required" json:"title" dc:"标题"` - Type string `json:"type" dc:"内容类型"` + Id int64 `v:"required" json:"id" dc:"短剧ID"` + Title string `v:"required|min-length:1|max-length:200" json:"title" dc:"标题"` + Type string `json:"type" v:"in:short_drama,comic_drama,ad_video" dc:"内容类型"` Config string `json:"config" dc:"类型专属配置(JSON格式)"` AspectRatio string `json:"aspectRatio" dc:"画面比例"` - Resolution string `json:"resolution" dc:"分辨率(720P/1080P)"` - EpisodeDuration int `json:"episodeDuration" dc:"时长(秒)"` + Resolution string `json:"resolution" v:"in:720P,1080P" dc:"分辨率(720P/1080P)"` + EpisodeDuration int `json:"episodeDuration" v:"min:1|max:3600" dc:"时长(秒)"` } // ==================== Delete ==================== type DeleteDramaReq struct { g.Meta `path:"/delete" method:"post" tags:"短剧管理" summary:"删除短剧"` - Id int64 `json:"id" dc:"短剧ID"` + Id int64 `v:"required" json:"id" dc:"短剧ID"` } // ==================== 字段定义 ==================== diff --git a/shortdrama/model/dto/episode_dto.go b/shortdrama/model/dto/episode_dto.go index bcaa46e..a8c1b73 100644 --- a/shortdrama/model/dto/episode_dto.go +++ b/shortdrama/model/dto/episode_dto.go @@ -8,39 +8,39 @@ import ( type AddEpisodeReq struct { g.Meta `path:"/add" method:"post" tags:"短剧管理" summary:"添加剧集"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - Title string `v:"required" json:"title" dc:"剧集标题"` - Description string `v:"required" json:"description" dc:"剧情描述"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + Title string `v:"required|min-length:1|max-length:200" json:"title" dc:"剧集标题"` + Description string `v:"required|min-length:1|max-length:5000" json:"description" dc:"剧情描述"` Script string `v:"required" json:"script" dc:"剧集脚本"` - Index int `json:"index" dc:"剧集序号"` + Index int `json:"index" v:"min:0" dc:"剧集序号"` } type UpdateEpisodeReq struct { g.Meta `path:"/update" method:"post" tags:"短剧管理" summary:"更新剧集"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - EpId int64 `json:"epId" dc:"剧集ID"` - Title string `v:"required" json:"title" dc:"剧集标题"` - Description string `v:"required" json:"description" dc:"剧情描述"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + EpId int64 `v:"required" json:"epId" dc:"剧集ID"` + Title string `v:"required|min-length:1|max-length:200" json:"title" dc:"剧集标题"` + Description string `v:"required|min-length:1|max-length:5000" json:"description" dc:"剧情描述"` Script string `v:"required" json:"script" dc:"剧集脚本"` - Index int `json:"index" dc:"剧集序号"` + Index int `json:"index" v:"min:0" dc:"剧集序号"` } type DeleteEpisodeReq struct { g.Meta `path:"/delete" method:"post" tags:"短剧管理" summary:"删除剧集"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - EpId int64 `json:"epId" dc:"剧集ID"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + EpId int64 `v:"required" json:"epId" dc:"剧集ID"` } type GenerateEpisodeReq struct { g.Meta `path:"/generate" method:"post" tags:"短剧管理" summary:"单集生成视频"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - EpId int64 `json:"epId" dc:"剧集ID"` - Mode string `json:"mode" dc:"生成模式(parallel/serial)"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + EpId int64 `v:"required" json:"epId" dc:"剧集ID"` + Mode string `json:"mode" v:"in:parallel,serial" dc:"生成模式(parallel/serial)"` } type EpisodePollReq struct { g.Meta `path:"/poll" method:"get" tags:"短剧管理" summary:"轮询剧集生成状态"` - EpId int64 `json:"epId" dc:"剧集ID"` + EpId int64 `v:"required" json:"epId" dc:"剧集ID"` } type EpisodePollRes struct { @@ -52,9 +52,9 @@ type EpisodePollRes struct { type GenerateScriptReq struct { g.Meta `path:"/generate-script" method:"post" tags:"短剧管理" summary:"生成剧集脚本"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - Title string `v:"required" json:"title" dc:"剧集标题"` - Description string `v:"required" json:"description" dc:"剧情描述"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + Title string `v:"required|min-length:1|max-length:200" json:"title" dc:"剧集标题"` + Description string `v:"required|min-length:1|max-length:5000" json:"description" dc:"剧情描述"` } type GenerateScriptRes struct { diff --git a/shortdrama/model/dto/generation_task_dto.go b/shortdrama/model/dto/generation_task_dto.go index 1450c15..6b909d4 100644 --- a/shortdrama/model/dto/generation_task_dto.go +++ b/shortdrama/model/dto/generation_task_dto.go @@ -9,20 +9,20 @@ import ( // ContinueSegmentReq 继续下一段 type ContinueSegmentReq struct { g.Meta `path:"/segment/continue" method:"post" tags:"短剧生成" summary:"确认当前段并继续下一段"` - TaskId int64 `json:"taskId" dc:"任务ID"` + TaskId int64 `v:"required" json:"taskId" dc:"任务ID"` } // FeedbackSegmentReq 反馈重新生成 type FeedbackSegmentReq struct { g.Meta `path:"/segment/feedback" method:"post" tags:"短剧生成" summary:"为当前段提供反馈并重新生成"` - TaskId int64 `json:"taskId" dc:"任务ID"` - Feedback string `json:"feedback" dc:"反馈内容"` + TaskId int64 `v:"required" json:"taskId" dc:"任务ID"` + Feedback string `v:"max-length:2000" json:"feedback" dc:"反馈内容"` } // GetEpisodeTaskReq 获取剧集当前任务 type GetEpisodeTaskReq struct { g.Meta `path:"/episode/task" method:"get" tags:"短剧生成" summary:"获取某集当前生成任务"` - EpId int64 `json:"epId" dc:"剧集ID"` + EpId int64 `v:"required" json:"epId" dc:"剧集ID"` } // GetEpisodeTaskRes 获取某集中所有段任务的响应 diff --git a/shortdrama/model/dto/model_config_dto.go b/shortdrama/model/dto/model_config_dto.go index 3090a83..ec3a1f4 100644 --- a/shortdrama/model/dto/model_config_dto.go +++ b/shortdrama/model/dto/model_config_dto.go @@ -10,9 +10,9 @@ import ( // GetModelConfigListReq 获取模型配置列表(分页) type GetModelConfigListReq struct { g.Meta `path:"/get-model-list" method:"get" tags:"模型配置" summary:"获取模型配置列表"` - Page int `json:"page" dc:"页码,默认1"` - PageSize int `json:"pageSize" dc:"每页条数,默认20"` - ModelType string `json:"modelType" dc:"模型类型筛选: chat/video"` + Page int `json:"page" d:"1" v:"min:1" dc:"页码,默认1"` + PageSize int `json:"pageSize" d:"20" v:"min:1|max:100" dc:"每页条数,默认20"` + ModelType string `json:"modelType" v:"in:chat,video" dc:"模型类型筛选: chat/video"` Keyword string `json:"keyword" dc:"模型名称关键词"` } @@ -25,12 +25,12 @@ type GetModelConfigListRes struct { type SaveModelConfigReq struct { g.Meta `path:"/save-model-config" method:"post" tags:"模型配置" summary:"保存模型配置"` Id int64 `json:"id"` - ModelType string `json:"modelType" v:"required" dc:"chat=对话模型 video=视频模型"` - ModelName string `json:"modelName" v:"required" dc:"模型名称"` + ModelType string `json:"modelType" v:"required|in:chat,video" dc:"chat=对话模型 video=视频模型"` + ModelName string `json:"modelName" v:"required|min-length:1|max-length:200" dc:"模型名称"` Schema *gjson.Json `json:"schema" dc:"请求体JSON Schema"` FirstFrameMapping string `json:"firstFrameMapping" dc:"首帧映射字段名,如 input.media"` ReferenceTemplate string `json:"referenceTemplate" dc:"参考图引用模板,如 {\"type\":\"image\",\"url\":\"%s\"}"` - Price int `json:"price" dc:"价格(分)"` - PriceUnit string `json:"priceUnit" dc:"价格单位(second=每秒/video=每次视频)"` - ConcurrencyCount int `json:"concurrencyCount" dc:"并发处理数(同一模型最多同时处理的请求数量)"` + Price int `json:"price" v:"min:0" dc:"价格(分)"` + PriceUnit string `json:"priceUnit" v:"in:second,video" dc:"价格单位(second=每秒/video=每次视频)"` + ConcurrencyCount int `json:"concurrencyCount" v:"min:1" dc:"并发处理数(同一模型最多同时处理的请求数量)"` } diff --git a/shortdrama/model/dto/payment_channel_trade_dto.go b/shortdrama/model/dto/payment_channel_trade_dto.go index 6fdbe69..4d0fcaf 100644 --- a/shortdrama/model/dto/payment_channel_trade_dto.go +++ b/shortdrama/model/dto/payment_channel_trade_dto.go @@ -4,5 +4,5 @@ import "github.com/gogf/gf/v2/frame/g" type ListChannelTradeReq struct { g.Meta `path:"/list-by-order" method:"get" tags:"短剧管理" summary:"渠道交易记录"` - OrderId int64 `json:"order_id"` + OrderId int64 `v:"required" json:"order_id"` } diff --git a/shortdrama/model/dto/payment_config_dto.go b/shortdrama/model/dto/payment_config_dto.go index 246183c..48fb3aa 100644 --- a/shortdrama/model/dto/payment_config_dto.go +++ b/shortdrama/model/dto/payment_config_dto.go @@ -26,12 +26,12 @@ type GetPaymentConfigRes struct { type SavePaymentConfigReq struct { g.Meta `path:"/save" method:"post" tags:"支付配置" summary:"保存支付配置"` - Channel string `json:"channel" v:"required" dc:"支付渠道: wechat/alipay/offline"` - ChannelType string `json:"channelType" dc:"支付类型: jsapi/h5/app/native/manual"` - AppId string `json:"appId"` - MchId string `json:"mchId"` - ApiKey string `json:"apiKey"` - AppSecret string `json:"appSecret"` - PrivateKey string `json:"privateKey"` - PublicKey string `json:"publicKey"` + Channel string `json:"channel" v:"required|in:wechat,alipay,offline" dc:"支付渠道: wechat/alipay/offline"` + ChannelType string `json:"channelType" v:"required|in:jsapi,h5,app,native,manual" dc:"支付类型: jsapi/h5/app/native/manual"` + AppId string `json:"appId" dc:"应用ID"` + MchId string `json:"mchId" dc:"商户ID"` + ApiKey string `json:"apiKey" dc:"API密钥"` + AppSecret string `json:"appSecret" dc:"应用密钥"` + PrivateKey string `json:"privateKey" dc:"私钥"` + PublicKey string `json:"publicKey" dc:"公钥"` } diff --git a/shortdrama/model/dto/payment_order_dto.go b/shortdrama/model/dto/payment_order_dto.go index 25982f8..78f5fbb 100644 --- a/shortdrama/model/dto/payment_order_dto.go +++ b/shortdrama/model/dto/payment_order_dto.go @@ -1,13 +1,17 @@ package dto -import "github.com/gogf/gf/v2/frame/g" +import ( + "video-factory/shortdrama/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) type PrepayReq struct { g.Meta `path:"/prepay" method:"post" tags:"短剧管理" summary:"预支付"` Amount int64 `v:"required|min:1" json:"amount"` Channel string `v:"required|in:wechat,alipay,offline" json:"channel"` - OrderType string `json:"order_type" dc:"recharge / renewal"` - Duration int `json:"duration" dc:"续费年数(renewal时必填)"` + OrderType string `json:"order_type" v:"in:recharge,renewal" dc:"recharge / renewal"` + Duration int `json:"duration" v:"min:1" dc:"续费年数(renewal时必填)"` } type PrepayRes struct { @@ -20,7 +24,7 @@ type PrepayRes struct { type PaymentStatusReq struct { g.Meta `path:"/status" method:"get" tags:"短剧管理" summary:"支付状态查询"` - OrderNo string `json:"order_no"` + OrderNo string `v:"required" json:"order_no"` } type PaymentStatusRes struct { @@ -51,3 +55,14 @@ type CreateRenewOrderRes struct { Amount int64 `json:"amount"` Status string `json:"status"` } + +type ListPaymentOrderReq struct { + g.Meta `path:"/list" method:"get" tags:"短剧管理" summary:"支付订单列表"` + Page int `json:"page" dc:"页码"` + PageSize int `json:"pageSize" dc:"每页数量"` +} + +type ListPaymentOrderRes struct { + List []*entity.PaymentOrder `json:"list"` + Total int `json:"total"` +} diff --git a/shortdrama/model/dto/prop_dto.go b/shortdrama/model/dto/prop_dto.go index 90b1000..c06d780 100644 --- a/shortdrama/model/dto/prop_dto.go +++ b/shortdrama/model/dto/prop_dto.go @@ -9,7 +9,7 @@ import ( type ListPropReq struct { g.Meta `path:"/list" method:"get" tags:"短剧管理" summary:"道具列表"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` } type ListPropRes struct { @@ -18,23 +18,23 @@ type ListPropRes struct { type AddPropReq struct { g.Meta `path:"/add" method:"post" tags:"短剧管理" summary:"添加道具"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - Name string `v:"required" json:"name" dc:"道具名称"` - Description string `v:"required" json:"description" dc:"道具描述"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + Name string `v:"required|min-length:1|max-length:100" json:"name" dc:"道具名称"` + Description string `v:"required|min-length:1|max-length:2000" json:"description" dc:"道具描述"` ImageFile *ghttp.UploadFile `json:"imageFile" dc:"道具图片"` } type UpdatePropReq struct { g.Meta `path:"/update" method:"post" tags:"短剧管理" summary:"更新道具"` - Id int64 `json:"id" dc:"道具ID"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - Name string `v:"required" json:"name" dc:"道具名称"` - Description string `v:"required" json:"description" dc:"道具描述"` + Id int64 `v:"required" json:"id" dc:"道具ID"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + Name string `v:"required|min-length:1|max-length:100" json:"name" dc:"道具名称"` + Description string `v:"required|min-length:1|max-length:2000" json:"description" dc:"道具描述"` ImageFile *ghttp.UploadFile `json:"imageFile" dc:"道具图片"` } type DeletePropReq struct { g.Meta `path:"/delete" method:"post" tags:"短剧管理" summary:"删除道具"` - Id int64 `json:"id" dc:"道具ID"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` + Id int64 `v:"required" json:"id" dc:"道具ID"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` } diff --git a/shortdrama/model/dto/region_pricing_dto.go b/shortdrama/model/dto/region_pricing_dto.go index aa2137c..27edcf8 100644 --- a/shortdrama/model/dto/region_pricing_dto.go +++ b/shortdrama/model/dto/region_pricing_dto.go @@ -8,10 +8,10 @@ import ( type ListRegionPricingReq struct { g.Meta `path:"/list" method:"get" tags:"短剧管理" summary:"地区定价列表"` - Page int `json:"page" dc:"页码,默认1"` - PageSize int `json:"pageSize" dc:"每页条数,默认20"` + Page int `json:"page" d:"1" v:"min:1" dc:"页码,默认1"` + PageSize int `json:"pageSize" d:"20" v:"min:1|max:100" dc:"每页条数,默认20"` Keyword string `json:"keyword" dc:"地区名称关键词"` - Protected int `json:"protected" d:"-1" dc:"类型筛选: -1=全部 0=普通 1=受保护"` + Protected int `json:"protected" d:"-1" v:"in:-1,0,1" dc:"类型筛选: -1=全部 0=普通 1=受保护"` } type ListRegionPricingRes struct { @@ -22,11 +22,11 @@ type ListRegionPricingRes struct { type SaveRegionPricingReq struct { g.Meta `path:"/save" method:"post" tags:"短剧管理" summary:"保存地区定价"` Id int64 `json:"id"` - Province string `v:"required" json:"province"` - Region string `v:"required" json:"region"` - Protected int `json:"protected"` + Province string `v:"required|min-length:1|max-length:100" json:"province"` + Region string `v:"required|min-length:1|max-length:100" json:"region"` + Protected int `json:"protected" v:"in:0,1"` Price int64 `v:"required|min:0" json:"price"` - MaxCustomers int `json:"max_customers"` + MaxCustomers int `json:"max_customers" v:"min:0"` } type ListRegionsRes struct { diff --git a/shortdrama/model/dto/scene_dto.go b/shortdrama/model/dto/scene_dto.go index c4f305b..080381b 100644 --- a/shortdrama/model/dto/scene_dto.go +++ b/shortdrama/model/dto/scene_dto.go @@ -9,7 +9,7 @@ import ( type ListSceneReq struct { g.Meta `path:"/list" method:"get" tags:"短剧管理" summary:"场景列表"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` } type ListSceneRes struct { @@ -18,23 +18,23 @@ type ListSceneRes struct { type AddSceneReq struct { g.Meta `path:"/add" method:"post" tags:"短剧管理" summary:"添加场景"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - Name string `v:"required" json:"name" dc:"场景名称"` - Description string `v:"required" json:"description" dc:"场景描述"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + Name string `v:"required|min-length:1|max-length:100" json:"name" dc:"场景名称"` + Description string `v:"required|min-length:1|max-length:2000" json:"description" dc:"场景描述"` ImageFile *ghttp.UploadFile `json:"imageFile" dc:"场景图片"` } type UpdateSceneReq struct { g.Meta `path:"/update" method:"post" tags:"短剧管理" summary:"更新场景"` - Id int64 `json:"id" dc:"场景ID"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` - Name string `v:"required" json:"name" dc:"场景名称"` - Description string `v:"required" json:"description" dc:"场景描述"` + Id int64 `v:"required" json:"id" dc:"场景ID"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` + Name string `v:"required|min-length:1|max-length:100" json:"name" dc:"场景名称"` + Description string `v:"required|min-length:1|max-length:2000" json:"description" dc:"场景描述"` ImageFile *ghttp.UploadFile `json:"imageFile" dc:"场景图片"` } type DeleteSceneReq struct { g.Meta `path:"/delete" method:"post" tags:"短剧管理" summary:"删除场景"` - Id int64 `json:"id" dc:"场景ID"` - DramaId int64 `json:"dramaId" dc:"短剧ID"` + Id int64 `v:"required" json:"id" dc:"场景ID"` + DramaId int64 `v:"required" json:"dramaId" dc:"短剧ID"` } diff --git a/shortdrama/model/dto/user_dto.go b/shortdrama/model/dto/user_dto.go index e2298e6..947fe70 100644 --- a/shortdrama/model/dto/user_dto.go +++ b/shortdrama/model/dto/user_dto.go @@ -26,6 +26,6 @@ type LoginUser struct { type ChangePasswordReq struct { g.Meta `path:"/change-password" method:"post" tags:"短剧管理" summary:"修改密码"` - OldPassword string `json:"old_password"` - NewPassword string `json:"new_password"` + OldPassword string `v:"required" json:"old_password"` + NewPassword string `v:"required|min-length:6" json:"new_password"` } diff --git a/shortdrama/model/dto/user_model_config_dto.go b/shortdrama/model/dto/user_model_config_dto.go index 8922c43..7681dbc 100644 --- a/shortdrama/model/dto/user_model_config_dto.go +++ b/shortdrama/model/dto/user_model_config_dto.go @@ -8,7 +8,7 @@ import ( type GetUserModelConfigReq struct { g.Meta `path:"/get-user-config" method:"get" tags:"模型配置" summary:"获取用户模型配置"` - ModelType string `json:"modelType" dc:"模型类型(chat/video),为空时返回首个活跃配置"` + ModelType string `json:"modelType" v:"in:chat,video" dc:"模型类型(chat/video),为空时返回首个活跃配置"` } type GetUserModelConfigRes struct { @@ -17,27 +17,27 @@ type GetUserModelConfigRes struct { // SaveUserModelConfigItem 单条用户模型配置 type SaveUserModelConfigItem struct { - ModelConfigId int64 `json:"modelConfigId" dc:"关联模型配置ID"` - ApiKey string `json:"apiKey" dc:"用户API密钥"` - EndpointUrl string `json:"endpointUrl" dc:"端点地址(基础域名),如 https://api.example.com"` - RequestPath string `json:"requestPath" dc:"接口地址(请求路径),如 /v1/chat/completions"` - CallbackPath string `json:"callbackPath" dc:"回调接口地址(回调路径,仅视频模型)"` - Temperature float64 `json:"temperature" dc:"温度参数"` - MaxTokens int `json:"maxTokens" dc:"最大Token数(不超过系统配置)"` + ModelConfigId int64 `json:"modelConfigId" v:"required" dc:"关联模型配置ID"` + ApiKey string `json:"apiKey" v:"required|min-length:1|max-length:500" dc:"用户API密钥"` + EndpointUrl string `json:"endpointUrl" v:"required|max-length:500" dc:"端点地址(基础域名),如 https://api.example.com"` + RequestPath string `json:"requestPath" v:"required|max-length:500" dc:"接口地址(请求路径),如 /v1/chat/completions"` + CallbackPath string `json:"callbackPath" v:"max-length:500" dc:"回调接口地址(回调路径,仅视频模型)"` + Temperature float64 `json:"temperature" v:"min:0|max:2" dc:"温度参数"` + MaxTokens int `json:"maxTokens" v:"min:1|max:1000000" dc:"最大Token数(不超过系统配置)"` ConcurrencyCount int `json:"concurrencyCount" v:"min:1" dc:"并发处理数(必须大于0)"` } type SaveUserModelConfigReq struct { g.Meta `path:"/save-user-config" method:"post" tags:"模型配置" summary:"批量保存用户模型配置"` - Configs []*SaveUserModelConfigItem `json:"configs" dc:"模型配置列表"` + Configs []*SaveUserModelConfigItem `json:"configs" v:"required|min-length:1" dc:"模型配置列表"` } // GetUserModelListReq 获取用户模型配置列表(分页) type GetUserModelListReq struct { g.Meta `path:"/get-user-model-list" method:"get" tags:"模型配置" summary:"获取系统模型列表及用户配置状态"` - Page int `json:"page" dc:"页码,默认1"` - PageSize int `json:"pageSize" dc:"每页条数,默认20"` - ModelType string `json:"modelType" dc:"模型类型筛选: chat/video"` + Page int `json:"page" d:"1" v:"min:1" dc:"页码,默认1"` + PageSize int `json:"pageSize" d:"20" v:"min:1|max:100" dc:"每页条数,默认20"` + ModelType string `json:"modelType" v:"in:chat,video" dc:"模型类型筛选: chat/video"` Keyword string `json:"keyword" dc:"模型名称关键词"` } diff --git a/shortdrama/model/entity/character.go b/shortdrama/model/entity/character.go index 2f5b445..c4b14fd 100644 --- a/shortdrama/model/entity/character.go +++ b/shortdrama/model/entity/character.go @@ -3,10 +3,10 @@ package entity import "github.com/gogf/gf/v2/os/gtime" type Character struct { - Id int64 `orm:"id" json:"id" dc:"演员ID" json:"id" json:"id" dc:"演员ID"` + Id int64 `orm:"id" json:"id" dc:"演员ID" json:"id" dc:"演员ID"` DramaId int64 `orm:"drama_id" json:"dramaId" dc:"短剧ID" json:"drama_id" json:"dramaId" dc:"短剧ID"` - Name string `orm:"name" json:"name" dc:"演员名称" json:"name" json:"name" dc:"演员名称"` - Description string `orm:"description" json:"description" dc:"演员描述" json:"description" json:"description" dc:"演员描述"` + Name string `orm:"name" json:"name" dc:"演员名称" json:"name" dc:"演员名称"` + Description string `orm:"description" json:"description" dc:"演员描述" json:"description" dc:"演员描述"` VoicePath string `orm:"voice_path" json:"voicePath" dc:"声音文件路径" json:"voice_path" json:"voicePath" dc:"声音文件路径"` PortraitPath string `orm:"portrait_path" json:"portraitPath" dc:"形象文件路径" json:"portrait_path" json:"portraitPath" dc:"形象文件路径"` CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间" json:"created_at" json:"createdAt" dc:"创建时间"` diff --git a/shortdrama/model/entity/drama.go b/shortdrama/model/entity/drama.go index 627c0b9..1793af5 100644 --- a/shortdrama/model/entity/drama.go +++ b/shortdrama/model/entity/drama.go @@ -3,16 +3,16 @@ package entity import "github.com/gogf/gf/v2/os/gtime" type Drama struct { - Id int64 `orm:"id" json:"id" dc:"项目ID" json:"id" json:"id" dc:"项目ID"` + Id int64 `orm:"id" json:"id" dc:"项目ID"` UserId int64 `orm:"user_id" json:"userId" dc:"所属用户ID"` - Title string `orm:"title" json:"title" dc:"标题" json:"title" json:"title" dc:"标题"` - Type string `orm:"type" json:"type" dc:"内容类型(短剧/漫剧/广告视频)" json:"type" json:"type" dc:"内容类型(短剧/漫剧/广告视频)"` - Config string `orm:"config" json:"config" dc:"类型专属配置(JSON格式)" json:"config" json:"config" dc:"类型专属配置(JSON格式)"` - AspectRatio string `orm:"aspect_ratio" json:"aspectRatio" dc:"画面比例" json:"aspect_ratio" json:"aspectRatio" dc:"画面比例"` - Resolution string `orm:"resolution" json:"resolution" dc:"分辨率(720P/1080P)" json:"resolution" json:"resolution" dc:"分辨率(720P/1080P)"` - EpisodeDuration int64 `orm:"episode_duration" json:"episodeDuration" dc:"时长(秒)" json:"episode_duration" json:"episodeDuration" dc:"时长(秒)"` - EpCount int `orm:"episode_count" json:"epCount" dc:"剧集数量" json:"episode_count" json:"epCount" dc:"剧集数量"` - CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间" json:"created_at" json:"createdAt" dc:"创建时间"` - UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间" json:"updated_at" json:"updatedAt" dc:"更新时间"` - DeletedAt *gtime.Time `orm:"deleted_at" json:"deletedAt" dc:"删除时间" json:"deleted_at" json:"deletedAt" dc:"删除时间"` + Title string `orm:"title" json:"title" dc:"标题"` + Type string `orm:"type" json:"type" dc:"内容类型(短剧/漫剧/广告视频)"` + Config string `orm:"config" json:"config" dc:"类型专属配置(JSON格式)"` + AspectRatio string `orm:"aspect_ratio" json:"aspectRatio" dc:"画面比例"` + Resolution string `orm:"resolution" json:"resolution" dc:"分辨率(720P/1080P)"` + EpisodeDuration int64 `orm:"episode_duration" json:"episodeDuration" dc:"时长(秒)"` + EpCount int `orm:"episode_count" json:"epCount" dc:"剧集数量"` + CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间"` + DeletedAt *gtime.Time `orm:"deleted_at" json:"deletedAt" dc:"删除时间"` } diff --git a/shortdrama/model/entity/episode.go b/shortdrama/model/entity/episode.go index 9e090aa..4aaab88 100644 --- a/shortdrama/model/entity/episode.go +++ b/shortdrama/model/entity/episode.go @@ -3,13 +3,13 @@ package entity import "github.com/gogf/gf/v2/os/gtime" type Episode struct { - Id int64 `orm:"id" json:"id" dc:"剧集ID" json:"id" json:"id" dc:"剧集ID"` + Id int64 `orm:"id" json:"id" dc:"剧集ID" json:"id" dc:"剧集ID"` DramaId int64 `orm:"drama_id" json:"dramaId" dc:"短剧ID" json:"drama_id" json:"dramaId" dc:"短剧ID"` Index int `orm:"idx" json:"index" dc:"剧集序号" json:"idx" json:"index" dc:"剧集序号"` - Title string `orm:"title" json:"title" dc:"剧集标题" json:"title" json:"title" dc:"剧集标题"` - Description string `orm:"description" json:"description" dc:"剧情描述" json:"description" json:"description" dc:"剧情描述"` - Script string `orm:"script" json:"script" dc:"剧集脚本" json:"script" json:"script" dc:"剧集脚本"` - Status string `orm:"status" json:"status" dc:"生成状态" json:"status" json:"status" dc:"生成状态"` + Title string `orm:"title" json:"title" dc:"剧集标题" json:"title" dc:"剧集标题"` + Description string `orm:"description" json:"description" dc:"剧情描述" json:"description" dc:"剧情描述"` + Script string `orm:"script" json:"script" dc:"剧集脚本" json:"script" dc:"剧集脚本"` + Status string `orm:"status" json:"status" dc:"生成状态" json:"status" dc:"生成状态"` VideoUrl string `orm:"video_url" json:"videoUrl" dc:"视频URL" json:"video_url" json:"videoUrl" dc:"视频URL"` CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间" json:"created_at" json:"createdAt" dc:"创建时间"` UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间" json:"updated_at" json:"updatedAt" dc:"更新时间"` diff --git a/shortdrama/model/entity/prop.go b/shortdrama/model/entity/prop.go index 6cb1edf..5a2a2ba 100644 --- a/shortdrama/model/entity/prop.go +++ b/shortdrama/model/entity/prop.go @@ -3,10 +3,10 @@ package entity import "github.com/gogf/gf/v2/os/gtime" type Prop struct { - Id int64 `orm:"id" json:"id" dc:"道具ID" json:"id" json:"id" dc:"道具ID"` + Id int64 `orm:"id" json:"id" dc:"道具ID" json:"id" dc:"道具ID"` DramaId int64 `orm:"drama_id" json:"dramaId" dc:"短剧ID" json:"drama_id" json:"dramaId" dc:"短剧ID"` - Name string `orm:"name" json:"name" dc:"道具名称" json:"name" json:"name" dc:"道具名称"` - Description string `orm:"description" json:"description" dc:"道具描述" json:"description" json:"description" dc:"道具描述"` + Name string `orm:"name" json:"name" dc:"道具名称" json:"name" dc:"道具名称"` + Description string `orm:"description" json:"description" dc:"道具描述" json:"description" dc:"道具描述"` ImagePath string `orm:"image_path" json:"imagePath" dc:"道具图片路径" json:"image_path" json:"imagePath" dc:"道具图片路径"` CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间" json:"created_at" json:"createdAt" dc:"创建时间"` UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间" json:"updated_at" json:"updatedAt" dc:"更新时间"` diff --git a/shortdrama/model/entity/scene.go b/shortdrama/model/entity/scene.go index bf0a4b7..ad8563f 100644 --- a/shortdrama/model/entity/scene.go +++ b/shortdrama/model/entity/scene.go @@ -3,10 +3,10 @@ package entity import "github.com/gogf/gf/v2/os/gtime" type Scene struct { - Id int64 `orm:"id" json:"id" dc:"场景ID" json:"id" json:"id" dc:"场景ID"` + Id int64 `orm:"id" json:"id" dc:"场景ID" json:"id" dc:"场景ID"` DramaId int64 `orm:"drama_id" json:"dramaId" dc:"短剧ID" json:"drama_id" json:"dramaId" dc:"短剧ID"` - Name string `orm:"name" json:"name" dc:"场景名称" json:"name" json:"name" dc:"场景名称"` - Description string `orm:"description" json:"description" dc:"场景描述" json:"description" json:"description" dc:"场景描述"` + Name string `orm:"name" json:"name" dc:"场景名称" json:"name" dc:"场景名称"` + Description string `orm:"description" json:"description" dc:"场景描述" json:"description" dc:"场景描述"` ImagePath string `orm:"image_path" json:"imagePath" dc:"场景图片路径" json:"image_path" json:"imagePath" dc:"场景图片路径"` CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间" json:"created_at" json:"createdAt" dc:"创建时间"` UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间" json:"updated_at" json:"updatedAt" dc:"更新时间"` diff --git a/shortdrama/model/entity/user.go b/shortdrama/model/entity/user.go index fec0916..fb0d941 100644 --- a/shortdrama/model/entity/user.go +++ b/shortdrama/model/entity/user.go @@ -7,7 +7,7 @@ type User struct { Role string `orm:"role" json:"role"` Username string `orm:"username" json:"username"` Phone string `orm:"phone" json:"phone"` - Password string `orm:"password" json:"password"` + Password string `orm:"password" json:"-"` Name string `orm:"name" json:"name"` Province string `orm:"province" json:"province"` Region string `orm:"region" json:"region"` diff --git a/shortdrama/service/agent_service.go b/shortdrama/service/agent_service.go index 2df54ea..f308174 100644 --- a/shortdrama/service/agent_service.go +++ b/shortdrama/service/agent_service.go @@ -43,7 +43,7 @@ func (s *agentService) ExtractRegionFromAddress(ctx context.Context, address str } } if province == "" { - return "", "", errors.New("无法从地址中识别出所在省份,请确认地址包含省/直辖市/自治区信息") + return "", "", errors.New("unable to identify province from address, ensure it contains province/municipality/autonomous region") } rest := strings.TrimSpace(strings.TrimPrefix(address, province)) @@ -74,7 +74,7 @@ func (s *agentService) ExtractRegionFromAddress(ctx context.Context, address str } } if region == "" { - return "", "", fmt.Errorf("无法从地址中识别出所在城市,请确认地址包含市/州/盟信息") + return "", "", fmt.Errorf("unable to identify city from address") } return province, region, nil } @@ -83,12 +83,12 @@ func (s *agentService) ExtractRegionFromAddress(ctx context.Context, address str func (s *agentService) CreateAgent(ctx context.Context, username, password, phone, name, province, region string, regionProtected bool) (*entity.User, error) { existing, _ := dao.User.GetByUsername(ctx, username) if existing != nil { - return nil, errors.New("用户名已存在") + return nil, errors.New("username already exists") } if phone != "" { existing, _ = dao.User.GetByPhone(ctx, phone) if existing != nil { - return nil, errors.New("手机号已被使用") + return nil, errors.New("phone already in use") } } @@ -108,12 +108,12 @@ func (s *agentService) CreateAgent(ctx context.Context, username, password, phon if regionProtected { if len(activeAgents) > 0 { - return nil, errors.New("该地区已有未到期代理商,无法添加受地区保护的代理商") + return nil, errors.New("active agent already exists in this region") } } else { for _, a := range activeAgents { if a.RegionProtected == 1 { - return nil, errors.New("该地区已有未到期受保护代理商,无法添加普通代理商") + return nil, errors.New("protected agent already exists in this region") } } } @@ -125,7 +125,7 @@ func (s *agentService) CreateAgent(ctx context.Context, username, password, phon } pricing, _ := dao.RegionPricing.GetByRegion(ctx, region, protectedVal) if pricing == nil { - return nil, errors.New("该地区未配置价格,请先在系统配置中设置地区定价") + return nil, errors.New("region pricing not configured") } hash, _ := bcryptGenerate(password) @@ -162,7 +162,7 @@ func (s *agentService) CreateAgent(ctx context.Context, username, password, phon func (s *agentService) RenewAgent(ctx context.Context, agentId int64, duration ...int) error { agent, err := dao.User.GetOne(ctx, agentId) if err != nil || agent == nil || agent.Role != "agent" { - return errors.New("代理商不存在") + return errors.New("agent not found") } years := 1 @@ -188,7 +188,7 @@ func (s *agentService) RenewAgentByOrder(ctx context.Context, agentId int64, raw Duration int `json:"duration"` } if err := json.Unmarshal([]byte(raw), ¶ms); err != nil { - return errors.New("续费参数异常") + return errors.New("invalid renewal parameters") } return s.RenewAgent(ctx, agentId, params.Duration) } diff --git a/shortdrama/service/background_music_service.go b/shortdrama/service/background_music_service.go index f3f7d83..ea8e9aa 100644 --- a/shortdrama/service/background_music_service.go +++ b/shortdrama/service/background_music_service.go @@ -51,7 +51,7 @@ func (s *bgmService) UpdateBackgroundMusic(ctx context.Context, id, dramaId int6 return err } if m == nil { - return fmt.Errorf("背景音不存在: %d", id) + return fmt.Errorf("background music not found: %d", id) } if name != "" { m.Name = name diff --git a/shortdrama/service/character_service.go b/shortdrama/service/character_service.go index 4e34eab..fdf9a77 100644 --- a/shortdrama/service/character_service.go +++ b/shortdrama/service/character_service.go @@ -52,7 +52,7 @@ func (s *characterService) UpdateCharacter(ctx context.Context, dramaId, charId return err } if c == nil { - return fmt.Errorf("演员不存在: %d", charId) + return fmt.Errorf("character not found: %d", charId) } if name != "" { c.Name = name @@ -95,12 +95,12 @@ func (s *characterService) SaveCharacterFile(ctx context.Context, dramaId int64, return "", err } if d == nil { - return "", fmt.Errorf("短剧不存在: %d", dramaId) + return "", fmt.Errorf("drama not found: %d", dramaId) } workspaceDir := WorkspaceDir(d.Title) subPath := filepath.Join(workspaceDir, subDir) if err := os.MkdirAll(subPath, 0755); err != nil { - return "", fmt.Errorf("创建目录失败: %w", err) + return "", fmt.Errorf("failed to create directory: %w", err) } name := filename if len(actorName) > 0 && actorName[0] != "" { @@ -110,11 +110,11 @@ func (s *characterService) SaveCharacterFile(ctx context.Context, dramaId int64, dest := filepath.Join(subPath, name) out, err := os.Create(dest) if err != nil { - return "", fmt.Errorf("创建文件失败: %w", err) + return "", fmt.Errorf("failed to create file: %w", err) } defer func() { _ = out.Close() }() if _, err := io.Copy(out, file); err != nil { - return "", fmt.Errorf("写入文件失败: %w", err) + return "", fmt.Errorf("failed to write file: %w", err) } return dest, nil } @@ -139,16 +139,16 @@ func ValidateUploadFile(ctx context.Context, fileSize int64, fileName string, ca allowedExts = allowedAudioExts maxSize = maxAudioSize default: - return fmt.Errorf("不支持的文件类型: %s", category) + return fmt.Errorf("unsupported file type: %s", category) } if fileSize > maxSize { - return fmt.Errorf("文件大小超出限制(最大 %dMB)", maxSize/(1024*1024)) + return fmt.Errorf("file size exceeds limit (max %dMB)", maxSize/(1024*1024)) } ext := strings.ToLower(filepath.Ext(fileName)) if !allowedExts[ext] { - return fmt.Errorf("不支持的文件格式: %s", ext) + return fmt.Errorf("unsupported file format: %s", ext) } return nil diff --git a/shortdrama/service/customer_service.go b/shortdrama/service/customer_service.go index 18a7a8b..97fc05a 100644 --- a/shortdrama/service/customer_service.go +++ b/shortdrama/service/customer_service.go @@ -34,24 +34,24 @@ func (s *customerService) CreateCustomer(ctx context.Context, phone, name, addre agent, err := dao.User.GetOne(ctx, agentId) if err != nil || agent == nil || agent.Role != "agent" { - return nil, errors.New("代理商不存在") + return nil, errors.New("agent not found") } ap, _ := dao.AgentProfile.Get(ctx, agentId) if ap != nil && ap.ExpiredAt != nil && ap.ExpiredAt.Before(gtime.Now()) { - return nil, errors.New("代理商已过期") + return nil, errors.New("agent has expired") } ap, _ = dao.AgentProfile.Get(ctx, agentId) if ap != nil && ap.MaxCustomers > 0 { count, _ := dao.CustomerProfile.CountByAgent(ctx, agentId) if count >= ap.MaxCustomers { - return nil, fmt.Errorf("已达到最大客户数上限(%d)", ap.MaxCustomers) + return nil, fmt.Errorf("max customer limit reached (%d)", ap.MaxCustomers) } } existing, _ := dao.User.GetByPhone(ctx, phone) if existing != nil { - return nil, errors.New("该手机号已注册") + return nil, errors.New("phone number already registered") } defaultPwd := phone @@ -89,7 +89,7 @@ func (s *customerService) CreateCustomer(ctx context.Context, phone, name, addre func (s *customerService) CheckBalance(ctx context.Context, customerId int64, durationSec int64) (bool, int64, error) { cp, _ := dao.CustomerProfile.Get(ctx, customerId) if cp == nil { - return false, 0, errors.New("客户不存在") + return false, 0, errors.New("customer not found") } cfg := ModelConfigService.GetActiveModel(ctx, "video") cost := durationSec * int64(cfg.Price) @@ -100,10 +100,10 @@ func (s *customerService) CheckBalance(ctx context.Context, customerId int64, du func (s *customerService) DeductBalance(ctx context.Context, customerId int64, amount int64, remark, createdBy string) error { cp, err := dao.CustomerProfile.Get(ctx, customerId) if err != nil || cp == nil { - return errors.New("客户不存在") + return errors.New("customer not found") } if cp.Balance < amount { - return errors.New("余额不足") + return errors.New("insufficient balance") } newBalance := cp.Balance - amount if err := dao.CustomerProfile.UpdateBalance(ctx, customerId, newBalance); err != nil { @@ -163,7 +163,7 @@ func (s *customerService) GetCustomerDetail(ctx context.Context, id int64) (*dto // UpdateCustomer 更新客户信息 func (s *customerService) UpdateCustomer(ctx context.Context, agentId int64, req *dto.UpdateCustomerReq) error { if ap, _ := dao.AgentProfile.Get(ctx, agentId); ap != nil && ap.ExpiredAt != nil && ap.ExpiredAt.Before(gtime.Now()) { - return errors.New("代理商已过期,无法编辑客户") + return errors.New("agent has expired, cannot edit customer") } m := g.Map{} diff --git a/shortdrama/service/drama_service.go b/shortdrama/service/drama_service.go index d3427fc..2200f9b 100644 --- a/shortdrama/service/drama_service.go +++ b/shortdrama/service/drama_service.go @@ -25,7 +25,7 @@ var DramaService = new(dramaService) func (s *dramaService) Create(ctx context.Context, title, contentType, config, aspectRatio string, episodeDuration int64, resolution string, userId int64) (int64, error) { existing, _ := dao.Drama.GetByTitle(ctx, title) if existing != nil { - return 0, fmt.Errorf("短剧标题已存在: %s", title) + return 0, fmt.Errorf("drama title already exists: %s", title) } id, err := dao.Drama.Insert(ctx, &entity.Drama{ @@ -42,7 +42,7 @@ func (s *dramaService) Create(ctx context.Context, title, contentType, config, a } if err := CreateWorkspace(title); err != nil { - g.Log().Warningf(ctx, "创建工作空间目录失败: %v", err) + g.Log().Warningf(ctx, "create workspace dir failed: %v", err) } return id, nil } @@ -83,13 +83,13 @@ func (s *dramaService) Update(ctx context.Context, id int64, title, contentType, return err } if d == nil { - return fmt.Errorf("短剧不存在: %d", id) + return fmt.Errorf("drama not found: %d", id) } oldTitle := d.Title if title != "" && title != oldTitle { existing, _ := dao.Drama.GetByTitle(ctx, title) if existing != nil { - return fmt.Errorf("短剧标题已存在: %s", title) + return fmt.Errorf("drama title already exists: %s", title) } d.Title = title } @@ -145,7 +145,7 @@ func (s *dramaService) Delete(ctx context.Context, id int64) error { if d != nil { if err := RemoveWorkspace(d.Title); err != nil { - g.Log().Warningf(ctx, "删除工作空间目录失败: %v", err) + g.Log().Warningf(ctx, "remove workspace dir failed: %v", err) } } return nil @@ -153,17 +153,38 @@ func (s *dramaService) Delete(ctx context.Context, id int64) error { // ==================== Workspace ==================== -const workspaceRoot = "workspace" +var workspaceRoot string + +func init() { + wd, err := os.Getwd() + if err == nil { + workspaceRoot = filepath.Join(wd, "workspace") + } else { + workspaceRoot = "workspace" + } +} var workspaceSubdirs = []string{"产出视频", "演员形象", "演员声音", "场景", "道具", "背景音乐"} -// sanitizeDirName 将短剧标题转为安全的目录名 +// sanitizeDirName 将标题转为安全的目录名(仅保留字母、数字、中文和下划线) func sanitizeDirName(title string) string { - replacer := strings.NewReplacer( - "/", "_", "\\", "_", ":", "_", "*", "_", - "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_", - ) - return replacer.Replace(title) + var b strings.Builder + b.Grow(len(title)) + for _, r := range title { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || (r >= 0x4E00 && r <= 0x9FFF) || (r >= 0x3400 && r <= 0x4DBF) { + b.WriteRune(r) + } else { + b.WriteRune('_') + } + } + result := b.String() + for result != "" && result[0] == '_' { + result = result[1:] + } + if result == "" { + result = "unnamed" + } + return result } // WorkspaceDir 返回短剧的 workspace 目录路径 @@ -176,7 +197,7 @@ func CreateWorkspace(title string) error { root := WorkspaceDir(title) for _, sub := range workspaceSubdirs { if err := os.MkdirAll(filepath.Join(root, sub), 0755); err != nil { - return fmt.Errorf("创建工作空间目录 %s 失败: %w", sub, err) + return fmt.Errorf("create workspace directory %s failed: %w", sub, err) } } return nil @@ -198,22 +219,22 @@ func SaveScenePropFile(ctx context.Context, dramaId int64, file io.Reader, filen return "", err } if d == nil { - return "", fmt.Errorf("短剧不存在: %d", dramaId) + return "", fmt.Errorf("drama not found: %d", dramaId) } workspaceDir := WorkspaceDir(d.Title) subPath := filepath.Join(workspaceDir, subDir) if err := os.MkdirAll(subPath, 0755); err != nil { - return "", fmt.Errorf("创建目录失败: %w", err) + return "", fmt.Errorf("create directory failed: %w", err) } ext := filepath.Ext(filename) dest := filepath.Join(subPath, sanitizeDirName(name)+ext) out, err := os.Create(dest) if err != nil { - return "", fmt.Errorf("创建文件失败: %w", err) + return "", fmt.Errorf("create file failed: %w", err) } defer func() { _ = out.Close() }() if _, err := io.Copy(out, file); err != nil { - return "", fmt.Errorf("写入文件失败: %w", err) + return "", fmt.Errorf("write file failed: %w", err) } return dest, nil } diff --git a/shortdrama/service/episode_service.go b/shortdrama/service/episode_service.go index 4bd7046..09b12bd 100644 --- a/shortdrama/service/episode_service.go +++ b/shortdrama/service/episode_service.go @@ -75,7 +75,7 @@ func (s *episodeService) AddEpisode(ctx context.Context, dramaId int64, title, d return 0, err } if d == nil { - return 0, fmt.Errorf("短剧不存在: %d", dramaId) + return 0, fmt.Errorf("drama not found: %d", dramaId) } var epId int64 now := time.Now().Format("2006-01-02 15:04:05") @@ -105,7 +105,7 @@ func (s *episodeService) UpdateEpisode(ctx context.Context, dramaId, epId int64, return err } if e == nil { - return fmt.Errorf("剧集不存在: %d", epId) + return fmt.Errorf("episode not found: %d", epId) } if title != "" { e.Title = title @@ -126,7 +126,7 @@ func (s *episodeService) UpdateEpisode(ctx context.Context, dramaId, epId int64, return err } if d == nil { - return fmt.Errorf("短剧不存在: %d", dramaId) + return fmt.Errorf("drama not found: %d", dramaId) } needsTaskUpdate := script != "" @@ -140,7 +140,8 @@ func (s *episodeService) UpdateEpisode(ctx context.Context, dramaId, epId int64, if _, e := tx.Model(public.TableNameGenerationTask).Ctx(ctx).Where("episode_id", epId).Delete(); e != nil { return e } - if e := createPendingTasks(ctx, dramaId, epId, script, tx, d, e.Index); e != nil { + if err2 := createPendingTasks(ctx, dramaId, epId, script, tx, d, e.Index); err2 != nil { + return err2 } } return nil @@ -201,33 +202,33 @@ func (s *episodeService) DeleteEpisode(ctx context.Context, dramaId, epId int64) func (s *episodeService) GenerateScript(ctx context.Context, dramaId int64, episodeTitle, description string) (script string, err error) { d, err := dao.Drama.GetOne(ctx, dramaId) if err != nil { - g.Log().Errorf(ctx, "GenerateScript 查询短剧失败 dramaId=%d: %v", dramaId, err) + g.Log().Errorf(ctx, "GenerateScript query drama failed dramaId=%d: %v", dramaId, err) return } if d == nil { - g.Log().Warningf(ctx, "GenerateScript 短剧不存在 dramaId=%d", dramaId) - err = fmt.Errorf("短剧不存在") + g.Log().Warningf(ctx, "GenerateScript drama not found dramaId=%d", dramaId) + err = fmt.Errorf("drama not found") return } modelCfg := UserModelConfigService.GetMergedConfig(ctx, d.UserId, "chat") if modelCfg.ApiKey == "" || modelCfg.ModelName == "" { - g.Log().Warningf(ctx, "GenerateScript 对话模型未配置 userId=%d ApiKey=%q ModelName=%q", d.UserId, modelCfg.ApiKey, modelCfg.ModelName) - err = fmt.Errorf("模型未配置") + g.Log().Warningf(ctx, "GenerateScript chat model not configured userId=%d ApiKey=%q ModelName=%q", d.UserId, modelCfg.ApiKey, modelCfg.ModelName) + err = fmt.Errorf("model not configured") return } - g.Log().Infof(ctx, "GenerateScript 开始生成 userId=%d dramaId=%d model=%s baseUrl=%s", d.UserId, dramaId, modelCfg.ModelName, modelCfg.BaseUrl) + g.Log().Infof(ctx, "GenerateScript start generating userId=%d dramaId=%d model=%s baseUrl=%s", d.UserId, dramaId, modelCfg.ModelName, modelCfg.BaseUrl) // 加载演员/场景/道具上下文 genCtx, err := BuildGenerationContext(ctx, d) if err != nil { - g.Log().Errorf(ctx, "GenerateScript 加载上下文失败 dramaId=%d: %v", dramaId, err) - err = fmt.Errorf("加载短剧上下文失败: %w", err) + g.Log().Errorf(ctx, "GenerateScript load context failed dramaId=%d: %v", dramaId, err) + err = fmt.Errorf("load drama context failed: %w", err) return } systemPrompt := PromptService.GetScriptGenerationPrompt(ctx) - userInput := s.buildScriptGenUserInput(d, episodeTitle, description, genCtx) + userInput := s.buildScriptGenUserInput(ctx, d, episodeTitle, description, genCtx) chatCfg := &agent.ModelConfig{ ModelName: modelCfg.ModelName, @@ -243,36 +244,36 @@ func (s *episodeService) GenerateScript(ctx context.Context, dramaId int64, epis {Role: agent.RoleSystem, Content: systemPrompt}, {Role: agent.RoleUser, Content: userInput}, } - g.Log().Infof(ctx, "GenerateScript 调用AI模型 model=%s max_tokens=%d prompt_len=%d", chatCfg.ModelName, chatCfg.MaxTokens, len(systemPrompt)+len(userInput)) + g.Log().Infof(ctx, "GenerateScript calling AI model=%s max_tokens=%d prompt_len=%d", chatCfg.ModelName, chatCfg.MaxTokens, len(systemPrompt)+len(userInput)) result, err := agent.CallChatModel(ctx, chatCfg, &agent.ChatRequest{ Messages: messages, MaxTokens: chatCfg.MaxTokens, }) if err != nil { - g.Log().Errorf(ctx, "GenerateScript AI调用失败: %v", err) - err = fmt.Errorf("生成脚本失败: %w", err) + g.Log().Errorf(ctx, "GenerateScript AI call failed: %v", err) + err = fmt.Errorf("generate script failed: %w", err) return } raw := result.Content if raw == "" { - g.Log().Warningf(ctx, "GenerateScript AI返回内容为空") - err = fmt.Errorf("生成的脚本为空") + g.Log().Warningf(ctx, "GenerateScript AI returned empty content") + err = fmt.Errorf("generated script is empty") return } - g.Log().Infof(ctx, "GenerateScript AI返回成功 content_len=%d", len(raw)) + g.Log().Infof(ctx, "GenerateScript AI returned successfully content_len=%d", len(raw)) // 尝试解析为JSON shots数组 var shots []domain.Shot var parseErr error if parseErr = json.Unmarshal([]byte(raw), &shots); parseErr == nil && len(shots) > 0 { script = raw - g.Log().Infof(ctx, "JSON镜头脚本生成成功: 短剧=%s, 剧集=%s, 镜头数=%d", d.Title, episodeTitle, len(shots)) + g.Log().Infof(ctx, "JSON shot script generated: drama=%s, episode=%s, shots=%d", d.Title, episodeTitle, len(shots)) return } // JSON解析失败,将错误信息拼入提示词让AI自行修正 - g.Log().Warningf(ctx, "JSON解析失败,请求AI修正: %v", parseErr) + g.Log().Warningf(ctx, "JSON parse failed, requesting AI fix: %v", parseErr) fixPrompt := fmt.Sprintf("你输出的JSON格式有误,请修正后重新输出。\n\n错误信息:%v\n\n你之前输出的内容:\n%s\n\n请只输出修正后的合法JSON数组,不要任何其他内容。", parseErr, raw) retryMsgs := []*agent.ChatMessage{ {Role: agent.RoleSystem, Content: systemPrompt}, @@ -280,7 +281,7 @@ func (s *episodeService) GenerateScript(ctx context.Context, dramaId int64, epis {Role: agent.RoleAssistant, Content: raw}, {Role: agent.RoleUser, Content: fixPrompt}, } - g.Log().Infof(ctx, "GenerateScript 调用AI修正模型 model=%s max_tokens=%d 目的=JSON脚本修正重试", chatCfg.ModelName, chatCfg.MaxTokens) + g.Log().Infof(ctx, "GenerateScript calling AI fix model=%s max_tokens=%d purpose=JSON script fix retry", chatCfg.ModelName, chatCfg.MaxTokens) result2, retryErr := agent.CallChatModel(ctx, chatCfg, &agent.ChatRequest{ Messages: retryMsgs, MaxTokens: chatCfg.MaxTokens, @@ -290,23 +291,23 @@ func (s *episodeService) GenerateScript(ctx context.Context, dramaId int64, epis var parseErr2 error if parseErr2 = json.Unmarshal([]byte(result2.Content), &shots2); parseErr2 == nil && len(shots2) > 0 { script = result2.Content - g.Log().Infof(ctx, "JSON脚本修正成功: 短剧=%s, 剧集=%s, 镜头数=%d", d.Title, episodeTitle, len(shots2)) + g.Log().Infof(ctx, "JSON script fix succeeded: drama=%s, episode=%s, shots=%d", d.Title, episodeTitle, len(shots2)) return } - g.Log().Warningf(ctx, "JSON脚本修正后仍解析失败: %v", parseErr2) + g.Log().Warningf(ctx, "JSON script fix still parse failed: %v", parseErr2) } else if retryErr != nil { - g.Log().Warningf(ctx, "JSON脚本修正请求失败: %v", retryErr) + g.Log().Warningf(ctx, "JSON script fix request failed: %v", retryErr) } // 修正仍失败,回退为纯文本 script = raw - g.Log().Warningf(ctx, "JSON脚本回退为纯文本: 短剧=%s, 剧集=%s, 长度=%d字符", d.Title, episodeTitle, len([]rune(raw))) + g.Log().Warningf(ctx, "JSON script fallback to plain text: drama=%s, episode=%s, length=%d chars", d.Title, episodeTitle, len([]rune(raw))) g.Log().Printf(ctx, "模型返回的文本:\n%s", raw) return } // buildScriptGenUserInput 构建脚本生成的用户输入提示 -func (s *episodeService) buildScriptGenUserInput(d *entity.Drama, episodeTitle, description string, genCtx *GenerationContext) string { +func (s *episodeService) buildScriptGenUserInput(ctx context.Context, d *entity.Drama, episodeTitle, description string, genCtx *GenerationContext) string { var b strings.Builder fmt.Fprintf(&b, "每集时长:%d秒\n\n", d.EpisodeDuration) @@ -354,8 +355,8 @@ func (s *episodeService) buildScriptGenUserInput(d *entity.Drama, episodeTitle, b.WriteString("【输出格式】\nJSON数组,每个元素是一个镜头对象(shot),包含以下字段:\n- index: 镜头序号(从1开始)\n- startTime: 开始时间(格式MM:SS)\n- endTime: 结束时间(格式MM:SS)\n- event: 画面描述——观众在屏幕上直接看到的一切。场景环境、角色动作、表情变化、物体位置等。**这是视频模型生成画面的唯一依据,所有画面内容必须写在这里**,严禁把画面描述放入 narration 字段。不包含旁白和台词。\n- dialogue: 主台词——角色在画面中亲口说出的对白。多人对话用「角色名:台词」格式。旁白和内心独白不属于这里。如果本镜头无人说话,留空即可。\n- narration: 旁白配音——需要配音演员念出来的解说文字。必须是**通过画面无法直接传达**的信息(如角色内心独白、故事背景交代、时间跳跃说明等)。**如果一段文字描述的是观众能直接看到的画面内容,它就不属于旁白,必须放进 event 字段。** 本镜头无旁白时留空即可。\n- cameraMovement: 运镜方式,从以下标准类型中选择一种:固定镜头、推、拉、摇、移、跟、升、降、旋转、晃动、航拍\n- shotSize: 景别,从以下标准类型中选择一种:远景、全景、中景、近景、特写\n- characters: 出演人物数组,填写演员名称,如[\"张三\", \"李四\"],从「可用演员」中选择\n- scene: 场景名称,从「可用场景」中选择\n- props: 道具名称数组,如[\"剑\", \"酒杯\"],从「可用道具」中选择\n\n直接输出JSON数组,不要markdown代码块标记,不要其他任何内容。\n\n") b.WriteString("【创作规范】\n\n1. 剧情完整性【核心规则】—— 剧情描述是唯一创作依据,严格按每一句话、每一个细节逐拍还原。\n - 剧情描述中出现的每一句角色说的话,必须一字不差地写入对应镜头的dialogue字段。严禁把台词\"翻译\"成事件描述。\n - 剧情描述中的每一个具体细节(人物的动作、表情反应、对话、环境互动等)都必须忠实地保留在对应镜头的event、narration或dialogue字段中,不能省略、概括或改写为泛化描述。\n - 所有镜头时长之和应等于 %d 秒(50秒至少20个镜头,20-25个镜头为最佳密度)。\n\n2. 场景一致性 —— 每个镜头的 scene 字段值必须与 event 中画面描述的地点一致。\n - 如果 event 描述角色在\"实验室内\",scene 必须是该实验室的名称,不能写成其他地点。\n - 如果角色从一个场景移动到另一个场景,必须先切换 scene(如从「街道」切到「室内」),再描述新地点中的动作。\n\n3. 景别与动作匹配规则 —— 按镜头内容选择正确的 shotSize:\n - shotSize=特写:面部表情、微表情、眼神变化、细节反应(手部小动作、物品细节)。不能用于动作展示。\n - shotSize=近景:对话、上半身互动、情绪交流。能看清面部表情和手势。\n - shotSize=中景:日常动作、手持物体、多人交互。膝盖以上,既有动作又有空间感。\n - shotSize=全景:连贯动作、奔跑、打斗、空间关系。展示全身动作和环境关系。\n - shotSize=远景:环境交代、空镜过渡、场景建立。没有人或人很小,用于转场。\n\n4. 运镜搭配规则 —— 按场景类型选择 cameraMovement:\n - 对话/情绪交流 → 固定镜头或缓推\n - 动作/打斗 → 跟、摇、移\n - 人物进入/离开画面 → 跟、移\n - 情绪揭示 → 推\n - 环境展示 → 摇、升降、航拍\n - 紧张/混乱 → 晃动\n - 固定镜头是默认选项,无特殊需要不使用复杂运镜。\n\n5. 口型适配规则 —— 有台词的镜头(dialogue 不为空)必须遵守:\n - shotSize 必须为中景或近景(不能是远景、全景或特写)。\n - 说话角色必须面向或侧向镜头,不能背对镜头说话。\n - 不说话只做反应的角色可用其他景别。\n") - b.WriteString(config.GetShotDurationPrompt(d.Type)) - b.WriteString(config.GetStylePrompt(d.Type)) + b.WriteString(config.GetShotDurationPrompt(ctx, d.Type)) + b.WriteString(config.GetStylePrompt(ctx, d.Type)) b.WriteString("\n\n6. JSON格式要求:输出必须是合法的 JSON 数组,字符串值中若需使用引号请用「」代替 ASCII 双引号,确保 json.Unmarshal 能正确解析。\n7. 风控规避:视频模型对某些画面有安全审查机制,生成镜头描述时必须规避。政治敏感内容(真实政治人物/标志/当代军警标识)零容忍。暴力血腥画面(刀刺入身体、血溅、断肢)改为反应镜头或切镜回避,不展示伤害过程。当代背景中的军警抓捕动作将「警察/武警」改为「保安/黑衣人」,动作从「按倒铐住」改为「围住请离」。武器描写聚焦持武器的人物(「他握紧长刀,目光凌厉」),不写武器接触身体的画面。暴力结果用间接描写:写周围人反应而非伤口血迹。\n") return b.String() @@ -378,7 +379,7 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, // 读取反向提示词 negativePrompt := "" - if data, err := os.ReadFile("negative_prompt.md"); err == nil { + if data, err := os.ReadFile(getDataPath("negative_prompt.md")); err == nil { negativePrompt = strings.TrimSpace(string(data)) } @@ -521,7 +522,7 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, // JSON 镜头数组:按镜头分组,累加连续镜头直到触及时长或字符上限 var allShots []domain.Shot if err := json.Unmarshal([]byte(script), &allShots); err != nil || len(allShots) == 0 { - return fmt.Errorf("解析镜头脚本失败: %w", err) + return fmt.Errorf("parse shot script failed: %w", err) } type _shotGroup struct { shots []domain.Shot @@ -646,7 +647,7 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, "num_segments": len(taskGroups), }).Insert() if err != nil { - return fmt.Errorf("创建第%d段待生成任务失败: %w", i+1, err) + return fmt.Errorf("create segment %d task failed: %w", i+1, err) } } return nil @@ -674,18 +675,34 @@ func splitScriptForSegment(fullScript string, segStartTime, segDur, totalDur int } return "" } - // 纯文本脚本:按时长比例切分字符 - runes := []rune(fullScript) - if len(runes) == 0 || totalDur <= 0 { - return fullScript + // 纯文本脚本:按段落切分,避免截断句子 + paragraphs := strings.Split(fullScript, "\n\n") + if len(paragraphs) <= 1 || totalDur <= 0 { + // 只有一段或无时间信息时直接按字符比例 + runes := []rune(fullScript) + if len(runes) == 0 { + return fullScript + } + startRune := len(runes) * segStartTime / totalDur + endRune := len(runes) * (segStartTime + segDur) / totalDur + if startRune >= len(runes) { + return "" + } + if endRune > len(runes) { + endRune = len(runes) + } + return string(runes[startRune:endRune]) } - startRune := len(runes) * segStartTime / totalDur - endRune := len(runes) * (segStartTime + segDur) / totalDur - if startRune >= len(runes) { + startPara := len(paragraphs) * segStartTime / totalDur + endPara := len(paragraphs) * (segStartTime + segDur) / totalDur + if startPara >= len(paragraphs) { return "" } - if endRune > len(runes) { - endRune = len(runes) + if endPara > len(paragraphs) { + endPara = len(paragraphs) } - return string(runes[startRune:endRune]) + if endPara <= startPara { + endPara = startPara + 1 + } + return strings.Join(paragraphs[startPara:endPara], "\n\n") } diff --git a/shortdrama/service/generation_service.go b/shortdrama/service/generation_service.go index 9ce9086..7961c23 100644 --- a/shortdrama/service/generation_service.go +++ b/shortdrama/service/generation_service.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "math" + "math/rand" "net/http" "os" "os/exec" @@ -15,6 +16,7 @@ import ( "strings" "sync" "time" + "video-factory/common" "video-factory/shortdrama/agent" "video-factory/shortdrama/consts" @@ -34,6 +36,14 @@ type generationService struct{} var GenerationService = new(generationService) +// feedbackWg tracks FeedbackSegment goroutines for graceful shutdown +var feedbackWg sync.WaitGroup + +// WaitFeedbackDone waits for all FeedbackSegment goroutines to complete +func WaitFeedbackDone() { + feedbackWg.Wait() +} + // ==================== Generation ==================== // GenerateEpisode 生成一集:按模型约束拆分片段,每段独立 goroutine 并行生成 @@ -43,7 +53,7 @@ func (s *generationService) GenerateEpisode(ctx context.Context, dramaId, epId i return err } if d == nil { - return fmt.Errorf("短剧不存在") + return fmt.Errorf("drama not found") } ep, err := dao.Episode.GetOne(ctx, epId) @@ -51,16 +61,16 @@ func (s *generationService) GenerateEpisode(ctx context.Context, dramaId, epId i return err } if ep == nil { - return fmt.Errorf("剧集不存在") + return fmt.Errorf("episode not found") } chatCfg := UserModelConfigService.GetMergedConfig(ctx, d.UserId, "chat") if chatCfg.ApiKey == "" || chatCfg.ModelName == "" { - return fmt.Errorf("聊天模型未配置") + return fmt.Errorf("chat model not configured") } videoCfg := UserModelConfigService.GetMergedConfig(ctx, d.UserId, "video") if videoCfg.ApiKey == "" || videoCfg.BaseUrl == "" || videoCfg.ModelName == "" { - return fmt.Errorf("视频模型未配置") + return fmt.Errorf("video model not configured") } // 客户身份:余额检查并预扣费 if r := g.RequestFromCtx(ctx); r != nil { @@ -68,14 +78,14 @@ func (s *generationService) GenerateEpisode(ctx context.Context, dramaId, epId i userId := r.GetCtxVar("userId").Int64() enough, cost, err := CustomerService.CheckBalance(ctx, userId, d.EpisodeDuration) if err != nil { - return fmt.Errorf("余额检查失败: %w", err) + return fmt.Errorf("balance check failed: %w", err) } if !enough { - return fmt.Errorf("余额不足,生成需要 %.2f 元", float64(cost)/100) + return fmt.Errorf("insufficient balance, generation costs %.2f", float64(cost)/100) } remark := fmt.Sprintf("生成视频(第%d集) %.2f 元", ep.Index, float64(cost)/100) if err := CustomerService.DeductBalance(ctx, userId, cost, remark, "system"); err != nil { - return fmt.Errorf("扣费失败: %w", err) + return fmt.Errorf("deduction failed: %w", err) } g.Log().Infof(ctx, "客户#%d 预扣费 %.2f 元(时长=%ds)", userId, float64(cost)/100, d.EpisodeDuration) } @@ -84,14 +94,14 @@ func (s *generationService) GenerateEpisode(ctx context.Context, dramaId, epId i // 默认使用并行生成模式 mode = "serial" // 预加载当前短剧的演员/场景/道具,构建引用索引 - genCtx2, err := BuildGenerationContext(context.Background(), d) + genCtx2, err := BuildGenerationContext(ctx, d) if err != nil { - return fmt.Errorf("构建生成上下文失败: %w", err) + return fmt.Errorf("build generation context failed: %w", err) } g.Log().Infof(ctx, "预加载引用数据: %d个演员, %d个场景, %d个道具, %d个引用", len(genCtx2.Characters), len(genCtx2.Scenes), len(genCtx2.Props), len(genCtx2.OrderedRefs)) - genCtx := agent.WithDramaID(context.Background(), d.Id) + genCtx := agent.WithDramaID(ctx, d.Id) segDurs := calcSegDurs(d.EpisodeDuration, videoCfg.ModelConfig) numSegments := len(segDurs) @@ -120,7 +130,10 @@ func (s *generationService) GenerateEpisode(ctx context.Context, dramaId, epId i } // 加载任务列表(状态已更新为 generating) - tasks, _ := dao.GenerationTask.ListByEpisode(ctx, epId) + tasks, err2 := dao.GenerationTask.ListByEpisode(ctx, epId) + if err2 != nil { + g.Log().Warningf(ctx, "failed to list generation tasks: %v", err2) + } // 构建 taskId 查找表 taskIds := make(map[int]int64) @@ -136,7 +149,7 @@ func (s *generationService) GenerateEpisode(ctx context.Context, dramaId, epId i taskId := taskIds[i] g.Log().Infof(genCtx, "第%d集第%d段开始串行生成(segDur=%ds))", ep.Index, i+1, segDur) if err := s.generateOneSegment(genCtx, d, ep, taskId, i, segDur, "", genCtx2); err != nil { - g.Log().Errorf(genCtx, "第%d集第%d段串行生成失败: %v", ep.Index, i+1, err) + g.Log().Errorf(genCtx, "episode %d segment %d serial generation failed: %v", ep.Index, i+1, err) _ = dao.GenerationTask.UpdateFailed(genCtx, taskId, err.Error()) clearPollCache(genCtx, epId) if ts, e := dao.GenerationTask.ListByEpisode(genCtx, epId); e == nil { @@ -150,7 +163,7 @@ func (s *generationService) GenerateEpisode(ctx context.Context, dramaId, epId i if i < len(segDurs)-1 { g.Log().Infof(genCtx, "等待第%d段视频生成完成,为第%d段准备首帧...", i+1, i+2) if err := s.waitForSegmentVideo(genCtx, d, ep, taskId); err != nil { - g.Log().Errorf(genCtx, "等待第%d段视频完成失败: %v", i+1, err) + g.Log().Errorf(genCtx, "wait for segment %d video completion failed: %v", i+1, err) return err } } @@ -167,7 +180,7 @@ func (s *generationService) GenerateEpisode(ctx context.Context, dramaId, epId i defer func() { if r := recover(); r != nil { errMsg := fmt.Sprintf("panic: %v", r) - g.Log().Errorf(genCtx, "第%d集第%d段生成panic: %v", ep.Index, idx+1, r) + g.Log().Errorf(genCtx, "episode %d segment %d generation panic: %v", ep.Index, idx+1, r) _ = dao.GenerationTask.UpdateFailed(genCtx, tid, errMsg) clearPollCache(genCtx, epId) } @@ -177,7 +190,7 @@ func (s *generationService) GenerateEpisode(ctx context.Context, dramaId, epId i startTime := time.Now() g.Log().Infof(genCtx, "第%d集第%d段开始生成(segDur=%ds)", ep.Index, idx+1, dur) if err := s.generateOneSegment(genCtx, d, ep, tid, idx, dur, "", genCtx2); err != nil { - g.Log().Errorf(genCtx, "第%d集第%d段生成失败(耗时%v): %v", ep.Index, idx+1, time.Since(startTime), err) + g.Log().Errorf(genCtx, "episode %d segment %d generation failed (elapsed %v): %v", ep.Index, idx+1, time.Since(startTime), err) _ = dao.GenerationTask.UpdateFailed(genCtx, tid, err.Error()) clearPollCache(genCtx, epId) if ts, e := dao.GenerationTask.ListByEpisode(genCtx, epId); e == nil { @@ -222,7 +235,7 @@ func (s *generationService) generateOneSegment(ctx context.Context, d *entity.Dr } segOutput = model.ParseAgentOutput(result, segIdx) if len(segOutput.Scenes) == 0 { - g.Log().Warningf(ctx, "第%d集第%d段 Agent 未生成场景数据,请检查输入", ep.Index, segIdx+1) + g.Log().Warningf(ctx, "episode %d segment %d Agent did not generate scene data, check input", ep.Index, segIdx+1) } // 从 Agent 原始输出中提取图片数据 @@ -238,15 +251,12 @@ func (s *generationService) generateOneSegment(ctx context.Context, d *entity.Dr } } - // 重新加载演员列表 - characters, _, _ = dao.Character.ListPageByDrama(ctx, d.Id, 1, -1) - // 从数据库已有形象回填 base64 if len(characters) > 0 { charMap := make(map[string]string) for _, c := range characters { if c.PortraitPath != "" { - if b64, err := imageFileToBase64(c.PortraitPath); err == nil { + if b64, err := common.ImageFileToBase64(c.PortraitPath); err == nil { charMap[c.Name] = b64 } } @@ -269,7 +279,7 @@ func (s *generationService) generateOneSegment(ctx context.Context, d *entity.Dr scene.ImagePath = path scene.ImageBase64 = "" } else { - g.Log().Warningf(ctx, "保存场景图片失败: %v", err) + g.Log().Warningf(ctx, "save scene image failed: %v", err) } } } @@ -277,7 +287,10 @@ func (s *generationService) generateOneSegment(ctx context.Context, d *entity.Dr // 读取 task 中已有的 NumSegments // 读取 task 中已有的 NumSegments - task, _ := dao.GenerationTask.GetOne(ctx, taskId) + task, err2 := dao.GenerationTask.GetOne(ctx, taskId) + if err2 != nil { + g.Log().Warningf(ctx, "failed to get generation task %d: %v", taskId, err2) + } numSegments := 1 if task != nil && task.NumSegments > 0 { numSegments = task.NumSegments @@ -333,10 +346,10 @@ func (s *generationService) generateOneSegment(ctx context.Context, d *entity.Dr taskID, requestJSON, submitErr := s.submitFromExistingScript(ctx, taskId, videoCfg, segIdx, d.Title, ep.Title) if submitErr != nil { // 回退到完整构建(task.script 可能为空) - g.Log().Warningf(ctx, "复用 task.script 提交失败,回退到完整构建: %v", submitErr) + g.Log().Warningf(ctx, "reuse task.script submit failed, falling back to full build: %v", submitErr) taskID, requestJSON, submitErr = s.submitVideoTask(ctx, d, ep, segIdx, segDur, segOutput.Scenes, videoRefs, taskId) if submitErr != nil { - g.Log().Errorf(ctx, "第%d段视频提交失败: %v", segIdx+1, submitErr) + g.Log().Errorf(ctx, "segment %d video submit failed: %v", segIdx+1, submitErr) return submitErr } } @@ -463,13 +476,8 @@ func (s *generationService) generateSegment(ctx context.Context, d *entity.Drama } func (s *generationService) buildSegPrompt(ctx context.Context, d *entity.Drama, ep *entity.Episode, segIdx, segDur, totalSegs int, modelCfg *entity.ModelConfig) string { - maxRef := 9 - if maxRef <= 0 { - maxRef = 9 - } - usableRef := maxRef - 1 // 首帧占一个参考位 + maxRef, usableRef := 9, 8 // AgentMaxRefs=9, AgentRefOffset=1,首帧占一个参考位 - _ = ctx return fmt.Sprintf(`%s 【分段生成】 @@ -618,10 +626,10 @@ func (s *generationService) ContinueSegment(ctx context.Context, taskId int64) e return err } if task == nil { - return fmt.Errorf("任务不存在: %d", taskId) + return fmt.Errorf("task not found: %d", taskId) } if task.Status != consts.TaskStatusReview { - return fmt.Errorf("任务状态不是 review,无法确认") + return fmt.Errorf("task status is not review, cannot confirm") } // 检查是否还有其他未完成的段(除了当前段) @@ -654,7 +662,7 @@ func (s *generationService) ContinueSegment(ctx context.Context, taskId int64) e // 最后一段 → 先合并视频,再在同一个事务中更新 task + episode g.Log().Infof(ctx, "第%d集所有段已确认,开始合并视频", task.EpisodeId) if err := s.mergeEpisodeVideo(ctx, task.EpisodeId, taskId); err != nil { - return fmt.Errorf("合并视频失败: %w", err) + return fmt.Errorf("merge video failed: %w", err) } return nil @@ -667,10 +675,10 @@ func (s *generationService) FeedbackSegment(ctx context.Context, taskId int64, f return err } if task == nil { - return fmt.Errorf("任务不存在: %d", taskId) + return fmt.Errorf("task not found: %d", taskId) } if task.Status != consts.TaskStatusReview { - return fmt.Errorf("任务状态不是 review,无法反馈") + return fmt.Errorf("task status is not review, cannot feedback") } // 事务:重置任务为 generating + 剧集状态重置 @@ -687,26 +695,28 @@ func (s *generationService) FeedbackSegment(ctx context.Context, taskId int64, f d, err := dao.Drama.GetOne(ctx, task.DramaId) if err != nil || d == nil { - return fmt.Errorf("短剧不存在") + return fmt.Errorf("drama not found") } modelCfg := UserModelConfigService.GetMergedConfig(ctx, d.UserId, "video") if modelCfg.ApiKey == "" || modelCfg.BaseUrl == "" { - return fmt.Errorf("视频模型未配置") + return fmt.Errorf("video model not configured") } ep, err := dao.Episode.GetOne(ctx, task.EpisodeId) if err != nil || ep == nil { - return fmt.Errorf("剧集不存在") + return fmt.Errorf("episode not found") } + feedbackWg.Add(1) go func() { - genCtx := context.Background() + defer feedbackWg.Done() + genCtx := context.WithoutCancel(ctx) genCtx = agent.WithDramaID(genCtx, d.Id) // 删除旧视频文件 if task.VideoUrl != "" { if err := os.Remove(task.VideoUrl); err != nil && !os.IsNotExist(err) { - g.Log().Warningf(genCtx, "删除旧视频文件失败: %v", err) + g.Log().Warningf(genCtx, "delete old video file failed: %v", err) } else if err == nil { g.Log().Infof(genCtx, "已删除旧视频文件: %s", task.VideoUrl) } @@ -732,14 +742,18 @@ func (s *generationService) FeedbackSegment(ctx context.Context, taskId int64, f // 统一处理首帧(segIdx==0 删除,>0 自动解析或截取尾帧) prepareFirstFrame(genCtx, bodyMap, task.SegmentIdx, taskId, task.EpisodeId, d.Title, ep.Title, modelCfg.FirstFrameMapping, modelCfg.Schema) - bodyBytes, _ := json.Marshal(bodyMap) + bodyBytes, err := json.Marshal(bodyMap) + if err != nil { + g.Log().Errorf(genCtx, "marshal body failed: %v", err) + return + } // 调用模型前写入模型名称 _ = dao.GenerationTask.UpdateFields(genCtx, taskId, g.Map{"model_name": modelCfg.ModelName}) newTaskID, resolvedBody, submitErr := resubmitVideoTask(genCtx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.Schema, bodyBytes, modelCfg.FirstFrameMapping) if submitErr != nil { - g.Log().Errorf(genCtx, "第%d集第%d段重新提交视频失败: %v", ep.Index, task.SegmentIdx+1, submitErr) + g.Log().Errorf(genCtx, "episode %d segment %d video resubmit failed: %v", ep.Index, task.SegmentIdx+1, submitErr) _ = dao.GenerationTask.UpdateFailed(genCtx, taskId, submitErr.Error()) clearPollCache(genCtx, task.EpisodeId) if ts, e := dao.GenerationTask.ListByEpisode(genCtx, task.EpisodeId); e == nil { @@ -778,7 +792,7 @@ func (s *generationService) mergeEpisodeVideo(ctx context.Context, epId int64, l return err } if len(tasks) == 0 { - return fmt.Errorf("没有任务可合并") + return fmt.Errorf("no tasks to merge") } // 检查所有段都已完成(允许 lastTaskId 对应的任务为 review 状态) @@ -787,7 +801,7 @@ func (s *generationService) mergeEpisodeVideo(ctx context.Context, epId int64, l continue } if t.Status != consts.TaskStatusCompleted { - return fmt.Errorf("第%d段任务尚未确认,状态: %s", t.SegmentIdx+1, t.Status) + return fmt.Errorf("segment %d task not yet confirmed, status: %s", t.SegmentIdx+1, t.Status) } } @@ -807,7 +821,7 @@ func (s *generationService) mergeEpisodeVideo(ctx context.Context, epId int64, l outputDir = filepath.Join(WorkspaceDir(dramaTitle), "产出视频") } if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("创建输出目录失败: %w", err) + return fmt.Errorf("create output directory failed: %w", err) } // 按 segment_idx 顺序收集已下载的视频文件(直接用原文件,不复制) @@ -820,7 +834,7 @@ func (s *generationService) mergeEpisodeVideo(ctx context.Context, epId int64, l if strings.HasPrefix(t.VideoUrl, "http://") || strings.HasPrefix(t.VideoUrl, "https://") { localPath := filepath.Join(outputDir, fmt.Sprintf("%s_seg_%d.mp4", safeEp, t.SegmentIdx)) if dlErr := s.downloadFile(ctx, t.VideoUrl, localPath); dlErr != nil { - return fmt.Errorf("第%d段视频下载失败: %w", t.SegmentIdx+1, dlErr) + return fmt.Errorf("segment %d video download failed: %w", t.SegmentIdx+1, dlErr) } segFiles = append(segFiles, localPath) } else { @@ -834,7 +848,7 @@ func (s *generationService) mergeEpisodeVideo(ctx context.Context, epId int64, l } if len(segFiles) == 0 { - return fmt.Errorf("没有可合并的视频文件") + return fmt.Errorf("no video files to merge") } lastSegFile := segFiles[len(segFiles)-1] @@ -843,11 +857,11 @@ func (s *generationService) mergeEpisodeVideo(ctx context.Context, epId int64, l if len(segFiles) == 1 { if err := s.copyFile(lastSegFile, finalPath); err != nil { - return fmt.Errorf("复制视频文件失败: %w", err) + return fmt.Errorf("copy video file failed: %w", err) } } else { if err := s.concatVideos(segFiles, finalPath); err != nil { - return fmt.Errorf("视频合并失败: %w", err) + return fmt.Errorf("video merge failed: %w", err) } } @@ -856,7 +870,7 @@ func (s *generationService) mergeEpisodeVideo(ctx context.Context, epId int64, l finalPath = bgmPath g.Log().Infof(ctx, "已叠加背景音乐: %s", finalPath) } else { - g.Log().Warningf(ctx, "叠加背景音乐失败(跳过): %v", err) + g.Log().Warningf(ctx, "overlay background music failed (skipped): %v", err) } // 事务:更新最后一段 task 状态 + 剧集状态和 video_url @@ -1043,14 +1057,17 @@ func formatDramaConfig(contentType, configStr string) string { // parseMMSSToSeconds 将 MM:SS 格式转为秒数 func parseMMSSToSeconds(timeStr string) int { parts := strings.Split(timeStr, ":") - if len(parts) == 2 { - m := 0 - sec := 0 - fmt.Sscanf(parts[0], "%d", &m) - fmt.Sscanf(parts[1], "%d", &sec) - return m*60 + sec + if len(parts) != 2 { + return 0 } - return 0 + var m, sec int + if _, err := fmt.Sscanf(parts[0], "%d", &m); err != nil { + return 0 + } + if _, err := fmt.Sscanf(parts[1], "%d", &sec); err != nil { + return 0 + } + return m*60 + sec } // ==================== PromptService (migrated from prompt_service.go) ==================== @@ -1058,15 +1075,29 @@ func parseMMSSToSeconds(timeStr string) int { const promptFilePath = "prompt.md" var ( - promptOnce sync.Once - promptSystem string - promptScript string + promptOnce sync.Once + promptSystem string + promptScript string + negativeOnce sync.Once + negativePromptCached string ) +func getDataPath(filename string) string { + exe, err := os.Executable() + if err == nil { + dir := filepath.Dir(exe) + p := filepath.Join(dir, filename) + if _, err := os.Stat(p); err == nil { + return p + } + } + return filename +} + func loadPrompt(ctx context.Context) { - data, err := os.ReadFile(promptFilePath) + data, err := os.ReadFile(getDataPath(promptFilePath)) if err != nil { - g.Log().Warningf(ctx, "读取提示词文件失败: %v", err) + g.Log().Warningf(ctx, "read prompt file failed: %v", err) return } parts := strings.SplitN(string(data), "\n---\n", 2) @@ -1080,6 +1111,17 @@ type promptService struct{} var PromptService = new(promptService) +func getNegativePrompt() string { + negativeOnce.Do(func() { + if data, err := os.ReadFile(getDataPath("negative_prompt.md")); err == nil { + if np := strings.TrimSpace(string(data)); np != "" { + negativePromptCached = np + } + } + }) + return negativePromptCached +} + // GetSystemPrompt 获取导演工作流系统提示词(文件内容的第一段,以 --- 分隔) func (s *promptService) GetSystemPrompt(ctx context.Context) string { promptOnce.Do(func() { loadPrompt(ctx) }) @@ -1119,22 +1161,22 @@ type GenerationContext struct { func BuildGenerationContext(ctx context.Context, drama *entity.Drama) (*GenerationContext, error) { characters, _, err := dao.Character.ListPageByDrama(ctx, drama.Id, 1, -1) if err != nil { - return nil, fmt.Errorf("加载演员失败: %w", err) + return nil, fmt.Errorf("load actors failed: %w", err) } scenes, err := dao.Scene.ListByDrama(ctx, drama.Id) if err != nil { - return nil, fmt.Errorf("加载场景失败: %w", err) + return nil, fmt.Errorf("load scenes failed: %w", err) } props, err := dao.Prop.ListByDrama(ctx, drama.Id) if err != nil { - return nil, fmt.Errorf("加载道具失败: %w", err) + return nil, fmt.Errorf("load props failed: %w", err) } bgmList, err := dao.BackgroundMusic.ListByDrama(ctx, drama.Id) if err != nil { - return nil, fmt.Errorf("加载背景音失败: %w", err) + return nil, fmt.Errorf("load background music failed: %w", err) } ctx2 := &GenerationContext{ @@ -1163,7 +1205,7 @@ func (c *GenerationContext) buildRefIndex() error { continue } if existing, ok := charIdx[ch.Name]; ok { - return fmt.Errorf("演员名冲突: '%s' (已有形象路径 %s,重复 %s)", ch.Name, existing, ch.PortraitPath) + return fmt.Errorf("actor name conflict: '%s' (existing portrait %s, duplicate %s)", ch.Name, existing, ch.PortraitPath) } charIdx[ch.Name] = ch.PortraitPath } @@ -1177,7 +1219,7 @@ func (c *GenerationContext) buildRefIndex() error { continue } if _, ok := sceneIdx[sc.Name]; ok { - return fmt.Errorf("场景名冲突: '%s'", sc.Name) + return fmt.Errorf("scene name conflict: '%s'", sc.Name) } sceneIdx[sc.Name] = sc.ImagePath } @@ -1191,7 +1233,7 @@ func (c *GenerationContext) buildRefIndex() error { continue } if _, ok := propIdx[p.Name]; ok { - return fmt.Errorf("道具名冲突: '%s'", p.Name) + return fmt.Errorf("prop name conflict: '%s'", p.Name) } propIdx[p.Name] = p.ImagePath } @@ -1245,20 +1287,30 @@ func (c *GenerationContext) FindRefIndex(category, name string) int { // ==================== Background Music ==================== // overlayBackgroundMusic 使用 ffmpeg 为视频叠加背景音乐(循环混音) +// 如果有多个有效 BGM 文件,会随机选择其中一个使用 func (s *generationService) overlayBackgroundMusic(ctx context.Context, dramaId int64, videoPath string) (string, error) { bgmList, err := dao.BackgroundMusic.ListByDrama(ctx, dramaId) if err != nil || len(bgmList) == 0 { - return "", fmt.Errorf("无背景音乐配置") + return "", fmt.Errorf("no background music configured") } - bgmPath := bgmList[0].FilePath - if bgmPath == "" { - return "", fmt.Errorf("背景音乐文件路径为空") + + // 过滤出有效的 BGM 文件 + var validTracks []*entity.BackgroundMusic + for _, bgm := range bgmList { + if bgm.FilePath != "" { + if _, err := os.Stat(bgm.FilePath); err == nil { + validTracks = append(validTracks, bgm) + } + } } - if _, err := os.Stat(bgmPath); os.IsNotExist(err) { - return "", fmt.Errorf("背景音乐文件不存在: %s", bgmPath) + if len(validTracks) == 0 { + return "", fmt.Errorf("no valid background music files") } + + // 随机选择一条 BGM 曲目 + bgmPath := validTracks[rand.Intn(len(validTracks))].FilePath if _, err := exec.LookPath("ffmpeg"); err != nil { - return "", fmt.Errorf("ffmpeg 不可用: %w", err) + return "", fmt.Errorf("ffmpeg unavailable: %w", err) } ext := filepath.Ext(videoPath) @@ -1276,11 +1328,11 @@ func (s *generationService) overlayBackgroundMusic(ctx context.Context, dramaId bgmOutput, ) if output, err := cmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("ffmpeg 失败: %w, 输出: %s", err, string(output)) + return "", fmt.Errorf("ffmpeg failed: %w, output: %s", err, string(output)) } if err := os.Rename(bgmOutput, videoPath); err != nil { - return "", fmt.Errorf("替换视频文件失败: %w", err) + return "", fmt.Errorf("replace video file failed: %w", err) } return videoPath, nil } @@ -1290,10 +1342,10 @@ func (s *generationService) overlayBackgroundMusic(ctx context.Context, dramaId // extractLastFrame 使用 ffmpeg 截取视频尾帧并返回 base64 data URL(不落盘) func extractLastFrame(ctx context.Context, videoPath string) (string, error) { if _, err := os.Stat(videoPath); os.IsNotExist(err) { - return "", fmt.Errorf("视频文件不存在: %s", videoPath) + return "", fmt.Errorf("video file not found: %s", videoPath) } if _, err := exec.LookPath("ffmpeg"); err != nil { - return "", fmt.Errorf("ffmpeg 不可用: %w", err) + return "", fmt.Errorf("ffmpeg unavailable: %w", err) } cmd := exec.Command("ffmpeg", "-sseof", "-0.1", @@ -1305,7 +1357,7 @@ func extractLastFrame(ctx context.Context, videoPath string) (string, error) { ) out, err := cmd.Output() if err != nil { - return "", fmt.Errorf("ffmpeg 截取尾帧失败: %w", err) + return "", fmt.Errorf("ffmpeg extract last frame failed: %w", err) } return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(out), nil } @@ -1313,14 +1365,14 @@ func extractLastFrame(ctx context.Context, videoPath string) (string, error) { // saveLastFrameToFile 从视频文件中提取尾帧保存为图片文件 func saveLastFrameToFile(ctx context.Context, videoPath, outputPath string) error { if _, err := os.Stat(videoPath); os.IsNotExist(err) { - return fmt.Errorf("视频文件不存在: %s", videoPath) + return fmt.Errorf("video file not found: %s", videoPath) } if _, err := exec.LookPath("ffmpeg"); err != nil { - return fmt.Errorf("ffmpeg 不可用: %w", err) + return fmt.Errorf("ffmpeg unavailable: %w", err) } dir := filepath.Dir(outputPath) if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("创建目录失败: %w", err) + return fmt.Errorf("create directory failed: %w", err) } cmd := exec.Command("ffmpeg", "-sseof", "-0.1", @@ -1330,7 +1382,7 @@ func saveLastFrameToFile(ctx context.Context, videoPath, outputPath string) erro "-y", outputPath, ) if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("ffmpeg 截取尾帧失败: %w, 输出: %s", err, string(out)) + return fmt.Errorf("ffmpeg extract last frame failed: %w, output: %s", err, string(out)) } return nil } @@ -1356,7 +1408,7 @@ func getOrCreateLastFrame(ctx context.Context, videoPath, dramaTitle, epTitle st } if err := saveLastFrameToFile(ctx, videoPath, framePath); err != nil { - g.Log().Warningf(ctx, "截取尾帧失败: %v", err) + g.Log().Warningf(ctx, "extract last frame failed: %v", err) return "" } g.Log().Infof(ctx, "已截取尾帧: %s -> %s", videoPath, framePath) @@ -1366,7 +1418,7 @@ func getOrCreateLastFrame(ctx context.Context, videoPath, dramaTitle, epTitle st // concatVideos 使用 ffmpeg concat 协议合并多个视频 func (s *generationService) concatVideos(inputs []string, output string) error { if len(inputs) < 2 { - return fmt.Errorf("需要至少2个输入文件才能合并") + return fmt.Errorf("at least 2 input files required to merge") } filelist := output + ".filelist.txt" @@ -1380,7 +1432,7 @@ func (s *generationService) concatVideos(inputs []string, output string) error { lines = append(lines, "file '"+escaped+"'") } if err := os.WriteFile(filelist, []byte(strings.Join(lines, "\n")+"\n"), 0644); err != nil { - return fmt.Errorf("创建文件列表失败: %w", err) + return fmt.Errorf("create file list failed: %w", err) } defer os.Remove(filelist) @@ -1392,21 +1444,22 @@ func (s *generationService) concatVideos(inputs []string, output string) error { "-y", output, ) if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("ffmpeg concat 失败: %w, 输出: %s", err, string(out)) + return fmt.Errorf("ffmpeg concat failed: %w, output: %s", err, string(out)) } return nil } // downloadFile 下载远程文件到本地并验证视频有效性 func (s *generationService) downloadFile(ctx context.Context, url, dest string) error { - resp, err := http.Get(url) + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Get(url) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode) + return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) } out, err := os.Create(dest) @@ -1425,11 +1478,11 @@ func (s *generationService) downloadFile(ctx context.Context, url, dest string) return err } if info.Size() == 0 { - return fmt.Errorf("下载的文件为空") + return fmt.Errorf("downloaded file is empty") } if err := s.probeVideo(dest); err != nil { - return fmt.Errorf("下载的视频文件无效: %w", err) + return fmt.Errorf("downloaded video file invalid: %w", err) } return nil @@ -1439,7 +1492,7 @@ func (s *generationService) downloadFile(ctx context.Context, url, dest string) func (s *generationService) probeVideo(path string) error { cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path) if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("视频文件无效: %w, ffprobe 输出: %s", err, string(out)) + return fmt.Errorf("video file invalid: %w, ffprobe output: %s", err, string(out)) } return nil } @@ -1468,16 +1521,16 @@ func (s *generationService) copyFile(src, dst string) error { func (s *generationService) saveBase64Image(ctx context.Context, dramaTitle, b64Data, subDir, fileName string) (string, error) { data, err := base64.StdEncoding.DecodeString(b64Data) if err != nil { - return "", fmt.Errorf("解码 base64 失败: %w", err) + return "", fmt.Errorf("decode base64 failed: %w", err) } wsDir := WorkspaceDir(dramaTitle) subPath := filepath.Join(wsDir, subDir) if err := os.MkdirAll(subPath, 0755); err != nil { - return "", fmt.Errorf("创建目录失败: %w", err) + return "", fmt.Errorf("create directory failed: %w", err) } dest := filepath.Join(subPath, fileName) if err := os.WriteFile(dest, data, 0644); err != nil { - return "", fmt.Errorf("写入文件失败: %w", err) + return "", fmt.Errorf("write file failed: %w", err) } return dest, nil } @@ -1487,34 +1540,15 @@ func (s *generationService) downloadToLocal(ctx context.Context, url, dramaTitle wsDir := WorkspaceDir(dramaTitle) subPath := filepath.Join(wsDir, subDir) if err := os.MkdirAll(subPath, 0755); err != nil { - return "", fmt.Errorf("创建目录失败: %w", err) + return "", fmt.Errorf("create directory failed: %w", err) } dest := filepath.Join(subPath, fileName) if err := s.downloadFile(ctx, url, dest); err != nil { - return "", fmt.Errorf("下载文件失败: %w", err) + return "", fmt.Errorf("download file failed: %w", err) } return dest, nil } -// imageFileToBase64 读取图片文件并转为 data:image/...;base64 字符串 -func imageFileToBase64(path string) (string, error) { - data, err := os.ReadFile(path) - if err != nil { - return "", err - } - ext := strings.ToLower(filepath.Ext(path)) - mime := "image/png" - switch ext { - case ".jpg", ".jpeg": - mime = "image/jpeg" - case ".gif": - mime = "image/gif" - case ".webp": - mime = "image/webp" - } - return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil -} - // ==================== Polling ==================== const pollCacheTTL = 30 * time.Second @@ -1564,7 +1598,20 @@ func (s *generationService) GetEpisodePollStatus(ctx context.Context, epId int64 return v.Val().(*dto.EpisodePollRes), nil } - // 缓存未命中 — 不查 DB,让前端继续轮询等待缓存写入 + // 缓存未命中 — 查 DB 作为兜底 + tasks, err := dao.GenerationTask.ListByEpisode(ctx, epId) + if err != nil || len(tasks) == 0 { + // 写入短暂空缓存,避免频繁查 DB + emptyRes := &dto.EpisodePollRes{ + Status: "pending", + } + _ = gcache.Set(ctx, cacheKey, emptyRes, 5*time.Second) + return nil, nil + } + setPollCache(ctx, epId, tasks) + if v, err := gcache.Get(ctx, cacheKey); err == nil && !v.IsNil() { + return v.Val().(*dto.EpisodePollRes), nil + } return nil, nil } @@ -1585,7 +1632,7 @@ func (s *generationService) StartVideoPoller(ctx context.Context) { for { select { case <-ticker.C: - s.pollPendingVideos(context.Background()) + s.pollPendingVideos(ctx) case <-ctx.Done(): return } @@ -1600,6 +1647,12 @@ func (s *generationService) pollPendingVideos(ctx context.Context) { return } + // 限制每次轮询最多处理 200 个任务,避免一次性加载过多 + if len(allTasks) > 200 { + g.Log().Warningf(ctx, "poll task count=%d exceeds limit 200, processing first 200 only", len(allTasks)) + allTasks = allTasks[:200] + } + // 按 (DramaId, EpisodeId) 分组 groups := make(map[int64][]*entity.GenerationTask) for _, t := range allTasks { @@ -1661,12 +1714,12 @@ func (s *generationService) processOneEpisode(ctx context.Context, tasks []*enti // 加载短剧和 merged config drama, _ := dao.Drama.GetOne(ctx, current.DramaId) if drama == nil { - g.Log().Warningf(ctx, "轮询器: 短剧 %d 不存在,跳过", current.DramaId) + g.Log().Warningf(ctx, "poller: drama %d not found, skipping", current.DramaId) return } modelCfg := UserModelConfigService.GetMergedConfig(ctx, drama.UserId, "video") if modelCfg.ApiKey == "" || modelCfg.BaseUrl == "" { - g.Log().Warningf(ctx, "轮询器: 短剧 %d 用户 %d 视频模型未配置", current.DramaId, drama.UserId) + g.Log().Warningf(ctx, "poller: drama %d user %d video model not configured", current.DramaId, drama.UserId) return } @@ -1675,13 +1728,13 @@ func (s *generationService) processOneEpisode(ctx context.Context, tasks []*enti videoURL, err := s.pollVideoTaskOnce(ctx, modelCfg, current.VideoTaskId) if err != nil { if strings.Contains(err.Error(), "FAILED") || strings.Contains(err.Error(), "failed") { - g.Log().Warningf(ctx, "轮询器: 任务 %d 第%d段视频任务失败: %v", current.Id, current.SegmentIdx+1, err) + g.Log().Warningf(ctx, "poller: task %d segment %d video task failed: %v", current.Id, current.SegmentIdx+1, err) _ = dao.GenerationTask.UpdateFailed(ctx, current.Id, err.Error()) clearPollCache(ctx, current.EpisodeId) } else if strings.Contains(err.Error(), "RUNNING") { g.Log().Debugf(ctx, "轮询器: 任务 %d 第%d段视频正在生成中,继续等待...", current.Id, current.SegmentIdx+1) } else { - g.Log().Warningf(ctx, "轮询器: 任务 %d 第%d段视频查询异常: %v", current.Id, current.SegmentIdx+1, err) + g.Log().Warningf(ctx, "poller: task %d segment %d video query error: %v", current.Id, current.SegmentIdx+1, err) } return } @@ -1699,7 +1752,7 @@ func (s *generationService) processOneEpisode(ctx context.Context, tasks []*enti localPath, dlErr := s.downloadToLocal(ctx, videoURL, dramaTitle, "产出视频", fmt.Sprintf("%s_seg_%d.mp4", safeEp, current.SegmentIdx)) if dlErr != nil { - g.Log().Warningf(ctx, "轮询器: 任务 %d 第%d段视频下载失败,使用远程地址: %v", current.Id, current.SegmentIdx+1, dlErr) + g.Log().Warningf(ctx, "poller: task %d segment %d video download failed, using remote url: %v", current.Id, current.SegmentIdx+1, dlErr) _ = dao.GenerationTask.UpdateFields(ctx, current.Id, g.Map{"video_url": videoURL, "video_task_id": ""}) } else { g.Log().Infof(ctx, "轮询器: 任务 %d 第%d段视频已下载: %s", current.Id, current.SegmentIdx+1, localPath) @@ -1729,7 +1782,7 @@ func (s *generationService) processOneEpisode(ctx context.Context, tasks []*enti // 使用 task.script(bodyJSON)直接提交,不走 Agent var bodyMap map[string]any if err := json.Unmarshal([]byte(current.Script), &bodyMap); err != nil { - g.Log().Errorf(ctx, "轮询器: 任务 %d script 不是合法 JSON: %v", current.Id, err) + g.Log().Errorf(ctx, "poller: task %d script is not valid JSON: %v", current.Id, err) _ = dao.GenerationTask.UpdateFailed(ctx, current.Id, "script 不是合法 JSON") return } @@ -1744,14 +1797,18 @@ func (s *generationService) processOneEpisode(ctx context.Context, tasks []*enti // 统一处理首帧(segIdx==0 删除,>0 自动解析或截取尾帧) prepareFirstFrame(ctx, bodyMap, current.SegmentIdx, current.Id, current.EpisodeId, drama.Title, epTitle, modelCfg.FirstFrameMapping, modelCfg.Schema) - bodyJSON, _ := json.Marshal(bodyMap) + bodyJSON, err := json.Marshal(bodyMap) + if err != nil { + g.Log().Errorf(ctx, "marshal body failed: %v", err) + return + } // 调用模型前写入模型名称 _ = dao.GenerationTask.UpdateFields(ctx, current.Id, g.Map{"model_name": modelCfg.ModelName}) taskId, _, err := resubmitVideoTask(ctx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.Schema, bodyJSON, modelCfg.FirstFrameMapping) if err != nil { - g.Log().Errorf(ctx, "轮询器: 第%d段视频提交失败: %v", current.SegmentIdx+1, err) + g.Log().Errorf(ctx, "poller: segment %d video submit failed: %v", current.SegmentIdx+1, err) return } @@ -1772,17 +1829,20 @@ func (s *generationService) pollVideoTaskOnce(ctx context.Context, modelCfg *Mer req, err := http.NewRequestWithContext(ctx, "GET", queryURL, nil) if err != nil { - return "", fmt.Errorf("创建查询请求失败: %w", err) + return "", fmt.Errorf("create query request failed: %w", err) } req.Header.Set("Authorization", "Bearer "+modelCfg.ApiKey) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return "", fmt.Errorf("查询请求失败: %w", err) + return "", fmt.Errorf("query request failed: %w", err) } defer resp.Body.Close() - data, _ := io.ReadAll(resp.Body) + data, err2 := io.ReadAll(resp.Body) + if err2 != nil { + return "", fmt.Errorf("query task response read failed: %w", err2) + } var result struct { Code string `json:"code"` Message string `json:"message"` @@ -1801,7 +1861,7 @@ func (s *generationService) pollVideoTaskOnce(ctx context.Context, modelCfg *Mer } `json:"output"` } if err := json.Unmarshal(data, &result); err != nil { - return "", fmt.Errorf("解析响应失败: %s", string(data)) + return "", fmt.Errorf("parse response failed: %s", string(data)) } // 兼容多种响应格式:status / task_status / output.task_status @@ -1830,8 +1890,8 @@ func (s *generationService) pollVideoTaskOnce(ctx context.Context, modelCfg *Mer if videoURL != "" { return videoURL, nil } - g.Log().Warningf(ctx, "任务 %s 状态为 SUCCEEDED 但未返回视频URL,完整响应: %s", taskId, string(data)) - return "", fmt.Errorf("SUCCEEDED但视频URL为空,完整响应: %s", string(data)) + g.Log().Warningf(ctx, "task %s status is SUCCEEDED but no video URL returned, full response: %s", taskId, string(data)) + return "", fmt.Errorf("SUCCEEDED but video URL is empty, full response: %s", string(data)) case "FAILED", "failed": errMsg := status if result.Output != nil && result.Output.Message != "" { @@ -1843,20 +1903,20 @@ func (s *generationService) pollVideoTaskOnce(ctx context.Context, modelCfg *Mer } // 通过 API 响应中的 code/message 查看具体错误 - return "", fmt.Errorf("任务异常: %s - %s", result.Code, result.Message) + return "", fmt.Errorf("task error: %s - %s", result.Code, result.Message) } func (s *generationService) waitForSegmentVideo(ctx context.Context, d *entity.Drama, ep *entity.Episode, taskId int64) error { modelCfg := UserModelConfigService.GetMergedConfig(ctx, d.UserId, "video") if modelCfg.ApiKey == "" || modelCfg.BaseUrl == "" { - return fmt.Errorf("视频模型未配置或查询地址为空") + return fmt.Errorf("video model not configured or query URL is empty") } task, err := dao.GenerationTask.GetOne(ctx, taskId) if err != nil || task == nil { - return fmt.Errorf("获取任务失败: %w", err) + return fmt.Errorf("get task failed: %w", err) } if task.VideoTaskId == "" { - return fmt.Errorf("任务 %d 尚无 video_task_id", taskId) + return fmt.Errorf("task %d has no video_task_id yet", taskId) } videoTaskID := task.VideoTaskId @@ -1870,7 +1930,7 @@ func (s *generationService) waitForSegmentVideo(ctx context.Context, d *entity.D localPath, dlErr := s.downloadToLocal(ctx, videoURL, dramaTitle, "产出视频", fmt.Sprintf("%s_seg_%d.mp4", safeEp, task.SegmentIdx)) if dlErr != nil { - g.Log().Warningf(ctx, "串行等待: 任务 %d 第%d段视频下载失败,使用远程地址: %v", task.Id, task.SegmentIdx+1, dlErr) + g.Log().Warningf(ctx, "serial wait: task %d segment %d video download failed, using remote url: %v", task.Id, task.SegmentIdx+1, dlErr) _ = dao.GenerationTask.UpdateFields(ctx, task.Id, g.Map{"video_url": videoURL, "video_task_id": ""}) } else { g.Log().Infof(ctx, "串行等待: 任务 %d 第%d段视频已下载: %s", task.Id, task.SegmentIdx+1, localPath) @@ -1881,13 +1941,13 @@ func (s *generationService) waitForSegmentVideo(ctx context.Context, d *entity.D if err != nil { // 非 duration 错误才终止 if !strings.Contains(strings.ToLower(err.Error()), "duration") { - return fmt.Errorf("轮询视频任务失败: %w", err) + return fmt.Errorf("poll video task failed: %w", err) } - g.Log().Warningf(ctx, "串行等待: 第%d段视频任务 duration 错误,重试: %v", task.SegmentIdx+1, err) + g.Log().Warningf(ctx, "serial wait: segment %d video task duration error, retrying: %v", task.SegmentIdx+1, err) } time.Sleep(6 * time.Second) } - return fmt.Errorf("串行等待超时: 任务 %d 第%d段视频在 %d 次轮询后仍未就绪", task.Id, task.SegmentIdx+1, maxAttempts) + return fmt.Errorf("serial wait timeout: task %d segment %d not ready after %d polls", task.Id, task.SegmentIdx+1, maxAttempts) } // ==================== Video Generation ==================== @@ -1895,12 +1955,12 @@ func (s *generationService) waitForSegmentVideo(ctx context.Context, d *entity.D func (s *generationService) submitFromExistingScript(ctx context.Context, taskId int64, modelCfg *MergedModelConfig, segIdx int, dramaTitle, epTitle string) (string, string, error) { task, err := dao.GenerationTask.GetOne(ctx, taskId) if err != nil || task == nil || task.Script == "" { - return "", "", fmt.Errorf("task.script 为空") + return "", "", fmt.Errorf("task.script is empty") } var bodyMap map[string]any if err := json.Unmarshal([]byte(task.Script), &bodyMap); err != nil { - return "", "", fmt.Errorf("解析 task.script 失败: %w", err) + return "", "", fmt.Errorf("parse task.script failed: %w", err) } // 设置模型名并统一处理首帧 @@ -1908,7 +1968,10 @@ func (s *generationService) submitFromExistingScript(ctx context.Context, taskId prepareFirstFrame(ctx, bodyMap, segIdx, taskId, task.EpisodeId, dramaTitle, epTitle, modelCfg.FirstFrameMapping, modelCfg.Schema) // 将修改后的 body(含文件路径)序列化,用于保存到 task.script - updatedBody, _ := json.Marshal(bodyMap) + updatedBody, err := json.Marshal(bodyMap) + if err != nil { + return "", "", fmt.Errorf("marshal updated body failed: %w", err) + } // 复用 resubmitVideoTask 将文件路径转 base64 并提交 API taskID, _, err := resubmitVideoTask(ctx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.Schema, updatedBody, modelCfg.FirstFrameMapping) @@ -1923,11 +1986,11 @@ func (s *generationService) submitFromExistingScript(ctx context.Context, taskId func (s *generationService) submitVideoTask(ctx context.Context, d *entity.Drama, ep *entity.Episode, segIdx, segDur int, scenes []model.SegmentScene, refs []model.VideoRef, genTaskId int64) (string, string, error) { modelCfg := UserModelConfigService.GetMergedConfig(ctx, d.UserId, "video") if modelCfg.ApiKey == "" || modelCfg.BaseUrl == "" || modelCfg.ModelName == "" { - return "", "", fmt.Errorf("视频模型未配置") + return "", "", fmt.Errorf("video model not configured") } if len(scenes) == 0 { - g.Log().Warningf(ctx, "第%d集第%d段无场景数据,使用默认场景描述", ep.Index, segIdx+1) + g.Log().Warningf(ctx, "episode %d segment %d no scene data, using default scene description", ep.Index, segIdx+1) scenes = []model.SegmentScene{ { Description: fmt.Sprintf("短剧《%s》第%d集第%d段", d.Title, ep.Index, segIdx+1), @@ -2023,12 +2086,7 @@ func (s *generationService) submitVideoTask(ctx context.Context, d *entity.Drama } // 读取 negative_prompt.md 文件作为反向提示词 - negativePrompt := "" - if data, err := os.ReadFile("negative_prompt.md"); err == nil { - if np := strings.TrimSpace(string(data)); np != "" { - negativePrompt = np - } - } + negativePrompt := getNegativePrompt() // 从 video_schema 判断当前模型是否支持 duration 参数 effectiveDur := segDur @@ -2064,16 +2122,59 @@ func (s *generationService) submitVideoTask(ctx context.Context, d *entity.Drama taskId, requestJSON, err := createVideoTask(ctx, modelCfg.ApiKey, modelCfg.BaseUrl, modelCfg.ModelName, prompt, negativePrompt, refURLs, effectiveDur, d.Resolution, d.AspectRatio, modelCfg.Schema, seed, segIdx, genTaskId, ep.Id, d.Title, ep.Title, modelCfg.FirstFrameMapping) if err != nil { - return "", "", fmt.Errorf("视频合成请求失败: %w", err) + return "", "", fmt.Errorf("video composition request failed: %w", err) } if taskId == "" { - return "", "", fmt.Errorf("视频合成任务ID为空") + return "", "", fmt.Errorf("video composition task ID is empty") } return taskId, requestJSON, nil } +// callVideoAPI sends a POST request with JSON body to the video API and extracts task ID from the response. +func callVideoAPI(ctx context.Context, apiKey, baseURL string, payload []byte, schema string) (string, []byte, error) { + req, err := http.NewRequestWithContext(ctx, "POST", baseURL, strings.NewReader(string(payload))) + if err != nil { + return "", nil, fmt.Errorf("create request failed: %w", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + // 从 schema 解析并设置请求头 + for k, v := range schemaHeaders(schema) { + req.Header.Set(k, v) + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respData, _ := io.ReadAll(resp.Body) + var result struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Output *struct { + TaskID string `json:"task_id"` + } `json:"output"` + } + if err := json.Unmarshal(respData, &result); err != nil { + return "", nil, fmt.Errorf("parse response failed: %s", string(respData)) + } + taskID := result.ID + if taskID == "" { + taskID = result.TaskID + } + if taskID == "" && result.Output != nil { + taskID = result.Output.TaskID + } + if taskID == "" { + return "", nil, fmt.Errorf("cannot get task ID from response: %s", string(respData)) + } + return taskID, respData, nil +} + // API 请求体结构由 model_config.video_schema 定义,包含: // // params: 额外参数(如 audio/shot_type/watermark 等) @@ -2154,51 +2255,24 @@ func createVideoTask(ctx context.Context, apiKey, baseURL, modelName, prompt, ne } } - payload, _ := json.Marshal(body) + payload, err := json.Marshal(body) + if err != nil { + return "", "", fmt.Errorf("marshal payload failed: %w", err) + } promptStr, _ := getSchemaField(body, "prompt").(string) // 将 body 中的本地文件路径转为 base64,用于 API 请求(不修改 payload,保留文件路径给 task.script) convertBodyMediaToBase64(body, mediaURLField) - apiPayload, _ := json.Marshal(body) + apiPayload, err := json.Marshal(body) + if err != nil { + return "", "", fmt.Errorf("marshal api payload failed: %w", err) + } g.Log().Infof(ctx, "视频API请求 model=%s body_size=%d prompt_len=%d 目的=提交视频生成任务", modelName, len(apiPayload), len([]rune(promptStr))) - req, err := http.NewRequestWithContext(ctx, "POST", baseURL, strings.NewReader(string(apiPayload))) - if err != nil { - return "", "", fmt.Errorf("创建请求失败: %w", err) - } - req.Header.Set("Authorization", "Bearer "+apiKey) - // 从 schema 解析并设置请求头 - for k, v := range schemaHeaders(videoSchema) { - req.Header.Set(k, v) - } - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) + taskID, _, err := callVideoAPI(ctx, apiKey, baseURL, apiPayload, videoSchema) if err != nil { - return "", "", fmt.Errorf("请求失败: %w", err) - } - defer resp.Body.Close() - - respData, _ := io.ReadAll(resp.Body) - var result struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - Output *struct { - TaskID string `json:"task_id"` - } `json:"output"` - } - if err := json.Unmarshal(respData, &result); err != nil { - return "", "", fmt.Errorf("解析响应失败: %s", string(respData)) - } - taskID := result.ID - if taskID == "" { - taskID = result.TaskID - } - if taskID == "" && result.Output != nil { - taskID = result.Output.TaskID - } - if taskID == "" { - return "", "", fmt.Errorf("无法从响应中获取任务ID: %s", string(respData)) + return "", "", err } return taskID, string(payload), nil } @@ -2207,7 +2281,7 @@ func createVideoTask(ctx context.Context, apiKey, baseURL, modelName, prompt, ne func resubmitVideoTask(ctx context.Context, apiKey, baseURL, schema string, bodyJSON []byte, firstFrameMapping string) (string, []byte, error) { var bodyMap map[string]any if err := json.Unmarshal(bodyJSON, &bodyMap); err != nil { - return "", nil, fmt.Errorf("解析 bodyJSON 失败: %w", err) + return "", nil, fmt.Errorf("parse bodyJSON failed: %w", err) } // 保存原始 bodyJSON 用于返回(task.script 使用文件路径) @@ -2216,7 +2290,10 @@ func resubmitVideoTask(ctx context.Context, apiKey, baseURL, schema string, body // 将 body 中的本地文件路径转为 base64,用于 API 请求 _, _, urlField, _ := resolveMediaFields(schema, firstFrameMapping) convertBodyMediaToBase64(bodyMap, urlField) - apiPayload, _ := json.Marshal(bodyMap) + apiPayload, err := json.Marshal(bodyMap) + if err != nil { + return "", nil, fmt.Errorf("marshal api payload failed: %w", err) + } // 提取模型名用于日志 modelName := "" @@ -2229,43 +2306,10 @@ func resubmitVideoTask(ctx context.Context, apiKey, baseURL, schema string, body } } g.Log().Infof(ctx, "视频API重新提交 model=%s body_size=%d 目的=重新提交视频生成任务或崩溃恢复", modelName, len(apiPayload)) - req, err := http.NewRequestWithContext(ctx, "POST", baseURL, strings.NewReader(string(apiPayload))) - if err != nil { - return "", nil, fmt.Errorf("创建请求失败: %w", err) - } - req.Header.Set("Authorization", "Bearer "+apiKey) - // 从 schema 解析并设置请求头 - for k, v := range schemaHeaders(schema) { - req.Header.Set(k, v) - } - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) + taskID, _, err := callVideoAPI(ctx, apiKey, baseURL, apiPayload, schema) if err != nil { - return "", nil, fmt.Errorf("请求失败: %w", err) - } - defer resp.Body.Close() - - respData, _ := io.ReadAll(resp.Body) - var result struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - Output *struct { - TaskID string `json:"task_id"` - } `json:"output"` - } - if err := json.Unmarshal(respData, &result); err != nil { - return "", nil, fmt.Errorf("解析响应失败: %s", string(respData)) - } - taskID := result.ID - if taskID == "" { - taskID = result.TaskID - } - if taskID == "" && result.Output != nil { - taskID = result.Output.TaskID - } - if taskID == "" { - return "", nil, fmt.Errorf("无法从响应中获取任务ID: %s", string(respData)) + return "", nil, err } return taskID, origPayload, nil } @@ -2345,7 +2389,7 @@ func convertBodyMediaToBase64(body map[string]any, urlField string) { if u == "" || strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") { continue } - if b64, err := imageFileToBase64(u); err == nil { + if b64, err := common.ImageFileToBase64(u); err == nil { refs[i] = b64 } } @@ -2362,7 +2406,7 @@ func convertBodyMediaToBase64(body map[string]any, urlField string) { if u == "" || strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") { continue } - if b64, err := imageFileToBase64(u); err == nil { + if b64, err := common.ImageFileToBase64(u); err == nil { m[urlField] = b64 } } diff --git a/shortdrama/service/model_config_service.go b/shortdrama/service/model_config_service.go index 343754c..f997656 100644 --- a/shortdrama/service/model_config_service.go +++ b/shortdrama/service/model_config_service.go @@ -26,7 +26,7 @@ func (s *modelConfigService) getModelList(ctx context.Context) []*entity.ModelCo if err != nil || len(list) == 0 { return make([]*entity.ModelConfig, 0) } - _ = gcache.Set(ctx, cacheKeyModelList, list, 0) + _ = gcache.Set(ctx, cacheKeyModelList, list, 300) return list } diff --git a/shortdrama/service/payment_config_service.go b/shortdrama/service/payment_config_service.go index d8f949d..411f0d0 100644 --- a/shortdrama/service/payment_config_service.go +++ b/shortdrama/service/payment_config_service.go @@ -23,7 +23,7 @@ func (s *paymentConfigService) getPaymentConfigList(ctx context.Context) ([]*ent if err != nil { return nil, err } - _ = gcache.Set(ctx, cacheKeyPaymentConfig, list, 0) + _ = gcache.Set(ctx, cacheKeyPaymentConfig, list, 300) return list, nil } diff --git a/shortdrama/service/payment_order_service.go b/shortdrama/service/payment_order_service.go index 56b2544..4ac82bc 100644 --- a/shortdrama/service/payment_order_service.go +++ b/shortdrama/service/payment_order_service.go @@ -5,6 +5,7 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" + "encoding/xml" "errors" "fmt" "io" @@ -26,62 +27,21 @@ type paymentService struct{} var PaymentService = new(paymentService) -type PaymentConfig struct { - Wechat WechatConfig `json:"wechat"` - Alipay AlipayConfig `json:"alipay"` -} - -type WechatConfig struct { - AppId string `json:"app_id"` - MchId string `json:"mch_id"` - ApiKey string `json:"api_key"` - AppSecret string `json:"app_secret"` -} - -type AlipayConfig struct { - AppId string `json:"app_id"` - PrivateKey string `json:"private_key"` - PublicKey string `json:"public_key"` -} - -func (s *paymentService) GetPaymentConfig(channel, channelType string) (WechatConfig, AlipayConfig, error) { - ctx := context.Background() - switch channel { - case "wechat": - wc, err := dao.PaymentConfigDao.GetByChannel(ctx, "wechat", channelType) - if err != nil { - return WechatConfig{}, AlipayConfig{}, err - } - if wc == nil || wc.AppId == "" || wc.MchId == "" || wc.ApiKey == "" { - return WechatConfig{}, AlipayConfig{}, errors.New("微信支付(" + channelType + ")未配置") - } - return WechatConfig{ - AppId: wc.AppId, - MchId: wc.MchId, - ApiKey: wc.ApiKey, - AppSecret: wc.AppSecret, - }, AlipayConfig{}, nil - case "alipay": - ac, err := dao.PaymentConfigDao.GetByChannel(ctx, "alipay", channelType) - if err != nil { - return WechatConfig{}, AlipayConfig{}, err - } - if ac == nil || ac.AppId == "" || ac.PrivateKey == "" { - return WechatConfig{}, AlipayConfig{}, errors.New("支付宝支付(" + channelType + ")未配置") - } - return WechatConfig{}, AlipayConfig{ - AppId: ac.AppId, - PrivateKey: ac.PrivateKey, - PublicKey: ac.PublicKey, - }, nil +func (s *paymentService) GetPaymentConfig(ctx context.Context, channel, channelType string) (*entity.PaymentConfig, error) { + cfg, err := dao.PaymentConfigDao.GetByChannel(ctx, channel, channelType) + if err != nil { + return nil, err } - return WechatConfig{}, AlipayConfig{}, errors.New("不支持的支付渠道") + if cfg == nil { + return nil, errors.New("payment config not found: " + channel + "/" + channelType) + } + return cfg, nil } // Prepay 创建支付订单并调起渠道 func (s *paymentService) Prepay(ctx context.Context, userId int64, amount int64, channel string, orderType string, duration int) (*entity.PaymentOrder, string, string, string, error) { if amount < 1 { - return nil, "", "", "", errors.New("金额必须大于0") + return nil, "", "", "", errors.New("amount must be greater than 0") } if orderType == "" { @@ -97,12 +57,12 @@ func (s *paymentService) Prepay(ctx context.Context, userId int64, amount int64, case "offline": chanType = "manual" default: - return nil, "", "", "", errors.New("不支持的支付渠道") + return nil, "", "", "", errors.New("unsupported payment channel") } // 外部渠道需要检查支付配置 if channel != "offline" { - if _, _, err := s.GetPaymentConfig(channel, chanType); err != nil { + if _, err := s.GetPaymentConfig(ctx, channel, chanType); err != nil { return nil, "", "", "", err } } @@ -137,10 +97,16 @@ func (s *paymentService) Prepay(ctx context.Context, userId int64, amount int64, switch channel { case "wechat": - wechatCfg, _, _ := s.GetPaymentConfig("wechat", chanType) + wechatCfg, err := s.GetPaymentConfig(ctx, "wechat", chanType) + if err != nil { + return nil, "", "", "", err + } codeUrl, prepayJson, err = s.callWechat(ctx, order, wechatCfg) case "alipay": - _, alipayCfg, _ := s.GetPaymentConfig("alipay", chanType) + alipayCfg, err := s.GetPaymentConfig(ctx, "alipay", chanType) + if err != nil { + return nil, "", "", "", err + } codeUrl, redirectUrl, err = s.callAlipay(ctx, order, alipayCfg) case "offline": // 线下支付无需调起外部渠道,直接返回 @@ -168,7 +134,7 @@ func (s *paymentService) Prepay(ctx context.Context, userId int64, amount int64, func (s *paymentService) CreateRenewalOrder(ctx context.Context, agentId int64, duration int, pricingId int64) (*entity.PaymentOrder, error) { agent, err := dao.User.GetOne(ctx, agentId) if err != nil || agent == nil || agent.Role != "agent" { - return nil, errors.New("代理商不存在") + return nil, errors.New("agent not found") } var pricing *entity.RegionPricing @@ -183,7 +149,7 @@ func (s *paymentService) CreateRenewalOrder(ctx context.Context, agentId int64, pricing, err = dao.RegionPricing.GetByRegion(ctx, agent.Region, protectedVal) } if err != nil || pricing == nil { - return nil, errors.New("该地区未配置定价") + return nil, errors.New("pricing not configured for this region") } orderNo := s.generateOrderNo() @@ -225,13 +191,13 @@ func (s *paymentService) CreateRenewalOrder(ctx context.Context, agentId int64, func (s *paymentService) ConfirmOffline(ctx context.Context, orderNo string) error { order, err := dao.PaymentOrder.GetByOrderNo(ctx, orderNo) if err != nil || order == nil { - return errors.New("订单不存在") + return errors.New("order not found") } if order.Status != "pending" { - return errors.New("订单状态不允许确认收款") + return errors.New("order status does not allow confirmation") } if order.Channel != "offline" { - return errors.New("仅支持确认线下支付订单") + return errors.New("only offline payment orders can be confirmed") } return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { @@ -254,7 +220,7 @@ func (s *paymentService) HandleNotify(ctx context.Context, channel string, body switch channel { case "wechat": - wechatCfg, _, err := s.GetPaymentConfig("wechat", "native") + wechatCfg, err := s.GetPaymentConfig(ctx, "wechat", "native") if err != nil { return err } @@ -263,7 +229,7 @@ func (s *paymentService) HandleNotify(ctx context.Context, channel string, body return err } case "alipay": - _, alipayCfg, err := s.GetPaymentConfig("alipay", "native") + alipayCfg, err := s.GetPaymentConfig(ctx, "alipay", "native") if err != nil { return err } @@ -272,12 +238,12 @@ func (s *paymentService) HandleNotify(ctx context.Context, channel string, body return err } default: - return errors.New("未知渠道") + return errors.New("unknown channel") } order, err := dao.PaymentOrder.GetByOrderNo(ctx, orderNo) if err != nil || order == nil { - return errors.New("订单不存在") + return errors.New("order not found") } if order.Status == "success" { return nil // 防止重复回调 @@ -310,11 +276,15 @@ func (s *paymentService) HandleNotify(ctx context.Context, channel string, body func (s *paymentService) GetStatus(ctx context.Context, orderNo string) (string, int64, error) { order, err := dao.PaymentOrder.GetByOrderNo(ctx, orderNo) if err != nil || order == nil { - return "", 0, errors.New("订单不存在") + return "", 0, errors.New("order not found") } return order.Status, order.Amount, nil } +func (s *paymentService) ListOrders(ctx context.Context, userId int64, page, pageSize int) ([]*entity.PaymentOrder, int, error) { + return dao.PaymentOrder.ListPageByUser(ctx, userId, page, pageSize) +} + func (s *paymentService) generateOrderNo() string { now := time.Now() r := rand.Intn(10000) @@ -323,9 +293,9 @@ func (s *paymentService) generateOrderNo() string { // ==================== 微信支付 ==================== -func (s *paymentService) callWechat(ctx context.Context, order *entity.PaymentOrder, cfg WechatConfig) (codeUrl, prepayJson string, err error) { +func (s *paymentService) callWechat(ctx context.Context, order *entity.PaymentOrder, cfg *entity.PaymentConfig) (codeUrl, prepayJson string, err error) { if cfg.AppId == "" || cfg.MchId == "" || cfg.ApiKey == "" { - return "", "", errors.New("微信支付未配置") + return "", "", errors.New("wechat payment not configured") } nonceStr := s.randomStr(32) params := map[string]string{ @@ -345,27 +315,27 @@ func (s *paymentService) callWechat(ctx context.Context, order *entity.PaymentOr xmlReq := s.mapToXML(params) resp, err := http.Post("https://api.mch.weixin.qq.com/pay/unifiedorder", "text/xml", strings.NewReader(xmlReq)) if err != nil { - return "", "", fmt.Errorf("调用微信下单失败: %w", err) + return "", "", fmt.Errorf("wechat order failed: %w", err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) result := s.xmlToMap(string(b)) if result["return_code"] != "SUCCESS" || result["result_code"] != "SUCCESS" { - return "", "", fmt.Errorf("微信下单失败: %s", result["return_msg"]) + return "", "", fmt.Errorf("wechat order failed: %s", result["return_msg"]) } return result["code_url"], "", nil } // verifyWechatNotify 验证微信回调签名,返回 (orderNo, transactionId) -func (s *paymentService) verifyWechatNotify(body []byte, cfg WechatConfig) (string, string, error) { +func (s *paymentService) verifyWechatNotify(body []byte, cfg *entity.PaymentConfig) (string, string, error) { result := s.xmlToMap(string(body)) if result["return_code"] != "SUCCESS" { - return "", "", errors.New("微信回调失败") + return "", "", errors.New("wechat callback failed") } sign := s.wechatSign(result, cfg.ApiKey) if sign != result["sign"] { - return "", "", errors.New("微信回调签名验证失败") + return "", "", errors.New("wechat callback signature verification failed") } return result["out_trade_no"], result["transaction_id"], nil } @@ -395,9 +365,9 @@ func (s *paymentService) wechatSign(params map[string]string, apiKey string) str // ==================== 支付宝支付 ==================== -func (s *paymentService) callAlipay(ctx context.Context, order *entity.PaymentOrder, cfg AlipayConfig) (codeUrl, redirectUrl string, err error) { +func (s *paymentService) callAlipay(ctx context.Context, order *entity.PaymentOrder, cfg *entity.PaymentConfig) (codeUrl, redirectUrl string, err error) { if cfg.AppId == "" || cfg.PrivateKey == "" { - return "", "", errors.New("支付宝支付未配置") + return "", "", errors.New("alipay payment not configured") } bizContent := fmt.Sprintf(`{"out_trade_no":"%s","total_amount":"%.2f","subject":"%s"}`, order.OrderNo, float64(order.Amount)/100, order.Subject) @@ -418,7 +388,7 @@ func (s *paymentService) callAlipay(ctx context.Context, order *entity.PaymentOr resp, err := http.PostForm("https://openapi.alipay.com/gateway.do", params) if err != nil { - return "", "", fmt.Errorf("调用支付宝下单失败: %w", err) + return "", "", fmt.Errorf("alipay order failed: %w", err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) @@ -433,25 +403,25 @@ func (s *paymentService) callAlipay(ctx context.Context, order *entity.PaymentOr Sign string `json:"sign"` } if err := json.Unmarshal(b, &result); err != nil { - return "", "", errors.New("支付宝返回解析失败") + return "", "", errors.New("alipay response parse failed") } if result.Response.Code != "10000" { - return "", "", fmt.Errorf("支付宝下单失败: %s", result.Response.SubMsg) + return "", "", fmt.Errorf("alipay order failed: %s", result.Response.SubMsg) } return result.Response.QrCode, "", nil } -func (s *paymentService) verifyAlipayNotify(body []byte, cfg AlipayConfig) (string, string, error) { +func (s *paymentService) verifyAlipayNotify(body []byte, cfg *entity.PaymentConfig) (string, string, error) { vals, err := url.ParseQuery(string(body)) if err != nil { - return "", "", errors.New("支付宝回调解析失败") + return "", "", errors.New("alipay callback parse failed") } if vals.Get("trade_status") != "TRADE_SUCCESS" { - return "", "", errors.New("支付宝回调状态非成功") + return "", "", errors.New("alipay callback status is not success") } sign := s.alipaySign(vals, cfg.PublicKey) if sign != vals.Get("sign") { - return "", "", errors.New("支付宝回调签名验证失败") + return "", "", errors.New("alipay callback signature verification failed") } return vals.Get("out_trade_no"), vals.Get("trade_no"), nil } @@ -506,55 +476,27 @@ func (s *paymentService) mapToXML(m map[string]string) string { func (s *paymentService) xmlToMap(xmlStr string) map[string]string { result := make(map[string]string) - var key, value string - inKey := false - inValue := false - for i := 0; i < len(xmlStr); i++ { - c := xmlStr[i] - if c == '<' { - if i+1 < len(xmlStr) && xmlStr[i+1] == '/' { - inKey = false - if key != "" && value != "" { - result[key] = value - key = "" - value = "" + decoder := xml.NewDecoder(strings.NewReader(xmlStr)) + var key string + for { + token, err := decoder.Token() + if err != nil { + break + } + switch t := token.(type) { + case xml.StartElement: + key = t.Name.Local + case xml.CharData: + if key != "" { + val := strings.TrimSpace(string(t)) + if val != "" { + result[key] = val } - continue - } - if i+1 < len(xmlStr) && xmlStr[i+1] == '!' { - continue - } - if !inKey && !inValue { - inKey = true key = "" - continue } - } - if c == '>' { - if inKey { - inKey = false - inValue = true - value = "" - continue - } - if inValue { - inValue = false - continue - } - } - if inKey { - key += string(c) - } else if inValue { - value += string(c) + case xml.EndElement: + key = "" } } return result } - -// InitDefaultPaymentConfig 初始化默认支付配置(空配置) -func InitDefaultPaymentConfig() PaymentConfig { - return PaymentConfig{ - Wechat: WechatConfig{}, - Alipay: AlipayConfig{}, - } -} diff --git a/shortdrama/service/prop_service.go b/shortdrama/service/prop_service.go index c329bba..96e6a66 100644 --- a/shortdrama/service/prop_service.go +++ b/shortdrama/service/prop_service.go @@ -52,7 +52,7 @@ func (s *propService) UpdateProp(ctx context.Context, id, dramaId int64, name, d return err } if c == nil { - return fmt.Errorf("道具不存在: %d", id) + return fmt.Errorf("prop not found: %d", id) } if name != "" { c.Name = name diff --git a/shortdrama/service/region_pricing_service.go b/shortdrama/service/region_pricing_service.go index f31b8c0..5fea80b 100644 --- a/shortdrama/service/region_pricing_service.go +++ b/shortdrama/service/region_pricing_service.go @@ -24,7 +24,7 @@ func (s *regionPricingService) getList(ctx context.Context) ([]*entity.RegionPri if err != nil { return nil, err } - _ = gcache.Set(ctx, cacheKeyRegionPricing, list, 0) + _ = gcache.Set(ctx, cacheKeyRegionPricing, list, 300) return list, nil } diff --git a/shortdrama/service/scene_service.go b/shortdrama/service/scene_service.go index faa2643..9dc98ce 100644 --- a/shortdrama/service/scene_service.go +++ b/shortdrama/service/scene_service.go @@ -52,7 +52,7 @@ func (s *sceneService) UpdateScene(ctx context.Context, id, dramaId int64, name, return err } if c == nil { - return fmt.Errorf("场景不存在: %d", id) + return fmt.Errorf("scene not found: %d", id) } if name != "" { c.Name = name diff --git a/shortdrama/service/transaction_service.go b/shortdrama/service/transaction_service.go index 156d8fd..f5b1e97 100644 --- a/shortdrama/service/transaction_service.go +++ b/shortdrama/service/transaction_service.go @@ -22,11 +22,11 @@ func (s *transactionService) Insert(ctx context.Context, data *entity.AccountTra func (s *transactionService) Recharge(ctx context.Context, customerId int64, amount int64, orderNo, remark string) error { if amount <= 0 { - return fmt.Errorf("充值金额必须大于0") + return fmt.Errorf("recharge amount must be greater than 0") } cp, err := dao.CustomerProfile.Get(ctx, customerId) if err != nil || cp == nil { - return fmt.Errorf("客户不存在") + return fmt.Errorf("customer not found") } newBalance := cp.Balance + amount if err := dao.CustomerProfile.UpdateBalance(ctx, customerId, newBalance); err != nil { diff --git a/shortdrama/service/user_model_config_service.go b/shortdrama/service/user_model_config_service.go index 6d82f40..b576295 100644 --- a/shortdrama/service/user_model_config_service.go +++ b/shortdrama/service/user_model_config_service.go @@ -58,10 +58,10 @@ func (s *userModelConfigService) GetUserConfig(ctx context.Context, userId int64 } if err != nil || cfg == nil { m := &entity.UserModelConfig{UserId: userId} - _ = gcache.Set(ctx, cacheKey, m, 0) + _ = gcache.Set(ctx, cacheKey, m, 300) return m } - _ = gcache.Set(ctx, cacheKey, cfg, 0) + _ = gcache.Set(ctx, cacheKey, cfg, 300) return cfg } diff --git a/shortdrama/service/user_service.go b/shortdrama/service/user_service.go index 58580ce..f0564ac 100644 --- a/shortdrama/service/user_service.go +++ b/shortdrama/service/user_service.go @@ -5,6 +5,7 @@ import ( "errors" "time" + "video-factory/common" "video-factory/shortdrama/dao" "video-factory/shortdrama/model/entity" @@ -12,56 +13,23 @@ import ( "golang.org/x/crypto/bcrypt" ) -const jwtSecret = "video-factory-jwt-secret-2024" - type userService struct{} var UserService = new(userService) -type JwtClaims struct { - UserId int64 `json:"user_id"` - Role string `json:"role"` - AgentId int64 `json:"agent_id,omitempty"` - jwt.RegisteredClaims -} - -func init() { - ctx := context.Background() - adminExisting, _ := dao.User.GetByUsername(ctx, "admin") - if adminExisting == nil { - hash, _ := bcrypt.GenerateFromPassword([]byte("Tongli686^*^"), bcrypt.DefaultCost) - _, _ = dao.User.Insert(ctx, &entity.User{ - Role: "admin", - Username: "admin", - Password: string(hash), - Name: "管理员", - }) - } - test1Existing, _ := dao.User.GetByUsername(ctx, "test1") - if test1Existing == nil { - hash, _ := bcrypt.GenerateFromPassword([]byte("Tongli686^*^"), bcrypt.DefaultCost) - _, _ = dao.User.Insert(ctx, &entity.User{ - Role: "agent", - Username: "test1", - Password: string(hash), - Name: "测试代理1", - }) - } -} - func (s *userService) Login(ctx context.Context, account, password string) (*entity.User, string, error) { var user *entity.User var err error if account != "" { user, err = dao.User.GetByAccount(ctx, account) } else { - return nil, "", errors.New("请输入账号") + return nil, "", errors.New("please enter account") } if err != nil || user == nil { - return nil, "", errors.New("账号不存在") + return nil, "", errors.New("account not found") } if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil { - return nil, "", errors.New("密码错误") + return nil, "", errors.New("incorrect password") } var agentId int64 @@ -73,7 +41,7 @@ func (s *userService) Login(ctx context.Context, account, password string) (*ent } now := time.Now() - claims := JwtClaims{ + claims := common.JwtClaims{ UserId: user.Id, Role: user.Role, AgentId: agentId, @@ -82,23 +50,13 @@ func (s *userService) Login(ctx context.Context, account, password string) (*ent IssuedAt: jwt.NewNumericDate(now), }, } - token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(jwtSecret)) + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(common.GetJwtSecret())) if err != nil { return nil, "", err } return user, token, nil } -func (s *userService) ParseToken(tokenStr string) (*JwtClaims, error) { - token, err := jwt.ParseWithClaims(tokenStr, &JwtClaims{}, func(token *jwt.Token) (interface{}, error) { - return []byte(jwtSecret), nil - }) - if err != nil { - return nil, err - } - claims, ok := token.Claims.(*JwtClaims) - if !ok || !token.Valid { - return nil, errors.New("无效的 token") - } - return claims, nil +func (s *userService) ParseToken(tokenStr string) (*common.JwtClaims, error) { + return common.ParseToken(tokenStr) } diff --git a/tools/split_merged/split_merged.go b/tools/split_merged/split_merged.go deleted file mode 100644 index f371f47..0000000 --- a/tools/split_merged/split_merged.go +++ /dev/null @@ -1,263 +0,0 @@ -package main - -import ( - "fmt" - "os" - "path/filepath" - "strconv" - - "github.com/Eyevinn/mp4ff/mp4" -) - -func main() { - if len(os.Args) < 4 { - fmt.Println("用法: split_merged <输入文件> <输出目录> <段数>") - os.Exit(1) - } - inputPath := os.Args[1] - outDir := os.Args[2] - numSegments, err := strconv.Atoi(os.Args[3]) - if err != nil || numSegments <= 0 { - fmt.Printf("无效的段数: %s\n", os.Args[3]) - os.Exit(1) - } - - // 读取合并文件 - f, err := mp4.ReadMP4File(inputPath) - if err != nil { - fmt.Printf("读取文件失败: %v\n", err) - os.Exit(1) - } - - if err := os.MkdirAll(outDir, 0755); err != nil { - fmt.Printf("创建输出目录失败: %v\n", err) - os.Exit(1) - } - - // 收集每轨道按段划分的 sample 范围 - type trakInfo struct { - trak *mp4.TrakBox - samples uint32 // 总 sample 数 - perSeg uint32 // 每段 sample 数 - } - - var traks []trakInfo - for _, trak := range f.Moov.Traks { - stbl := trak.Mdia.Minf.Stbl - if stbl.Stsz != nil { - totalSamples := stbl.Stsz.SampleNumber - perSeg := totalSamples / uint32(numSegments) - traks = append(traks, trakInfo{ - trak: trak, - samples: totalSamples, - perSeg: perSeg, - }) - fmt.Printf("轨道 %s: 总 %d samples, 每段 %d\n", trak.Mdia.Hdlr.HandlerType, totalSamples, perSeg) - } - } - - if len(traks) == 0 { - fmt.Println("未找到可分割的轨道") - os.Exit(1) - } - - // 逐段切分 - for seg := 0; seg < numSegments; seg++ { - outPath := filepath.Join(outDir, fmt.Sprintf("seg_%d.mp4", seg)) - fmt.Printf("切分第 %d 段 -> %s\n", seg, outPath) - - outFile := mp4.NewFile() - outFile.Ftyp = f.Ftyp - outFile.Moov = f.Moov // 稍后会修改 - - // 为每轨道创建仅包含本段 samples 的 stbl - for _, ti := range traks { - stbl := ti.trak.Mdia.Minf.Stbl - startSample := seg * int(ti.perSeg) - endSample := startSample + int(ti.perSeg) - if seg == numSegments-1 { - endSample = int(ti.samples) // 最后一段拿剩余所有 samples - } - - fmt.Printf(" 轨道 %s: samples [%d, %d)\n", ti.trak.Mdia.Hdlr.HandlerType, startSample, endSample) - - // 拷贝 stsz entry(只保留本段范围) - if stbl.Stsz != nil { - newStsz := &mp4.StszBox{} - if stbl.Stsz.SampleUniformSize > 0 { - newStsz.SampleUniformSize = stbl.Stsz.SampleUniformSize - newStsz.SampleNumber = uint32(endSample - startSample) - } else { - newStsz.SampleSize = make([]uint32, endSample-startSample) - copy(newStsz.SampleSize, stbl.Stsz.SampleSize[startSample:endSample]) - newStsz.SampleNumber = uint32(len(newStsz.SampleSize)) - } - stbl.Stsz = newStsz - } - - // 计算本段 chunk 范围:通过 stsc 找到对应的 chunk - // 直接根据 perSeg chunks 计算。每段 chunks = totalChunks / numSegments - chunkCount := uint32(0) - if stbl.Stco != nil { - chunkCount = uint32(len(stbl.Stco.ChunkOffset)) - } else if stbl.Co64 != nil { - chunkCount = uint32(len(stbl.Co64.ChunkOffset)) - } - perSegChunks := chunkCount / uint32(numSegments) - startChunk := seg * int(perSegChunks) - endChunk := startChunk + int(perSegChunks) - if seg == numSegments-1 { - endChunk = int(chunkCount) - } - - // 重新构建 stco(只保留本段 chunk) - if stbl.Stco != nil { - oldChunks := stbl.Stco.ChunkOffset - stbl.Stco.ChunkOffset = oldChunks[startChunk:endChunk] - // 调整偏移量:减去 mdat 中本段数据的起始位置 - baseOffset := oldChunks[startChunk] - for i := range stbl.Stco.ChunkOffset { - stbl.Stco.ChunkOffset[i] -= baseOffset - } - } - if stbl.Co64 != nil { - oldChunks := stbl.Co64.ChunkOffset - stbl.Co64.ChunkOffset = oldChunks[startChunk:endChunk] - baseOffset := oldChunks[startChunk] - for i := range stbl.Co64.ChunkOffset { - stbl.Co64.ChunkOffset[i] -= baseOffset - } - } - - // 重新构建 stsc(只保留本段 chunk 的 entries) - if stbl.Stsc != nil { - var newEntries []mp4.StscEntry - for _, e := range stbl.Stsc.Entries { - if e.FirstChunk-1 >= uint32(startChunk) && e.FirstChunk-1 < uint32(endChunk) { - newEntries = append(newEntries, mp4.StscEntry{ - FirstChunk: e.FirstChunk - uint32(startChunk), - SamplesPerChunk: e.SamplesPerChunk, - SampleDescriptionIndex: e.SampleDescriptionIndex, - }) - } - } - stbl.Stsc.Entries = newEntries - } - - // 重设 stts(重建,只保留本段 samples 对应的时间) - if stbl.Stts != nil { - newStts := &mp4.SttsBox{} - currentSample := uint32(0) - for i := range stbl.Stts.SampleCount { - count := stbl.Stts.SampleCount[i] - delta := stbl.Stts.SampleTimeDelta[i] - nextSample := currentSample + count - - if nextSample <= uint32(startSample) { - currentSample = nextSample - continue - } - if currentSample >= uint32(endSample) { - break - } - - overlapStart := uint32(0) - if currentSample < uint32(startSample) { - overlapStart = uint32(startSample) - currentSample - } - overlapEnd := count - if nextSample > uint32(endSample) { - overlapEnd = uint32(endSample) - currentSample - } - - if overlapStart < overlapEnd { - newStts.SampleCount = append(newStts.SampleCount, overlapEnd-overlapStart) - newStts.SampleTimeDelta = append(newStts.SampleTimeDelta, delta) - } - currentSample = nextSample - } - stbl.Stts = newStts - } - - // 修正 stco 偏移:加上 ftyp+moov+mdat_header 偏移 - mdatDataStart := uint32(0) // 稍后在写入时修正 - _ = mdatDataStart - } - - // 提取本段 mdat 数据 - mdatStart := uint32(0) - if len(traks) > 0 && traks[0].trak.Mdia.Minf.Stbl.Stco != nil { - // 使用第一个轨道的第一个 chunk 偏移量作为数据起始位置 - mdatStart = traks[0].trak.Mdia.Minf.Stbl.Stco.ChunkOffset[0] - } - - // 重构 mdat:从合并文件提取本段数据 - outFile.Mdat = &mp4.MdatBox{} - mdatPayloadLen := uint64(0) - // 计算本段 mdat 数据长度 - for _, ti := range traks { - stbl := ti.trak.Mdia.Minf.Stbl - if stbl.Stsz != nil { - if stbl.Stsz.SampleUniformSize > 0 { - mdatPayloadLen += uint64(stbl.Stsz.SampleUniformSize) * uint64(stbl.Stsz.SampleNumber) - } else { - for _, s := range stbl.Stsz.SampleSize { - mdatPayloadLen += uint64(s) - } - } - } - } - - if mdatPayloadLen > 0 { - segData := make([]byte, mdatPayloadLen) - // 从原始 mdat 拷贝 - offset := uint64(mdatStart) - _ = offset - copyPos := uint64(0) - for _, ti := range traks { - stbl := ti.trak.Mdia.Minf.Stbl - if stbl.Stsz != nil { - if stbl.Stsz.SampleUniformSize > 0 { - dataLen := uint64(stbl.Stsz.SampleUniformSize) * uint64(stbl.Stsz.SampleNumber) - copy(segData[copyPos:], f.Mdat.Data[offset:offset+dataLen]) - copyPos += dataLen - offset += dataLen - } else { - for _, s := range stbl.Stsz.SampleSize { - copy(segData[copyPos:], f.Mdat.Data[offset:offset+uint64(s)]) - copyPos += uint64(s) - offset += uint64(s) - } - } - } - } - outFile.Mdat.SetData(segData) - } - - // 更新 mdhd/tkhd/mvhd 时长 - outFile.Moov.Mvhd = f.Moov.Mvhd // 拷贝原始 mvhd - // 简化处理:直接使用原始 mvhd 时长 - - // 重建 Children - outFile.Children = []mp4.Box{outFile.Ftyp, outFile.Moov, outFile.Mdat} - - // 修正 stco 偏移:调整到实际文件位置 - combinedBase := outFile.Ftyp.Size() + outFile.Moov.Size() + outFile.Mdat.HeaderSize() - for _, ti := range traks { - stbl := ti.trak.Mdia.Minf.Stbl - if stbl.Stco != nil { - for i := range stbl.Stco.ChunkOffset { - stbl.Stco.ChunkOffset[i] += uint32(combinedBase) - } - } - } - - if err := mp4.WriteToFile(outFile, outPath); err != nil { - fmt.Printf("写入 %s 失败: %v\n", outPath, err) - continue - } - fmt.Printf(" 完成: %s\n", outPath) - } - - fmt.Println("切分完成") -}