diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b184c4a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# 构建与运行时产物(避免把生产数据库与图片塞进构建上下文) +data/ +workspace/ +*.db + +# 渲染器依赖:由 Dockerfile renderer stage 安装,宿主 node_modules 平台不匹配,排除以防覆盖 +scripts/avatar-render/node_modules/ + +# VCS 与 IDE +.git +.gitignore +.idea/ +.vscode/ +.DS_Store diff --git a/.gitignore b/.gitignore index 0bba3ea..380ec5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # 数据库与运行时产物 slogan.db *.db +data/ slogan-agent workspace/ diff --git a/Dockerfile b/Dockerfile index a0ac85d..a67908d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ ENV CGO_ENABLED=0 ENV GOTOOLCHAIN=auto WORKDIR /build COPY . . -RUN go mod download && go mod tidy +RUN go mod download RUN go build -ldflags="-s -w" -o main ./main.go # 3D 化身渲染器(Node + headless-gl,需原生编译) @@ -29,6 +29,6 @@ COPY --from=builder /build/config.yml . COPY --from=builder /build/main . COPY --from=renderer /render/node_modules ./scripts/avatar-render/node_modules COPY scripts/avatar-render/ ./scripts/avatar-render/ -RUN mkdir -p /app/workspace +RUN mkdir -p /app/workspace /app/data EXPOSE 3007 ENTRYPOINT ["./main"] diff --git a/config.yml b/config.yml index 51d9b06..910f93c 100644 --- a/config.yml +++ b/config.yml @@ -1,6 +1,19 @@ +# SQLite 落盘到 data/ 子目录:本地开发与容器都便于挂载持久化(data/ 已入 .gitignore) database: default: - name: slogan.db + name: data/slogan.db + type: sqlite + debug: false + plan: + name: data/slogan_plan.db + type: sqlite + debug: false + pay: + name: data/slogan_pay.db + type: sqlite + debug: false + cps: + name: data/slogan_cps.db type: sqlite debug: false cache: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d523f72 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + slogan-agent: + build: . + container_name: slogan-agent + restart: unless-stopped + ports: + - "3007:3007" + volumes: + # SQLite 数据库(config.yml 已指向 data/ 子目录,容器内 /app/data 与宿主机 ./data 互通) + - ./data:/app/data + # 生成的图片 / GLB 等运行时产物 + - ./workspace:/app/workspace + # 容器内运行前需在 config.yml 调整: + # render.node_bin → "/usr/bin/node"(镜像内置 alpine node,非 macOS nvm 路径) + # payment.notify_url → 公网可达地址(支付回调容器内 localhost 不可达) diff --git a/scripts/gen_outfit_plan/main.go b/scripts/gen_outfit_plan/main.go index bc88976..42f5efb 100644 --- a/scripts/gen_outfit_plan/main.go +++ b/scripts/gen_outfit_plan/main.go @@ -64,7 +64,10 @@ func main() { // 幂等:已有方案则只重试效果图(主方案 → select-main → 等 3 张 done) var mainPlan *entity.OutfitPlan - existing, _ := dao.OutfitPlan.ListByUser(ctx, user.Id) + existing, err := dao.OutfitPlan.ListByUser(ctx, user.Id) + if err != nil { + panic(fmt.Sprintf("读取方案列表失败: %v", err)) + } for _, p := range existing { if p.MainFlag == 1 { mainPlan = p @@ -103,7 +106,10 @@ func main() { waitEffects(ctx, mainPlan.Id) - all, _ := dao.OutfitPlan.ListByUser(ctx, user.Id) + all, err := dao.OutfitPlan.ListByUser(ctx, user.Id) + if err != nil { + panic(fmt.Sprintf("读取方案列表失败: %v", err)) + } fmt.Printf("完成!wenwu901 现有 %d 套方案:\n", len(all)) for _, p := range all { fmt.Printf(" - plan %d: %s(评分 %d,%s)\n", p.Id, p.Title, p.Score, p.Source) @@ -185,7 +191,10 @@ func waitTaskDone(ctx context.Context, taskId, userId int64) { // waitEffects 等主方案的 3 张效果图(正面/侧面/背面)全部 done func waitEffects(ctx context.Context, planId int64) { for i := 0; i < 20; i++ { - images, _ := dao.PlanEffectImage.ListByPlan(ctx, planId) + images, err := dao.PlanEffectImage.ListByPlan(ctx, planId) + if err != nil { + panic(fmt.Sprintf("读取效果图列表失败: %v", err)) + } done := 0 for _, im := range images { if im.Status == consts.EffectStatusDone { diff --git a/scripts/split_db/main.go b/scripts/split_db/main.go new file mode 100644 index 0000000..f789493 --- /dev/null +++ b/scripts/split_db/main.go @@ -0,0 +1,238 @@ +// 一次性迁移工具:把单文件 slogan.db 拆分为 4 个 SQLite 库 +// +// slogan.db 主库(用户域 + 低频配置) +// slogan_plan.db 穿搭方案域 +// slogan_pay.db 会员/支付域 +// slogan_cps.db CPS 联盟域 +// +// 用法:在 slogan-agent 目录执行 `go run ./scripts/split_db` +package main + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" +) + +const mainDB = "slogan.db" + +var groups = []struct { + file string + tables []string +}{ + {"slogan_plan.db", []string{ + "slogan_outfit_generation_task", "slogan_outfit_plan", "slogan_plan_outfit_item", + "slogan_plan_effect_image", "slogan_plan_review", "slogan_hairstyle_asset", + }}, + {"slogan_pay.db", []string{ + "slogan_member_plan", "slogan_user_member", "slogan_payment_order", + "slogan_pay_notify_log", "slogan_ad_reward_log", + }}, + {"slogan_cps.db", []string{ + "slogan_cps_category", "slogan_cps_product", "slogan_cps_click_log", + "slogan_scene_category_map", + }}, +} + +func main() { + dir, err := os.Getwd() + if err != nil { + fatal("getwd: %v", err) + } + mainPath := filepath.Join(dir, mainDB) + if _, err := os.Stat(mainPath); err != nil { + fatal("slogan.db 不存在(请在 slogan-agent 目录执行): %v", err) + } + backup := filepath.Join(dir, "slogan_backup_"+time.Now().Format("20060102_150405")+".db") + if err := copyFile(mainPath, backup); err != nil { + fatal("备份失败: %v", err) + } + fmt.Printf("已备份 -> %s\n", backup) + + src, err := sql.Open("sqlite", mainPath) + if err != nil { + fatal("open %s: %v", mainDB, err) + } + src.SetMaxOpenConns(1) + defer src.Close() + + ddl, err := loadDDL(src) + if err != nil { + fatal("读取 DDL: %v", err) + } + srcAbs, err := filepath.Abs(mainPath) + if err != nil { + fatal("abs: %v", err) + } + + failed := false + for _, g := range groups { + if err := migrateGroup(dir, src, srcAbs, g.file, g.tables, ddl); err != nil { + fmt.Printf("❌ %s 迁移失败: %v\n", g.file, err) + failed = true + } + } + if failed { + os.Exit(1) + } + + // 主库删除已迁出的表(连带索引),清理自增序列残留 + for _, g := range groups { + for _, t := range g.tables { + if _, err := src.Exec("DROP TABLE IF EXISTS " + t); err != nil { + fatal("DROP %s: %v", t, err) + } + } + names := quoteList(g.tables) + if _, err := src.Exec("DELETE FROM sqlite_sequence WHERE name IN (" + names + ")"); err != nil { + fmt.Printf("⚠ 清理 sqlite_sequence 失败(可忽略): %v\n", err) + } + } + fmt.Printf("✅ %s 主库已清理,剩余表:\n", mainDB) + if err := listTables(src, mainDB); err != nil { + fatal("list: %v", err) + } + fmt.Println("✅ 拆分完成") +} + +// migrateGroup 新建目标库文件并拷贝表 + 索引 + 校验行数 +func migrateGroup(dir string, src *sql.DB, srcAbs, file string, tables []string, ddl map[string][]string) error { + dstPath := filepath.Join(dir, file) + _ = os.Remove(dstPath) // 覆盖上次失败残留 + dst, err := sql.Open("sqlite", dstPath) + if err != nil { + return err + } + dst.SetMaxOpenConns(1) + defer dst.Close() + + if _, err := dst.Exec(fmt.Sprintf("ATTACH DATABASE %q AS src", srcAbs)); err != nil { + return fmt.Errorf("attach: %w", err) + } + defer dst.Exec("DETACH DATABASE src") + + for _, t := range tables { + createDDL, ok := firstByType(ddl[t], "table") + if !ok { + return fmt.Errorf("表 %s 未找到建表 DDL", t) + } + if _, err := dst.Exec(createDDL); err != nil { + return fmt.Errorf("create %s: %w", t, err) + } + if _, err := dst.Exec(fmt.Sprintf("INSERT INTO %s SELECT * FROM src.%s", t, t)); err != nil { + return fmt.Errorf("copy %s: %w", t, err) + } + } + for _, t := range tables { + for _, idx := range ddl[t] { + if !strings.HasPrefix(idx, "CREATE INDEX") && !strings.HasPrefix(idx, "CREATE UNIQUE INDEX") { + continue + } + if _, err := dst.Exec(idx); err != nil { + return fmt.Errorf("index %s: %w", idx, err) + } + } + } + // 校验行数 + for _, t := range tables { + srcN, dstN, err := countPair(src, dst, t) + if err != nil { + return err + } + if srcN != dstN { + return fmt.Errorf("%s 行数不一致: src=%d dst=%d", t, srcN, dstN) + } + fmt.Printf("✅ %-24s %8d 行\n", t, dstN) + } + return nil +} + +func loadDDL(db *sql.DB) (map[string][]string, error) { + rows, err := db.Query("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND type IN ('table','index') ORDER BY type DESC") + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string][]string{} + for rows.Next() { + var typ, name, sqlText string + if err := rows.Scan(&typ, &name, &sqlText); err != nil { + return nil, err + } + if strings.HasPrefix(sqlText, "CREATE TABLE") { + out[name] = append([]string{sqlText}, out[name]...) // table 放最前 + } else { + out[name] = append(out[name], sqlText) + } + } + return out, rows.Err() +} + +func firstByType(ddls []string, prefix string) (string, bool) { + for _, d := range ddls { + if strings.HasPrefix(d, "CREATE TABLE") { + return d, true + } + } + return "", false +} + +func countPair(src, dst *sql.DB, table string) (int, int, error) { + srcN, err := count(src, table) + if err != nil { + return 0, 0, err + } + dstN, err := count(dst, table) + if err != nil { + return 0, 0, err + } + return srcN, dstN, nil +} + +func count(db *sql.DB, table string) (int, error) { + var n int + err := db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n) + return n, err +} + +func listTables(db *sql.DB, file string) error { + rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return err + } + fmt.Printf(" %s\n", name) + } + return rows.Err() +} + +func quoteList(items []string) string { + q := make([]string, len(items)) + for i, s := range items { + q[i] = "'" + s + "'" + } + return strings.Join(q, ",") +} + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0o644) +} + +func fatal(format string, args ...any) { + fmt.Printf("❌ "+format+"\n", args...) + os.Exit(1) +} diff --git a/styleagent/agent/avatar_tripo_client.go b/styleagent/agent/avatar_tripo_client.go index d623f20..c07d42a 100644 --- a/styleagent/agent/avatar_tripo_client.go +++ b/styleagent/agent/avatar_tripo_client.go @@ -129,6 +129,9 @@ func (c *TripoClient) PollTask(ctx context.Context, taskID string) (string, erro return "", err } status, _ := data["status"].(string) + if status == "" { + return "", fmt.Errorf("Tripo 任务响应缺少 status: %s", mustJSONStr(data)) + } switch status { case "success": if output, ok := data["output"].(map[string]any); ok { @@ -188,7 +191,10 @@ func (c *TripoClient) do(req *http.Request) (map[string]any, error) { return nil, fmt.Errorf("Tripo 请求失败: %w", err) } defer resp.Body.Close() - raw, _ := io.ReadAll(resp.Body) + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取 Tripo 响应失败: %w", err) + } var r struct { Code int `json:"code"` Message string `json:"message"` diff --git a/styleagent/agent/imagegen_wanx_client.go b/styleagent/agent/imagegen_wanx_client.go index 2fa9aae..d9e455f 100644 --- a/styleagent/agent/imagegen_wanx_client.go +++ b/styleagent/agent/imagegen_wanx_client.go @@ -127,7 +127,10 @@ func (c *wanxClient) submit(ctx context.Context, body []byte) (string, error) { return "", err } defer resp.Body.Close() - data, _ := io.ReadAll(resp.Body) + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取万相提交响应失败: %w", err) + } var r struct { Output struct { TaskID string `json:"task_id"` @@ -161,8 +164,11 @@ func (c *wanxClient) poll(ctx context.Context, taskID string) (string, error) { if err != nil { return "", err } - data, _ := io.ReadAll(resp.Body) + data, err := io.ReadAll(resp.Body) resp.Body.Close() + if err != nil { + return "", fmt.Errorf("读取万相任务响应失败: %w", err) + } var r wanxTaskResp if err := json.Unmarshal(data, &r); err != nil { return "", fmt.Errorf("万相任务查询解析失败: %s", string(data)) diff --git a/styleagent/agent/weather_qweather.go b/styleagent/agent/weather_qweather.go index 77d80b6..f4f5413 100644 --- a/styleagent/agent/weather_qweather.go +++ b/styleagent/agent/weather_qweather.go @@ -79,8 +79,14 @@ func GetDaily(ctx context.Context, cityCode, startDate, endDate string) (*Weathe if d.FxDate < startDate || d.FxDate > endDate { continue } - maxV, _ := strconv.Atoi(d.TempMax) - minV, _ := strconv.Atoi(d.TempMin) + maxV, err := strconv.Atoi(d.TempMax) + if err != nil { + return nil, fmt.Errorf("和风天气温度解析失败(tempMax=%q): %w", d.TempMax, err) + } + minV, err := strconv.Atoi(d.TempMin) + if err != nil { + return nil, fmt.Errorf("和风天气温度解析失败(tempMin=%q): %w", d.TempMin, err) + } result.Days = append(result.Days, DayWeather{ Date: d.FxDate, TempMax: maxV, TempMin: minV, TextDay: d.TextDay, }) diff --git a/styleagent/consts/db_group.go b/styleagent/consts/db_group.go new file mode 100644 index 0000000..0f8ca28 --- /dev/null +++ b/styleagent/consts/db_group.go @@ -0,0 +1,8 @@ +package consts + +// 数据库组:config.yml database.* 中的分组名,与 slogans 各域 SQLite 文件一一对应 +const ( + DBGroupPlan = "plan" + DBGroupPay = "pay" + DBGroupCps = "cps" +) diff --git a/styleagent/controller/payment_order_controller.go b/styleagent/controller/payment_order_controller.go index f6447db..e30358f 100644 --- a/styleagent/controller/payment_order_controller.go +++ b/styleagent/controller/payment_order_controller.go @@ -51,14 +51,18 @@ func MemberNotify(r *ghttp.Request) { ok := service.PaymentOrderService.VerifyNotify(params, hash, g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String()) if !ok { - _ = service.PayNotifyLogService.Insert(ctx, orderNo, body, hash, remoteIP, "bad_sign") + if err := service.PayNotifyLogService.Insert(ctx, orderNo, body, hash, remoteIP, "bad_sign"); err != nil { + g.Log().Warningf(ctx, "写入支付回调日志失败(bad_sign): %v", err) + } r.Response.Write("fail") r.ExitAll() return } state, err := service.PaymentOrderService.HandlePaidNotify(ctx, orderNo, r.Get("transaction_id").String(), body) - _ = service.PayNotifyLogService.Insert(ctx, orderNo, body, hash, remoteIP, state) + if logErr := service.PayNotifyLogService.Insert(ctx, orderNo, body, hash, remoteIP, state); logErr != nil { + g.Log().Warningf(ctx, "写入支付回调日志失败: %v", logErr) + } // duplicate(幂等重复回调)同样返回 success,避免支付渠道无限重试 if err != nil || state == "no_order" { r.Response.Write("fail") diff --git a/styleagent/dao/ad_reward_log_dao.go b/styleagent/dao/ad_reward_log_dao.go index e0e01fd..1b935be 100644 --- a/styleagent/dao/ad_reward_log_dao.go +++ b/styleagent/dao/ad_reward_log_dao.go @@ -8,6 +8,7 @@ import ( "slogan-agent/styleagent/consts" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -17,7 +18,7 @@ type adRewardLogDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameAdRewardLog+` ( + _, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameAdRewardLog+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL DEFAULT 0, ad_type TEXT NOT NULL DEFAULT '', @@ -30,7 +31,9 @@ func init() { g.Log().Warningf(ctx, "create ad_reward_log table failed: %v", err) } // 唯一索引兜底并发:同一 (user, day, type) 最多 limit 个 slot(如 effect_extra 2 / vip_trial 1) - _, _ = g.DB().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_reward_unique ON `+consts.TableNameAdRewardLog+`(user_id, reward_key, slot)`) + if _, err := dbPay().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_reward_unique ON `+consts.TableNameAdRewardLog+`(user_id, reward_key, slot)`); err != nil { + g.Log().Warningf(ctx, "create index idx_ad_reward_unique failed: %v", err) + } } // rewardKey 自然日去重粒度:"2026-07-31:effect_extra" @@ -38,16 +41,29 @@ func rewardKey(adType string) string { return fmt.Sprintf("%s:%s", time.Now().Format("2006-01-02"), adType) } -func (d *adRewardLogDao) CountTodayByType(ctx context.Context, userId int64, adType string) (int, error) { - n, err := g.DB().Model(consts.TableNameAdRewardLog).Ctx(ctx). - Where("user_id", userId).Where("reward_key", rewardKey(adType)).Count() - return int(n), err -} - -// Insert 领取记录:在 1..limit 的 slot 中找一个空闲位写入;全满(唯一索引冲突)返回错误 → 视为限频 -func (d *adRewardLogDao) Insert(ctx context.Context, userId int64, adType string, limit int) (int64, error) { +// InsertTx 事务版本:领取记录与会员赠送原子提交 +func (d *adRewardLogDao) InsertTx(ctx context.Context, tx gdb.TX, userId int64, adType string, limit int) (int64, error) { for slot := 1; slot <= limit; slot++ { - r, err := g.DB().Model(consts.TableNameAdRewardLog).Ctx(ctx).Data(g.Map{ + r, err := tx.Model(consts.TableNameAdRewardLog).Ctx(ctx).Data(g.Map{ + "user_id": userId, "ad_type": adType, "reward_key": rewardKey(adType), "slot": slot, "status": "ok", + }).Insert() + if err == nil { + return r.LastInsertId() + } + } + return 0, errors.New("ad reward quota exhausted") +} + +func (d *adRewardLogDao) CountTodayByType(ctx context.Context, userId int64, adType string) (int, error) { + n, err := dbPay().Model(consts.TableNameAdRewardLog).Ctx(ctx). + Where("user_id", userId).Where("reward_key", rewardKey(adType)).Count() + return int(n), err +} + +// Insert 领取记录:在 1..limit 的 slot 中找一个空闲位写入;全满(唯一索引冲突)返回错误 → 视为限频 +func (d *adRewardLogDao) Insert(ctx context.Context, userId int64, adType string, limit int) (int64, error) { + for slot := 1; slot <= limit; slot++ { + r, err := dbPay().Model(consts.TableNameAdRewardLog).Ctx(ctx).Data(g.Map{ "user_id": userId, "ad_type": adType, "reward_key": rewardKey(adType), "slot": slot, "status": "ok", }).Insert() if err == nil { diff --git a/styleagent/dao/cps_category_dao.go b/styleagent/dao/cps_category_dao.go index cd01d65..a06f391 100644 --- a/styleagent/dao/cps_category_dao.go +++ b/styleagent/dao/cps_category_dao.go @@ -14,7 +14,7 @@ type cpsCategoryDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsCategory+` ( + _, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsCategory+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', @@ -40,7 +40,7 @@ func seedCpsCategories(ctx context.Context) { {"digital", "数码", consts.CpsSourceJdEcom, ""}, } for i, c := range base { - if _, err := g.DB().Exec(ctx, + if _, err := dbCps().Exec(ctx, "INSERT OR IGNORE INTO "+consts.TableNameCpsCategory+ " (code, name, parent_code, source, source_cat_id, sort) VALUES (?, ?, '', ?, ?, ?)", c.code, c.name, c.source, c.sourceCatId, i); err != nil { @@ -51,7 +51,7 @@ func seedCpsCategories(ctx context.Context) { func (d *cpsCategoryDao) List(ctx context.Context) ([]*entity.CpsCategory, error) { var list []*entity.CpsCategory - err := g.DB().Model(consts.TableNameCpsCategory).Ctx(ctx). + err := dbCps().Model(consts.TableNameCpsCategory).Ctx(ctx). OrderAsc("sort").OrderAsc("id").Scan(&list) return list, err } diff --git a/styleagent/dao/cps_click_log_dao.go b/styleagent/dao/cps_click_log_dao.go index b0dbc32..d7d61ee 100644 --- a/styleagent/dao/cps_click_log_dao.go +++ b/styleagent/dao/cps_click_log_dao.go @@ -14,7 +14,7 @@ type cpsClickLogDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsClickLog+` ( + _, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsClickLog+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL DEFAULT 0, source TEXT NOT NULL DEFAULT '', @@ -29,7 +29,7 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create cps_click_log table failed: %v", err) } - _, err = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cps_click_user ON "+ + _, err = dbCps().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cps_click_user ON "+ consts.TableNameCpsClickLog+"(user_id, created_at)") if err != nil { g.Log().Warningf(ctx, "create cps_click_log index failed: %v", err) @@ -37,7 +37,7 @@ func init() { } func (d *cpsClickLogDao) Insert(ctx context.Context, log *entity.CpsClickLog) (int64, error) { - r, err := g.DB().Exec(ctx, "INSERT INTO "+consts.TableNameCpsClickLog+ + r, err := dbCps().Exec(ctx, "INSERT INTO "+consts.TableNameCpsClickLog+ " (user_id, source, outer_id, scene, plan_id, category_code, deeplink, ip, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))", log.UserId, log.Source, log.OuterId, log.Scene, log.PlanId, log.CategoryCode, log.Deeplink, log.Ip) @@ -49,7 +49,7 @@ func (d *cpsClickLogDao) Insert(ctx context.Context, log *entity.CpsClickLog) (i func (d *cpsClickLogDao) ListByUser(ctx context.Context, userId int64, limit int) ([]*entity.CpsClickLog, error) { var list []*entity.CpsClickLog - err := g.DB().Model(consts.TableNameCpsClickLog).Ctx(ctx). + err := dbCps().Model(consts.TableNameCpsClickLog).Ctx(ctx). Where("user_id", userId).OrderDesc("id").Limit(limit).Scan(&list) return list, err } diff --git a/styleagent/dao/cps_product_dao.go b/styleagent/dao/cps_product_dao.go index d730581..e5d5eac 100644 --- a/styleagent/dao/cps_product_dao.go +++ b/styleagent/dao/cps_product_dao.go @@ -4,7 +4,9 @@ import ( "context" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "strings" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -14,7 +16,7 @@ type cpsProductDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsProduct+` ( + _, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsProduct+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL DEFAULT '', outer_id TEXT NOT NULL DEFAULT '', @@ -34,13 +36,13 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create cps_product table failed: %v", err) } - _, err = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cps_product_cat ON "+ + _, err = dbCps().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cps_product_cat ON "+ consts.TableNameCpsProduct+"(source, category_code, status)") if err != nil { g.Log().Warningf(ctx, "create cps_product index failed: %v", err) } // Upsert 的 ON CONFLICT 依赖唯一索引 - _, err = g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_cps_product_outer ON "+ + _, err = dbCps().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_cps_product_outer ON "+ consts.TableNameCpsProduct+"(source, outer_id)") if err != nil { g.Log().Warningf(ctx, "create cps_product unique index failed: %v", err) @@ -48,7 +50,7 @@ func init() { } func (d *cpsProductDao) Upsert(ctx context.Context, p *entity.CpsProduct) error { - _, err := g.DB().Exec(ctx, `INSERT INTO `+consts.TableNameCpsProduct+ + _, err := dbCps().Exec(ctx, `INSERT INTO `+consts.TableNameCpsProduct+ ` (source, outer_id, category_code, name, cover_url, price_fen, shop_name, commission_rate, city, scene_tags, raw, status, sync_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now','localtime'), datetime('now','localtime')) ON CONFLICT(source, outer_id) DO UPDATE SET @@ -63,7 +65,7 @@ func (d *cpsProductDao) Upsert(ctx context.Context, p *entity.CpsProduct) error func (d *cpsProductDao) ListByCategory(ctx context.Context, source, categoryCode, city string, page, pageSize int) ([]*entity.CpsProduct, error) { var list []*entity.CpsProduct - m := g.DB().Model(consts.TableNameCpsProduct).Ctx(ctx). + m := dbCps().Model(consts.TableNameCpsProduct).Ctx(ctx). Where("status", 1).Where("source", source).Where("category_code", categoryCode) if city != "" { m = m.Where("city", city) @@ -73,7 +75,7 @@ func (d *cpsProductDao) ListByCategory(ctx context.Context, source, categoryCode } func (d *cpsProductDao) CountByCategory(ctx context.Context, source, categoryCode, city string) (int, error) { - m := g.DB().Model(consts.TableNameCpsProduct).Ctx(ctx). + m := dbCps().Model(consts.TableNameCpsProduct).Ctx(ctx). Where("status", 1).Where("source", source).Where("category_code", categoryCode) if city != "" { m = m.Where("city", city) @@ -83,17 +85,56 @@ func (d *cpsProductDao) CountByCategory(ctx context.Context, source, categoryCod func (d *cpsProductDao) Get(ctx context.Context, id int64) (*entity.CpsProduct, error) { var p entity.CpsProduct - err := g.DB().Model(consts.TableNameCpsProduct).Ctx(ctx).Where("id", id).Scan(&p) + err := dbCps().Model(consts.TableNameCpsProduct).Ctx(ctx).Where("id", id).Scan(&p) if err != nil || p.Id == 0 { return nil, err } return &p, nil } +// cpsUpsertBatchSize 每批条数:11 参数/条 × 80 = 880 < SQLite 变量上限 999 +const cpsUpsertBatchSize = 80 + +// UpsertBatch 批量 Upsert(单条 multi-row SQL + ON CONFLICT),每批独立事务,批间失败互不影响 +func (d *cpsProductDao) UpsertBatch(ctx context.Context, list []*entity.CpsProduct) error { + for start := 0; start < len(list); start += cpsUpsertBatchSize { + end := start + cpsUpsertBatchSize + if end > len(list) { + end = len(list) + } + batch := list[start:end] + sqlText, args := buildCpsUpsertSQL(batch) + if err := dbCps().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + _, err := tx.Ctx(ctx).Exec(sqlText, args...) + return err + }); err != nil { + return err + } + } + return nil +} + +func buildCpsUpsertSQL(batch []*entity.CpsProduct) (string, []any) { + var sb strings.Builder + sb.WriteString("INSERT INTO " + consts.TableNameCpsProduct + + " (source, outer_id, category_code, name, cover_url, price_fen, shop_name, commission_rate, city, scene_tags, raw, status, sync_at, created_at) VALUES ") + args := make([]any, 0, len(batch)*11) + for i, p := range batch { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString("(?,?,?,?,?,?,?,?,?,?,?,1,datetime('now','localtime'),datetime('now','localtime'))") + args = append(args, p.Source, p.OuterId, p.CategoryCode, p.Name, p.CoverUrl, p.PriceFen, + p.ShopName, p.CommissionRate, p.City, p.SceneTags, p.Raw) + } + sb.WriteString(" ON CONFLICT(source, outer_id) DO UPDATE SET name=excluded.name, cover_url=excluded.cover_url, price_fen=excluded.price_fen, shop_name=excluded.shop_name, commission_rate=excluded.commission_rate, city=excluded.city, scene_tags=excluded.scene_tags, raw=excluded.raw, status=1, sync_at=datetime('now','localtime')") + return sb.String(), args +} + // GetByOuter 按联盟来源 + 外部 ID 取商品(点击日志回填商品信息用) func (d *cpsProductDao) GetByOuter(ctx context.Context, source, outerId string) (*entity.CpsProduct, error) { var p entity.CpsProduct - err := g.DB().Model(consts.TableNameCpsProduct).Ctx(ctx). + err := dbCps().Model(consts.TableNameCpsProduct).Ctx(ctx). Where("source", source).Where("outer_id", outerId).Scan(&p) if err != nil || p.Id == 0 { return nil, err diff --git a/styleagent/dao/db.go b/styleagent/dao/db.go new file mode 100644 index 0000000..ef3e761 --- /dev/null +++ b/styleagent/dao/db.go @@ -0,0 +1,11 @@ +package dao + +import ( + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// 数据库组归属:DAO 按业务域拆分到独立 SQLite 文件,经所属组访问 +func dbPlan() gdb.DB { return g.DB("plan") } +func dbPay() gdb.DB { return g.DB("pay") } +func dbCps() gdb.DB { return g.DB("cps") } diff --git a/styleagent/dao/hairstyle_asset_dao.go b/styleagent/dao/hairstyle_asset_dao.go index f4f6561..f9b3c43 100644 --- a/styleagent/dao/hairstyle_asset_dao.go +++ b/styleagent/dao/hairstyle_asset_dao.go @@ -4,6 +4,7 @@ import ( "context" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "strings" "github.com/gogf/gf/v2/frame/g" ) @@ -14,7 +15,7 @@ type hairstyleAssetDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameHairstyleAsset+` ( + _, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameHairstyleAsset+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL DEFAULT '', style_tag TEXT NOT NULL DEFAULT '', @@ -31,12 +32,10 @@ func init() { } func seedHairstyles(ctx context.Context) { - var cnt int - r, err := g.DB().Model(consts.TableNameHairstyleAsset).Ctx(ctx).Count() + r, err := dbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx).Count() if err != nil || r > 0 { return } - _ = cnt items := []struct { name, tag, face string sort int @@ -50,10 +49,21 @@ func seedHairstyles(ctx context.Context) { {"丸子头", "可爱", "all", 7}, {"波浪卷发", "浪漫", "all", 8}, } + // 批量 multi-row INSERT(种子数据一次性写入) + var sb strings.Builder + sb.WriteString("INSERT INTO " + consts.TableNameHairstyleAsset + + " (name, style_tag, glb_url, thumb_url, applicable_face, sort, created_at) VALUES ") + args := make([]any, 0, len(items)*6) for i, it := range items { - _, _ = g.DB().Exec(ctx, - "INSERT INTO "+consts.TableNameHairstyleAsset+" (name, style_tag, glb_url, thumb_url, applicable_face, sort, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime'))", - it.name, it.tag, "/workspace/templates/hairstyle_"+itoa(i+1)+".glb", "/workspace/templates/hairstyle_thumb_"+itoa(i+1)+".png", it.face, it.sort) + if i > 0 { + sb.WriteString(",") + } + sb.WriteString("(?,?,?,?,?,?,datetime('now','localtime'))") + args = append(args, it.name, it.tag, "/workspace/templates/hairstyle_"+itoa(i+1)+".glb", + "/workspace/templates/hairstyle_thumb_"+itoa(i+1)+".png", it.face, it.sort) + } + if _, err := dbPlan().Exec(ctx, sb.String(), args...); err != nil { + g.Log().Warningf(ctx, "seed hairstyle_asset failed: %v", err) } } @@ -73,13 +83,13 @@ func itoa(n int) string { func (d *hairstyleAssetDao) ListAll(ctx context.Context) ([]*entity.HairstyleAsset, error) { var list []*entity.HairstyleAsset - err := g.DB().Model(consts.TableNameHairstyleAsset).Ctx(ctx).OrderAsc("sort").Scan(&list) + err := dbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx).OrderAsc("sort").Scan(&list) return list, err } func (d *hairstyleAssetDao) GetOne(ctx context.Context, id int64) (*entity.HairstyleAsset, error) { var h entity.HairstyleAsset - err := g.DB().Model(consts.TableNameHairstyleAsset).Ctx(ctx).Where("id", id).Scan(&h) + err := dbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx).Where("id", id).Scan(&h) if err != nil || h.Id == 0 { return nil, err } diff --git a/styleagent/dao/member_plan_dao.go b/styleagent/dao/member_plan_dao.go index 4b8d976..2276b28 100644 --- a/styleagent/dao/member_plan_dao.go +++ b/styleagent/dao/member_plan_dao.go @@ -6,6 +6,7 @@ import ( "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -15,7 +16,7 @@ type memberPlanDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameMemberPlan+` ( + _, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameMemberPlan+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL DEFAULT '', price_fen INTEGER NOT NULL DEFAULT 0, @@ -32,7 +33,7 @@ func init() { } func seedMemberPlans(ctx context.Context) { - r, err := g.DB().Model(consts.TableNameMemberPlan).Ctx(ctx).Count() + r, err := dbPay().Model(consts.TableNameMemberPlan).Ctx(ctx).Count() if err != nil || r > 0 { return } @@ -47,22 +48,32 @@ func seedMemberPlans(ctx context.Context) { {"年卡 ¥199", 19900, 365, `["effect_unlimited","ai_priority","cps_commission_x15","store_discount"]`, 2}, } for _, p := range plans { - _, _ = g.DB().Exec(ctx, + if _, err := dbPay().Exec(ctx, "INSERT INTO "+consts.TableNameMemberPlan+" (name, price_fen, duration_days, features, sort, status, created_at) VALUES (?, ?, ?, ?, ?, 1, datetime('now','localtime'))", - p.name, p.price, p.days, p.features, p.sort) + p.name, p.price, p.days, p.features, p.sort); err != nil { + g.Log().Warningf(ctx, "seed member_plan %s failed: %v", p.name, err) + } } } func (d *memberPlanDao) ListEnabled(ctx context.Context) ([]*entity.MemberPlan, error) { var list []*entity.MemberPlan - err := g.DB().Model(consts.TableNameMemberPlan).Ctx(ctx). + err := dbPay().Model(consts.TableNameMemberPlan).Ctx(ctx). Where("status", 1).OrderAsc("sort").OrderAsc("id").Scan(&list) return list, err } -func (d *memberPlanDao) GetOne(ctx context.Context, id int64) (*entity.MemberPlan, error) { +// GetOneTx 事务版本:支付回调事务内读取套餐配置 +func (d *memberPlanDao) GetOneTx(ctx context.Context, tx gdb.TX, id int64) (*entity.MemberPlan, error) { var p *entity.MemberPlan - err := g.DB().Model(consts.TableNameMemberPlan).Ctx(ctx). + err := tx.Model(consts.TableNameMemberPlan).Ctx(ctx). + Where("id", id).Where("status", 1).Scan(&p) + return p, err +} + +func (d *memberPlanDao) GetOne(ctx context.Context, id int64) (*entity.MemberPlan, error) { + var p *entity.MemberPlan + err := dbPay().Model(consts.TableNameMemberPlan).Ctx(ctx). Where("id", id).Where("status", 1).Scan(&p) return p, err } diff --git a/styleagent/dao/outfit_generation_task_dao.go b/styleagent/dao/outfit_generation_task_dao.go index f54b9ee..02bb3b6 100644 --- a/styleagent/dao/outfit_generation_task_dao.go +++ b/styleagent/dao/outfit_generation_task_dao.go @@ -5,6 +5,7 @@ import ( "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -14,7 +15,7 @@ type outfitGenTaskDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameOutfitGenTask+` ( + _, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameOutfitGenTask+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, start_date TEXT NOT NULL DEFAULT '', @@ -30,11 +31,13 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create outfit_generation_task table failed: %v", err) } - _, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_gen_task_user ON "+consts.TableNameOutfitGenTask+"(user_id, created_at)") + if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_gen_task_user ON "+consts.TableNameOutfitGenTask+"(user_id, created_at)"); err != nil { + g.Log().Warningf(ctx, "create index idx_slogan_gen_task_user failed: %v", err) + } } func (d *outfitGenTaskDao) Insert(ctx context.Context, data *entity.OutfitGenerationTask) (int64, error) { - r, err := g.DB().Exec(ctx, + r, err := dbPlan().Exec(ctx, "INSERT INTO "+consts.TableNameOutfitGenTask+" (user_id, start_date, end_date, location, weather_snapshot, status, error, model_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))", data.UserId, data.StartDate, data.EndDate, data.Location, data.WeatherSnapshot, data.Status, data.Error, data.ModelName) if err != nil { @@ -45,7 +48,7 @@ func (d *outfitGenTaskDao) Insert(ctx context.Context, data *entity.OutfitGenera func (d *outfitGenTaskDao) GetOne(ctx context.Context, id, userId int64) (*entity.OutfitGenerationTask, error) { var t entity.OutfitGenerationTask - err := g.DB().Model(consts.TableNameOutfitGenTask).Ctx(ctx). + err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx). Where("id", id).Where("user_id", userId).Scan(&t) if err != nil || t.Id == 0 { return nil, err @@ -54,13 +57,21 @@ func (d *outfitGenTaskDao) GetOne(ctx context.Context, id, userId int64) (*entit } func (d *outfitGenTaskDao) Update(ctx context.Context, id int64, data g.Map) error { - _, err := g.DB().Model(consts.TableNameOutfitGenTask).Ctx(ctx). + _, err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx). Data(data).Where("id", id).Update() return err } func (d *outfitGenTaskDao) UpdateStatus(ctx context.Context, id int64, status, errMsg string) error { - _, err := g.DB().Model(consts.TableNameOutfitGenTask).Ctx(ctx).Data(g.Map{ + _, err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx).Data(g.Map{ + "status": status, "error": errMsg, "updated_at": "datetime('now','localtime')", + }).Where("id", id).Update() + return err +} + +// UpdateStatusTx 事务版本:方案落库事务内同步任务状态 +func (d *outfitGenTaskDao) UpdateStatusTx(ctx context.Context, tx gdb.TX, id int64, status, errMsg string) error { + _, err := tx.Model(consts.TableNameOutfitGenTask).Ctx(ctx).Data(g.Map{ "status": status, "error": errMsg, "updated_at": "datetime('now','localtime')", }).Where("id", id).Update() return err @@ -69,7 +80,7 @@ func (d *outfitGenTaskDao) UpdateStatus(ctx context.Context, id int64, status, e // ListUnfinished 返回未完成的任务(重启恢复用) func (d *outfitGenTaskDao) ListUnfinished(ctx context.Context) ([]*entity.OutfitGenerationTask, error) { var list []*entity.OutfitGenerationTask - err := g.DB().Model(consts.TableNameOutfitGenTask).Ctx(ctx). + err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx). Where("status NOT IN (?)", g.Slice{consts.TaskStatusDone, consts.TaskStatusFailed}). OrderAsc("id").Limit(50).Scan(&list) return list, err diff --git a/styleagent/dao/outfit_plan_dao.go b/styleagent/dao/outfit_plan_dao.go index 2130d76..1e24c8c 100644 --- a/styleagent/dao/outfit_plan_dao.go +++ b/styleagent/dao/outfit_plan_dao.go @@ -5,6 +5,7 @@ import ( "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -14,7 +15,7 @@ type outfitPlanDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameOutfitPlan+` ( + _, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameOutfitPlan+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, user_id INTEGER NOT NULL, @@ -33,16 +34,20 @@ func init() { g.Log().Warningf(ctx, "create outfit_plan table failed: %v", err) } // 容错迁移:CREATE TABLE IF NOT EXISTS 不给旧库加列,duplicate column 错误可忽略 - if _, err := g.DB().Exec(ctx, "ALTER TABLE "+consts.TableNameOutfitPlan+ + if _, err := dbPlan().Exec(ctx, "ALTER TABLE "+consts.TableNameOutfitPlan+ " ADD COLUMN occasion TEXT NOT NULL DEFAULT ''"); err != nil { g.Log().Warningf(ctx, "migrate outfit_plan.occasion skipped: %v", err) } - _, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_user ON "+consts.TableNameOutfitPlan+"(user_id, created_at)") - _, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_task ON "+consts.TableNameOutfitPlan+"(task_id)") + if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_user ON "+consts.TableNameOutfitPlan+"(user_id, created_at)"); err != nil { + g.Log().Warningf(ctx, "create index idx_slogan_plan_user failed: %v", err) + } + if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_task ON "+consts.TableNameOutfitPlan+"(task_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_slogan_plan_task failed: %v", err) + } } func (d *outfitPlanDao) Insert(ctx context.Context, data *entity.OutfitPlan) (int64, error) { - r, err := g.DB().Exec(ctx, + r, err := dbPlan().Exec(ctx, "INSERT INTO "+consts.TableNameOutfitPlan+" (task_id, user_id, date_range, location, title, source, score, main_flag, hairstyle_id, hair_color, weather_ref, occasion, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))", data.TaskId, data.UserId, data.DateRange, data.Location, data.Title, data.Source, data.Score, data.MainFlag, data.HairstyleId, data.HairColor, data.WeatherRef, data.Occasion) @@ -52,23 +57,48 @@ func (d *outfitPlanDao) Insert(ctx context.Context, data *entity.OutfitPlan) (in return r.LastInsertId() } +// ===== 事务版本(runGenerateTask / SelectMain 流程使用,保证方案+单品原子落库) ===== + +func (d *outfitPlanDao) InsertTx(ctx context.Context, tx gdb.TX, data *entity.OutfitPlan) (int64, error) { + r, err := tx.Ctx(ctx).Exec( + "INSERT INTO "+consts.TableNameOutfitPlan+" (task_id, user_id, date_range, location, title, source, score, main_flag, hairstyle_id, hair_color, weather_ref, occasion, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))", + data.TaskId, data.UserId, data.DateRange, data.Location, data.Title, data.Source, + data.Score, data.MainFlag, data.HairstyleId, data.HairColor, data.WeatherRef, data.Occasion) + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +func (d *outfitPlanDao) ClearMainFlagTx(ctx context.Context, tx gdb.TX, taskId int64) error { + _, err := tx.Model(consts.TableNameOutfitPlan).Ctx(ctx). + Data(g.Map{"main_flag": 0}).Where("task_id", taskId).Update() + return err +} + +func (d *outfitPlanDao) SetMainFlagTx(ctx context.Context, tx gdb.TX, id int64) error { + _, err := tx.Model(consts.TableNameOutfitPlan).Ctx(ctx). + Data(g.Map{"main_flag": 1}).Where("id", id).Update() + return err +} + func (d *outfitPlanDao) ListByUser(ctx context.Context, userId int64) ([]*entity.OutfitPlan, error) { var list []*entity.OutfitPlan - err := g.DB().Model(consts.TableNameOutfitPlan).Ctx(ctx). + err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). Where("user_id", userId).OrderDesc("id").Limit(50).Scan(&list) return list, err } func (d *outfitPlanDao) ListByTask(ctx context.Context, taskId int64) ([]*entity.OutfitPlan, error) { var list []*entity.OutfitPlan - err := g.DB().Model(consts.TableNameOutfitPlan).Ctx(ctx). + err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). Where("task_id", taskId).OrderAsc("id").Scan(&list) return list, err } func (d *outfitPlanDao) GetOne(ctx context.Context, id, userId int64) (*entity.OutfitPlan, error) { var p entity.OutfitPlan - err := g.DB().Model(consts.TableNameOutfitPlan).Ctx(ctx). + err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). Where("id", id).Where("user_id", userId).Scan(&p) if err != nil || p.Id == 0 { return nil, err @@ -77,13 +107,13 @@ func (d *outfitPlanDao) GetOne(ctx context.Context, id, userId int64) (*entity.O } func (d *outfitPlanDao) ClearMainFlag(ctx context.Context, taskId int64) error { - _, err := g.DB().Model(consts.TableNameOutfitPlan).Ctx(ctx). + _, err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). Data(g.Map{"main_flag": 0}).Where("task_id", taskId).Update() return err } func (d *outfitPlanDao) SetMainFlag(ctx context.Context, id int64) error { - _, err := g.DB().Model(consts.TableNameOutfitPlan).Ctx(ctx). + _, err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). Data(g.Map{"main_flag": 1}).Where("id", id).Update() return err } diff --git a/styleagent/dao/partner_store_dao.go b/styleagent/dao/partner_store_dao.go index b7d3459..083c398 100644 --- a/styleagent/dao/partner_store_dao.go +++ b/styleagent/dao/partner_store_dao.go @@ -47,9 +47,11 @@ func seedStores(ctx context.Context) { {"简约风服装馆", "北京市海淀区中关村大街27号", "到店核销佣金 6%", 2, 39.9822, 116.3171}, } for _, it := range items { - _, _ = g.DB().Exec(ctx, + if _, err := g.DB().Exec(ctx, "INSERT INTO "+consts.TableNamePartnerStore+" (name, type, lat, lng, address, commission_policy, status, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, datetime('now','localtime'))", - it.name, it.typ, it.lat, it.lng, it.addr, it.policy) + it.name, it.typ, it.lat, it.lng, it.addr, it.policy); err != nil { + g.Log().Warningf(ctx, "seed partner_store %s failed: %v", it.name, err) + } } } diff --git a/styleagent/dao/pay_notify_log_dao.go b/styleagent/dao/pay_notify_log_dao.go index f70b115..c495652 100644 --- a/styleagent/dao/pay_notify_log_dao.go +++ b/styleagent/dao/pay_notify_log_dao.go @@ -15,7 +15,7 @@ type payNotifyLogDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePayNotifyLog+` ( + _, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePayNotifyLog+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '', @@ -30,7 +30,7 @@ func init() { } func (d *payNotifyLogDao) Insert(ctx context.Context, log *entity.PayNotifyLog) error { - _, err := g.DB().Model(consts.TableNamePayNotifyLog).Ctx(ctx).Data(g.Map{ + _, err := dbPay().Model(consts.TableNamePayNotifyLog).Ctx(ctx).Data(g.Map{ "order_no": log.OrderNo, "body": log.Body, "sign": log.Sign, "remote_ip": log.RemoteIp, "status": log.Status, }).Insert() diff --git a/styleagent/dao/payment_order_dao.go b/styleagent/dao/payment_order_dao.go index 0c06fc8..ab77f14 100644 --- a/styleagent/dao/payment_order_dao.go +++ b/styleagent/dao/payment_order_dao.go @@ -6,6 +6,7 @@ import ( "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -15,7 +16,7 @@ type paymentOrderDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePaymentOrder+` ( + _, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePaymentOrder+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL DEFAULT 0, @@ -31,11 +32,13 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create payment_order table failed: %v", err) } - _, _ = g.DB().Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_payment_order_user ON `+consts.TableNamePaymentOrder+`(user_id, created_at)`) + if _, err := dbPay().Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_payment_order_user ON `+consts.TableNamePaymentOrder+`(user_id, created_at)`); err != nil { + g.Log().Warningf(ctx, "create index idx_payment_order_user failed: %v", err) + } } func (d *paymentOrderDao) Insert(ctx context.Context, order *entity.PaymentOrder) (int64, error) { - r, err := g.DB().Model(consts.TableNamePaymentOrder).Ctx(ctx).Data(g.Map{ + r, err := dbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx).Data(g.Map{ "order_no": order.OrderNo, "user_id": order.UserId, "plan_id": order.PlanId, "amount_fen": order.AmountFen, "channel": order.Channel, "status": order.Status, }).Insert() @@ -47,14 +50,33 @@ func (d *paymentOrderDao) Insert(ctx context.Context, order *entity.PaymentOrder func (d *paymentOrderDao) GetByOrderNo(ctx context.Context, orderNo string) (*entity.PaymentOrder, error) { var o *entity.PaymentOrder - err := g.DB().Model(consts.TableNamePaymentOrder).Ctx(ctx). + err := dbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx). Where("order_no", orderNo).Scan(&o) return o, err } // MarkPaid 状态机 pending→paid(只更新 pending 行,返回是否成功,回调并发安全) func (d *paymentOrderDao) MarkPaid(ctx context.Context, orderNo, tradeNo, notifyRaw string) (bool, error) { - r, err := g.DB().Exec(ctx, + r, err := dbPay().Exec(ctx, + "UPDATE "+consts.TableNamePaymentOrder+" SET status=?, trade_no=?, notify_raw=?, paid_at=datetime('now','localtime') WHERE order_no=? AND status=?", + consts.PayStatusPaid, tradeNo, notifyRaw, orderNo, consts.PayStatusPending) + if err != nil { + return false, err + } + n, _ := r.RowsAffected() + return n > 0, nil +} + +// ===== 事务版本(HandlePaidNotify 回调流程使用,保证订单状态与会员开通原子) ===== + +func (d *paymentOrderDao) GetByOrderNoTx(ctx context.Context, tx gdb.TX, orderNo string) (*entity.PaymentOrder, error) { + var o *entity.PaymentOrder + err := tx.Model(consts.TableNamePaymentOrder).Ctx(ctx).Where("order_no", orderNo).Scan(&o) + return o, err +} + +func (d *paymentOrderDao) MarkPaidTx(ctx context.Context, tx gdb.TX, orderNo, tradeNo, notifyRaw string) (bool, error) { + r, err := tx.Ctx(ctx).Exec( "UPDATE "+consts.TableNamePaymentOrder+" SET status=?, trade_no=?, notify_raw=?, paid_at=datetime('now','localtime') WHERE order_no=? AND status=?", consts.PayStatusPaid, tradeNo, notifyRaw, orderNo, consts.PayStatusPending) if err != nil { @@ -66,7 +88,7 @@ func (d *paymentOrderDao) MarkPaid(ctx context.Context, orderNo, tradeNo, notify func (d *paymentOrderDao) GetByUser(ctx context.Context, userId int64) ([]*entity.PaymentOrder, error) { var list []*entity.PaymentOrder - err := g.DB().Model(consts.TableNamePaymentOrder).Ctx(ctx). + err := dbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx). Where("user_id", userId).OrderDesc("id").Limit(20).Scan(&list) return list, err } diff --git a/styleagent/dao/plan_effect_image_dao.go b/styleagent/dao/plan_effect_image_dao.go index d71ffed..0c0e934 100644 --- a/styleagent/dao/plan_effect_image_dao.go +++ b/styleagent/dao/plan_effect_image_dao.go @@ -4,6 +4,7 @@ import ( "context" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "strings" "github.com/gogf/gf/v2/frame/g" ) @@ -14,7 +15,7 @@ type planEffectImageDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanEffectImage+` ( + _, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanEffectImage+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id INTEGER NOT NULL, angle TEXT NOT NULL DEFAULT '', @@ -27,11 +28,13 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create plan_effect_image table failed: %v", err) } - _, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_effect_plan ON "+consts.TableNamePlanEffectImage+"(plan_id)") + if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_effect_plan ON "+consts.TableNamePlanEffectImage+"(plan_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_slogan_effect_plan failed: %v", err) + } } func (d *planEffectImageDao) Insert(ctx context.Context, data *entity.PlanEffectImage) (int64, error) { - r, err := g.DB().Exec(ctx, + r, err := dbPlan().Exec(ctx, "INSERT INTO "+consts.TableNamePlanEffectImage+" (plan_id, angle, url, status, prompt_snapshot, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))", data.PlanId, data.Angle, data.Url, data.Status, data.PromptSnapshot) if err != nil { @@ -40,16 +43,36 @@ func (d *planEffectImageDao) Insert(ctx context.Context, data *entity.PlanEffect return r.LastInsertId() } +// InsertBatch 批量插入(单条 multi-row SQL,缓存命中已生成的记录直接落库) +func (d *planEffectImageDao) InsertBatch(ctx context.Context, list []*entity.PlanEffectImage) error { + if len(list) == 0 { + return nil + } + var sb strings.Builder + sb.WriteString("INSERT INTO " + consts.TableNamePlanEffectImage + + " (plan_id, angle, url, status, prompt_snapshot, created_at, updated_at) VALUES ") + args := make([]any, 0, len(list)*5) + for i, it := range list { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString("(?,?,?,?,?,datetime('now','localtime'),datetime('now','localtime'))") + args = append(args, it.PlanId, it.Angle, it.Url, it.Status, it.PromptSnapshot) + } + _, err := dbPlan().Exec(ctx, sb.String(), args...) + return err +} + func (d *planEffectImageDao) ListByPlan(ctx context.Context, planId int64) ([]*entity.PlanEffectImage, error) { var list []*entity.PlanEffectImage - err := g.DB().Model(consts.TableNamePlanEffectImage).Ctx(ctx). + err := dbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx). Where("plan_id", planId).OrderAsc("id").Scan(&list) return list, err } // CountByUserToday 统计用户当日已生成的效果图数量(join outfit_plan 拿 user_id) func (d *planEffectImageDao) CountByUserToday(ctx context.Context, userId int64) (int, error) { - n, err := g.DB().Model(consts.TableNamePlanEffectImage+" p"). + n, err := dbPlan().Model(consts.TableNamePlanEffectImage+" p"). InnerJoin(consts.TableNameOutfitPlan+" o", "p.plan_id = o.id"). Ctx(ctx). Where("o.user_id", userId). @@ -60,14 +83,14 @@ func (d *planEffectImageDao) CountByUserToday(ctx context.Context, userId int64) } func (d *planEffectImageDao) UpdateStatus(ctx context.Context, id int64, status, url string) error { - _, err := g.DB().Model(consts.TableNamePlanEffectImage).Ctx(ctx).Data(g.Map{ + _, err := dbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx).Data(g.Map{ "status": status, "url": url, "updated_at": "datetime('now','localtime')", }).Where("id", id).Update() return err } func (d *planEffectImageDao) DeleteByPlan(ctx context.Context, planId int64) error { - _, err := g.DB().Model(consts.TableNamePlanEffectImage).Ctx(ctx). + _, err := dbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx). Unscoped().Where("plan_id", planId).Delete() return err } diff --git a/styleagent/dao/plan_outfit_item_dao.go b/styleagent/dao/plan_outfit_item_dao.go index 950146f..2b737db 100644 --- a/styleagent/dao/plan_outfit_item_dao.go +++ b/styleagent/dao/plan_outfit_item_dao.go @@ -4,7 +4,9 @@ import ( "context" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "strings" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -14,7 +16,7 @@ type planOutfitItemDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanOutfitItem+` ( + _, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanOutfitItem+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id INTEGER NOT NULL, slot TEXT NOT NULL DEFAULT '', @@ -28,11 +30,13 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create plan_outfit_item table failed: %v", err) } - _, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_item ON "+consts.TableNamePlanOutfitItem+"(plan_id)") + if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_item ON "+consts.TableNamePlanOutfitItem+"(plan_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_slogan_plan_item failed: %v", err) + } } func (d *planOutfitItemDao) Insert(ctx context.Context, data *entity.PlanOutfitItem) (int64, error) { - r, err := g.DB().Exec(ctx, + r, err := dbPlan().Exec(ctx, "INSERT INTO "+consts.TableNamePlanOutfitItem+" (plan_id, slot, source, wardrobe_item_id, product_name, name, desc, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))", data.PlanId, data.Slot, data.Source, data.WardrobeItemId, data.ProductName, data.Name, data.Desc) if err != nil { @@ -41,15 +45,35 @@ func (d *planOutfitItemDao) Insert(ctx context.Context, data *entity.PlanOutfitI return r.LastInsertId() } +// InsertBatchTx 批量插入所有方案的单品(单条 multi-row SQL,事务内) +func (d *planOutfitItemDao) InsertBatchTx(ctx context.Context, tx gdb.TX, items []*entity.PlanOutfitItem) error { + if len(items) == 0 { + return nil + } + var sb strings.Builder + sb.WriteString("INSERT INTO " + consts.TableNamePlanOutfitItem + + " (plan_id, slot, source, wardrobe_item_id, product_name, name, desc, created_at) VALUES ") + args := make([]any, 0, len(items)*7) + for i, it := range items { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString("(?,?,?,?,?,?,?,datetime('now','localtime'))") + args = append(args, it.PlanId, it.Slot, it.Source, it.WardrobeItemId, it.ProductName, it.Name, it.Desc) + } + _, err := tx.Ctx(ctx).Exec(sb.String(), args...) + return err +} + func (d *planOutfitItemDao) ListByPlan(ctx context.Context, planId int64) ([]*entity.PlanOutfitItem, error) { var list []*entity.PlanOutfitItem - err := g.DB().Model(consts.TableNamePlanOutfitItem).Ctx(ctx). + err := dbPlan().Model(consts.TableNamePlanOutfitItem).Ctx(ctx). Where("plan_id", planId).OrderAsc("id").Scan(&list) return list, err } func (d *planOutfitItemDao) DeleteByPlan(ctx context.Context, planId int64) error { - _, err := g.DB().Model(consts.TableNamePlanOutfitItem).Ctx(ctx). + _, err := dbPlan().Model(consts.TableNamePlanOutfitItem).Ctx(ctx). Unscoped().Where("plan_id", planId).Delete() return err } diff --git a/styleagent/dao/plan_review_dao.go b/styleagent/dao/plan_review_dao.go index 96bb4ee..bab431f 100644 --- a/styleagent/dao/plan_review_dao.go +++ b/styleagent/dao/plan_review_dao.go @@ -14,7 +14,7 @@ type planReviewDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanReview+` ( + _, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanReview+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id INTEGER NOT NULL, user_id INTEGER NOT NULL, @@ -28,7 +28,7 @@ func init() { } func (d *planReviewDao) Insert(ctx context.Context, data *entity.PlanReview) (int64, error) { - r, err := g.DB().Exec(ctx, + r, err := dbPlan().Exec(ctx, "INSERT INTO "+consts.TableNamePlanReview+" (plan_id, user_id, action, note, created_at) VALUES (?, ?, ?, ?, datetime('now','localtime'))", data.PlanId, data.UserId, data.Action, data.Note) if err != nil { @@ -39,7 +39,7 @@ func (d *planReviewDao) Insert(ctx context.Context, data *entity.PlanReview) (in func (d *planReviewDao) ListByUserAndPlan(ctx context.Context, userId, planId int64) ([]*entity.PlanReview, error) { var list []*entity.PlanReview - err := g.DB().Model(consts.TableNamePlanReview).Ctx(ctx). + err := dbPlan().Model(consts.TableNamePlanReview).Ctx(ctx). Where("user_id", userId).Where("plan_id", planId).OrderDesc("id").Limit(20).Scan(&list) return list, err } diff --git a/styleagent/dao/scene_category_map_dao.go b/styleagent/dao/scene_category_map_dao.go index d9752c0..5d871f8 100644 --- a/styleagent/dao/scene_category_map_dao.go +++ b/styleagent/dao/scene_category_map_dao.go @@ -14,7 +14,7 @@ type sceneCategoryMapDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameSceneCategoryMap+` ( + _, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameSceneCategoryMap+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, scene_type TEXT NOT NULL DEFAULT '', occasion TEXT NOT NULL DEFAULT '', @@ -40,7 +40,7 @@ func seedSceneCategoryMap(ctx context.Context) { {SceneType: consts.CpsSceneOccasion, Occasion: "运动", Source: consts.CpsSourceMeituanOta, CategoryCode: "ticket", Priority: 1}, } for _, s := range seeds { - if _, err := g.DB().Exec(ctx, + if _, err := dbCps().Exec(ctx, "INSERT OR IGNORE INTO "+consts.TableNameSceneCategoryMap+ " (scene_type, occasion, source, category_code, priority) VALUES (?, ?, ?, ?, ?)", s.SceneType, s.Occasion, s.Source, s.CategoryCode, s.Priority); err != nil { @@ -52,7 +52,7 @@ func seedSceneCategoryMap(ctx context.Context) { // QueryByScene 场景 → 映射列表(occasion 精确匹配优先,通用匹配兜底) func (d *sceneCategoryMapDao) QueryByScene(ctx context.Context, sceneType, occasion string) ([]*entity.SceneCategoryMap, error) { var list []*entity.SceneCategoryMap - m := g.DB().Model(consts.TableNameSceneCategoryMap).Ctx(ctx).Where("scene_type", sceneType) + m := dbCps().Model(consts.TableNameSceneCategoryMap).Ctx(ctx).Where("scene_type", sceneType) if occasion != "" { m = m.Where("occasion", occasion).OrderAsc("priority") } else { diff --git a/styleagent/dao/user_member_dao.go b/styleagent/dao/user_member_dao.go index aa42150..0a718f8 100644 --- a/styleagent/dao/user_member_dao.go +++ b/styleagent/dao/user_member_dao.go @@ -6,6 +6,7 @@ import ( "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -15,7 +16,7 @@ type userMemberDao struct{} func init() { ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUserMember+` ( + _, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUserMember+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, plan_id INTEGER NOT NULL DEFAULT 0, @@ -31,14 +32,31 @@ func init() { func (d *userMemberDao) GetByUser(ctx context.Context, userId int64) (*entity.UserMember, error) { var m *entity.UserMember - err := g.DB().Model(consts.TableNameUserMember).Ctx(ctx). + err := dbPay().Model(consts.TableNameUserMember).Ctx(ctx). Where("user_id", userId).Scan(&m) return m, err } // Upsert 无则插入有则整体覆盖(expire_at 由 Service 算好传入) func (d *userMemberDao) Upsert(ctx context.Context, userId, planId int64, expireAt, source string) error { - _, err := g.DB().Exec(ctx, + _, err := dbPay().Exec(ctx, + "INSERT INTO "+consts.TableNameUserMember+" (user_id, plan_id, expire_at, source, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime')) "+ + "ON CONFLICT(user_id) DO UPDATE SET plan_id=excluded.plan_id, expire_at=excluded.expire_at, source=excluded.source, updated_at=datetime('now','localtime')", + userId, planId, expireAt, source) + return err +} + +// GetByUserTx 事务版本:事务内读取当前会员状态,避免跨连接读到并发中间态 +func (d *userMemberDao) GetByUserTx(ctx context.Context, tx gdb.TX, userId int64) (*entity.UserMember, error) { + var m *entity.UserMember + err := tx.Model(consts.TableNameUserMember).Ctx(ctx). + Where("user_id", userId).Scan(&m) + return m, err +} + +// UpsertTx 事务版本:支付回调/广告奖励流程使用,保证与订单状态原子 +func (d *userMemberDao) UpsertTx(ctx context.Context, tx gdb.TX, userId, planId int64, expireAt, source string) error { + _, err := tx.Ctx(ctx).Exec( "INSERT INTO "+consts.TableNameUserMember+" (user_id, plan_id, expire_at, source, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime')) "+ "ON CONFLICT(user_id) DO UPDATE SET plan_id=excluded.plan_id, expire_at=excluded.expire_at, source=excluded.source, updated_at=datetime('now','localtime')", userId, planId, expireAt, source) @@ -47,7 +65,7 @@ func (d *userMemberDao) Upsert(ctx context.Context, userId, planId int64, expire // IsVip 当前是否会员(未过期) func (d *userMemberDao) IsVip(ctx context.Context, userId int64) bool { - n, err := g.DB().Model(consts.TableNameUserMember).Ctx(ctx). + n, err := dbPay().Model(consts.TableNameUserMember).Ctx(ctx). Where("user_id", userId).Where("expire_at > datetime('now','localtime')").Count() return err == nil && n > 0 } diff --git a/styleagent/dao/user_photo_dao.go b/styleagent/dao/user_photo_dao.go index c613ee4..962394d 100644 --- a/styleagent/dao/user_photo_dao.go +++ b/styleagent/dao/user_photo_dao.go @@ -25,7 +25,9 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create user_photo table failed: %v", err) } - _, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_user_photo_user ON "+consts.TableNameUserPhoto+"(user_id, type)") + if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_user_photo_user ON "+consts.TableNameUserPhoto+"(user_id, type)"); err != nil { + g.Log().Warningf(ctx, "create index idx_slogan_user_photo_user failed: %v", err) + } } func (d *userPhotoDao) Insert(ctx context.Context, data *entity.UserPhoto) (int64, error) { diff --git a/styleagent/dao/wardrobe_item_dao.go b/styleagent/dao/wardrobe_item_dao.go index 52d3ba0..682d2b1 100644 --- a/styleagent/dao/wardrobe_item_dao.go +++ b/styleagent/dao/wardrobe_item_dao.go @@ -33,7 +33,9 @@ func init() { " ADD COLUMN name TEXT NOT NULL DEFAULT ''"); err != nil { g.Log().Warningf(ctx, "migrate wardrobe_item.name skipped: %v", err) } - _, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_wardrobe_user ON "+consts.TableNameWardrobeItem+"(user_id, category)") + if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_wardrobe_user ON "+consts.TableNameWardrobeItem+"(user_id, category)"); err != nil { + g.Log().Warningf(ctx, "create index idx_slogan_wardrobe_user failed: %v", err) + } } func (d *wardrobeItemDao) Insert(ctx context.Context, data *entity.WardrobeItem) (int64, error) { diff --git a/styleagent/service/ad_reward_log_service.go b/styleagent/service/ad_reward_log_service.go index 0bff724..8be976e 100644 --- a/styleagent/service/ad_reward_log_service.go +++ b/styleagent/service/ad_reward_log_service.go @@ -7,6 +7,7 @@ import ( "slogan-agent/styleagent/consts" "slogan-agent/styleagent/dao" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -32,12 +33,19 @@ func (s *adService) Claim(ctx context.Context, userId int64, adType string) (*Ad if used >= limit { return nil, errors.New("今日次数已用完") } - if _, err := dao.AdRewardLog.Insert(ctx, userId, adType, limit); err != nil { + // 领取记录 + 会员赠送同一事务,避免"次数记了会员没送" + err = g.DB(consts.DBGroupPay).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if _, err := dao.AdRewardLog.InsertTx(ctx, tx, userId, adType, limit); err != nil { + return err + } + if adType == consts.AdTypeVipTrial { + return dao.UserMember.UpsertTx(ctx, tx, userId, 0, NextExpire(nil, 1), consts.MemberSourceAdTrial) + } + return nil + }) + if err != nil { return nil, errors.New("今日次数已用完") // 唯一索引兜底并发 } - if adType == consts.AdTypeVipTrial { - _ = dao.UserMember.Upsert(ctx, userId, 0, NextExpire(nil, 1), consts.MemberSourceAdTrial) - } return &AdRewardResult{AdType: adType, RemainingToday: limit - used - 1}, nil } diff --git a/styleagent/service/avatar_model_service.go b/styleagent/service/avatar_model_service.go index d760922..89a42c8 100644 --- a/styleagent/service/avatar_model_service.go +++ b/styleagent/service/avatar_model_service.go @@ -53,7 +53,10 @@ func (s *avatarService) Build(ctx context.Context, userId int64) (*entity.Avatar }) var record *entity.AvatarModel - existing, _ := dao.AvatarModel.GetByUser(ctx, userId) + existing, err := dao.AvatarModel.GetByUser(ctx, userId) + if err != nil { + return nil, err + } if existing != nil { if err := dao.AvatarModel.Update(ctx, existing.Id, map[string]any{ "face_template_id": 0, "body_template_id": 0, "skin_tone_index": 0, @@ -177,6 +180,10 @@ func (s *avatarService) Get(ctx context.Context, userId int64) (*entity.AvatarMo } func mustJSON(v any) string { - b, _ := json.Marshal(v) + b, err := json.Marshal(v) + if err != nil { + g.Log().Warningf(context.Background(), "avatar params marshal failed: %v", err) + return "{}" + } return string(b) } diff --git a/styleagent/service/cps_product_service.go b/styleagent/service/cps_product_service.go index 5ab7cf6..910143b 100644 --- a/styleagent/service/cps_product_service.go +++ b/styleagent/service/cps_product_service.go @@ -100,6 +100,7 @@ func (s *cpsProductService) syncCategory(ctx context.Context, p agent.Provider, g.Log().Warningf(ctx, "联盟 %s 同步类目 %s 失败: %v", p.Source(), c.Code, err) return } + list := make([]*entity.CpsProduct, 0, len(products)) for i := range products { prod := &products[i] if prod.Source == "" { @@ -108,9 +109,11 @@ func (s *cpsProductService) syncCategory(ctx context.Context, p agent.Provider, if prod.CategoryCode == "" { prod.CategoryCode = c.Code } - if err := dao.CpsProduct.Upsert(ctx, toCpsProductEntity(prod)); err != nil { - g.Log().Warningf(ctx, "写入商品 %s 失败: %v", prod.OuterId, err) - } + list = append(list, toCpsProductEntity(prod)) + } + // 批量 Upsert(multi-row SQL,每批独立事务),避免逐条执行的 N+1 + if err := dao.CpsProduct.UpsertBatch(ctx, list); err != nil { + g.Log().Warningf(ctx, "批量写入商品失败(类目 %s): %v", c.Code, err) } } @@ -159,7 +162,7 @@ func (s *cpsProductService) ClickLink(ctx context.Context, userId, productId int if err != nil { return "", err } - _ = CpsClickLogService.Click(ctx, &entity.CpsClickLog{ + if err := CpsClickLogService.Click(ctx, &entity.CpsClickLog{ UserId: userId, Source: prod.Source, OuterId: prod.OuterId, @@ -168,7 +171,9 @@ func (s *cpsProductService) ClickLink(ctx context.Context, userId, productId int CategoryCode: prod.CategoryCode, Deeplink: link, Ip: ip, - }) + }); err != nil { + g.Log().Warningf(ctx, "记录 CPS 点击日志失败: %v", err) + } return link, nil } @@ -180,7 +185,9 @@ func (s *cpsProductService) StartSyncLoop(ctx context.Context) { return } g.Log().Info(ctx, "CPS 定时同步开始") - _ = s.SyncProducts(ctx, "", "") + if err := s.SyncProducts(ctx, "", ""); err != nil { + g.Log().Warningf(ctx, "CPS 定时同步失败: %v", err) + } g.Log().Info(ctx, "CPS 定时同步结束") }); err != nil { g.Log().Warningf(ctx, "CPS 定时同步注册失败: %v", err) diff --git a/styleagent/service/member_plan_service.go b/styleagent/service/member_plan_service.go index d8581f8..3847b36 100644 --- a/styleagent/service/member_plan_service.go +++ b/styleagent/service/member_plan_service.go @@ -7,6 +7,8 @@ import ( "slogan-agent/styleagent/dao" "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" ) type memberPlanService struct{} @@ -35,16 +37,20 @@ func (s *memberPlanService) Status(ctx context.Context, userId int64) (*MemberSt } st.IsVip = true st.ExpireAt = um.ExpireAt.Format("Y-m-d H:i:s") - if plan, _ := dao.MemberPlan.GetOne(ctx, um.PlanId); plan != nil { + if plan, err := dao.MemberPlan.GetOne(ctx, um.PlanId); err != nil { + g.Log().Warningf(ctx, "读取会员套餐 %d 失败: %v", um.PlanId, err) + } else if plan != nil { st.PlanName = plan.Name - st.Benefits = parseBenefits(plan.Features) + st.Benefits = parseBenefits(ctx, plan.Features) } return st, nil } -func parseBenefits(features string) []string { +func parseBenefits(ctx context.Context, features string) []string { var list []string - _ = json.Unmarshal([]byte(features), &list) + if err := json.Unmarshal([]byte(features), &list); err != nil { + g.Log().Warningf(ctx, "解析套餐权益失败(features=%q): %v", features, err) + } if list == nil { list = make([]string, 0) } diff --git a/styleagent/service/outfit_generation_task_service.go b/styleagent/service/outfit_generation_task_service.go index 4dbb6c1..9b280b2 100644 --- a/styleagent/service/outfit_generation_task_service.go +++ b/styleagent/service/outfit_generation_task_service.go @@ -13,6 +13,7 @@ import ( "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gctx" ) @@ -61,7 +62,10 @@ func (s *outfitService) StartWorker(ctx context.Context) { return } for _, t := range tasks { - _ = dao.OutfitGenTask.UpdateStatus(ctx, t.Id, consts.TaskStatusFailed, "服务重启,任务中断,请重新生成") + if err := dao.OutfitGenTask.UpdateStatus(ctx, t.Id, consts.TaskStatusFailed, "服务重启,任务中断,请重新生成"); err != nil { + g.Log().Warningf(ctx, "标记任务 %d failed 失败: %v", t.Id, err) + continue + } g.Log().Infof(ctx, "任务 %d 已标记 failed(服务重启)", t.Id) } } @@ -69,7 +73,9 @@ func (s *outfitService) StartWorker(ctx context.Context) { // runGenerateTask 任务核心流程:planning → scoring → done/failed func runGenerateTask(ctx context.Context, taskId, userId int64, occasion string) { setTask := func(status, msg string) { - _ = dao.OutfitGenTask.UpdateStatus(ctx, taskId, status, msg) + if err := dao.OutfitGenTask.UpdateStatus(ctx, taskId, status, msg); err != nil { + g.Log().Warningf(ctx, "更新任务 %d 状态 %s 失败: %v", taskId, status, err) + } } fail := func(err error) { setTask(consts.TaskStatusFailed, err.Error()) @@ -99,8 +105,13 @@ func runGenerateTask(ctx context.Context, taskId, userId int64, occasion string) Season: "夏", } } - weatherJSON, _ := json.Marshal(weatherResult) - _ = dao.OutfitGenTask.Update(ctx, taskId, g.Map{"weather_snapshot": string(weatherJSON), "model_name": g.Cfg().MustGet(ctx, "llm.model_name", "").String()}) + weatherJSON, marshalErr := json.Marshal(weatherResult) + if marshalErr != nil { + g.Log().Warningf(ctx, "序列化天气数据失败: %v", marshalErr) + } + if err := dao.OutfitGenTask.Update(ctx, taskId, g.Map{"weather_snapshot": string(weatherJSON), "model_name": g.Cfg().MustGet(ctx, "llm.model_name", "").String()}); err != nil { + g.Log().Warningf(ctx, "写入任务 %d 天气快照失败: %v", taskId, err) + } // 2. LLM 配置 cfg, err := agent.GetModelConfig(ctx) @@ -149,7 +160,10 @@ func runGenerateTask(ctx context.Context, taskId, userId int64, occasion string) } // 6. 追加 AI 推荐方案(新品为主,与衣橱方案合并评分落库) - wardrobeJSON, _ := json.Marshal(items) + wardrobeJSON, marshalErr := json.Marshal(items) + if marshalErr != nil { + g.Log().Warningf(ctx, "序列化衣橱数据失败(影响 AI 推荐方案): %v", marshalErr) + } fallbackInput := agent.BuildFallbackUserInput(weatherSummaryText(weatherResult), occasion, string(wardrobeJSON), hairstyles, bodyDesc) fallback, err := agent.CreateRecommendPlan(ctx, cfg, agent.SystemPromptPlan(), fallbackInput) if err != nil { @@ -163,39 +177,47 @@ func runGenerateTask(ctx context.Context, taskId, userId int64, occasion string) scores[i] = scorePlan(p, items, ctxScore) } - // 7. 落库 plan + items - hairstylesAll, _ := dao.HairstyleAsset.ListAll(ctx) + // 7. 落库 plan + items(单事务:方案+单品批量写入+任务完成原子提交,失败整体回滚不留半套方案) + hairstylesAll, err := dao.HairstyleAsset.ListAll(ctx) + if err != nil { + g.Log().Warningf(ctx, "读取发型库失败,方案发型将不匹配: %v", err) + } dateRange := task.StartDate + " ~ " + task.EndDate weatherRef := weatherSummaryText(weatherResult) - for i, p := range plans { - planId, err := dao.OutfitPlan.Insert(ctx, &entity.OutfitPlan{ - TaskId: taskId, UserId: userId, DateRange: dateRange, Location: task.Location, - Title: p.Title, Source: planSource(p), Score: scores[i], - HairstyleId: matchHairstyle(p.Hairstyle, hairstylesAll), HairColor: p.HairColor, - WeatherRef: weatherRef, Occasion: occasion, - }) - if err != nil { - fail(err) - return - } - for _, it := range p.Items { - source := consts.PlanSourceWardrobe - productName := "" - if it.NewItem || it.ItemId == 0 { - source = consts.PlanSourceRecommend - productName = it.Name - } - _, err := dao.PlanOutfitItem.Insert(ctx, &entity.PlanOutfitItem{ - PlanId: planId, Slot: it.Slot, Source: source, - WardrobeItemId: it.ItemId, ProductName: productName, Name: it.Name, Desc: it.Desc, + err = g.DB(consts.DBGroupPlan).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + var items []*entity.PlanOutfitItem + for i, p := range plans { + planId, err := dao.OutfitPlan.InsertTx(ctx, tx, &entity.OutfitPlan{ + TaskId: taskId, UserId: userId, DateRange: dateRange, Location: task.Location, + Title: p.Title, Source: planSource(p), Score: scores[i], + HairstyleId: matchHairstyle(p.Hairstyle, hairstylesAll), HairColor: p.HairColor, + WeatherRef: weatherRef, Occasion: occasion, }) if err != nil { - fail(err) - return + return err + } + for _, it := range p.Items { + source := consts.PlanSourceWardrobe + productName := "" + if it.NewItem || it.ItemId == 0 { + source = consts.PlanSourceRecommend + productName = it.Name + } + items = append(items, &entity.PlanOutfitItem{ + PlanId: planId, Slot: it.Slot, Source: source, + WardrobeItemId: it.ItemId, ProductName: productName, Name: it.Name, Desc: it.Desc, + }) } } + if err := dao.PlanOutfitItem.InsertBatchTx(ctx, tx, items); err != nil { + return err + } + return dao.OutfitGenTask.UpdateStatusTx(ctx, tx, taskId, consts.TaskStatusDone, "") + }) + if err != nil { + fail(err) + return } - setTask(consts.TaskStatusDone, "") g.Log().Infof(ctx, "任务 %d 完成,共 %d 套方案", taskId, len(plans)) } diff --git a/styleagent/service/outfit_plan_service.go b/styleagent/service/outfit_plan_service.go index d6a1061..b61f4be 100644 --- a/styleagent/service/outfit_plan_service.go +++ b/styleagent/service/outfit_plan_service.go @@ -10,6 +10,8 @@ import ( "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gctx" ) @@ -41,21 +43,27 @@ func (s *outfitPlanService) GetPlanDetail(ctx context.Context, userId, planId in return nil, err } if plan.HairstyleId > 0 { - res.Hairstyle, _ = dao.HairstyleAsset.GetOne(ctx, plan.HairstyleId) + res.Hairstyle, err = dao.HairstyleAsset.GetOne(ctx, plan.HairstyleId) + if err != nil { + g.Log().Warningf(ctx, "读取发型 %d 失败: %v", plan.HairstyleId, err) + } } return res, nil } -// SelectMain 选定主方案(同任务其他方案清零)+ 异步生成效果图 +// SelectMain 选定主方案(同任务其他方案清零,同一事务保证不出现无主方案)+ 异步生成效果图 func (s *outfitPlanService) SelectMain(ctx context.Context, userId, planId int64) error { plan, err := dao.OutfitPlan.GetOne(ctx, planId, userId) if err != nil || plan == nil { return errors.New("方案不存在") } - if err := dao.OutfitPlan.ClearMainFlag(ctx, plan.TaskId); err != nil { - return err - } - if err := dao.OutfitPlan.SetMainFlag(ctx, planId); err != nil { + err = g.DB(consts.DBGroupPlan).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if err := dao.OutfitPlan.ClearMainFlagTx(ctx, tx, plan.TaskId); err != nil { + return err + } + return dao.OutfitPlan.SetMainFlagTx(ctx, tx, planId) + }) + if err != nil { return err } // 异步生成 3 视角效果图 diff --git a/styleagent/service/payment_order_service.go b/styleagent/service/payment_order_service.go index 210502e..b1adcec 100644 --- a/styleagent/service/payment_order_service.go +++ b/styleagent/service/payment_order_service.go @@ -16,6 +16,7 @@ import ( "slogan-agent/styleagent/dao" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" ) @@ -170,37 +171,59 @@ func (s *paymentOrderService) OrderStatus(ctx context.Context, orderNo string) ( return dao.PaymentOrder.GetByOrderNo(ctx, orderNo) } -// HandlePaidNotify 验签已在 handler 完成;状态机 pending→paid 幂等,成功开通/续期 +// HandlePaidNotify 验签已在 handler 完成;状态机 pending→paid 幂等,订单标记与会员开通同一事务,避免"扣款成功会员未开通" func (s *paymentOrderService) HandlePaidNotify(ctx context.Context, orderNo, tradeNo, notifyRaw string) (string, error) { - order, err := dao.PaymentOrder.GetByOrderNo(ctx, orderNo) + var ( + result string + userId int64 + expireAt string + ) + err := g.DB(consts.DBGroupPay).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + order, err := dao.PaymentOrder.GetByOrderNoTx(ctx, tx, orderNo) + if err != nil { + return err + } + if order == nil { + result = "no_order" + return nil + } + ok, err := dao.PaymentOrder.MarkPaidTx(ctx, tx, orderNo, tradeNo, notifyRaw) + if err != nil { + return err + } + if !ok { + result = "duplicate" // 已是 paid 或已关闭 + return nil + } + days := 30 + if plan, err := dao.MemberPlan.GetOneTx(ctx, tx, order.PlanId); err != nil { + return err + } else if plan != nil { + days = plan.DurationDays + } + um, err := dao.UserMember.GetByUserTx(ctx, tx, order.UserId) + if err != nil { + return err + } + var oldExpire *gtime.Time + if um != nil { + oldExpire = um.ExpireAt + } + expireAt = NextExpire(oldExpire, days) + if err := dao.UserMember.UpsertTx(ctx, tx, order.UserId, order.PlanId, expireAt, consts.MemberSourceVipPay); err != nil { + return err + } + userId = order.UserId + result = "ok" + return nil + }) if err != nil { return "no_order", err } - if order == nil { - return "no_order", nil + if result == "ok" { + g.Log().Infof(ctx, "会员开通成功 user=%d order=%s expire=%s", userId, orderNo, expireAt) } - ok, err := dao.PaymentOrder.MarkPaid(ctx, orderNo, tradeNo, notifyRaw) - if err != nil { - return "no_order", err - } - if !ok { - return "duplicate", nil // 已是 paid 或已关闭 - } - days := 30 - if plan, _ := dao.MemberPlan.GetOne(ctx, order.PlanId); plan != nil { - days = plan.DurationDays - } - um, _ := dao.UserMember.GetByUser(ctx, order.UserId) - var oldExpire *gtime.Time - if um != nil { - oldExpire = um.ExpireAt - } - expireAt := NextExpire(oldExpire, days) - if err := dao.UserMember.Upsert(ctx, order.UserId, order.PlanId, expireAt, consts.MemberSourceVipPay); err != nil { - return "no_order", err - } - g.Log().Infof(ctx, "会员开通成功 user=%d order=%s expire=%s", order.UserId, orderNo, expireAt) - return "ok", nil + return result, nil } // NextExpire 续期计算:未过期在原有效期上叠加,过期/无记录从现在起算 diff --git a/styleagent/service/plan_effect_image_service.go b/styleagent/service/plan_effect_image_service.go index cd7568e..53b8789 100644 --- a/styleagent/service/plan_effect_image_service.go +++ b/styleagent/service/plan_effect_image_service.go @@ -36,8 +36,16 @@ func (s *effectImageService) run(ctx context.Context, planId, userId int64) { if !dao.UserMember.IsVip(ctx, userId) { limit := ScoringRuleService.EffectLimit(ctx) if limit > 0 { - used, _ := dao.PlanEffectImage.CountByUserToday(ctx, userId) - extra, _ := dao.AdRewardLog.CountTodayByType(ctx, userId, consts.AdTypeEffectExtra) + used, err := dao.PlanEffectImage.CountByUserToday(ctx, userId) + if err != nil { + g.Log().Warningf(ctx, "统计当日效果图数量失败(本次放行): %v", err) + used = 0 + } + extra, err := dao.AdRewardLog.CountTodayByType(ctx, userId, consts.AdTypeEffectExtra) + if err != nil { + g.Log().Warningf(ctx, "统计广告奖励次数失败(本次放行): %v", err) + extra = 0 + } if used >= limit+extra { g.Log().Warningf(ctx, "效果图任务: 用户 %d 当日次数已用尽(%d/%d)", userId, used, limit+extra) return @@ -45,10 +53,19 @@ func (s *effectImageService) run(ctx context.Context, planId, userId int64) { } } - _ = dao.OutfitGenTask.UpdateStatus(ctx, plan.TaskId, consts.TaskStatusRendering, "") - defer dao.OutfitGenTask.UpdateStatus(ctx, plan.TaskId, consts.TaskStatusDone, "") + if err := dao.OutfitGenTask.UpdateStatus(ctx, plan.TaskId, consts.TaskStatusRendering, ""); err != nil { + g.Log().Warningf(ctx, "更新任务 %d 为渲染中失败: %v", plan.TaskId, err) + } + defer func() { + if err := dao.OutfitGenTask.UpdateStatus(ctx, plan.TaskId, consts.TaskStatusDone, ""); err != nil { + g.Log().Warningf(ctx, "更新任务 %d 为完成失败: %v", plan.TaskId, err) + } + }() - items, _ := dao.PlanOutfitItem.ListByPlan(ctx, planId) + items, err := dao.PlanOutfitItem.ListByPlan(ctx, planId) + if err != nil { + g.Log().Warningf(ctx, "读取方案单品失败(效果图描述将缺单品): %v", err) + } planDesc := planTitleDesc(plan.Title, items) // 用户全身正面照作 base image @@ -67,32 +84,44 @@ func (s *effectImageService) run(ctx context.Context, planId, userId int64) { g.Log().Warningf(ctx, "效果图生成不可用: %v", err) return } - for i, angle := range effectAngles { - cacheKey := effectCacheKey(plan, angle) - if url, ok := agent.CacheGet(cacheKey); ok { - _, _ = dao.PlanEffectImage.Insert(ctx, &entity.PlanEffectImage{ + // 缓存命中记录批量落库(1 条 multi-row SQL),未命中逐个插入并生成 + var cached []*entity.PlanEffectImage + var pending []*entity.PlanEffectImage + for _, angle := range effectAngles { + if url, ok := agent.CacheGet(effectCacheKey(plan, angle)); ok { + cached = append(cached, &entity.PlanEffectImage{ PlanId: planId, Angle: angle, Url: url, Status: consts.EffectStatusDone, PromptSnapshot: planDesc, }) - continue + } else { + pending = append(pending, &entity.PlanEffectImage{ + PlanId: planId, Angle: angle, Status: consts.EffectStatusRendering, + PromptSnapshot: planDesc, + }) } - recId, err := dao.PlanEffectImage.Insert(ctx, &entity.PlanEffectImage{ - PlanId: planId, Angle: angle, Status: consts.EffectStatusRendering, - PromptSnapshot: planDesc, - }) + } + if err := dao.PlanEffectImage.InsertBatch(ctx, cached); err != nil { + g.Log().Warningf(ctx, "效果图批量落库失败: %v", err) + } + for i, rec := range pending { + recId, err := dao.PlanEffectImage.Insert(ctx, rec) if err != nil { continue } url, err := client.Generate(ctx, &agent.GenerateReq{ - BaseImageURL: baseImageURL, Prompt: planDesc, Angle: angle, Seed: plan.Id*100 + int64(i), + BaseImageURL: baseImageURL, Prompt: planDesc, Angle: rec.Angle, Seed: plan.Id*100 + int64(i), }) if err != nil { - g.Log().Warningf(ctx, "效果图生成失败 plan=%d angle=%s: %v", planId, angle, err) - _ = dao.PlanEffectImage.UpdateStatus(ctx, recId, consts.EffectStatusFailed, "") + g.Log().Warningf(ctx, "效果图生成失败 plan=%d angle=%s: %v", planId, rec.Angle, err) + if updErr := dao.PlanEffectImage.UpdateStatus(ctx, recId, consts.EffectStatusFailed, ""); updErr != nil { + g.Log().Warningf(ctx, "标记效果图失败状态失败: %v", updErr) + } continue } - agent.CacheSet(cacheKey, url) - _ = dao.PlanEffectImage.UpdateStatus(ctx, recId, consts.EffectStatusDone, url) + agent.CacheSet(effectCacheKey(plan, rec.Angle), url) + if updErr := dao.PlanEffectImage.UpdateStatus(ctx, recId, consts.EffectStatusDone, url); updErr != nil { + g.Log().Warningf(ctx, "回写效果图完成状态失败: %v", updErr) + } } g.Log().Infof(ctx, "方案 %d 效果图生成完成", planId) } diff --git a/styleagent/service/user_service.go b/styleagent/service/user_service.go index b93e422..428546f 100644 --- a/styleagent/service/user_service.go +++ b/styleagent/service/user_service.go @@ -21,7 +21,10 @@ func (s *userService) Register(ctx context.Context, account, password, name stri if account == "" || password == "" { return 0, errors.New("账号和密码不能为空") } - existing, _ := dao.User.GetByAccount(ctx, account) + existing, err := dao.User.GetByAccount(ctx, account) + if err != nil { + return 0, err + } if existing != nil { return 0, errors.New("账号已存在") } @@ -45,7 +48,10 @@ func (s *userService) Login(ctx context.Context, account, password string) (*ent return nil, "", errors.New("请输入账号") } user, err := dao.User.GetByAccount(ctx, account) - if err != nil || user == nil { + if err != nil { + return nil, "", err + } + if user == nil { return nil, "", errors.New("账号不存在") } if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil { @@ -69,7 +75,10 @@ func (s *userService) Login(ctx context.Context, account, password string) (*ent func (s *userService) ChangePassword(ctx context.Context, userId int64, oldPwd, newPwd string) error { user, err := dao.User.GetOne(ctx, userId) - if err != nil || user == nil { + if err != nil { + return err + } + if user == nil { return errors.New("用户不存在") } if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(oldPwd)) != nil {