Files
qitongxue-api/internal/logic/seed.go
2026-09-30 17:01:18 +08:00

344 lines
18 KiB
Go
Raw 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/database/gdb"
"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)
}
// 软件开发服务介绍页默认模块:表为空时补一套默认排版(后台可自由增删改)
if err := seedServiceBlocks(ctx); err != nil {
g.Log().Warning(ctx, "[seed] service_blocks:", err)
}
// 开发项目四期:维护模板 / 付款分期模板(后台「模板设置」可改,只做缺则补)
if err := seedRows(ctx, consts.TableMaintainPlans, "name", seedMaintainPlans()); err != nil {
g.Log().Warning(ctx, "[seed] maintain_plans:", err)
}
if err := seedRows(ctx, consts.TablePayPlans, "name", seedPayPlans()); err != nil {
g.Log().Warning(ctx, "[seed] pay_plans:", err)
}
if err := seedRows(ctx, consts.TableSettings, "k", seedProjectFlowSettings()); err != nil {
g.Log().Warning(ctx, "[seed] project_flow_settings:", err)
}
}
// seedMaintainPlans 维护模板种子。
func seedMaintainPlans() []g.Map {
return []g.Map{
{"name": "一年免费维护", "free_years": 1, "yearly_fee_cents": 50000,
"source_only": 0, "sort": 1, "is_enabled": 1,
"remark": "交付后 1 年内免费维护,后续 ¥500/年"},
{"name": "按年收费维护", "free_years": 0, "yearly_fee_cents": 50000,
"source_only": 0, "sort": 2, "is_enabled": 1,
"remark": "无免费期,维护服务 ¥500/年(验收后即起算)"},
{"name": "仅交付源码", "free_years": 0, "yearly_fee_cents": 0,
"source_only": 1, "sort": 3, "is_enabled": 1,
"remark": "只交付源码,无售后维护"},
}
}
// seedPayPlans 付款分期模板种子(schedule JSON 由后端契约约定 trigger 节点)。
func seedPayPlans() []g.Map {
return []g.Map{
{"name": "两期 · 上线前结清", "sort": 1, "is_enabled": 1,
"schedule": `[{"seq":1,"percent":50,"trigger":"start"},{"seq":2,"percent":50,"trigger":"online_before"}]`,
"remark": "定金 50% 开工前,尾款 50% 上线前"},
{"name": "两期 · 尾款上线后", "sort": 2, "is_enabled": 1,
"schedule": `[{"seq":1,"percent":50,"trigger":"start"},{"seq":2,"percent":50,"trigger":"online_after"}]`,
"remark": "定金 50% 开工前,尾款 50% 上线验收后"},
{"name": "三期 · 中期演示续作", "sort": 3, "is_enabled": 1,
"schedule": `[{"seq":1,"percent":30,"trigger":"start"},{"seq":2,"percent":40,"trigger":"mid_demo"},{"seq":3,"percent":30,"trigger":"online_before"}]`,
"remark": "定金 30%,中期演示确认后续作 40%,上线前 30%"},
}
}
// seedProjectFlowSettings 开发项目流程设置键(后台「开发项目 → 模板设置」可改)。
func seedProjectFlowSettings() []g.Map {
return []g.Map{
{"k": "service.maintain.remindDays", "v": "15", "remark": "维护费到期前提醒天数(隔 3 天一发,最后 3 天每天发)"},
{"k": "service.maintain.adminNotifyUserId", "v": "", "remark": "维护费提醒同时通知的小程序用户 id(超管自己绑定的账号,空=只进管理端横幅)"},
}
}
// seedServiceBlocks 服务介绍页默认模块(仅当表为空时写入一次,之后完全以后台编辑为准)。
func seedServiceBlocks(ctx context.Context) error {
count, err := g.Model(consts.TableServiceBlocks).Count()
if err != nil {
return err
}
if count > 0 {
return nil
}
rows := []g.Map{
{
"kind": consts.ServiceBlockHero, "title": "从想法到上线\n让代码**落地**为产品",
"subtitle": "定制开发 · 交付源码 · 免费咨询", "content": "",
"sort": 1, "is_enabled": 1,
},
{
"kind": consts.ServiceBlockFlow, "title": "服务流程",
"content": "需求沟通|澄清目标与边界\n方案报价|评估周期与成本\n开发交付|敏捷迭代源码可交接\n售后保障|上线后持续维护",
"sort": 2, "is_enabled": 1,
},
{
"kind": consts.ServiceBlockChecklist, "title": "标准交付清单",
"content": "需求文档\n原型与 UI 设计稿\n前后端源码\n接口与部署文档\n测试与验收记录",
"sort": 3, "is_enabled": 1,
},
{
"kind": consts.ServiceBlockPrice, "title": "服务与价格",
"content": "官网展示型|官网搭建、页面设计、内容发布|3万起\n业务系统型|业务系统、流程管理、数据协同|8万起\n平台产品型|平台产品、权限体系、会员运营|面议",
"sort": 4, "is_enabled": 1,
},
{
"kind": consts.ServiceBlockWorks, "title": "作品案例",
"subtitle": "真实交付案例,点开可查看详情与参考报价",
"sort": 5, "is_enabled": 1,
},
{
"kind": consts.ServiceBlockDeveloper, "title": "8 年全栈开发经验 / 独立交付 60+ 项目",
"subtitle": "技术栈:Vue / React / 小程序 / Java / Python / 云部署",
"content": "",
"sort": 6, "is_enabled": 1,
},
{
"kind": consts.ServiceBlockGoods, "title": "查看全部服务",
"subtitle": "更多服务项与参考报价",
"sort": 7, "is_enabled": 1,
},
}
for _, row := range rows {
row["created_at"] = gdb.Raw("NOW()")
row["updated_at"] = gdb.Raw("NOW()")
if _, err := g.Model(consts.TableServiceBlocks).Data(row).Insert(); err != nil {
return err
}
}
return nil
}
// 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": "免单券下单占用自动释放时长(秒)"},
// ===== 站点(迭代-2026-09-29)=====
// 图片域名不落库:库内只存相对路径,拼接前缀在此配置;留空 = 按请求 Host 现拼(本地联调零配置)。
{"k": consts.SettingSiteBaseUrl, "v": "", "remark": "图片等静态资源的对外拼接前缀(如 https://api.example.com),留空=按请求域名动态拼接"},
}
}
// 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"},
}
}