package logic import ( "context" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "golang.org/x/crypto/bcrypt" "tool-api/internal/consts" ) // Seed 启动时幂等写入种子数据:管理员、模块、等级、工具目录。 // 数据库未就绪时仅告警,不阻断服务启动。 func Seed(ctx context.Context) { if err := seedAdmin(ctx); err != nil { g.Log().Warning(ctx, "[seed] admin:", err) } if err := seedRows(ctx, consts.TableModules, "module_key", seedModules()); err != nil { g.Log().Warning(ctx, "[seed] modules:", err) } if err := seedRows(ctx, consts.TableLevels, "level_key", seedLevels()); err != nil { g.Log().Warning(ctx, "[seed] levels:", err) } if err := seedRows(ctx, consts.TableTools, "tool_key", seedTools()); err != nil { g.Log().Warning(ctx, "[seed] tools:", err) } // 会员套餐由代码定义(暂无管理端),用 upsert 而非 insert-if-absent: // 否则改了 seedMemberPlans 的 product_id / 价格后重启不会生效,问题会极难自查。 if err := upsertRows(ctx, consts.TableMemberPlans, "plan_key", seedMemberPlans(), memberPlanSyncFields); err != nil { g.Log().Warning(ctx, "[seed] member_plans:", err) } // 次数包档位有管理端编辑入口(次数/价格/有效期都要运营自己改), // 所以用 seedRows(存在即跳过),绝不覆盖后台改动。 if err := seedRows(ctx, consts.TableQuotaPacks, "pack_key", seedQuotaPacks()); err != nil { g.Log().Warning(ctx, "[seed] quota_packs:", err) } // 全局额度周期:后台「额度设置」可改,同样只做「缺则补」 if err := seedRows(ctx, consts.TableSettings, "k", seedSettings()); err != nil { g.Log().Warning(ctx, "[seed] settings:", err) } } // memberPlanSyncFields 每次启动以代码为准覆盖的列。 // ⚠️ 因此「会员套餐的唯一真实来源是 seedMemberPlans」:临时直接改库虽然立刻生效, // 但下次重启会被这里覆盖回去,两边都要改。 // // 例外:quota_period_days(按套餐调整额度周期)由管理端维护,不能跟着代码覆盖, // 所以它刻意不在这个列表里。 var memberPlanSyncFields = []string{ "name", "subtitle", "level_key", "duration_days", "price_cents", "product_id", "badge", "sort", "is_enabled", "remark", } func seedAdmin(ctx context.Context) error { count, err := g.Model(consts.TableAdminUsers).Count() if err != nil { return err } if count > 0 { return nil } hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) if err != nil { return err } _, err = g.Model(consts.TableAdminUsers).Data(g.Map{ "account": "admin", "password": string(hash), "nick_name": "管理员", "role_name": "超级管理员", "role_value": "super", }).Insert() return err } // seedRows 按 uniqueKey 幂等插入(存在即跳过该行)。 // // 🔴 存在性判断必须 `.Unscoped()`:GoFrame v2.10 会给 Model 查询自动追加 `deleted_at IS NULL`。 // 若不加,被软删的种子行(当前即 tools)会因"查不到"被判为不存在 → 尝试重新 Insert // → 撞上**未**墓碑化的唯一键(uk tool_key)→ 每次启动刷屏报错。 // 加 Unscoped 后判"存在"→ 直接跳过,删除继续跨重启保持。 // (对 modules/levels/quota_packs/settings 等无 deleted_at 列的表,Unscoped 是空操作。) func seedRows(ctx context.Context, table, uniqueKey string, rows []g.Map) error { for _, row := range rows { count, err := g.Model(table).Unscoped().Where(uniqueKey, row[uniqueKey]).Count() if err != nil { return err } if count > 0 { continue } if _, err := g.Model(table).Data(row).Insert(); err != nil { return err } } return nil } // upsertRows 按 uniqueKey 写入:不存在则插入,已存在则用代码值覆盖 updateFields 指定的列。 // // 与 seedRows 的区别是「已存在的行也会被更新」,适用于配置由代码定义、没有管理端编辑入口的表。 // 有管理端编辑需求的表请继续用 seedRows,避免每次重启覆盖用户在后台的修改。 func upsertRows(ctx context.Context, table, uniqueKey string, rows []g.Map, updateFields []string) error { // 注意:此处**刻意不加** Unscoped(与上面的 seedRows 不同)。 // upsertRows 目前只服务 member_plans,而该表**没有** deleted_at 列 → 框架不会拼软删条件 // → 加不加 Unscoped 等价(空操作)。 // ⚠️ 若将来给 member_plans 增加软删列:不能盲加 Unscoped —— 那会让下面的 UPDATE 命中 // "已软删"的行,等于把软删数据"复活",未必是想要的语义,需重新评估。 for _, row := range rows { count, err := g.Model(table).Where(uniqueKey, row[uniqueKey]).Count() if err != nil { return err } if count == 0 { if _, err := g.Model(table).Data(row).Insert(); err != nil { return err } continue } // 只覆盖 row 里确实提供的列,未提供的保持库中现值 data := g.Map{"updated_at": gtime.Now()} for _, field := range updateFields { if value, ok := row[field]; ok { data[field] = value } } if _, err := g.Model(table). Where(uniqueKey, row[uniqueKey]). Data(data). Update(); err != nil { return err } } return nil } func seedModules() []g.Map { return []g.Map{ {"module_key": "image", "name": "图片处理", "icon": "picture", "description": "压缩、转换、抠图等图片工具", "sort": 1}, {"module_key": "doc", "name": "文档处理", "icon": "description", "description": "文档转换与处理(规划中)", "sort": 2}, {"module_key": "calc", "name": "计算换算", "icon": "computer", "description": "单位换算、房贷计算等", "sort": 3}, {"module_key": "time", "name": "时间效率", "icon": "time", "description": "番茄钟、倒数日等", "sort": 4}, {"module_key": "create", "name": "生成制作", "icon": "edit-outline", "description": "二维码、随机决定等", "sort": 5}, {"module_key": "life", "name": "生活查询", "icon": "home", "description": "日期计算、汇率等", "sort": 6}, } } func seedLevels() []g.Map { return []g.Map{ {"level_key": "v1", "name": "基础版", "modules": `["image"]`, "sort": 1, "remark": "默认等级:仅图片处理模块"}, {"level_key": "v2", "name": "进阶版", "modules": `["image","doc"]`, "sort": 2, "remark": "示例:追加文档处理模块"}, } } // seedTools 工具目录种子。 // // ⚠️ 这里刻意不写 free_quota / member_quota / pay_cost: // - 表里这三列的默认值是 3 / 100 / 1(见 migrate.go 与 sql/quota.sql) // - 种子是「存在即跳过」,若在这里写差异化初值,新装库会拿到 5/200/1, // 而升级库只会拿到列默认值 3/100/1 —— 同一份代码在不同环境表现不一致,极难排查 // - 因此额度统一以列默认值起步,差异化一律在【工具管理】里按工具配置 func seedTools() []g.Map { return []g.Map{ {"tool_key": "img-compress", "module_key": "image", "name": "图片压缩", "icon": "picture", "description": "批量压缩图片体积,画质几乎无损", "sort": 1, "is_hot": 1}, {"tool_key": "img-convert", "module_key": "image", "name": "格式转换", "icon": "picture", "description": "PNG / JPG / WebP 互转", "sort": 2, "is_hot": 1}, {"tool_key": "img-resize", "module_key": "image", "name": "修改尺寸", "icon": "picture", "description": "按宽高、比例或最长边缩放", "sort": 3}, {"tool_key": "img-crop", "module_key": "image", "name": "裁剪旋转", "icon": "picture", "description": "自由裁剪、旋转与镜像", "sort": 4}, {"tool_key": "img-watermark", "module_key": "image", "name": "文字水印", "icon": "picture", "description": "给图片添加平铺或居中水印", "sort": 5}, {"tool_key": "img-matting", "module_key": "image", "name": "去除背景", "icon": "picture", "description": "一键抠除纯色背景", "sort": 6, "is_hot": 1}, {"tool_key": "img-avatarfx", "module_key": "image", "name": "头像特效", "icon": "picture", "description": "国庆红旗圈、节日边框等头像特效制作", "sort": 7, "is_hot": 1}, {"tool_key": "img-grid9", "module_key": "image", "name": "九宫格切图", "icon": "picture", "description": "照片切成 9 张发朋友圈", "sort": 8}, {"tool_key": "img-collage", "module_key": "image", "name": "图片拼图", "icon": "picture", "description": "2-4 张照片拼成一张", "sort": 9}, {"tool_key": "img-qrcode", "module_key": "image", "name": "二维码生成", "icon": "picture", "description": "文本链接生成二维码图片", "sort": 10}, {"tool_key": "img-annotate", "module_key": "image", "name": "图片标注", "icon": "picture", "description": "涂鸦、文字、马赛克标注", "sort": 11}, } } // seedSettings 全局可改配置的初始值(只做「缺则补」,后台改过就不会被覆盖) func seedSettings() []g.Map { return []g.Map{ {"k": consts.SettingQuotaPeriodDays, "v": "7", "remark": "全局默认额度重置周期(天)"}, // ===== 优惠凭证域(迭代-2026-09-20)===== // 免单道具 ID / 价格:后台可配,**不硬编码**;下单用码/券时改用该道具走支付。 {"k": consts.SettingFreebieProductId, "v": consts.DefaultFreebieProductId, "remark": "免单道具 ID(须与 MP 后台【虚拟支付 → 道具管理】逐字符一致)"}, {"k": consts.SettingFreebiePriceCents, "v": "1", "remark": "免单道具价格(分)"}, {"k": consts.SettingPromoFailLimitPerMin, "v": "6", "remark": "同一用户 1 分钟内优惠码错误次数上限"}, {"k": consts.SettingCouponLockTtlSeconds, "v": "900", "remark": "免单券下单占用自动释放时长(秒)"}, } } // seedQuotaPacks 次数包档位种子。 // // ⚠️ product_id 必须与 MP 后台【虚拟支付 → 道具管理】中创建的道具一致(区分大小写), // price_cents 单位为「分」。tool_key 留空 = 通用档位,购买时由用户选择买给哪个工具; // 若想给某个工具单独定价,复制一行并把 tool_key 填成该工具即可(会覆盖通用档位展示)。 func seedQuotaPacks() []g.Map { return []g.Map{ {"pack_key": "quota_1", "name": "1 次", "times": 1, "price_cents": 100, "product_id": "quota_1", "valid_days": 365, "sort": 1, "is_enabled": 1, "remark": "单次体验"}, {"pack_key": "quota_50", "name": "50 次", "times": 50, "price_cents": 490, "product_id": "quota_50", "valid_days": 365, "sort": 2, "is_enabled": 1}, {"pack_key": "quota_100", "name": "100 次", "times": 100, "price_cents": 890, "product_id": "quota_100", "valid_days": 365, "badge": "热门", "sort": 3, "is_enabled": 1}, {"pack_key": "quota_300", "name": "300 次", "times": 300, "price_cents": 1990, "product_id": "quota_300", "valid_days": 365, "sort": 4, "is_enabled": 1}, {"pack_key": "quota_500", "name": "500 次", "times": 500, "price_cents": 2990, "product_id": "quota_500", "valid_days": 365, "sort": 5, "is_enabled": 1}, {"pack_key": "quota_1000", "name": "1000 次", "times": 1000, "price_cents": 4990, "product_id": "quota_1000", "valid_days": 365, "badge": "最划算", "sort": 6, "is_enabled": 1}, {"pack_key": "quota_10000", "name": "10000 次", "times": 10000, "price_cents": 39900, "product_id": "quota_10000", "valid_days": 365, "sort": 7, "is_enabled": 1, "remark": "工作室/批量场景"}, } } // seedMemberPlans 会员套餐种子。 // // ⚠️ product_id 与 price_cents 必须与 MP 后台【虚拟支付 → 道具管理】中创建的道具 // 严格一致(价格单位为「分」),否则支付会被平台拒绝。上线前请按实际道具 ID 修改。 // // ⚠️ 后台「道具价格」的单位是**整数元**(模板注释原文:「18(必填,需为整数,不超过10000元)」), // 而这里 price_cents 的单位是「分」,两者差 100 倍:price_cents = 元 × 100。 // // ⚠️ **当前定价含小数**(9.9 / 26.9 元),若要上架道具,必须先把价格调成整数元, // 否则后台道具导入 / 价格校验会拒绝(详见 doc/会员购买接入说明.md 4.2)。 // // 定价:月卡 9.9 元 / 季卡 26.9 元 / 年卡 99 元(实测值,暂为占位)。 // product_id 里嵌的是「分」,改价必须同步改 ID —— 道具一旦在后台发布就不能再改 ID, // 只能下架旧道具换新 ID,所以趁没发布先改干净。 func seedMemberPlans() []g.Map { return []g.Map{ {"plan_key": "vip-month", "name": "月卡", "subtitle": "先试试水", "level_key": "v2", "duration_days": 30, "price_cents": 990, "product_id": "vip_month_990", "sort": 1, "is_enabled": 1, "remark": "按月订阅,到期回落 v1"}, {"plan_key": "vip-season", "name": "季卡", "subtitle": "省 2 个月", "level_key": "v2", "duration_days": 90, "price_cents": 2690, "product_id": "vip_season_2690", "badge": "超值", "sort": 2, "is_enabled": 1, "remark": "按季订阅,到期回落 v1"}, {"plan_key": "vip-year", "name": "年卡", "subtitle": "一年最省心", "level_key": "v2", "duration_days": 365, "price_cents": 9900, "product_id": "vip_year_9900", "badge": "最划算", "sort": 3, "is_enabled": 1, "remark": "按年订阅,到期回落 v1"}, } }