This commit is contained in:
2026-08-04 14:45:17 +08:00
parent 4e556ea78a
commit e64421295f
40 changed files with 869 additions and 207 deletions
+14
View File
@@ -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
+1
View File
@@ -1,6 +1,7 @@
# 数据库与运行时产物
slogan.db
*.db
data/
slogan-agent
workspace/
+2 -2
View File
@@ -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"]
+14 -1
View File
@@ -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:
+15
View File
@@ -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 不可达)
+12 -3
View File
@@ -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 {
+238
View File
@@ -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)
}
+7 -1
View File
@@ -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"`
+8 -2
View File
@@ -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))
+8 -2
View File
@@ -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,
})
+8
View File
@@ -0,0 +1,8 @@
package consts
// 数据库组:config.yml database.* 中的分组名,与 slogans 各域 SQLite 文件一一对应
const (
DBGroupPlan = "plan"
DBGroupPay = "pay"
DBGroupCps = "cps"
)
@@ -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")
+27 -11
View File
@@ -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 {
+3 -3
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+49 -8
View File
@@ -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
+11
View File
@@ -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") }
+19 -9
View File
@@ -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
}
+18 -7
View File
@@ -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
}
+18 -7
View File
@@ -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
+40 -10
View File
@@ -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
}
+4 -2
View File
@@ -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)
}
}
}
+2 -2
View File
@@ -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()
+28 -6
View File
@@ -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
}
+30 -7
View File
@@ -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
}
+29 -5
View File
@@ -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
}
+3 -3
View File
@@ -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
}
+3 -3
View File
@@ -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 {
+22 -4
View File
@@ -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
}
+3 -1
View File
@@ -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) {
+3 -1
View File
@@ -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) {
+12 -4
View File
@@ -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
}
+9 -2
View File
@@ -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)
}
+13 -6
View File
@@ -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))
}
// 批量 Upsertmulti-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)
+10 -4
View File
@@ -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)
}
@@ -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))
}
+14 -6
View File
@@ -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 视角效果图
+49 -26
View File
@@ -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 续期计算:未过期在原有效期上叠加,过期/无记录从现在起算
+48 -19
View File
@@ -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)
}
+12 -3
View File
@@ -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 {