213 lines
11 KiB
Go
213 lines
11 KiB
Go
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"},
|
||
}
|
||
}
|