Files
2026-09-17 17:38:45 +08:00

213 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 幂等插入(存在即跳过该行)
func seedRows(ctx context.Context, table, uniqueKey string, rows []g.Map) error {
for _, row := range rows {
count, err := g.Model(table).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 {
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": "全局默认额度重置周期(天)"},
}
}
// 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"},
}
}