diff --git a/.gitignore b/.gitignore
index 18646b6..8a29ecd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,7 +13,10 @@ main
main.exe
output/
manifest/output/
+# 运行时日志(由 manifest/config/config.yaml 的 logger.path 指定)
+manifest/logs/
temp/
temp.yaml
bin
+# 环境配置(含 OfferID / AppKey 等密钥,切勿提交)
**/config/config.yaml
\ No newline at end of file
diff --git a/api/admin/v1/admin.go b/api/admin/v1/admin.go
index 5946c53..8a6d06b 100644
--- a/api/admin/v1/admin.go
+++ b/api/admin/v1/admin.go
@@ -31,16 +31,18 @@ type MyInfoRes struct {
// ===== 用户管理 =====
type AdminUserItem struct {
- Id int64 `json:"id"`
- Nickname string `json:"nickname"`
- AvatarUrl string `json:"avatar_url"`
- Openid string `json:"openid"`
- LevelKey string `json:"level_key"`
- LevelName string `json:"level_name"`
- Status int `json:"status"`
- UsageCount int64 `json:"usage_count"`
- LastUsedAt string `json:"last_used_at"`
- CreatedAt string `json:"created_at"`
+ Id int64 `json:"id"`
+ Nickname string `json:"nickname"`
+ AvatarUrl string `json:"avatar_url"`
+ Openid string `json:"openid"`
+ LevelKey string `json:"level_key"`
+ LevelName string `json:"level_name"`
+ LevelExpireAt string `json:"level_expire_at"`
+ QuotaPeriodDays int `json:"quota_period_days"` // 0 = 跟随全局设置
+ Status int `json:"status"`
+ UsageCount int64 `json:"usage_count"`
+ LastUsedAt string `json:"last_used_at"`
+ CreatedAt string `json:"created_at"`
}
type UserListReq struct {
@@ -141,6 +143,9 @@ type AdminToolItem struct {
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
+ FreeQuota int `json:"free_quota"` // 免费额度(每周期次数)
+ MemberQuota int `json:"member_quota"` // 会员额度(每周期次数)
+ PayCost int `json:"pay_cost"` // 付费额度每次消耗系数
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
IsHot int `json:"is_hot"`
@@ -161,13 +166,16 @@ type ToolsListRes struct {
}
type ToolsSaveReq struct {
- g.Meta `path:"/tools/save" method:"post" tags:"admin" summary:"保存工具"`
+ g.Meta `path:"/tools/save" method:"post" tags:"admin" summary:"保存工具(含额度配置)"`
Id int64 `json:"id"`
ToolKey string `v:"required-if:id,0#工具标识不能为空" json:"toolKey"`
ModuleKey string `v:"required#所属模块不能为空" json:"moduleKey"`
Name string `v:"required#工具名称不能为空" json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
+ FreeQuota int `v:"min:0#免费额度不能为负" json:"freeQuota"`
+ MemberQuota int `v:"min:0#会员额度不能为负" json:"memberQuota"`
+ PayCost int `v:"min:1#付费消耗系数至少为 1" json:"payCost"`
Sort int `json:"sort"`
IsEnabled int `json:"isEnabled"`
IsHot int `json:"isHot"`
diff --git a/api/admin/v1/quota.go b/api/admin/v1/quota.go
new file mode 100644
index 0000000..8ab01ed
--- /dev/null
+++ b/api/admin/v1/quota.go
@@ -0,0 +1,125 @@
+package v1
+
+import "github.com/gogf/gf/v2/frame/g"
+
+// ============================================================================
+// 管理端:额度配置 / 次数包 / 会员套餐 / 额度设置
+// ============================================================================
+
+// ===== 次数包档位 =====
+
+type QuotaPackItem struct {
+ Id int64 `json:"id"`
+ PackKey string `json:"pack_key"`
+ Name string `json:"name"`
+ Times int `json:"times"`
+ PriceCents int64 `json:"price_cents"`
+ ProductId string `json:"product_id"`
+ ValidDays int `json:"valid_days"`
+ ToolKey string `json:"tool_key"`
+ ToolName string `json:"tool_name"`
+ Badge string `json:"badge"`
+ Sort int `json:"sort"`
+ IsEnabled int `json:"is_enabled"`
+ Remark string `json:"remark"`
+}
+
+type QuotaPackListReq struct {
+ g.Meta `path:"/quota/pack/list" method:"get" tags:"admin" summary:"次数包档位列表"`
+}
+type QuotaPackListRes struct {
+ List []QuotaPackItem `json:"list"`
+}
+
+type QuotaPackSaveReq struct {
+ g.Meta `path:"/quota/pack/save" method:"post" tags:"admin" summary:"保存次数包档位"`
+ Id int64 `json:"id"`
+ PackKey string `v:"required-if:id,0#档位标识不能为空" json:"packKey"`
+ Name string `v:"required#展示名不能为空" json:"name"`
+ Times int `v:"min:1#次数必须大于 0" json:"times"`
+ PriceCents int64 `v:"min:1#价格必须大于 0" json:"priceCents"`
+ ProductId string `v:"required#微信道具 ID 不能为空" json:"productId"`
+ ValidDays int `json:"validDays"`
+ ToolKey string `json:"toolKey"`
+ Badge string `json:"badge"`
+ Sort int `json:"sort"`
+ IsEnabled int `json:"isEnabled"`
+ Remark string `json:"remark"`
+}
+type QuotaPackSaveRes struct{}
+
+type QuotaPackToggleReq struct {
+ g.Meta `path:"/quota/pack/toggle" method:"post" tags:"admin" summary:"次数包启停"`
+ Id int64 `v:"required" json:"id"`
+ Value int `json:"value"`
+}
+type QuotaPackToggleRes struct{}
+
+// ===== 额度设置 =====
+
+type QuotaSettingGetReq struct {
+ g.Meta `path:"/quota/setting/get" method:"get" tags:"admin" summary:"额度设置"`
+}
+type QuotaSettingGetRes struct {
+ PeriodDays int `json:"period_days"` // 全局默认额度重置周期(天)
+ DefaultPeriodDays int `json:"default_period_days"` // 代码兜底值,界面上作为占位提示
+}
+
+type QuotaSettingSaveReq struct {
+ g.Meta `path:"/quota/setting/save" method:"post" tags:"admin" summary:"保存额度设置"`
+ PeriodDays int `v:"min:1|max:3650#周期至少 1 天|周期最多 3650 天" json:"periodDays"`
+}
+type QuotaSettingSaveRes struct{}
+
+// ===== 会员套餐 =====
+
+type PlanItem struct {
+ Id int64 `json:"id"`
+ PlanKey string `json:"plan_key"`
+ Name string `json:"name"`
+ Subtitle string `json:"subtitle"`
+ LevelKey string `json:"level_key"`
+ LevelName string `json:"level_name"`
+ DurationDays int `json:"duration_days"`
+ PriceCents int64 `json:"price_cents"`
+ ProductId string `json:"product_id"`
+ Badge string `json:"badge"`
+ QuotaPeriodDays int `json:"quota_period_days"`
+ Sort int `json:"sort"`
+ IsEnabled int `json:"is_enabled"`
+ Remark string `json:"remark"`
+}
+
+type PlanListReq struct {
+ g.Meta `path:"/plan/list" method:"get" tags:"admin" summary:"会员套餐列表"`
+}
+type PlanListRes struct {
+ List []PlanItem `json:"list"`
+}
+
+// PlanSaveReq 只开放运营真正需要改的字段:价格、道具 ID、额度周期、上下架。
+// plan_key / level_key / duration_days 属跨端契约,仍由 seedMemberPlans 定义,
+// 避免后台改出「前端不认识的等级」。
+type PlanSaveReq struct {
+ g.Meta `path:"/plan/save" method:"post" tags:"admin" summary:"保存会员套餐(仅价格/道具/额度周期/上下架)"`
+ Id int64 `v:"required#套餐 ID 不能为空" json:"id"`
+ Name string `v:"required#套餐名不能为空" json:"name"`
+ Subtitle string `json:"subtitle"`
+ PriceCents int64 `v:"min:1#价格必须大于 0" json:"priceCents"`
+ ProductId string `v:"required#微信道具 ID 不能为空" json:"productId"`
+ Badge string `json:"badge"`
+ QuotaPeriodDays int `json:"quotaPeriodDays"`
+ Sort int `json:"sort"`
+ IsEnabled int `json:"isEnabled"`
+ Remark string `json:"remark"`
+}
+type PlanSaveRes struct{}
+
+// ===== 用户额度周期 =====
+
+type UserQuotaPeriodReq struct {
+ g.Meta `path:"/user/quota-period" method:"post" tags:"admin" summary:"单独调整用户额度周期"`
+ UserId int64 `v:"required#用户 ID 不能为空" json:"userId"`
+ PeriodDays int `v:"min:0|max:3650#周期不能为负|周期最多 3650 天" json:"periodDays"`
+}
+type UserQuotaPeriodRes struct{}
diff --git a/api/user/v1/member.go b/api/user/v1/member.go
new file mode 100644
index 0000000..af599d3
--- /dev/null
+++ b/api/user/v1/member.go
@@ -0,0 +1,107 @@
+package v1
+
+import "github.com/gogf/gf/v2/frame/g"
+
+// ============================================================================
+// 会员购买(微信小程序虚拟支付 · 道具直购)
+// ============================================================================
+
+// MemberPlanOut 可购买的会员套餐
+type MemberPlanOut struct {
+ PlanKey string `json:"plan_key"`
+ Name string `json:"name"`
+ Subtitle string `json:"subtitle"`
+ LevelKey string `json:"level_key"`
+ DurationDays int `json:"duration_days"`
+ PriceCents int64 `json:"price_cents"` // 单位:分
+ ProductId string `json:"product_id"`
+ Badge string `json:"badge"`
+}
+
+// MemberLevelOut 等级权益(用于前端渲染权益对比)
+type MemberLevelOut struct {
+ LevelKey string `json:"level_key"`
+ Name string `json:"name"`
+ Modules []string `json:"modules"`
+ ModuleNames []string `json:"module_names"`
+ IsCurrent bool `json:"is_current"`
+}
+
+// MemberStateOut 我的会员状态
+type MemberStateOut struct {
+ IsMember bool `json:"is_member"`
+ LevelKey string `json:"level_key"`
+ LevelName string `json:"level_name"`
+ ExpireAt string `json:"expire_at"` // 空串表示永久或未开通
+ DaysLeft int `json:"days_left"`
+ Modules []string `json:"modules"`
+}
+
+// MemberCenterReq 会员中心
+type MemberCenterReq struct {
+ g.Meta `path:"/member/center" method:"get" tags:"member" summary:"会员中心(套餐 + 会员状态 + 权益)"`
+}
+type MemberCenterRes struct {
+ Plans []MemberPlanOut `json:"plans"`
+ Levels []MemberLevelOut `json:"levels"`
+ State MemberStateOut `json:"state"`
+ Notices []string `json:"notices"`
+ XpayReady bool `json:"xpay_ready"` // 服务端虚拟支付参数是否已配置
+ Quotas []QuotaToolOut `json:"quotas"` // 各工具额度(会员权益页据此展示提升幅度)
+ Packs []QuotaPackOut `json:"packs"` // 次数包通用档位
+ PeriodDays int `json:"period_days"` // 当前生效的额度重置周期
+}
+
+// MemberOrderCreateReq 创建会员订单
+//
+// Code 为 wx.login 获取的临时登录凭证 —— 服务端用它换 session_key,
+// session_key 是「用户态签名 signature」的密钥;同时校验支付人与登录人一致。
+type MemberOrderCreateReq struct {
+ g.Meta `path:"/member/order" method:"post" tags:"member" summary:"创建会员订单(返回支付参数)"`
+ PlanKey string `v:"required#请选择会员套餐" json:"plan_key"`
+ Code string `json:"code"`
+}
+type MemberOrderCreateRes struct {
+ OutTradeNo string `json:"out_trade_no"`
+ Mode string `json:"mode"` // 固定 short_series_goods
+ SignData string `json:"signData"` // JSON 字符串,必须原样传给 wx.requestVirtualPayment
+ PaySig string `json:"paySig"`
+ Signature string `json:"signature"`
+ Env int `json:"env"`
+}
+
+// MemberOrderCheckReq 查询订单状态(前端支付成功回调后轮询;同时承担兜底发货)
+type MemberOrderCheckReq struct {
+ g.Meta `path:"/member/order/check" method:"post" tags:"member" summary:"查询会员订单状态"`
+ OutTradeNo string `v:"required#订单号不能为空" json:"out_trade_no"`
+}
+type MemberOrderCheckRes struct {
+ Status int `json:"status"`
+ Delivered bool `json:"delivered"`
+ State MemberStateOut `json:"state"`
+}
+
+// MemberOrdersReq 我的会员订单
+type MemberOrdersReq struct {
+ g.Meta `path:"/member/orders" method:"get" tags:"member" summary:"我的会员订单"`
+ Limit int `json:"limit"`
+}
+type MemberOrderOut struct {
+ OutTradeNo string `json:"out_trade_no"`
+ OrderType int `json:"order_type"` // 1=会员套餐 2=次数包
+ PlanKey string `json:"plan_key"`
+ PlanName string `json:"plan_name"`
+ Title string `json:"title"` // 统一展示标题(套餐名 / 次数包名)
+ ToolKey string `json:"tool_key"`
+ ToolName string `json:"tool_name"`
+ Times int `json:"times"`
+ PriceCents int64 `json:"price_cents"`
+ Status int `json:"status"`
+ StatusText string `json:"status_text"`
+ PayChannel string `json:"pay_channel"`
+ CreatedAt string `json:"created_at"`
+ DeliveredAt string `json:"delivered_at"`
+}
+type MemberOrdersRes struct {
+ List []MemberOrderOut `json:"list"`
+}
diff --git a/api/user/v1/quota.go b/api/user/v1/quota.go
new file mode 100644
index 0000000..7ce86ff
--- /dev/null
+++ b/api/user/v1/quota.go
@@ -0,0 +1,108 @@
+package v1
+
+import "github.com/gogf/gf/v2/frame/g"
+
+// ============================================================================
+// 工具额度与次数包
+//
+// 额度三池:免费 / 会员 / 付费(次数包),扣减顺序 免费 → 会员 → 付费。
+// 周期为滚动窗口,默认 7 天,后台可给「全局 / 会员套餐 / 单个用户」分别调整。
+// ============================================================================
+
+// QuotaToolOut 单个工具的额度状态
+type QuotaToolOut struct {
+ ToolKey string `json:"tool_key"`
+ Name string `json:"name"`
+ Icon string `json:"icon"`
+ FreeTotal int `json:"free_total"`
+ FreeUsed int `json:"free_used"`
+ FreeLeft int `json:"free_left"`
+ MemberTotal int `json:"member_total"`
+ MemberUsed int `json:"member_used"`
+ MemberLeft int `json:"member_left"`
+ PaidLeft int `json:"paid_left"` // 付费额度余额(次数)
+ PaidExpireAt string `json:"paid_expire_at"` // 付费额度到期日 yyyy-MM-dd
+ PayCost int `json:"pay_cost"` // 每次使用消耗的付费额度系数
+ IsMember bool `json:"is_member"`
+ PeriodDays int `json:"period_days"`
+ PeriodEnd string `json:"period_end"` // 当前周期结束时间
+ // Source 下一次扣减会命中的池:free / member / paid / none
+ Source string `json:"source"`
+ CanUse bool `json:"can_use"`
+ TotalLeft int `json:"total_left"` // 本周期内还能用几次(付费额度按系数折算)
+ Message string `json:"message"` // 额度用尽时的可读原因
+}
+
+// QuotaPackOut 次数包档位
+type QuotaPackOut struct {
+ PackKey string `json:"pack_key"`
+ Name string `json:"name"`
+ Times int `json:"times"`
+ PriceCents int64 `json:"price_cents"`
+ ValidDays int `json:"valid_days"`
+ Badge string `json:"badge"`
+ ToolKey string `json:"tool_key"` // 空 = 通用档位(购买时选择工具)
+ UnitPriceCents int64 `json:"unit_price_cents"` // 每 100 次的均价(分),服务端算好避免前端浮点误差
+}
+
+// QuotaMyReq 我的额度总览
+type QuotaMyReq struct {
+ g.Meta `path:"/quota/my" method:"get" tags:"quota" summary:"我的工具额度总览"`
+}
+type QuotaMyRes struct {
+ PeriodDays int `json:"period_days"`
+ IsMember bool `json:"is_member"`
+ LevelKey string `json:"level_key"`
+ LevelName string `json:"level_name"`
+ LevelExpireAt string `json:"level_expire_at"`
+ Tools []QuotaToolOut `json:"tools"`
+ TotalLeft int `json:"total_left"`
+ Packs []QuotaPackOut `json:"packs"`
+}
+
+// QuotaToolReq 单工具额度(进入工具页时查询,用于额度条与拦截提示)
+type QuotaToolReq struct {
+ g.Meta `path:"/quota/tool" method:"get" tags:"quota" summary:"单个工具的额度状态"`
+ ToolKey string `v:"required#工具标识不能为空" json:"tool_key"`
+}
+type QuotaToolRes struct {
+ Tool *QuotaToolOut `json:"tool"`
+}
+
+// QuotaConsumeReq 扣减一次额度(在「产出结果」时调用,而不是打开页面时)
+type QuotaConsumeReq struct {
+ g.Meta `path:"/quota/consume" method:"post" tags:"quota" summary:"扣减工具额度"`
+ ToolKey string `v:"required#工具标识不能为空" json:"tool_key"`
+}
+type QuotaConsumeRes struct {
+ Tool *QuotaToolOut `json:"tool"`
+}
+
+// QuotaPacksReq 某工具可购买的次数包档位
+type QuotaPacksReq struct {
+ g.Meta `path:"/quota/packs" method:"get" tags:"quota" summary:"次数包档位(按工具)"`
+ ToolKey string `json:"tool_key"`
+}
+type QuotaPacksRes struct {
+ List []QuotaPackOut `json:"list"`
+}
+
+// QuotaOrderCreateReq 创建次数包订单(支付参数与会员套餐完全同构)
+type QuotaOrderCreateReq struct {
+ g.Meta `path:"/quota/order" method:"post" tags:"quota" summary:"创建次数包订单(返回支付参数)"`
+ PackKey string `v:"required#请选择次数包" json:"pack_key"`
+ ToolKey string `v:"required#请选择要购买的次数包工具" json:"tool_key"`
+ Code string `json:"code"`
+}
+type QuotaOrderCreateRes struct {
+ OutTradeNo string `json:"out_trade_no"`
+ Mode string `json:"mode"`
+ SignData string `json:"signData"`
+ PaySig string `json:"paySig"`
+ Signature string `json:"signature"`
+ Env int `json:"env"`
+ PackKey string `json:"pack_key"`
+ ToolKey string `json:"tool_key"`
+ Times int `json:"times"`
+ ValidDays int `json:"valid_days"`
+}
diff --git a/api/user/v1/user.go b/api/user/v1/user.go
index 18b8634..bfed214 100644
--- a/api/user/v1/user.go
+++ b/api/user/v1/user.go
@@ -100,13 +100,14 @@ type ModuleOut struct {
}
type ToolOut struct {
- ToolKey string `json:"tool_key"`
- ModuleKey string `json:"module_key"`
- Name string `json:"name"`
- Icon string `json:"icon"`
- Description string `json:"description"`
- IsHot bool `json:"is_hot"`
- Locked bool `json:"locked"`
+ ToolKey string `json:"tool_key"`
+ ModuleKey string `json:"module_key"`
+ Name string `json:"name"`
+ Icon string `json:"icon"`
+ Description string `json:"description"`
+ IsHot bool `json:"is_hot"`
+ Locked bool `json:"locked"`
+ Quota *QuotaToolOut `json:"quota"` // 额度状态(免费/会员/付费),未登录或查询失败时为 null
}
type ToolsListReq struct {
diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go
index f29d32f..46556f6 100644
--- a/internal/cmd/cmd.go
+++ b/internal/cmd/cmd.go
@@ -7,6 +7,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gcmd"
+ "github.com/gogf/gf/v2/os/gcron"
"tool-api/internal/controller"
"tool-api/internal/logic"
@@ -18,19 +19,30 @@ var (
Usage: "main",
Brief: "start http server",
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
- // 启动时幂等写入种子数据(管理员/模块/等级/工具),数据库不可用时仅告警
+ // 启动时幂等迁移(建表 / 加列),数据库不可用时仅告警
+ logic.Migrate(ctx)
+ // 启动时幂等写入种子数据(管理员/模块/等级/工具/会员套餐/次数包/额度设置),数据库不可用时仅告警
logic.Seed(ctx)
+ // 会员订单兜底扫单:推送丢失时主动查单补发货(建议间隔 5 分钟)
+ if _, err = gcron.AddSingleton(ctx, "0 */5 * * * *", func(c context.Context) {
+ logic.SweepPendingOrders(c)
+ }, "member-order-sweep"); err != nil {
+ g.Log().Warningf(ctx, "[cron] 会员订单扫单任务注册失败: %v", err)
+ }
+
s := g.Server()
- // 公开接口:小程序登录、开发登录、管理端登录
+ // 公开接口:小程序登录、开发登录、管理端登录、虚拟支付发货推送回调
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(logic.CORS, logic.Response)
group.Bind(controller.UserPub, controller.AdminPub)
+ // 平台回调无 token,单独注册(响应体为 XML,不走统一 JSON 包装)
+ group.POST("/pay/notify", controller.PayNotify)
})
// 小程序用户接口
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(logic.CORS, logic.Response, logic.UserAuth)
- group.Bind(controller.UserAuth)
+ group.Bind(controller.UserAuth, controller.Member, controller.Quota)
})
// 管理端接口
s.Group("/", func(group *ghttp.RouterGroup) {
diff --git a/internal/consts/consts.go b/internal/consts/consts.go
index a7954cd..98b97dc 100644
--- a/internal/consts/consts.go
+++ b/internal/consts/consts.go
@@ -15,17 +15,69 @@ const (
// 业务错误码(响应 code 字段)
const (
- CodeLocked = 4001 // 模块未授权(等级锁定)
+ CodeLocked = 4001 // 模块未授权(等级锁定)
+ CodeQuotaExhausted = 4002 // 额度用尽(免费/会员/付费额度都已耗尽)
+ CodePayFail = 4101 // 下单/支付参数异常
)
+// 默认等级:会员到期后回落的目标等级
+const DefaultLevelKey = "v1"
+
// 表名
const (
- TableUsers = "users"
- TableModules = "modules"
- TableLevels = "levels"
- TableTools = "tools"
- TableUsageLogs = "tool_usage_logs"
- TableWorkbench = "user_workbench"
- TableAdminUsers = "admin_users"
- TableFeedbacks = "feedbacks"
+ TableUsers = "users"
+ TableModules = "modules"
+ TableLevels = "levels"
+ TableTools = "tools"
+ TableUsageLogs = "tool_usage_logs"
+ TableWorkbench = "user_workbench"
+ TableAdminUsers = "admin_users"
+ TableFeedbacks = "feedbacks"
+ TableMemberPlans = "member_plans"
+ TableMemberOrders = "member_orders"
+ TableQuotaPacks = "quota_packs"
+ TableUserToolQuota = "user_tool_quota"
+ TableQuotaUsage = "user_quota_usage"
+ TableSettings = "settings"
+)
+
+// 订单类型:同一个下单/发货链路承载「会员」与「次数包」两种商品
+const (
+ OrderTypeMember = 1 // 会员套餐(延长等级有效期)
+ OrderTypeQuota = 2 // 次数包(增加某工具的付费额度余额)
+)
+
+// 额度来源(扣减时实际命中的池)
+const (
+ QuotaSourceFree = "free" // 免费额度
+ QuotaSourceMember = "member" // 会员额度
+ QuotaSourcePaid = "paid" // 付费额度(次数包)
+ QuotaSourceNone = "none" // 已耗尽
+)
+
+// 额度设置键(settings 表)
+const (
+ SettingQuotaPeriodDays = "quota_period_days" // 全局默认额度重置周期(天)
+)
+
+// DefaultQuotaPeriodDays 额度周期兜底值:settings 与 config 都未配置时使用。
+// 后台「额度设置」可改,用户维度与会员套餐维度可各自覆盖。
+const DefaultQuotaPeriodDays = 7
+
+// 会员订单状态
+const (
+ OrderStatusPending = 0 // 待支付
+ OrderStatusDelivered = 1 // 已支付并发货(会员权益已生效)
+ OrderStatusRefunded = 2 // 已退款
+ OrderStatusClosed = 3 // 已关闭(超时未支付)
+)
+
+// 虚拟支付环境与支付渠道
+const (
+ XPayEnvProd = 0 // env 固定填 0(现网)
+ XPayCurrency = "CNY" // 币种固定 CNY
+ XPayMode = "short_series_goods" // 道具直购
+ PayChannelWx = "wxpay" // 微信虚拟支付(Android 等)
+ PayChannelIOS = "apple" // Apple IAP
+ XPayNotifyEvent = "xpay_goods_deliver_notify" // 发货推送事件名
)
diff --git a/internal/controller/admin.go b/internal/controller/admin.go
index 581b5ca..76b517f 100644
--- a/internal/controller/admin.go
+++ b/internal/controller/admin.go
@@ -77,3 +77,37 @@ func (c *cAdminAuth) FeedbackList(ctx context.Context, req *v1.FeedbackListReq)
func (c *cAdminAuth) FeedbackHandle(ctx context.Context, req *v1.FeedbackHandleReq) (res *v1.FeedbackHandleRes, err error) {
return logic.AdminFeedbackHandle(ctx, req.Id)
}
+
+// ===== 额度配置(次数包 / 额度设置 / 会员套餐 / 用户周期)=====
+
+func (c *cAdminAuth) QuotaPackList(ctx context.Context, req *v1.QuotaPackListReq) (res *v1.QuotaPackListRes, err error) {
+ return logic.AdminQuotaPackList(ctx)
+}
+
+func (c *cAdminAuth) QuotaPackSave(ctx context.Context, req *v1.QuotaPackSaveReq) (res *v1.QuotaPackSaveRes, err error) {
+ return logic.AdminQuotaPackSave(ctx, req)
+}
+
+func (c *cAdminAuth) QuotaPackToggle(ctx context.Context, req *v1.QuotaPackToggleReq) (res *v1.QuotaPackToggleRes, err error) {
+ return logic.AdminQuotaPackToggle(ctx, req.Id, req.Value)
+}
+
+func (c *cAdminAuth) QuotaSettingGet(ctx context.Context, req *v1.QuotaSettingGetReq) (res *v1.QuotaSettingGetRes, err error) {
+ return logic.AdminQuotaSettingGet(ctx)
+}
+
+func (c *cAdminAuth) QuotaSettingSave(ctx context.Context, req *v1.QuotaSettingSaveReq) (res *v1.QuotaSettingSaveRes, err error) {
+ return logic.AdminQuotaSettingSave(ctx, req.PeriodDays)
+}
+
+func (c *cAdminAuth) PlanList(ctx context.Context, req *v1.PlanListReq) (res *v1.PlanListRes, err error) {
+ return logic.AdminPlanList(ctx)
+}
+
+func (c *cAdminAuth) PlanSave(ctx context.Context, req *v1.PlanSaveReq) (res *v1.PlanSaveRes, err error) {
+ return logic.AdminPlanSave(ctx, req)
+}
+
+func (c *cAdminAuth) UserQuotaPeriod(ctx context.Context, req *v1.UserQuotaPeriodReq) (res *v1.UserQuotaPeriodRes, err error) {
+ return logic.AdminUserQuotaPeriod(ctx, req.UserId, req.PeriodDays)
+}
diff --git a/internal/controller/member.go b/internal/controller/member.go
new file mode 100644
index 0000000..1096c57
--- /dev/null
+++ b/internal/controller/member.go
@@ -0,0 +1,50 @@
+package controller
+
+import (
+ "context"
+
+ "github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/net/ghttp"
+
+ v1 "tool-api/api/user/v1"
+ "tool-api/internal/logic"
+)
+
+// cMember 会员购买相关接口(需用户登录)
+type cMember struct{}
+
+var Member = &cMember{}
+
+func (c *cMember) MemberCenter(ctx context.Context, req *v1.MemberCenterReq) (res *v1.MemberCenterRes, err error) {
+ return logic.MemberCenter(ctx)
+}
+
+func (c *cMember) MemberOrderCreate(ctx context.Context, req *v1.MemberOrderCreateReq) (res *v1.MemberOrderCreateRes, err error) {
+ return logic.MemberOrderCreate(ctx, req.PlanKey, req.Code)
+}
+
+func (c *cMember) MemberOrderCheck(ctx context.Context, req *v1.MemberOrderCheckReq) (res *v1.MemberOrderCheckRes, err error) {
+ return logic.MemberOrderCheck(ctx, req.OutTradeNo)
+}
+
+func (c *cMember) MemberOrders(ctx context.Context, req *v1.MemberOrdersReq) (res *v1.MemberOrdersRes, err error) {
+ return logic.MemberOrders(ctx, req.Limit)
+}
+
+// PayNotify 虚拟支付「发货推送」回调(平台调用,无 token,需在 MP 后台配置该 URL)。
+//
+// 返回 XML:成功 0,失败非 0 触发平台重试(最多 15 次)。
+// 这里直接写响应体,Response 中间件检测到已有输出便不再包装成 JSON。
+//
+// 刻意写成包级函数而不是 cMember 的方法:GoFrame 的 group.Bind 会把控制器上
+// 签名为 func(*ghttp.Request) 的方法一并自动注册成路由,那样就会多出一条
+// `/pay-notify`(且落在 UserAuth 组里)—— 平台回调不带 token,这条路由纯属多余入口。
+func PayNotify(r *ghttp.Request) {
+ ack, err := logic.PayNotifyHandle(r.Context(), r.GetBody())
+ if err != nil {
+ g.Log().Errorf(r.Context(), "[xpay] 发货推送处理异常: %v", err)
+ ack = logic.XPayNotifyAckFail("internal error")
+ }
+ r.Response.Header().Set("Content-Type", "application/xml")
+ r.Response.Write(ack)
+}
diff --git a/internal/controller/quota.go b/internal/controller/quota.go
new file mode 100644
index 0000000..6ed2a04
--- /dev/null
+++ b/internal/controller/quota.go
@@ -0,0 +1,34 @@
+package controller
+
+import (
+ "context"
+
+ v1 "tool-api/api/user/v1"
+ "tool-api/internal/logic"
+)
+
+// cQuota 工具额度与次数包(需登录)
+type cQuota struct{}
+
+// Quota 额度相关控制器,绑定在用户鉴权分组下
+var Quota = &cQuota{}
+
+func (c *cQuota) QuotaMy(ctx context.Context, req *v1.QuotaMyReq) (res *v1.QuotaMyRes, err error) {
+ return logic.QuotaMy(ctx)
+}
+
+func (c *cQuota) QuotaTool(ctx context.Context, req *v1.QuotaToolReq) (res *v1.QuotaToolRes, err error) {
+ return logic.QuotaToolOf(ctx, req.ToolKey)
+}
+
+func (c *cQuota) QuotaConsume(ctx context.Context, req *v1.QuotaConsumeReq) (res *v1.QuotaConsumeRes, err error) {
+ return logic.QuotaConsume(ctx, req.ToolKey)
+}
+
+func (c *cQuota) QuotaPacks(ctx context.Context, req *v1.QuotaPacksReq) (res *v1.QuotaPacksRes, err error) {
+ return logic.QuotaPacksFor(ctx, req.ToolKey)
+}
+
+func (c *cQuota) QuotaOrderCreate(ctx context.Context, req *v1.QuotaOrderCreateReq) (res *v1.QuotaOrderCreateRes, err error) {
+ return logic.QuotaOrderCreate(ctx, req.PackKey, req.ToolKey, req.Code)
+}
diff --git a/internal/logic/admin.go b/internal/logic/admin.go
index 2c34867..28ff764 100644
--- a/internal/logic/admin.go
+++ b/internal/logic/admin.go
@@ -87,14 +87,18 @@ func AdminUserList(ctx context.Context, req *v1.UserListReq) (*v1.UserListRes, e
list := make([]v1.AdminUserItem, 0, len(records))
for _, r := range records {
item := v1.AdminUserItem{
- Id: r["id"].Int64(),
- Nickname: r["nickname"].String(),
- AvatarUrl: r["avatar_url"].String(),
- Openid: r["openid"].String(),
- LevelKey: r["level_key"].String(),
- LevelName: levelNames[r["level_key"].String()],
- Status: r["status"].Int(),
- CreatedAt: r["created_at"].String(),
+ Id: r["id"].Int64(),
+ Nickname: r["nickname"].String(),
+ AvatarUrl: r["avatar_url"].String(),
+ Openid: r["openid"].String(),
+ LevelKey: r["level_key"].String(),
+ LevelName: levelNames[r["level_key"].String()],
+ QuotaPeriodDays: r["quota_period_days"].Int(),
+ Status: r["status"].Int(),
+ CreatedAt: r["created_at"].String(),
+ }
+ if expire := r["level_expire_at"].GTime(); expire != nil && !expire.IsZero() {
+ item.LevelExpireAt = expire.Format("Y-m-d")
}
list = append(list, item)
}
@@ -315,6 +319,9 @@ func AdminToolsList(ctx context.Context, req *v1.ToolsListReq) (*v1.ToolsListRes
Name: r["name"].String(),
Icon: r["icon"].String(),
Description: r["description"].String(),
+ FreeQuota: r["free_quota"].Int(),
+ MemberQuota: r["member_quota"].Int(),
+ PayCost: r["pay_cost"].Int(),
Sort: r["sort"].Int(),
IsEnabled: r["is_enabled"].Int(),
IsHot: r["is_hot"].Int(),
@@ -337,14 +344,21 @@ func moduleNameMap(ctx context.Context) (map[string]string, error) {
}
func AdminToolsSave(ctx context.Context, req *v1.ToolsSaveReq) (*v1.ToolsSaveRes, error) {
+ payCost := req.PayCost
+ if payCost <= 0 {
+ payCost = 1
+ }
data := g.Map{
- "module_key": req.ModuleKey,
- "name": req.Name,
- "icon": req.Icon,
- "description": req.Description,
- "sort": req.Sort,
- "is_enabled": req.IsEnabled,
- "is_hot": req.IsHot,
+ "module_key": req.ModuleKey,
+ "name": req.Name,
+ "icon": req.Icon,
+ "description": req.Description,
+ "free_quota": req.FreeQuota,
+ "member_quota": req.MemberQuota,
+ "pay_cost": payCost,
+ "sort": req.Sort,
+ "is_enabled": req.IsEnabled,
+ "is_hot": req.IsHot,
}
if req.Id > 0 {
// 编辑:tool_key 只读,不修改
diff --git a/internal/logic/admin_quota.go b/internal/logic/admin_quota.go
new file mode 100644
index 0000000..0be911e
--- /dev/null
+++ b/internal/logic/admin_quota.go
@@ -0,0 +1,217 @@
+package logic
+
+import (
+ "context"
+ "strconv"
+
+ "github.com/gogf/gf/v2/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/os/gtime"
+
+ v1 "tool-api/api/admin/v1"
+ "tool-api/internal/consts"
+)
+
+// ============================================================================
+// 管理端:额度配置 / 次数包 / 会员套餐 / 额度设置
+// ============================================================================
+
+// ===== 次数包档位 =====
+
+func AdminQuotaPackList(ctx context.Context) (*v1.QuotaPackListRes, error) {
+ records, err := g.Model(consts.TableQuotaPacks).OrderAsc("sort").OrderAsc("times").All()
+ if err != nil {
+ return nil, err
+ }
+ toolNames, err := toolNameMap(ctx)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]v1.QuotaPackItem, 0, len(records))
+ for _, r := range records {
+ toolKey := r["tool_key"].String()
+ toolName := ""
+ if toolKey != "" {
+ toolName = toolNames[toolKey]
+ if toolName == "" {
+ toolName = toolKey + "(工具不存在)"
+ }
+ }
+ list = append(list, v1.QuotaPackItem{
+ Id: r["id"].Int64(),
+ PackKey: r["pack_key"].String(),
+ Name: r["name"].String(),
+ Times: r["times"].Int(),
+ PriceCents: r["price_cents"].Int64(),
+ ProductId: r["product_id"].String(),
+ ValidDays: r["valid_days"].Int(),
+ ToolKey: toolKey,
+ ToolName: toolName,
+ Badge: r["badge"].String(),
+ Sort: r["sort"].Int(),
+ IsEnabled: r["is_enabled"].Int(),
+ Remark: r["remark"].String(),
+ })
+ }
+ return &v1.QuotaPackListRes{List: list}, nil
+}
+
+func AdminQuotaPackSave(ctx context.Context, req *v1.QuotaPackSaveReq) (*v1.QuotaPackSaveRes, error) {
+ if req.ToolKey != "" {
+ count, err := g.Model(consts.TableTools).Where("tool_key", req.ToolKey).Count()
+ if err != nil {
+ return nil, err
+ }
+ if count == 0 {
+ return nil, gerror.New("绑定的工具不存在")
+ }
+ }
+ validDays := req.ValidDays
+ if validDays <= 0 {
+ validDays = 365
+ }
+ data := g.Map{
+ "name": req.Name,
+ "times": req.Times,
+ "price_cents": req.PriceCents,
+ "product_id": req.ProductId,
+ "valid_days": validDays,
+ "tool_key": req.ToolKey,
+ "badge": req.Badge,
+ "sort": req.Sort,
+ "is_enabled": req.IsEnabled,
+ "remark": req.Remark,
+ "updated_at": gtime.Now(),
+ }
+ if req.Id > 0 {
+ // 编辑:pack_key 是跨端契约(决定道具),不允许改
+ if _, err := g.Model(consts.TableQuotaPacks).Where("id", req.Id).Data(data).Update(); err != nil {
+ return nil, err
+ }
+ return &v1.QuotaPackSaveRes{}, nil
+ }
+ count, err := g.Model(consts.TableQuotaPacks).Where("pack_key", req.PackKey).Count()
+ if err != nil {
+ return nil, err
+ }
+ if count > 0 {
+ return nil, gerror.New("档位标识已存在")
+ }
+ data["pack_key"] = req.PackKey
+ data["created_at"] = gtime.Now()
+ if _, err = g.Model(consts.TableQuotaPacks).Data(data).Insert(); err != nil {
+ return nil, err
+ }
+ return &v1.QuotaPackSaveRes{}, nil
+}
+
+func AdminQuotaPackToggle(ctx context.Context, id int64, value int) (*v1.QuotaPackToggleRes, error) {
+ if _, err := g.Model(consts.TableQuotaPacks).Where("id", id).
+ Data(g.Map{"is_enabled": value, "updated_at": gtime.Now()}).Update(); err != nil {
+ return nil, err
+ }
+ return &v1.QuotaPackToggleRes{}, nil
+}
+
+// ===== 额度设置 =====
+
+func AdminQuotaSettingGet(ctx context.Context) (*v1.QuotaSettingGetRes, error) {
+ return &v1.QuotaSettingGetRes{
+ PeriodDays: GlobalQuotaPeriodDays(ctx),
+ DefaultPeriodDays: consts.DefaultQuotaPeriodDays,
+ }, nil
+}
+
+func AdminQuotaSettingSave(ctx context.Context, periodDays int) (*v1.QuotaSettingSaveRes, error) {
+ if err := settingSave(ctx, consts.SettingQuotaPeriodDays,
+ strconv.Itoa(periodDays), "全局默认额度重置周期(天)"); err != nil {
+ return nil, err
+ }
+ return &v1.QuotaSettingSaveRes{}, nil
+}
+
+// ===== 会员套餐 =====
+
+func AdminPlanList(ctx context.Context) (*v1.PlanListRes, error) {
+ records, err := g.Model(consts.TableMemberPlans).OrderAsc("sort").All()
+ if err != nil {
+ return nil, err
+ }
+ levelNames, err := levelNameMap(ctx)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]v1.PlanItem, 0, len(records))
+ for _, r := range records {
+ levelKey := r["level_key"].String()
+ list = append(list, v1.PlanItem{
+ Id: r["id"].Int64(),
+ PlanKey: r["plan_key"].String(),
+ Name: r["name"].String(),
+ Subtitle: r["subtitle"].String(),
+ LevelKey: levelKey,
+ LevelName: levelNames[levelKey],
+ DurationDays: r["duration_days"].Int(),
+ PriceCents: r["price_cents"].Int64(),
+ ProductId: r["product_id"].String(),
+ Badge: r["badge"].String(),
+ QuotaPeriodDays: r["quota_period_days"].Int(),
+ Sort: r["sort"].Int(),
+ IsEnabled: r["is_enabled"].Int(),
+ Remark: r["remark"].String(),
+ })
+ }
+ return &v1.PlanListRes{List: list}, nil
+}
+
+// AdminPlanSave 只覆盖运营需要维护的列。
+//
+// 刻意不放开 plan_key / level_key / duration_days:
+// - plan_key 是订单与前端展示的关联键
+// - level_key 必须是 levels 表里真实存在的等级
+// - duration_days 涉及续费顺延语义
+//
+// 这三项仍以 seedMemberPlans 为准,避免后台改出前端不认识的组合。
+func AdminPlanSave(ctx context.Context, req *v1.PlanSaveReq) (*v1.PlanSaveRes, error) {
+ record, err := g.Model(consts.TableMemberPlans).Where("id", req.Id).One()
+ if err != nil {
+ return nil, err
+ }
+ if record.IsEmpty() {
+ return nil, gerror.New("套餐不存在")
+ }
+ if _, err = g.Model(consts.TableMemberPlans).Where("id", req.Id).Data(g.Map{
+ "name": req.Name,
+ "subtitle": req.Subtitle,
+ "price_cents": req.PriceCents,
+ "product_id": req.ProductId,
+ "badge": req.Badge,
+ "quota_period_days": req.QuotaPeriodDays,
+ "sort": req.Sort,
+ "is_enabled": req.IsEnabled,
+ "remark": req.Remark,
+ "updated_at": gtime.Now(),
+ }).Update(); err != nil {
+ return nil, err
+ }
+ return &v1.PlanSaveRes{}, nil
+}
+
+// ===== 用户额度周期 =====
+
+func AdminUserQuotaPeriod(ctx context.Context, userId int64, periodDays int) (*v1.UserQuotaPeriodRes, error) {
+ count, err := g.Model(consts.TableUsers).Where("id", userId).Count()
+ if err != nil {
+ return nil, err
+ }
+ if count == 0 {
+ return nil, gerror.New("用户不存在")
+ }
+ if _, err = g.Model(consts.TableUsers).Where("id", userId).Data(g.Map{
+ "quota_period_days": periodDays,
+ "updated_at": gtime.Now(),
+ }).Update(); err != nil {
+ return nil, err
+ }
+ return &v1.UserQuotaPeriodRes{}, nil
+}
diff --git a/internal/logic/level.go b/internal/logic/level.go
new file mode 100644
index 0000000..ee78e5c
--- /dev/null
+++ b/internal/logic/level.go
@@ -0,0 +1,72 @@
+package logic
+
+import (
+ "context"
+
+ "github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/os/gtime"
+
+ v1 "tool-api/api/user/v1"
+ "tool-api/internal/consts"
+ "tool-api/internal/model/entity"
+)
+
+// ===== 等级有效期(会员)判定 =====
+//
+// 背景:users.level_key 是单值,无法表达「会员到期」。引入 users.level_expire_at 后,
+// 所有依赖等级的地方都必须先经过 EffectiveLevelKey 收敛,否则会出现「会员已过期但
+// 工具仍可用」的越权问题。
+//
+// 约定:
+// - level_expire_at IS NULL → 永久有效(管理员手动分配 / 种子默认等级)
+// - level_expire_at <= now → 已过期,回落 consts.DefaultLevelKey
+// - level_expire_at > now → 正常返回 users.level_key
+
+// EffectiveLevelKey 计算用户当前实际生效的等级 key。一处判定,全处复用。
+func EffectiveLevelKey(user *entity.Users) string {
+ if user == nil || user.LevelKey == "" {
+ return consts.DefaultLevelKey
+ }
+ if IsLevelExpired(user) {
+ return consts.DefaultLevelKey
+ }
+ return user.LevelKey
+}
+
+// IsLevelExpired 等级是否已过期
+func IsLevelExpired(user *entity.Users) bool {
+ if user == nil || user.LevelExpireAt == nil || user.LevelExpireAt.IsZero() {
+ return false
+ }
+ return user.LevelExpireAt.Before(gtime.Now())
+}
+
+// EffectiveLevelInfo 当前生效等级(含已授权模块),已过期时返回默认等级
+func EffectiveLevelInfo(ctx context.Context, user *entity.Users) (*v1.LevelInfo, error) {
+ return GetLevelByKey(ctx, EffectiveLevelKey(user))
+}
+
+// levelWeight 等级权限权重,取 levels.sort(越大权限越高)。
+// 查询失败时回落 0,保证不因权重查询异常而误升权。
+func levelWeight(ctx context.Context, levelKey string) int {
+ if levelKey == "" {
+ return 0
+ }
+ value, err := g.Model(consts.TableLevels).
+ Where("level_key", levelKey).
+ Where("is_enabled", 1).
+ Value("sort")
+ if err != nil || value == nil {
+ return 0
+ }
+ return value.Int()
+}
+
+// CanLevelAccess 判断某个等级是否包含指定模块(供发货后自检 / 管理端复用)
+func CanLevelAccess(ctx context.Context, levelKey, moduleKey string) bool {
+ level, err := GetLevelByKey(ctx, levelKey)
+ if err != nil {
+ return false
+ }
+ return slicesContain(level.Modules, moduleKey)
+}
diff --git a/internal/logic/member.go b/internal/logic/member.go
new file mode 100644
index 0000000..4b02563
--- /dev/null
+++ b/internal/logic/member.go
@@ -0,0 +1,712 @@
+package logic
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math/rand"
+ "time"
+
+ "github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/errors/gcode"
+ "github.com/gogf/gf/v2/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/os/gtime"
+
+ v1 "tool-api/api/user/v1"
+ "tool-api/internal/consts"
+ "tool-api/internal/model/entity"
+)
+
+// ============================================================================
+// 会员(等级有效期)业务逻辑
+//
+// 商品模型:会员 = 一次「道具直购」换一段「等级有效期」。
+// 购买 → 支付成功 → 发货(延长 users.level_expire_at + 提升 level_key)
+// 到期 → EffectiveLevelKey 自动回落 consts.DefaultLevelKey
+//
+// 发货以平台「发货推送」为主,query_order 主动查单为兜底;前端支付回调不作为发货依据。
+// ============================================================================
+
+// MemberCenter 会员中心:套餐列表 + 我的会员状态 + 权益对比 + 用户须知
+func MemberCenter(ctx context.Context) (*v1.MemberCenterRes, error) {
+ user, err := getUserById(ctx, CtxUserId(ctx))
+ if err != nil {
+ return nil, err
+ }
+
+ plans, err := listPlans(ctx)
+ if err != nil {
+ return nil, err
+ }
+ levels, err := listLevelBriefs(ctx, user)
+ if err != nil {
+ return nil, err
+ }
+ // 额度也一并在会员中心返回:会员权益页要展示「开通后额度提升到多少」,
+ // 少一次往返,前端首屏更快。
+ quotas, err := quotaListForUser(ctx, user)
+ if err != nil {
+ return nil, err
+ }
+ packs, err := listQuotaPacks(ctx, "")
+ if err != nil {
+ return nil, err
+ }
+
+ res := &v1.MemberCenterRes{
+ Plans: plans,
+ Levels: levels,
+ State: buildMemberState(ctx, user),
+ Notices: memberNotices(),
+ XpayReady: xpayConfig(ctx).Configured(),
+ Quotas: quotas,
+ Packs: packs,
+ PeriodDays: EffectiveQuotaPeriodDays(ctx, user),
+ }
+ return res, nil
+}
+
+// MemberOrderCreate 创建会员订单,返回前端拉支付所需的 payData
+func MemberOrderCreate(ctx context.Context, planKey, code string) (*v1.MemberOrderCreateRes, error) {
+ userId := CtxUserId(ctx)
+ if !xpayConfig(ctx).Configured() {
+ return nil, gerror.NewCode(gcodePayFail(),
+ "虚拟支付尚未配置,请在 MP 后台【虚拟支付 → 基本配置】获取 OfferID 与现网 AppKey 后填入 config.yaml")
+ }
+
+ record, err := g.Model(consts.TableMemberPlans).
+ Where("plan_key", planKey).Where("is_enabled", 1).One()
+ if err != nil {
+ return nil, err
+ }
+ if record.IsEmpty() {
+ return nil, gerror.NewCode(gcodePayFail(), "套餐不存在或已下架")
+ }
+ plan := &entity.MemberPlans{}
+ if err = record.Struct(plan); err != nil {
+ return nil, err
+ }
+ if plan.ProductId == "" || plan.PriceCents <= 0 {
+ return nil, gerror.NewCode(gcodePayFail(), "套餐未配置微信道具 ID 或价格,请先在后台道具管理中核对")
+ }
+
+ pay, err := prepareXpayOrder(ctx, userId, code, plan.ProductId, plan.PriceCents, g.Map{
+ "order_type": consts.OrderTypeMember,
+ "plan_key": plan.PlanKey,
+ "level_key": plan.LevelKey,
+ "duration_days": plan.DurationDays,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &v1.MemberOrderCreateRes{
+ OutTradeNo: pay.OutTradeNo,
+ Mode: consts.XPayMode,
+ SignData: pay.SignData,
+ PaySig: pay.PaySig,
+ Signature: pay.Signature,
+ Env: pay.Env,
+ }, nil
+}
+
+// ===== 支付链路的公共部分 =====
+
+// xpayPayData 前端拉起虚拟支付所需的最小参数集
+type xpayPayData struct {
+ OutTradeNo string
+ SignData string
+ PaySig string
+ Signature string
+ Env int
+}
+
+// prepareXpayOrder 会员套餐与次数包共用的下单流程:
+// 换 session_key → 校验支付人与登录人一致 → 落订单 → 拼双签名。
+//
+// orderFields 由调用方提供商品相关列(order_type / plan_key / level_key / duration_days 或
+// pack_key / tool_key / times),公共列(单号、用户、状态、价格)在这里统一写入。
+func prepareXpayOrder(
+ ctx context.Context, userId int64, code, productId string, priceCents int64, orderFields g.Map,
+) (*xpayPayData, error) {
+ cfg := xpayConfig(ctx)
+ user, err := getUserById(ctx, userId)
+ if err != nil {
+ return nil, err
+ }
+
+ // 拿 session_key 并校验「支付人 = 登录人」:signature 用 session_key 签名,
+ // 若 code 属于另一个 openid,签出来的 signature 会被平台拒绝,这里提前拦截给出清晰报错。
+ openid, err := RefreshSessionKey(ctx, userId, code)
+ if err != nil {
+ return nil, err
+ }
+ if user.Openid != "" && openid != "" && openid != user.Openid {
+ return nil, gerror.NewCode(gcodePayFail(), "登录态与支付账号不一致,请重新进入小程序后再试")
+ }
+ if openid == "" {
+ return nil, gerror.NewCode(gcodePayFail(), "无法确定支付账号,请重新进入小程序后再试")
+ }
+
+ // 取回最新 session_key(RefreshSessionKey 可能只更新了库,这里从库里读回)
+ latest, err := getUserById(ctx, userId)
+ if err != nil {
+ return nil, err
+ }
+ if latest.SessionKey == "" {
+ return nil, gerror.NewCode(gcodePayFail(), "登录态已失效,请重新进入小程序后再试")
+ }
+
+ outTradeNo, err := newOutTradeNo()
+ if err != nil {
+ return nil, err
+ }
+ attach := g.Map{"userId": userId}
+ for k, v := range orderFields {
+ attach[k] = v
+ }
+ attachBytes, _ := json.Marshal(attach)
+
+ order := g.Map{
+ "out_trade_no": outTradeNo,
+ "wx_order_id": nil,
+ "user_id": userId,
+ "openid": openid,
+ "product_id": productId,
+ "price_cents": priceCents,
+ "status": consts.OrderStatusPending,
+ "pay_channel": consts.PayChannelWx,
+ "attach": string(attachBytes),
+ "created_at": gtime.Now(),
+ "updated_at": gtime.Now(),
+ }
+ for k, v := range orderFields {
+ order[k] = v
+ }
+ if _, err = g.Model(consts.TableMemberOrders).Data(order).Insert(); err != nil {
+ return nil, err
+ }
+
+ signData, err := BuildSignData(cfg.OfferId, productId, priceCents, outTradeNo, string(attachBytes), cfg.Env)
+ if err != nil {
+ return nil, err
+ }
+ // 这三个值必须与 MP 后台【虚拟支付 → 道具管理】里已发布的道具完全一致(区分大小写),
+ // 否则客户端会报 PRODUCT_ID_NOT_PUBLISH。这里留痕,便于对着后台核对。
+ g.Log().Infof(ctx, "[xpay] 下单 outTradeNo=%s type=%v productId=%s priceCents=%d offerId=%s env=%d",
+ outTradeNo, orderFields["order_type"], productId, priceCents, cfg.OfferId, cfg.Env)
+ return &xpayPayData{
+ OutTradeNo: outTradeNo,
+ SignData: signData,
+ PaySig: CalcPaySig(xpayURIPayRequest, signData, cfg.AppKey),
+ Signature: CalcSignature(signData, latest.SessionKey),
+ Env: cfg.Env,
+ }, nil
+}
+
+// MemberOrderCheck 查询订单状态;未发货时主动查单兜底补发(前端支付成功回调后轮询此接口)
+func MemberOrderCheck(ctx context.Context, outTradeNo string) (*v1.MemberOrderCheckRes, error) {
+ userId := CtxUserId(ctx)
+ order, err := findOrder(ctx, userId, outTradeNo)
+ if err != nil {
+ return nil, err
+ }
+ // 未发货 → 走一次主动查单(推送到达前的高频场景)
+ if order.Status != consts.OrderStatusDelivered && order.Status != consts.OrderStatusRefunded {
+ _, _ = queryAndDeliver(ctx, order)
+ order, err = findOrder(ctx, userId, outTradeNo)
+ if err != nil {
+ return nil, err
+ }
+ }
+ user, err := getUserById(ctx, userId)
+ if err != nil {
+ return nil, err
+ }
+ return &v1.MemberOrderCheckRes{
+ Status: order.Status,
+ Delivered: order.Status == consts.OrderStatusDelivered,
+ State: buildMemberState(ctx, user),
+ }, nil
+}
+
+// MemberOrders 我的订单列表
+func MemberOrders(ctx context.Context, limit int) (*v1.MemberOrdersRes, error) {
+ if limit <= 0 || limit > 50 {
+ limit = 20
+ }
+ records, err := g.Model(consts.TableMemberOrders).
+ Where("user_id", CtxUserId(ctx)).
+ OrderDesc("id").
+ Limit(limit).
+ All()
+ if err != nil {
+ return nil, err
+ }
+ planNames, err := planNameMap(ctx)
+ if err != nil {
+ return nil, err
+ }
+ packNames, err := quotaPackNameMap(ctx)
+ if err != nil {
+ return nil, err
+ }
+ toolNames, err := toolNameMap(ctx)
+ if err != nil {
+ return nil, err
+ }
+ res := &v1.MemberOrdersRes{List: []v1.MemberOrderOut{}}
+ for _, r := range records {
+ orderType := r["order_type"].Int()
+ if orderType == 0 {
+ orderType = consts.OrderTypeMember // 兼容加列之前的历史订单
+ }
+ out := v1.MemberOrderOut{
+ OutTradeNo: r["out_trade_no"].String(),
+ OrderType: orderType,
+ PlanKey: r["plan_key"].String(),
+ PlanName: planNames[r["plan_key"].String()],
+ ToolKey: r["tool_key"].String(),
+ ToolName: toolNames[r["tool_key"].String()],
+ Times: r["times"].Int(),
+ PriceCents: r["price_cents"].Int64(),
+ Status: r["status"].Int(),
+ StatusText: orderStatusText(r["status"].Int()),
+ PayChannel: r["pay_channel"].String(),
+ CreatedAt: r["created_at"].String(),
+ DeliveredAt: r["delivered_at"].String(),
+ }
+ if orderType == consts.OrderTypeQuota {
+ out.Title = packNames[r["pack_key"].String()]
+ if toolName := out.ToolName; toolName != "" && out.Title != "" {
+ out.Title = toolName + " · " + out.Title
+ }
+ } else {
+ out.Title = out.PlanName
+ }
+ res.List = append(res.List, out)
+ }
+ return res, nil
+}
+
+// ===== 发货 =====
+
+// PayNotifyHandle 处理虚拟支付发货推送。
+//
+// 安全策略:平台推送不保证携带可用于验签的字段,因此当虚拟支付已配置时,
+// 一律「先主动查单复核,确认已支付才发货」,查单结果即最可靠的验签;
+// 未配置(联调阶段)时按推送内容发货并打告警日志。
+func PayNotifyHandle(ctx context.Context, raw []byte) (ack string, err error) {
+ notify, err := ParseDeliverNotify(raw)
+ if err != nil {
+ g.Log().Warningf(ctx, "[xpay] 发货推送解析失败: %v raw=%s", err, string(raw))
+ return XPayNotifyAckFail("bad payload"), nil
+ }
+ g.Log().Infof(ctx, "[xpay] 收到发货推送 outTradeNo=%s openid=%s productId=%s qty=%d",
+ notify.OutTradeNo, notify.OpenId, notify.GoodsInfo.ProductId, notify.GoodsInfo.Quantity)
+
+ order, err := entityOrderByOutTradeNo(ctx, notify.OutTradeNo)
+ if err != nil {
+ g.Log().Warningf(ctx, "[xpay] 发货推送对应订单不存在: %s", notify.OutTradeNo)
+ // 订单不存在多为脏数据,回成功避免平台无意义重试 15 次
+ return XPayNotifyAck(), nil
+ }
+ if order.Status == consts.OrderStatusDelivered {
+ return XPayNotifyAck(), nil // 幂等:重复推送直接成功
+ }
+
+ wxOrderId := notify.WeChatPayInfo.MchOrderNo
+ if xpayConfig(ctx).Configured() {
+ state, qErr := XPayQueryOrder(ctx, order.Openid, order.OutTradeNo)
+ if qErr != nil {
+ g.Log().Warningf(ctx, "[xpay] 发货推送触发查单复核失败: outTradeNo=%s err=%v", order.OutTradeNo, qErr)
+ return XPayNotifyAckFail("query failed"), nil // 让平台重试
+ }
+ if !state.Paid {
+ g.Log().Warningf(ctx, "[xpay] 查单显示未支付,拒绝发货: %s", order.OutTradeNo)
+ return XPayNotifyAckFail("order not paid"), nil
+ }
+ if state.WxOrderId != "" {
+ wxOrderId = state.WxOrderId
+ }
+ } else {
+ g.Log().Warningf(ctx, "[xpay] 虚拟支付未配置,按推送内容直接发货(仅限联调): %s", order.OutTradeNo)
+ }
+ if wxOrderId == "" {
+ wxOrderId = order.OutTradeNo // 极端兜底:保证唯一索引不冲突,幂等仍由 status 保证
+ }
+
+ if err = deliverOrder(ctx, order.OutTradeNo, wxOrderId); err != nil {
+ g.Log().Errorf(ctx, "[xpay] 发货失败 outTradeNo=%s err=%v", order.OutTradeNo, err)
+ return XPayNotifyAckFail("deliver failed"), nil // 让平台重试
+ }
+ return XPayNotifyAck(), nil
+}
+
+// SweepPendingOrders 兜底扫单:把超时未发货的待支付订单逐笔主动查单,已支付则补发货。
+// 建议每 5 分钟执行一次(见 cmd.go 的 gcron 注册)。
+func SweepPendingOrders(ctx context.Context) {
+ if !xpayConfig(ctx).Configured() {
+ return
+ }
+ // 只扫 2 小时内、未发货的订单,避免历史脏数据无限重试
+ records, err := g.Model(consts.TableMemberOrders).
+ Where("status", consts.OrderStatusPending).
+ WhereGTE("created_at", gtime.Now().Add(-2*time.Hour)).
+ OrderAsc("id").
+ Limit(100).
+ All()
+ if err != nil {
+ g.Log().Warningf(ctx, "[xpay] 扫单失败: %v", err)
+ return
+ }
+ for _, r := range records {
+ order := &entity.MemberOrders{}
+ if err = r.Struct(order); err != nil {
+ continue
+ }
+ if _, err = queryAndDeliver(ctx, order); err != nil {
+ g.Log().Debugf(ctx, "[xpay] 扫单跳过 outTradeNo=%s err=%v", order.OutTradeNo, err)
+ }
+ }
+}
+
+// queryAndDeliver 主动查单 → 已支付则发货。返回是否完成发货。
+func queryAndDeliver(ctx context.Context, order *entity.MemberOrders) (bool, error) {
+ if order.Status == consts.OrderStatusDelivered {
+ return true, nil
+ }
+ if !xpayConfig(ctx).Configured() {
+ return false, nil
+ }
+ state, err := XPayQueryOrder(ctx, order.Openid, order.OutTradeNo)
+ if err != nil {
+ return false, err
+ }
+ if !state.Paid {
+ return false, nil
+ }
+ wxOrderId := state.WxOrderId
+ if wxOrderId == "" {
+ wxOrderId = order.OutTradeNo
+ }
+ if err = deliverOrder(ctx, order.OutTradeNo, wxOrderId); err != nil {
+ return false, err
+ }
+ g.Log().Infof(ctx, "[xpay] 查单兜底发货成功 outTradeNo=%s", order.OutTradeNo)
+ return true, nil
+}
+
+// deliverOrder 发货:置订单为已发货 + 延长会员有效期。全程在一个事务里,按 out_trade_no 加行锁保证幂等。
+func deliverOrder(ctx context.Context, outTradeNo, wxOrderId string) error {
+ return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
+ row, err := tx.Model(consts.TableMemberOrders).
+ Where("out_trade_no", outTradeNo).LockUpdate().One()
+ if err != nil {
+ return err
+ }
+ if row.IsEmpty() {
+ return gerror.Newf("订单不存在: %s", outTradeNo)
+ }
+ if row["status"].Int() == consts.OrderStatusDelivered {
+ return nil // 幂等:已发货直接返回
+ }
+
+ userId := row["user_id"].Int64()
+ days := row["duration_days"].Int()
+ if days <= 0 {
+ return gerror.Newf("订单 %s 有效天数异常: %d", outTradeNo, days)
+ }
+
+ // 两类商品在同一事务里分支发货,幂等仍由上面的 status 判定保证
+ switch row["order_type"].Int() {
+ case consts.OrderTypeQuota:
+ toolKey := row["tool_key"].String()
+ times := row["times"].Int()
+ expireAt, err := grantToolQuota(ctx, tx, userId, toolKey, times, days)
+ if err != nil {
+ return err
+ }
+ g.Log().Infof(ctx, "[quota] 次数包发货完成 userId=%d outTradeNo=%s tool=%s times=%d expire=%s",
+ userId, outTradeNo, toolKey, times, expireAt.String())
+ default:
+ levelKey := row["level_key"].String()
+ expireAt, finalLevel, err := extendMembership(ctx, tx, userId, levelKey, days)
+ if err != nil {
+ return err
+ }
+ // 套餐若配置了额度周期,则一并写入用户(「按会员套餐调整额度周期」的落地方式)
+ if err = applyPlanQuotaPeriod(ctx, tx, userId, row["plan_key"].String()); err != nil {
+ return err
+ }
+ g.Log().Infof(ctx, "[member] 发货完成 userId=%d outTradeNo=%s level=%s expire=%s",
+ userId, outTradeNo, finalLevel, expireAt.String())
+ }
+
+ now := gtime.Now()
+ if _, err = tx.Model(consts.TableMemberOrders).
+ Where("out_trade_no", outTradeNo).
+ Data(g.Map{
+ "wx_order_id": wxOrderId,
+ "status": consts.OrderStatusDelivered,
+ "paid_at": now,
+ "delivered_at": now,
+ "updated_at": now,
+ }).Update(); err != nil {
+ return err
+ }
+ return nil
+ })
+}
+
+// applyPlanQuotaPeriod 把会员套餐配置的额度周期写到用户上。
+// 套餐未配置(0)时不动用户原值,避免「买了没配周期的套餐」把用户的自定义周期清掉。
+func applyPlanQuotaPeriod(ctx context.Context, tx gdb.TX, userId int64, planKey string) error {
+ if planKey == "" {
+ return nil
+ }
+ value, err := tx.Model(consts.TableMemberPlans).
+ Where("plan_key", planKey).Value("quota_period_days")
+ if err != nil || value == nil {
+ return nil
+ }
+ days := value.Int()
+ if days <= 0 {
+ return nil
+ }
+ if _, err = tx.Model(consts.TableUsers).Where("id", userId).Data(g.Map{
+ "quota_period_days": days,
+ "updated_at": gtime.Now(),
+ }).Update(); err != nil {
+ return err
+ }
+ g.Log().Infof(ctx, "[quota] 按套餐调整额度周期 userId=%d plan=%s periodDays=%d", userId, planKey, days)
+ return nil
+}
+
+// extendMembership 延长会员有效期。规则:
+// - 同档续费(当前等级 == 购买等级)且未过期 → 在原到期时间上顺延
+// - 其他情况 → 从当前时间起算
+// - 若当前未过期等级的权限高于所购等级,保留高等级(只延长有效期,不降权)
+// - level_expire_at 为 NULL(永久)时,不覆盖为有限期,避免把永久会员改成到期会员
+func extendMembership(ctx context.Context, tx gdb.TX, userId int64, levelKey string, days int) (*gtime.Time, string, error) {
+ row, err := tx.Model(consts.TableUsers).Where("id", userId).LockUpdate().One()
+ if err != nil {
+ return nil, "", err
+ }
+ if row.IsEmpty() {
+ return nil, "", gerror.Newf("用户不存在: %d", userId)
+ }
+
+ now := gtime.Now()
+ curLevel := row["level_key"].String()
+ curExpire := row["level_expire_at"].GTime()
+ permanent := curExpire == nil || curExpire.IsZero()
+
+ // 永久会员:不因为一次购买而降级为有限期,仅按权重提升等级
+ if permanent {
+ finalLevel := curLevel
+ if levelWeight(ctx, levelKey) > levelWeight(ctx, curLevel) {
+ finalLevel = levelKey
+ }
+ if _, err = tx.Model(consts.TableUsers).Where("id", userId).Data(g.Map{
+ "level_key": finalLevel,
+ "updated_at": now,
+ }).Update(); err != nil {
+ return nil, "", err
+ }
+ return nil, finalLevel, nil
+ }
+
+ base := now
+ if curExpire.After(now) && curLevel == levelKey {
+ base = curExpire // 同档续费顺延
+ }
+ expireAt := base.AddDate(0, 0, days)
+
+ finalLevel := levelKey
+ if curExpire.After(now) && levelWeight(ctx, curLevel) > levelWeight(ctx, levelKey) {
+ finalLevel = curLevel // 不降权
+ }
+ if _, err = tx.Model(consts.TableUsers).Where("id", userId).Data(g.Map{
+ "level_key": finalLevel,
+ "level_expire_at": expireAt,
+ "updated_at": now,
+ }).Update(); err != nil {
+ return nil, "", err
+ }
+ return expireAt, finalLevel, nil
+}
+
+// ===== 辅助 =====
+
+func gcodePayFail() gcode.Code {
+ return gcode.New(consts.CodePayFail, "", nil)
+}
+
+func findOrder(ctx context.Context, userId int64, outTradeNo string) (*entity.MemberOrders, error) {
+ record, err := g.Model(consts.TableMemberOrders).
+ Where("user_id", userId).Where("out_trade_no", outTradeNo).One()
+ if err != nil {
+ return nil, err
+ }
+ if record.IsEmpty() {
+ return nil, gerror.New("订单不存在")
+ }
+ order := &entity.MemberOrders{}
+ if err = record.Struct(order); err != nil {
+ return nil, err
+ }
+ return order, nil
+}
+
+func entityOrderByOutTradeNo(ctx context.Context, outTradeNo string) (*entity.MemberOrders, error) {
+ record, err := g.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).One()
+ if err != nil {
+ return nil, err
+ }
+ if record.IsEmpty() {
+ return nil, gerror.Newf("订单不存在: %s", outTradeNo)
+ }
+ order := &entity.MemberOrders{}
+ if err = record.Struct(order); err != nil {
+ return nil, err
+ }
+ return order, nil
+}
+
+func listPlans(ctx context.Context) ([]v1.MemberPlanOut, error) {
+ records, err := g.Model(consts.TableMemberPlans).
+ Where("is_enabled", 1).OrderAsc("sort").All()
+ if err != nil {
+ return nil, err
+ }
+ list := []v1.MemberPlanOut{}
+ for _, r := range records {
+ list = append(list, v1.MemberPlanOut{
+ PlanKey: r["plan_key"].String(),
+ Name: r["name"].String(),
+ Subtitle: r["subtitle"].String(),
+ LevelKey: r["level_key"].String(),
+ DurationDays: r["duration_days"].Int(),
+ PriceCents: r["price_cents"].Int64(),
+ ProductId: r["product_id"].String(),
+ Badge: r["badge"].String(),
+ })
+ }
+ return list, nil
+}
+
+func planNameMap(ctx context.Context) (map[string]string, error) {
+ records, err := g.Model(consts.TableMemberPlans).All()
+ if err != nil {
+ return nil, err
+ }
+ out := map[string]string{}
+ for _, r := range records {
+ out[r["plan_key"].String()] = r["name"].String()
+ }
+ return out, nil
+}
+
+// listLevelBriefs 全部启用等级(用于前端渲染权益对比与购买价值感知)
+func listLevelBriefs(ctx context.Context, user *entity.Users) ([]v1.MemberLevelOut, error) {
+ records, err := g.Model(consts.TableLevels).Where("is_enabled", 1).OrderAsc("sort").All()
+ if err != nil {
+ return nil, err
+ }
+ modRecords, err := g.Model(consts.TableModules).Where("is_enabled", 1).All()
+ if err != nil {
+ return nil, err
+ }
+ moduleNames := map[string]string{}
+ for _, m := range modRecords {
+ moduleNames[m["module_key"].String()] = m["name"].String()
+ }
+ current := EffectiveLevelKey(user)
+ list := []v1.MemberLevelOut{}
+ for _, r := range records {
+ modules := []string{}
+ _ = json.Unmarshal([]byte(r["modules"].String()), &modules)
+ names := make([]string, 0, len(modules))
+ for _, mk := range modules {
+ if n := moduleNames[mk]; n != "" {
+ names = append(names, n)
+ }
+ }
+ list = append(list, v1.MemberLevelOut{
+ LevelKey: r["level_key"].String(),
+ Name: r["name"].String(),
+ Modules: modules,
+ ModuleNames: names,
+ IsCurrent: r["level_key"].String() == current,
+ })
+ }
+ return list, nil
+}
+
+func buildMemberState(ctx context.Context, user *entity.Users) v1.MemberStateOut {
+ effectiveKey := EffectiveLevelKey(user)
+ expired := IsLevelExpired(user)
+ state := v1.MemberStateOut{
+ LevelKey: effectiveKey,
+ ExpireAt: "",
+ Modules: []string{},
+ IsMember: false,
+ DaysLeft: 0,
+ }
+
+ // 会员状态:有到期时间且未过期,且等级高于默认等级
+ hasExpire := user.LevelExpireAt != nil && !user.LevelExpireAt.IsZero()
+ if hasExpire {
+ state.ExpireAt = user.LevelExpireAt.Format("Y-m-d H:i:s")
+ }
+ if hasExpire && !expired && effectiveKey != consts.DefaultLevelKey {
+ state.IsMember = true
+ left := user.LevelExpireAt.Sub(gtime.Now())
+ days := int(left.Hours() / 24)
+ if left.Hours() > float64(days*24) {
+ days++
+ }
+ state.DaysLeft = days
+ }
+
+ level, err := GetLevelByKey(ctx, effectiveKey)
+ if err == nil && level != nil {
+ state.LevelName = level.Name
+ state.Modules = level.Modules
+ }
+ return state
+}
+
+// newOutTradeNo 生成业务单号:8-32 位、不能以下划线开头(官方约束)。
+// 形如 T20260917150345 + 6 位随机 = 21 位。
+func newOutTradeNo() (string, error) {
+ return fmt.Sprintf("T%s%06d", gtime.Now().Format("YmdHis"), rand.Intn(1000000)), nil
+}
+
+func orderStatusText(status int) string {
+ switch status {
+ case consts.OrderStatusPending:
+ return "待支付"
+ case consts.OrderStatusDelivered:
+ return "已开通"
+ case consts.OrderStatusRefunded:
+ return "已退款"
+ case consts.OrderStatusClosed:
+ return "已关闭"
+ default:
+ return "未知"
+ }
+}
+
+// memberNotices 平台规则告知(技能要求:必须向用户说明退款、结算与费率)
+func memberNotices() []string {
+ return []string{
+ "个人主体小程序全终端月支付限额 10 万元",
+ "Android 等:T+3 结算,腾讯技术服务费 1%",
+ "iOS:约 45-60 天结算,Apple 佣金 12%",
+ "支付时间 180 天内的退款,平台退还手续费;超过 180 天不退还",
+ "iOS 端退款由用户向 App Store 申请,开发者无法主动退款",
+ }
+}
diff --git a/internal/logic/migrate.go b/internal/logic/migrate.go
new file mode 100644
index 0000000..bd61610
--- /dev/null
+++ b/internal/logic/migrate.go
@@ -0,0 +1,139 @@
+package logic
+
+import (
+ "context"
+
+ "github.com/gogf/gf/v2/frame/g"
+)
+
+// ============================================================================
+// 启动幂等迁移
+//
+// 为什么不用迁移工具:本项目部署方式是 `go run ./main.go`,仓库里也没有 goose/flyway,
+// 而 manifest/config 被 gitignore 后运维手上常常只有一份二进制 + 数据库地址。
+// 把「建表 + 加列」做成启动时的幂等 SQL,可以让升级只需要重启服务,不必再手工敲 DDL。
+//
+// 安全约定:
+// - 全部语句必须可重复执行(CREATE TABLE IF NOT EXISTS / 先查 information_schema 再 ALTER)
+// - 失败只告警不阻断启动:缺表会让相关接口报错,但不该连带整个服务起不来
+// ============================================================================
+
+type migrateTable struct {
+ name string
+ sql string
+}
+
+type migrateColumn struct {
+ table string
+ column string
+ sql string
+}
+
+// Migrate 启动时执行幂等迁移;失败仅告警。
+func Migrate(ctx context.Context) {
+ for _, t := range migrateTables() {
+ if _, err := g.DB().Exec(ctx, t.sql); err != nil {
+ g.Log().Warningf(ctx, "[migrate] 建表 %s 失败: %v", t.name, err)
+ }
+ }
+ for _, c := range migrateColumns() {
+ exists, err := columnExists(ctx, c.table, c.column)
+ if err != nil {
+ g.Log().Warningf(ctx, "[migrate] 检查列 %s.%s 失败: %v", c.table, c.column, err)
+ continue
+ }
+ if exists {
+ continue
+ }
+ if _, err = g.DB().Exec(ctx, c.sql); err != nil {
+ g.Log().Warningf(ctx, "[migrate] 加列 %s.%s 失败: %v", c.table, c.column, err)
+ continue
+ }
+ g.Log().Infof(ctx, "[migrate] 已新增列 %s.%s", c.table, c.column)
+ }
+}
+
+// columnExists 用 information_schema 判断列是否已存在(DATABASE() 取当前库,避免写死库名)
+func columnExists(ctx context.Context, table, column string) (bool, error) {
+ value, err := g.DB().GetValue(ctx,
+ "SELECT COUNT(*) FROM information_schema.COLUMNS "+
+ "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
+ table, column)
+ if err != nil {
+ return false, err
+ }
+ return value.Int() > 0, nil
+}
+
+func migrateTables() []migrateTable {
+ return []migrateTable{
+ {"quota_packs", "CREATE TABLE IF NOT EXISTS `quota_packs` (" +
+ "`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
+ "`pack_key` varchar(64) NOT NULL COMMENT '档位标识,跨端契约'," +
+ "`name` varchar(64) NOT NULL DEFAULT '' COMMENT '展示名'," +
+ "`times` int NOT NULL DEFAULT 0 COMMENT '可用次数'," +
+ "`price_cents` bigint NOT NULL DEFAULT 0 COMMENT '价格(分)'," +
+ "`product_id` varchar(64) NOT NULL DEFAULT '' COMMENT '微信虚拟支付道具 ID'," +
+ "`valid_days` int NOT NULL DEFAULT 365 COMMENT '有效期天数,默认一年'," +
+ "`tool_key` varchar(64) NOT NULL DEFAULT '' COMMENT '绑定工具,空=通用档位'," +
+ "`badge` varchar(32) NOT NULL DEFAULT '' COMMENT '角标文案'," +
+ "`sort` int NOT NULL DEFAULT 0," +
+ "`is_enabled` tinyint NOT NULL DEFAULT 1," +
+ "`remark` varchar(255) NOT NULL DEFAULT ''," +
+ "`created_at` datetime DEFAULT NULL," +
+ "`updated_at` datetime DEFAULT NULL," +
+ "PRIMARY KEY (`id`)," +
+ "UNIQUE KEY `uk_pack_key` (`pack_key`)" +
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='次数包档位(付费额度包)'"},
+ {"user_tool_quota", "CREATE TABLE IF NOT EXISTS `user_tool_quota` (" +
+ "`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
+ "`user_id` bigint NOT NULL," +
+ "`tool_key` varchar(64) NOT NULL," +
+ "`times_left` int NOT NULL DEFAULT 0 COMMENT '剩余次数'," +
+ "`total_bought` int NOT NULL DEFAULT 0 COMMENT '累计购买次数'," +
+ "`expire_at` datetime DEFAULT NULL COMMENT '到期时间,NULL 视为已过期'," +
+ "`created_at` datetime DEFAULT NULL," +
+ "`updated_at` datetime DEFAULT NULL," +
+ "PRIMARY KEY (`id`)," +
+ "UNIQUE KEY `uk_user_tool` (`user_id`,`tool_key`)" +
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户工具付费额度余额'"},
+ {"user_quota_usage", "CREATE TABLE IF NOT EXISTS `user_quota_usage` (" +
+ "`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
+ "`user_id` bigint NOT NULL," +
+ "`tool_key` varchar(64) NOT NULL," +
+ "`period_start` datetime NOT NULL COMMENT '当前周期起点'," +
+ "`period_end` datetime NOT NULL COMMENT '当前周期终点'," +
+ "`free_used` int NOT NULL DEFAULT 0 COMMENT '本周期已用免费额度'," +
+ "`member_used` int NOT NULL DEFAULT 0 COMMENT '本周期已用会员额度'," +
+ "`updated_at` datetime DEFAULT NULL," +
+ "PRIMARY KEY (`id`)," +
+ "UNIQUE KEY `uk_user_tool` (`user_id`,`tool_key`)" +
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户工具额度周期用量'"},
+ {"settings", "CREATE TABLE IF NOT EXISTS `settings` (" +
+ "`k` varchar(64) NOT NULL," +
+ "`v` varchar(255) NOT NULL DEFAULT ''," +
+ "`remark` varchar(255) NOT NULL DEFAULT ''," +
+ "`updated_at` datetime DEFAULT NULL," +
+ "PRIMARY KEY (`k`)" +
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台可改的全局配置'"},
+ }
+}
+
+func migrateColumns() []migrateColumn {
+ return []migrateColumn{
+ // 工具额度配置:默认值让老数据在升级后立刻可用(免费 3 次/周期、会员 100 次/周期、系数 1)
+ {"tools", "free_quota", "ALTER TABLE `tools` ADD COLUMN `free_quota` int NOT NULL DEFAULT 3 COMMENT '免费额度(每周期次数)'"},
+ {"tools", "member_quota", "ALTER TABLE `tools` ADD COLUMN `member_quota` int NOT NULL DEFAULT 100 COMMENT '会员额度(每周期次数)'"},
+ {"tools", "pay_cost", "ALTER TABLE `tools` ADD COLUMN `pay_cost` int NOT NULL DEFAULT 1 COMMENT '付费额度每次消耗系数'"},
+
+ // 额度周期:0 = 跟随全局设置
+ {"users", "quota_period_days", "ALTER TABLE `users` ADD COLUMN `quota_period_days` int NOT NULL DEFAULT 0 COMMENT '额度重置周期(天),0=跟随全局'"},
+ {"member_plans", "quota_period_days", "ALTER TABLE `member_plans` ADD COLUMN `quota_period_days` int NOT NULL DEFAULT 0 COMMENT '购买后生效的额度周期(天),0=不调整'"},
+
+ // 订单表承载两类商品
+ {"member_orders", "order_type", "ALTER TABLE `member_orders` ADD COLUMN `order_type` tinyint NOT NULL DEFAULT 1 COMMENT '1=会员套餐 2=次数包'"},
+ {"member_orders", "pack_key", "ALTER TABLE `member_orders` ADD COLUMN `pack_key` varchar(64) NOT NULL DEFAULT '' COMMENT '次数包档位'"},
+ {"member_orders", "tool_key", "ALTER TABLE `member_orders` ADD COLUMN `tool_key` varchar(64) NOT NULL DEFAULT '' COMMENT '次数包绑定工具'"},
+ {"member_orders", "times", "ALTER TABLE `member_orders` ADD COLUMN `times` int NOT NULL DEFAULT 0 COMMENT '次数包次数'"},
+ }
+}
diff --git a/internal/logic/quota.go b/internal/logic/quota.go
new file mode 100644
index 0000000..d6aa686
--- /dev/null
+++ b/internal/logic/quota.go
@@ -0,0 +1,608 @@
+package logic
+
+import (
+ "context"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/errors/gcode"
+ "github.com/gogf/gf/v2/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/os/gtime"
+
+ v1 "tool-api/api/user/v1"
+ "tool-api/internal/consts"
+ "tool-api/internal/model/entity"
+)
+
+// ============================================================================
+// 额度引擎
+//
+// 三种额度,按「免费 → 会员 → 付费」的顺序扣减,先扣对用户最有利的池:
+// 1. 免费额度 free_quota —— 每个周期(默认 7 天)重置,所有用户都有
+// 2. 会员额度 member_quota —— 每周期重置,仅会员可用(在免费额度用尽后启用)
+// 3. 付费额度 次数包 —— 买断制、按工具绑定、在有效期内累计使用,不随周期重置
+//
+// 周期是「滚动窗口」而非自然周:首次使用时把周期起点定为当时时间,
+// 起点 + 周期天数 = 终点;到点归零。这样做的好处是后台改周期天数后
+// 立刻按新长度重算,不用等下一个自然周。
+//
+// 周期的取值优先级:用户单独设置 > 会员套餐(发货时写入用户)> 全局设置 > 默认 7 天。
+// ============================================================================
+
+const (
+ quotaSettingCacheTTL = 30 * time.Second
+ quotaMaxPeriodDays = 3650
+)
+
+// 扣减时返回的额度判定结果,附带「还能不能用」与失败原因。
+type quotaVerdict struct {
+ Source string // 命中/将命中的池:free / member / paid / none
+ Message string // source == none 时的可读原因
+}
+
+// ===== 全局设置读取(带短缓存,避免每次扣减都查库)=====
+
+type settingCacheItem struct {
+ value string
+ at time.Time
+}
+
+var (
+ settingCacheMu sync.RWMutex
+ settingCache = map[string]settingCacheItem{}
+)
+
+// SettingValue 读取 settings 表中的配置值,带 30 秒进程内缓存
+func SettingValue(ctx context.Context, key string) string {
+ settingCacheMu.RLock()
+ item, ok := settingCache[key]
+ settingCacheMu.RUnlock()
+ if ok && time.Since(item.at) < quotaSettingCacheTTL {
+ return item.value
+ }
+ value, err := g.Model(consts.TableSettings).Where("k", key).Value("v")
+ if err != nil || value == nil {
+ return ""
+ }
+ settingCacheMu.Lock()
+ settingCache[key] = settingCacheItem{value: value.String(), at: time.Now()}
+ settingCacheMu.Unlock()
+ return value.String()
+}
+
+// settingSave 写入配置并立即失效缓存(管理端保存后马上生效)
+func settingSave(ctx context.Context, key, value, remark string) error {
+ if _, err := g.Model(consts.TableSettings).Data(g.Map{
+ "k": key,
+ "v": value,
+ "remark": remark,
+ "updated_at": gtime.Now(),
+ }).Save(); err != nil {
+ return err
+ }
+ settingCacheMu.Lock()
+ delete(settingCache, key)
+ settingCacheMu.Unlock()
+ return nil
+}
+
+// GlobalQuotaPeriodDays 全局默认额度周期(天)
+func GlobalQuotaPeriodDays(ctx context.Context) int {
+ raw := SettingValue(ctx, consts.SettingQuotaPeriodDays)
+ days, err := strconv.Atoi(raw)
+ if err != nil || days <= 0 || days > quotaMaxPeriodDays {
+ return consts.DefaultQuotaPeriodDays
+ }
+ return days
+}
+
+// EffectiveQuotaPeriodDays 用户实际生效的额度周期:
+// 用户单独设置(后台可改)优先,其次全局设置。
+func EffectiveQuotaPeriodDays(ctx context.Context, user *entity.Users) int {
+ if user != nil && user.QuotaPeriodDays > 0 && user.QuotaPeriodDays <= quotaMaxPeriodDays {
+ return user.QuotaPeriodDays
+ }
+ return GlobalQuotaPeriodDays(ctx)
+}
+
+// ===== 工具读取 =====
+
+func toolRow(ctx context.Context, toolKey string) (*entity.Tools, error) {
+ record, err := g.Model(consts.TableTools).Where("tool_key", toolKey).One()
+ if err != nil {
+ return nil, err
+ }
+ if record.IsEmpty() {
+ return nil, gerror.New("工具不存在")
+ }
+ tool := &entity.Tools{}
+ if err = record.Struct(tool); err != nil {
+ return nil, err
+ }
+ return tool, nil
+}
+
+// ===== 周期用量 =====
+
+type quotaUsageState struct {
+ Id int64
+ FreeUsed int
+ MemberUsed int
+ PeriodStart *gtime.Time
+ PeriodEnd *gtime.Time
+ Version int // 供构建输出用(本周期是否被重置过)
+ ResetOccurred bool
+ PeriodDaysSnapshot int
+}
+
+// lockOrInitUsage 行锁读取(不存在则创建)某用户某工具的周期用量;
+// 周期到期或后台改短周期导致已过期时,就地归零并开启新周期。
+func lockOrInitUsage(ctx context.Context, tx gdb.TX, userId int64, toolKey string, periodDays int) (*quotaUsageState, error) {
+ now := gtime.Now()
+ record, err := tx.Model(consts.TableQuotaUsage).
+ Where("user_id", userId).Where("tool_key", toolKey).LockUpdate().One()
+ if err != nil {
+ return nil, err
+ }
+ if record.IsEmpty() {
+ data := g.Map{
+ "user_id": userId,
+ "tool_key": toolKey,
+ "period_start": now,
+ "period_end": now.AddDate(0, 0, periodDays),
+ "free_used": 0,
+ "member_used": 0,
+ "updated_at": now,
+ }
+ lastId, insertErr := tx.Model(consts.TableQuotaUsage).Data(data).InsertAndGetId()
+ if insertErr != nil {
+ // 并发下可能已被其它请求插入:重新加锁读取
+ record, err = tx.Model(consts.TableQuotaUsage).
+ Where("user_id", userId).Where("tool_key", toolKey).LockUpdate().One()
+ if err != nil {
+ return nil, err
+ }
+ if record.IsEmpty() {
+ return nil, insertErr
+ }
+ } else {
+ return "aUsageState{
+ Id: lastId,
+ PeriodStart: now,
+ PeriodEnd: now.AddDate(0, 0, periodDays),
+ PeriodDaysSnapshot: periodDays,
+ }, nil
+ }
+ }
+
+ state := "aUsageState{
+ Id: record["id"].Int64(),
+ FreeUsed: record["free_used"].Int(),
+ MemberUsed: record["member_used"].Int(),
+ PeriodStart: record["period_start"].GTime(),
+ PeriodEnd: record["period_end"].GTime(),
+ }
+ if state.PeriodStart == nil || state.PeriodStart.IsZero() {
+ return state.reset(ctx, tx, now, periodDays)
+ }
+
+ // 按「当前配置的周期天数」重算终点:后台改长/改短都能立刻对齐
+ expectedEnd := state.PeriodStart.AddDate(0, 0, periodDays)
+ if !now.Before(expectedEnd) {
+ // 周期已结束 → 归零开新周期
+ return state.reset(ctx, tx, now, periodDays)
+ }
+ if state.PeriodEnd == nil || !state.PeriodEnd.Equal(expectedEnd) {
+ if _, err = tx.Model(consts.TableQuotaUsage).Where("id", state.Id).Data(g.Map{
+ "period_end": expectedEnd,
+ "updated_at": now,
+ }).Update(); err != nil {
+ return nil, err
+ }
+ state.PeriodEnd = expectedEnd
+ }
+ return state, nil
+}
+
+func (s *quotaUsageState) reset(ctx context.Context, tx gdb.TX, now *gtime.Time, periodDays int) (*quotaUsageState, error) {
+ end := now.AddDate(0, 0, periodDays)
+ if _, err := tx.Model(consts.TableQuotaUsage).Where("id", s.Id).Data(g.Map{
+ "period_start": now,
+ "period_end": end,
+ "free_used": 0,
+ "member_used": 0,
+ "updated_at": now,
+ }).Update(); err != nil {
+ return nil, err
+ }
+ s.FreeUsed = 0
+ s.MemberUsed = 0
+ s.PeriodStart = now
+ s.PeriodEnd = end
+ s.ResetOccurred = true
+ s.PeriodDaysSnapshot = periodDays
+ return s, nil
+}
+
+// ===== 付费额度余额 =====
+
+type paidBalance struct {
+ Id int64
+ TimesLeft int
+ ExpireAt *gtime.Time
+ Exists bool
+}
+
+func paidExpired(b *paidBalance, now *gtime.Time) bool {
+ if b == nil || !b.Exists {
+ return true
+ }
+ if b.ExpireAt == nil || b.ExpireAt.IsZero() {
+ return true
+ }
+ return !now.Before(b.ExpireAt)
+}
+
+// lockPaidBalance 行锁读取用户在某工具上的付费额度余额;为空时返回不存在(不创建)
+func lockPaidBalance(ctx context.Context, tx gdb.TX, userId int64, toolKey string) (*paidBalance, error) {
+ record, err := tx.Model(consts.TableUserToolQuota).
+ Where("user_id", userId).Where("tool_key", toolKey).LockUpdate().One()
+ if err != nil {
+ return nil, err
+ }
+ if record.IsEmpty() {
+ return &paidBalance{Exists: false}, nil
+ }
+ return &paidBalance{
+ Id: record["id"].Int64(),
+ TimesLeft: record["times_left"].Int(),
+ ExpireAt: record["expire_at"].GTime(),
+ Exists: true,
+ }, nil
+}
+
+// grantToolQuota 发货:给用户增加某工具的付费额度。
+// 同工具重复购买 → 次数累加、有效期顺延(base = max(now, 当前到期时间))。
+func grantToolQuota(
+ ctx context.Context, tx gdb.TX, userId int64, toolKey string, times, validDays int,
+) (*gtime.Time, error) {
+ if times <= 0 {
+ return nil, gerror.New("次数包次数异常")
+ }
+ if validDays <= 0 {
+ validDays = 365
+ }
+ now := gtime.Now()
+ record, err := tx.Model(consts.TableUserToolQuota).
+ Where("user_id", userId).Where("tool_key", toolKey).LockUpdate().One()
+ if err != nil {
+ return nil, err
+ }
+
+ base := now
+ curLeft := 0
+ if !record.IsEmpty() {
+ curExpire := record["expire_at"].GTime()
+ curLeft = record["times_left"].Int()
+ if curExpire != nil && curExpire.After(now) {
+ base = curExpire
+ } else {
+ curLeft = 0 // 已过期:旧余额作废,重新计
+ }
+ }
+ expireAt := base.AddDate(0, 0, validDays)
+
+ if record.IsEmpty() {
+ if _, err = tx.Model(consts.TableUserToolQuota).Data(g.Map{
+ "user_id": userId,
+ "tool_key": toolKey,
+ "times_left": times,
+ "total_bought": times,
+ "expire_at": expireAt,
+ "created_at": now,
+ "updated_at": now,
+ }).Insert(); err != nil {
+ return nil, err
+ }
+ return expireAt, nil
+ }
+ if _, err = tx.Model(consts.TableUserToolQuota).Where("id", record["id"].Int64()).Data(g.Map{
+ "times_left": curLeft + times,
+ "total_bought": record["total_bought"].Int() + times,
+ "expire_at": expireAt,
+ "updated_at": now,
+ }).Update(); err != nil {
+ return nil, err
+ }
+ return expireAt, nil
+}
+
+// ===== 额度判定 =====
+
+// judgeQuota 计算当前会命中哪个池,以及还有没有额度
+func judgeQuota(tool *entity.Tools, usage *quotaUsageState, paid *paidBalance, isMember bool, now *gtime.Time) quotaVerdict {
+ payCost := tool.PayCost
+ if payCost <= 0 {
+ payCost = 1
+ }
+ if usage == nil || usage.FreeUsed < tool.FreeQuota {
+ return quotaVerdict{Source: consts.QuotaSourceFree}
+ }
+ if isMember && usage.MemberUsed < tool.MemberQuota {
+ return quotaVerdict{Source: consts.QuotaSourceMember}
+ }
+ if paid != nil && paid.Exists && !paidExpired(paid, now) && paid.TimesLeft >= payCost {
+ return quotaVerdict{Source: consts.QuotaSourcePaid}
+ }
+ return quotaVerdict{Source: consts.QuotaSourceNone, Message: quotaExhaustedMessage(tool)}
+}
+
+func quotaExhaustedMessage(tool *entity.Tools) string {
+ return "「" + tool.Name + "」本周期额度已用完,开通会员可获得更多额度,或购买次数包继续使用"
+}
+
+// ConsumeToolQuota 扣减一次额度(产出结果时调用)。
+//
+// 事务内按 out_trade_no 之外的另一把锁保证并发安全:行锁落在 user_quota_usage /
+// user_tool_quota 上,两个请求同时扣同一个人同一工具时串行执行,不会超发。
+func ConsumeToolQuota(ctx context.Context, userId int64, toolKey string) (*v1.QuotaToolOut, error) {
+ user, err := getUserById(ctx, userId)
+ if err != nil {
+ return nil, err
+ }
+ tool, err := toolRow(ctx, toolKey)
+ if err != nil {
+ return nil, err
+ }
+ if tool.IsEnabled != 1 {
+ return nil, gerror.New("工具不存在或已下架")
+ }
+ isMember := EffectiveLevelKey(user) != consts.DefaultLevelKey
+ periodDays := EffectiveQuotaPeriodDays(ctx, user)
+ now := gtime.Now()
+
+ err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
+ usage, err := lockOrInitUsage(ctx, tx, userId, toolKey, periodDays)
+ if err != nil {
+ return err
+ }
+ paid, err := lockPaidBalance(ctx, tx, userId, toolKey)
+ if err != nil {
+ return err
+ }
+ verdict := judgeQuota(tool, usage, paid, isMember, now)
+ switch verdict.Source {
+ case consts.QuotaSourceFree:
+ if _, err = tx.Model(consts.TableQuotaUsage).Where("id", usage.Id).Data(g.Map{
+ "free_used": usage.FreeUsed + 1,
+ "updated_at": now,
+ }).Update(); err != nil {
+ return err
+ }
+ case consts.QuotaSourceMember:
+ if _, err = tx.Model(consts.TableQuotaUsage).Where("id", usage.Id).Data(g.Map{
+ "member_used": usage.MemberUsed + 1,
+ "updated_at": now,
+ }).Update(); err != nil {
+ return err
+ }
+ case consts.QuotaSourcePaid:
+ payCost := tool.PayCost
+ if payCost <= 0 {
+ payCost = 1
+ }
+ if _, err = tx.Model(consts.TableUserToolQuota).Where("id", paid.Id).Data(g.Map{
+ "times_left": paid.TimesLeft - payCost,
+ "updated_at": now,
+ }).Update(); err != nil {
+ return err
+ }
+ default:
+ return gerror.NewCode(gcodeQuotaExhausted(), verdict.Message)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ // 扣减后回读一次真实状态(含周期已滚动的情形)
+ status, err := ToolQuotaStatus(ctx, user, tool)
+ if err != nil {
+ return nil, err
+ }
+ return status, nil
+}
+
+// ===== 状态查询 =====
+
+// ToolQuotaStatus 单个工具的额度状态
+func ToolQuotaStatus(ctx context.Context, user *entity.Users, tool *entity.Tools) (*v1.QuotaToolOut, error) {
+ isMember := EffectiveLevelKey(user) != consts.DefaultLevelKey
+ periodDays := EffectiveQuotaPeriodDays(ctx, user)
+ now := gtime.Now()
+
+ usage := "aUsageState{}
+ record, err := g.Model(consts.TableQuotaUsage).
+ Where("user_id", user.Id).Where("tool_key", tool.ToolKey).One()
+ if err != nil {
+ return nil, err
+ }
+ if !record.IsEmpty() {
+ usage.Id = record["id"].Int64()
+ usage.FreeUsed = record["free_used"].Int()
+ usage.MemberUsed = record["member_used"].Int()
+ usage.PeriodStart = record["period_start"].GTime()
+ usage.PeriodEnd = record["period_end"].GTime()
+ // 只读场景不写库:过期即视为用量归零
+ if usage.PeriodEnd == nil || !now.Before(usage.PeriodEnd) {
+ usage.FreeUsed = 0
+ usage.MemberUsed = 0
+ usage.PeriodStart = now
+ usage.PeriodEnd = now.AddDate(0, 0, periodDays)
+ } else if usage.PeriodStart != nil {
+ expectedEnd := usage.PeriodStart.AddDate(0, 0, periodDays)
+ if expectedEnd.After(now) {
+ usage.PeriodEnd = expectedEnd
+ }
+ }
+ } else {
+ usage.PeriodStart = now
+ usage.PeriodEnd = now.AddDate(0, 0, periodDays)
+ }
+
+ paid := &paidBalance{}
+ paidRecord, err := g.Model(consts.TableUserToolQuota).
+ Where("user_id", user.Id).Where("tool_key", tool.ToolKey).One()
+ if err != nil {
+ return nil, err
+ }
+ if !paidRecord.IsEmpty() {
+ paid.Exists = true
+ paid.Id = paidRecord["id"].Int64()
+ paid.TimesLeft = paidRecord["times_left"].Int()
+ paid.ExpireAt = paidRecord["expire_at"].GTime()
+ }
+
+ return buildQuotaOut(tool, usage, paid, isMember, periodDays, now), nil
+}
+
+// QuotaSnapshotForTools 批量计算多个工具的额度状态(2 次查询搞定,避免 11 个工具 11 次查库)
+func QuotaSnapshotForTools(
+ ctx context.Context, user *entity.Users, tools []*entity.Tools,
+) (map[string]*v1.QuotaToolOut, error) {
+ out := make(map[string]*v1.QuotaToolOut, len(tools))
+ if len(tools) == 0 {
+ return out, nil
+ }
+ isMember := EffectiveLevelKey(user) != consts.DefaultLevelKey
+ periodDays := EffectiveQuotaPeriodDays(ctx, user)
+ now := gtime.Now()
+
+ usageRecords, err := g.Model(consts.TableQuotaUsage).Where("user_id", user.Id).All()
+ if err != nil {
+ return nil, err
+ }
+ usageMap := map[string]*quotaUsageState{}
+ for _, r := range usageRecords {
+ usageMap[r["tool_key"].String()] = "aUsageState{
+ Id: r["id"].Int64(),
+ FreeUsed: r["free_used"].Int(),
+ MemberUsed: r["member_used"].Int(),
+ PeriodStart: r["period_start"].GTime(),
+ PeriodEnd: r["period_end"].GTime(),
+ }
+ }
+
+ paidRecords, err := g.Model(consts.TableUserToolQuota).Where("user_id", user.Id).All()
+ if err != nil {
+ return nil, err
+ }
+ paidMap := map[string]*paidBalance{}
+ for _, r := range paidRecords {
+ paidMap[r["tool_key"].String()] = &paidBalance{
+ Id: r["id"].Int64(),
+ TimesLeft: r["times_left"].Int(),
+ ExpireAt: r["expire_at"].GTime(),
+ Exists: true,
+ }
+ }
+
+ for _, tool := range tools {
+ usage := usageMap[tool.ToolKey]
+ if usage == nil {
+ usage = "aUsageState{}
+ }
+ // 只读路径不写库:过期即按归零展示
+ if usage.PeriodEnd == nil || !now.Before(usage.PeriodEnd) {
+ usage.FreeUsed = 0
+ usage.MemberUsed = 0
+ usage.PeriodStart = now
+ usage.PeriodEnd = now.AddDate(0, 0, periodDays)
+ } else if usage.PeriodStart != nil {
+ if expectedEnd := usage.PeriodStart.AddDate(0, 0, periodDays); expectedEnd.After(now) {
+ usage.PeriodEnd = expectedEnd
+ }
+ }
+ paid := paidMap[tool.ToolKey]
+ if paid == nil {
+ paid = &paidBalance{}
+ }
+ out[tool.ToolKey] = buildQuotaOut(tool, usage, paid, isMember, periodDays, now)
+ }
+ return out, nil
+}
+
+func buildQuotaOut(
+ tool *entity.Tools, usage *quotaUsageState, paid *paidBalance,
+ isMember bool, periodDays int, now *gtime.Time,
+) *v1.QuotaToolOut {
+ payCost := tool.PayCost
+ if payCost <= 0 {
+ payCost = 1
+ }
+ freeLeft := clampMin(tool.FreeQuota-usage.FreeUsed, 0)
+ memberLeft := 0
+ if isMember {
+ memberLeft = clampMin(tool.MemberQuota-usage.MemberUsed, 0)
+ }
+ paidLeft := 0
+ paidExpireAt := ""
+ if paid != nil && paid.Exists && !paidExpired(paid, now) {
+ paidLeft = clampMax(paid.TimesLeft, 0)
+ if paid.ExpireAt != nil && !paid.ExpireAt.IsZero() {
+ paidExpireAt = paid.ExpireAt.Format("Y-m-d")
+ }
+ }
+
+ verdict := judgeQuota(tool, usage, paid, isMember, now)
+ totalLeft := freeLeft + memberLeft + paidLeft/payCost
+
+ out := &v1.QuotaToolOut{
+ ToolKey: tool.ToolKey,
+ Name: tool.Name,
+ Icon: tool.Icon,
+ FreeTotal: tool.FreeQuota,
+ FreeUsed: usage.FreeUsed,
+ FreeLeft: freeLeft,
+ MemberTotal: tool.MemberQuota,
+ MemberUsed: usage.MemberUsed,
+ MemberLeft: memberLeft,
+ PaidLeft: paidLeft,
+ PaidExpireAt: paidExpireAt,
+ PayCost: payCost,
+ IsMember: isMember,
+ PeriodDays: periodDays,
+ Source: verdict.Source,
+ CanUse: verdict.Source != consts.QuotaSourceNone,
+ TotalLeft: totalLeft,
+ }
+ if usage.PeriodEnd != nil && !usage.PeriodEnd.IsZero() {
+ out.PeriodEnd = usage.PeriodEnd.Format("Y-m-d H:i:s")
+ }
+ if verdict.Source == consts.QuotaSourceNone {
+ out.Message = verdict.Message
+ }
+ return out
+}
+
+func clampMin(v, min int) int {
+ if v < min {
+ return min
+ }
+ return v
+}
+
+func clampMax(v, max int) int {
+ if v > max {
+ return max
+ }
+ return v
+}
+
+func gcodeQuotaExhausted() gcode.Code {
+ return gcode.New(consts.CodeQuotaExhausted, "", nil)
+}
diff --git a/internal/logic/quota_api.go b/internal/logic/quota_api.go
new file mode 100644
index 0000000..37b8e66
--- /dev/null
+++ b/internal/logic/quota_api.go
@@ -0,0 +1,248 @@
+package logic
+
+import (
+ "context"
+
+ "github.com/gogf/gf/v2/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
+
+ v1 "tool-api/api/user/v1"
+ "tool-api/internal/consts"
+ "tool-api/internal/model/entity"
+)
+
+// ============================================================================
+// 额度相关的对外接口实现
+// ============================================================================
+
+// QuotaMy 我的额度总览:周期、会员状态、每个工具的额度明细、通用次数包档位
+func QuotaMy(ctx context.Context) (*v1.QuotaMyRes, error) {
+ user, err := getUserById(ctx, CtxUserId(ctx))
+ if err != nil {
+ return nil, err
+ }
+ tools, err := quotaListForUser(ctx, user)
+ if err != nil {
+ return nil, err
+ }
+ packs, err := listQuotaPacks(ctx, "")
+ if err != nil {
+ return nil, err
+ }
+ level, err := EffectiveLevelInfo(ctx, user)
+ if err != nil {
+ return nil, err
+ }
+
+ totalLeft := 0
+ for i := range tools {
+ totalLeft += tools[i].TotalLeft
+ }
+ res := &v1.QuotaMyRes{
+ PeriodDays: EffectiveQuotaPeriodDays(ctx, user),
+ IsMember: EffectiveLevelKey(user) != consts.DefaultLevelKey,
+ LevelKey: EffectiveLevelKey(user),
+ Tools: tools,
+ TotalLeft: totalLeft,
+ Packs: packs,
+ }
+ if level != nil {
+ res.LevelName = level.Name
+ }
+ if user.LevelExpireAt != nil && !user.LevelExpireAt.IsZero() {
+ res.LevelExpireAt = user.LevelExpireAt.Format("Y-m-d")
+ }
+ return res, nil
+}
+
+// QuotaToolOf 单个工具的额度状态(进入工具页时调用)
+func QuotaToolOf(ctx context.Context, toolKey string) (*v1.QuotaToolRes, error) {
+ user, err := getUserById(ctx, CtxUserId(ctx))
+ if err != nil {
+ return nil, err
+ }
+ tool, err := toolRow(ctx, toolKey)
+ if err != nil {
+ return nil, err
+ }
+ status, err := ToolQuotaStatus(ctx, user, tool)
+ if err != nil {
+ return nil, err
+ }
+ return &v1.QuotaToolRes{Tool: status}, nil
+}
+
+// QuotaConsume 扣减一次额度(产出结果时调用)
+func QuotaConsume(ctx context.Context, toolKey string) (*v1.QuotaConsumeRes, error) {
+ status, err := ConsumeToolQuota(ctx, CtxUserId(ctx), toolKey)
+ if err != nil {
+ return nil, err
+ }
+ return &v1.QuotaConsumeRes{Tool: status}, nil
+}
+
+// QuotaPacksFor 某工具可购买的次数包(通用档位 + 该工具专属档位)
+func QuotaPacksFor(ctx context.Context, toolKey string) (*v1.QuotaPacksRes, error) {
+ list, err := listQuotaPacks(ctx, toolKey)
+ if err != nil {
+ return nil, err
+ }
+ return &v1.QuotaPacksRes{List: list}, nil
+}
+
+// QuotaOrderCreate 创建次数包订单;与会员订单共用下单/发货链路
+func QuotaOrderCreate(ctx context.Context, packKey, toolKey, code string) (*v1.QuotaOrderCreateRes, error) {
+ userId := CtxUserId(ctx)
+ if !xpayConfig(ctx).Configured() {
+ return nil, gerror.NewCode(gcodePayFail(),
+ "虚拟支付尚未配置,请在 MP 后台【虚拟支付 → 基本配置】获取 OfferID 与现网 AppKey 后填入 config.yaml")
+ }
+
+ pack, err := quotaPackByKey(ctx, packKey)
+ if err != nil {
+ return nil, err
+ }
+ if pack == nil {
+ return nil, gerror.NewCode(gcodePayFail(), "次数包不存在或已下架")
+ }
+ // 专属档位只能卖给它绑定的工具,避免把 A 工具的专属价套到 B 工具上
+ if pack.ToolKey != "" && pack.ToolKey != toolKey {
+ return nil, gerror.NewCode(gcodePayFail(), "该次数包仅适用于指定工具")
+ }
+ if pack.ProductId == "" || pack.PriceCents <= 0 {
+ return nil, gerror.NewCode(gcodePayFail(), "次数包未配置微信道具 ID 或价格,请先在后台核对")
+ }
+ tool, err := toolRow(ctx, toolKey)
+ if err != nil {
+ return nil, err
+ }
+ if tool.IsEnabled != 1 {
+ return nil, gerror.NewCode(gcodePayFail(), "工具不存在或已下架")
+ }
+ if pack.Times <= 0 {
+ return nil, gerror.NewCode(gcodePayFail(), "次数包次数配置异常")
+ }
+
+ validDays := pack.ValidDays
+ if validDays <= 0 {
+ validDays = 365
+ }
+
+ pay, err := prepareXpayOrder(ctx, userId, code, pack.ProductId, pack.PriceCents, g.Map{
+ "order_type": consts.OrderTypeQuota,
+ "pack_key": pack.PackKey,
+ "tool_key": tool.ToolKey,
+ "times": pack.Times,
+ "duration_days": validDays, // 次数包复用 duration_days 承载「有效期天数」
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &v1.QuotaOrderCreateRes{
+ OutTradeNo: pay.OutTradeNo,
+ Mode: consts.XPayMode,
+ SignData: pay.SignData,
+ PaySig: pay.PaySig,
+ Signature: pay.Signature,
+ Env: pay.Env,
+ PackKey: pack.PackKey,
+ ToolKey: tool.ToolKey,
+ Times: pack.Times,
+ ValidDays: validDays,
+ }, nil
+}
+
+// ===== 内部辅助 =====
+
+// quotaListForUser 用户可见(已启用)的全部工具的额度状态
+func quotaListForUser(ctx context.Context, user *entity.Users) ([]v1.QuotaToolOut, error) {
+ records, err := g.Model(consts.TableTools).Where("is_enabled", 1).Order("sort asc").All()
+ if err != nil {
+ return nil, err
+ }
+ tools := make([]*entity.Tools, 0, len(records))
+ for _, r := range records {
+ tool := &entity.Tools{}
+ if err = r.Struct(tool); err != nil {
+ continue
+ }
+ tools = append(tools, tool)
+ }
+ snapshot, err := QuotaSnapshotForTools(ctx, user, tools)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]v1.QuotaToolOut, 0, len(tools))
+ for _, tool := range tools {
+ if out, ok := snapshot[tool.ToolKey]; ok {
+ list = append(list, *out)
+ }
+ }
+ return list, nil
+}
+
+func quotaPackByKey(ctx context.Context, packKey string) (*entity.QuotaPacks, error) {
+ record, err := g.Model(consts.TableQuotaPacks).
+ Where("pack_key", packKey).Where("is_enabled", 1).One()
+ if err != nil {
+ return nil, err
+ }
+ if record.IsEmpty() {
+ return nil, nil
+ }
+ pack := &entity.QuotaPacks{}
+ if err = record.Struct(pack); err != nil {
+ return nil, err
+ }
+ return pack, nil
+}
+
+// listQuotaPacks 可用次数包档位;toolKey 非空时返回「通用档位 + 该工具专属档位」
+func listQuotaPacks(ctx context.Context, toolKey string) ([]v1.QuotaPackOut, error) {
+ m := g.Model(consts.TableQuotaPacks).Where("is_enabled", 1)
+ if toolKey == "" {
+ m = m.Where("tool_key", "")
+ } else {
+ m = m.Where("tool_key = ? OR tool_key = ?", "", toolKey)
+ }
+ records, err := m.OrderAsc("times").All()
+ if err != nil {
+ return nil, err
+ }
+ list := make([]v1.QuotaPackOut, 0, len(records))
+ for _, r := range records {
+ times := r["times"].Int()
+ price := r["price_cents"].Int64()
+ unit := int64(0)
+ if times > 0 {
+ unit = price * 100 / int64(times) // 每 100 次均价(分),服务端算好避免前端浮点误差
+ }
+ validDays := r["valid_days"].Int()
+ if validDays <= 0 {
+ validDays = 365
+ }
+ list = append(list, v1.QuotaPackOut{
+ PackKey: r["pack_key"].String(),
+ Name: r["name"].String(),
+ Times: times,
+ PriceCents: price,
+ ValidDays: validDays,
+ Badge: r["badge"].String(),
+ ToolKey: r["tool_key"].String(),
+ UnitPriceCents: unit,
+ })
+ }
+ return list, nil
+}
+
+func quotaPackNameMap(ctx context.Context) (map[string]string, error) {
+ records, err := g.Model(consts.TableQuotaPacks).All()
+ if err != nil {
+ return nil, err
+ }
+ m := map[string]string{}
+ for _, r := range records {
+ m[r["pack_key"].String()] = r["name"].String()
+ }
+ return m, nil
+}
diff --git a/internal/logic/seed.go b/internal/logic/seed.go
index 165cad8..abf8cd5 100644
--- a/internal/logic/seed.go
+++ b/internal/logic/seed.go
@@ -4,6 +4,7 @@ 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"
@@ -24,6 +25,31 @@ func Seed(ctx context.Context) {
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 {
@@ -65,6 +91,39 @@ func seedRows(ctx context.Context, table, uniqueKey string, rows []g.Map) error
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},
@@ -83,6 +142,13 @@ func seedLevels() []g.Map {
}
}
+// 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},
@@ -98,3 +164,49 @@ func seedTools() []g.Map {
{"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"},
+ }
+}
diff --git a/internal/logic/tools.go b/internal/logic/tools.go
index 886fd1b..a5fe0f2 100644
--- a/internal/logic/tools.go
+++ b/internal/logic/tools.go
@@ -11,6 +11,7 @@ import (
v1 "tool-api/api/user/v1"
"tool-api/internal/consts"
+ "tool-api/internal/model/entity"
)
func gcodeUnauthorized() gcode.Code {
@@ -27,7 +28,7 @@ func ToolsList(ctx context.Context) (*v1.ToolsListRes, error) {
if err != nil {
return nil, err
}
- level, err := GetLevelByKey(ctx, user.LevelKey)
+ level, err := EffectiveLevelInfo(ctx, user)
if err != nil {
return nil, err
}
@@ -41,19 +42,36 @@ func ToolsList(ctx context.Context) (*v1.ToolsListRes, error) {
return nil, err
}
+ // 额度快照:一次算完所有工具,避免前端再为每个工具单独请求
+ toolEntities := make([]*entity.Tools, 0, len(toolRecords))
+ for _, r := range toolRecords {
+ tool := &entity.Tools{}
+ if err = r.Struct(tool); err == nil {
+ toolEntities = append(toolEntities, tool)
+ }
+ }
+ quotaSnapshot, err := QuotaSnapshotForTools(ctx, user, toolEntities)
+ if err != nil {
+ // 额度查询失败不该拖垮工具目录:降级为不返回额度
+ g.Log().Warningf(ctx, "[quota] 工具额度快照失败: %v", err)
+ quotaSnapshot = map[string]*v1.QuotaToolOut{}
+ }
+
res := &v1.ToolsListRes{Modules: []v1.ModuleOut{}, List: []v1.ToolOut{}}
countByModule := map[string]int{}
for _, t := range toolRecords {
moduleKey := t["module_key"].String()
countByModule[moduleKey]++
+ toolKey := t["tool_key"].String()
res.List = append(res.List, v1.ToolOut{
- ToolKey: t["tool_key"].String(),
+ ToolKey: toolKey,
ModuleKey: moduleKey,
Name: t["name"].String(),
Icon: t["icon"].String(),
Description: t["description"].String(),
IsHot: t["is_hot"].Int() == 1,
Locked: !slicesContain(level.Modules, moduleKey),
+ Quota: quotaSnapshot[toolKey],
})
}
for _, m := range modRecords {
@@ -78,13 +96,16 @@ func slicesContain(list []string, s string) bool {
return false
}
-// ToolsUsed 工具使用上报(锁定/下架时拒绝)
+// ToolsUsed 工具使用上报(打开工具页时调用,锁定/下架时拒绝)。
+//
+// ⚠️ 这里只记统计,**不扣额度**。额度扣减走 POST /quota/consume,
+// 时机是「产出结果」(保存/导出成功)而不是打开页面 —— 用户看了看没产出就不该扣。
func ToolsUsed(ctx context.Context, toolKey string) (*v1.ToolsUsedRes, error) {
user, err := getUserById(ctx, CtxUserId(ctx))
if err != nil {
return nil, err
}
- level, err := GetLevelByKey(ctx, user.LevelKey)
+ level, err := EffectiveLevelInfo(ctx, user)
if err != nil {
return nil, err
}
@@ -128,7 +149,7 @@ func WorkbenchGet(ctx context.Context) (*v1.WorkbenchGetRes, error) {
return &v1.WorkbenchGetRes{ToolKeys: keys}, nil
}
// 首次进入:预置已授权模块下的热推工具
- level, err := GetLevelByKey(ctx, user.LevelKey)
+ level, err := EffectiveLevelInfo(ctx, user)
if err != nil {
return nil, err
}
@@ -151,7 +172,7 @@ func WorkbenchSave(ctx context.Context, toolKeys []string) (*v1.WorkbenchSaveRes
if err != nil {
return nil, err
}
- level, err := GetLevelByKey(ctx, user.LevelKey)
+ level, err := EffectiveLevelInfo(ctx, user)
if err != nil {
return nil, err
}
diff --git a/internal/logic/user.go b/internal/logic/user.go
index b70444e..b9ab9f6 100644
--- a/internal/logic/user.go
+++ b/internal/logic/user.go
@@ -19,11 +19,11 @@ import (
// WxLogin 微信登录(code 换 openid)
func WxLogin(ctx context.Context, code string) (*v1.WxLoginRes, error) {
- openid, err := resolveOpenid(ctx, code)
+ session, err := code2Session(ctx, code)
if err != nil {
return nil, err
}
- return loginByOpenid(ctx, openid)
+ return loginByOpenid(ctx, session.Openid, session.SessionKey)
}
// DevLogin 开发联调登录(仅 debug 配置开放)
@@ -31,16 +31,16 @@ func DevLogin(ctx context.Context) (*v1.WxLoginRes, error) {
if !IsDebug(ctx) {
return nil, gerror.New("当前环境未开启 debug,禁止开发登录")
}
- return loginByOpenid(ctx, "dev-user")
+ return loginByOpenid(ctx, "dev-user", "")
}
-// UserLevel 我的等级与已授权模块
+// UserLevel 我的等级与已授权模块(已过期回落默认等级)
func UserLevel(ctx context.Context) (*v1.LevelRes, error) {
user, err := getUserById(ctx, CtxUserId(ctx))
if err != nil {
return nil, err
}
- return GetLevelByKey(ctx, user.LevelKey)
+ return EffectiveLevelInfo(ctx, user)
}
// GetUserInfo 当前登录用户信息
@@ -52,15 +52,25 @@ func GetUserInfo(ctx context.Context) (*v1.UserInfo, error) {
return &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: user.AvatarUrl}, nil
}
-func resolveOpenid(ctx context.Context, code string) (string, error) {
+// wxSession jscode2session 返回:openid + session_key
+type wxSession struct {
+ Openid string `json:"openid"`
+ SessionKey string `json:"session_key"`
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+}
+
+// code2Session code 换 openid + session_key。
+// session_key 是虚拟支付「用户态签名 signature」的密钥,必须与 openid 一起取回。
+func code2Session(ctx context.Context, code string) (*wxSession, error) {
// 联调快捷通道:debug 模式下 code 传 "dev"
if code == "dev" && IsDebug(ctx) {
- return "dev-user", nil
+ return &wxSession{Openid: "dev-user"}, nil
}
appId := g.Cfg().MustGet(ctx, "wx.appId").String()
secret := g.Cfg().MustGet(ctx, "wx.appSecret").String()
if appId == "" || secret == "" {
- return "", gerror.New("服务端未配置微信 appId/appSecret,请填写 manifest/config/config.yaml 后重启")
+ return nil, gerror.New("服务端未配置微信 appId/appSecret,请填写 manifest/config/config.yaml 后重启")
}
url := fmt.Sprintf(
"https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
@@ -68,32 +78,65 @@ func resolveOpenid(ctx context.Context, code string) (string, error) {
)
resp, err := g.Client().Get(ctx, url)
if err != nil {
- return "", gerror.Newf("请求微信接口失败: %v", err)
+ return nil, gerror.Newf("请求微信接口失败: %v", err)
}
defer resp.Close()
- var out struct {
- Openid string `json:"openid"`
- ErrCode int `json:"errcode"`
- ErrMsg string `json:"errmsg"`
- }
- if err = json.Unmarshal(resp.ReadAll(), &out); err != nil {
- return "", gerror.Newf("解析微信响应失败: %v", err)
+ out := &wxSession{}
+ if err = json.Unmarshal(resp.ReadAll(), out); err != nil {
+ return nil, gerror.Newf("解析微信响应失败: %v", err)
}
if out.ErrCode != 0 || out.Openid == "" {
- return "", gerror.Newf("微信登录失败: %d %s", out.ErrCode, out.ErrMsg)
+ return nil, gerror.Newf("微信登录失败: %d %s", out.ErrCode, out.ErrMsg)
}
- return out.Openid, nil
+ return out, nil
}
-func loginByOpenid(ctx context.Context, openid string) (*v1.WxLoginRes, error) {
+func resolveOpenid(ctx context.Context, code string) (string, error) {
+ session, err := code2Session(ctx, code)
+ if err != nil {
+ return "", err
+ }
+ return session.Openid, nil
+}
+
+// RefreshSessionKey 换新 session_key 并落库(虚拟支付下单前调用)。
+// 返回该 code 对应的 openid,供调用方校验「支付人 = 登录人」。
+func RefreshSessionKey(ctx context.Context, userId int64, code string) (string, error) {
+ if code == "" {
+ // 未传 code:复用已落库的 session_key(可能已过期,支付时由平台判失败)
+ user, err := getUserById(ctx, userId)
+ if err != nil {
+ return "", err
+ }
+ return user.Openid, nil
+ }
+ session, err := code2Session(ctx, code)
+ if err != nil {
+ return "", err
+ }
+ if _, err = g.Model(consts.TableUsers).Where("id", userId).Data(g.Map{
+ "session_key": session.SessionKey,
+ }).Update(); err != nil {
+ return "", err
+ }
+ return session.Openid, nil
+}
+
+func loginByOpenid(ctx context.Context, openid, sessionKey string) (*v1.WxLoginRes, error) {
// 原子 upsert(原生 SQL):小程序启动时 onLaunch 与页面 onShow 会并发各发一次登录,
// 冲突时仅更新登录时间。不依赖 ORM 的 OnDuplicate 拼接,行为跨版本稳定。
+ // session_key 为 NULL 时保留原值,避免空登录把可用于签名的 key 覆盖掉。
now := gtime.Now()
+ var sessionKeyArg interface{}
+ if sessionKey != "" {
+ sessionKeyArg = sessionKey
+ }
if _, err := g.DB().Exec(ctx,
- "INSERT INTO `users`(`openid`, `nickname`, `level_key`, `status`, `created_at`, `last_login_at`, `updated_at`) "+
- "VALUES(?, ?, ?, ?, ?, ?, ?) "+
- "ON DUPLICATE KEY UPDATE `last_login_at` = VALUES(`last_login_at`)",
- openid, "微信用户", "v1", 1, now, now, now,
+ "INSERT INTO `users`(`openid`, `session_key`, `nickname`, `level_key`, `status`, `created_at`, `last_login_at`, `updated_at`) "+
+ "VALUES(?, ?, ?, ?, ?, ?, ?, ?) "+
+ "ON DUPLICATE KEY UPDATE `last_login_at` = VALUES(`last_login_at`), "+
+ "`session_key` = IF(VALUES(`session_key`) IS NULL, `session_key`, VALUES(`session_key`))",
+ openid, sessionKeyArg, "微信用户", consts.DefaultLevelKey, 1, now, now, now,
); err != nil {
return nil, err
}
@@ -140,7 +183,7 @@ func Register(ctx context.Context, username, password, nickname string) (*v1.WxL
"username": username,
"password_hash": string(hash),
"nickname": nickname,
- "level_key": "v1",
+ "level_key": consts.DefaultLevelKey,
"status": 1,
"created_at": now,
"last_login_at": now,
@@ -287,11 +330,13 @@ func saveWorkbenchRaw(ctx context.Context, userId int64, keys []string) error {
// loginResult 统一组装登录返回(token + 用户 + 等级)
func loginResult(ctx context.Context, user *entity.Users) (*v1.WxLoginRes, error) {
- level, err := GetLevelByKey(ctx, user.LevelKey)
+ // 等级取「实际生效」值:会员过期后 token 里不能残留高等级
+ effectiveKey := EffectiveLevelKey(user)
+ level, err := GetLevelByKey(ctx, effectiveKey)
if err != nil {
return nil, err
}
- token, err := IssueToken(ctx, consts.AudUser, user.Id, map[string]string{"levelKey": user.LevelKey})
+ token, err := IssueToken(ctx, consts.AudUser, user.Id, map[string]string{"levelKey": effectiveKey})
if err != nil {
return nil, err
}
diff --git a/internal/logic/xpay.go b/internal/logic/xpay.go
new file mode 100644
index 0000000..0d0c9af
--- /dev/null
+++ b/internal/logic/xpay.go
@@ -0,0 +1,302 @@
+package logic
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "encoding/xml"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/gogf/gf/v2/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
+
+ "tool-api/internal/consts"
+)
+
+// ============================================================================
+// 微信小程序虚拟支付(道具直购)对接层
+//
+// 依据:https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/
+// business-capabilities/virtual-payment/person
+//
+// 两套签名:
+// paySig —— 服务端用「现网 AppKey」对 `uri&post_body` 做 HMAC-SHA256
+// C 端 uri 固定 requestVirtualPayment;B 端为接口路径 /xpay/xxx
+// signature —— 服务端用「session_key」对 signData 做 HMAC-SHA256(用户态签名)
+//
+// 关键约束:
+// - post_body 必须与实际发出的请求体「完全一致」(不格式化、不改键顺序)
+// - 金额单位是「分」,全程不换算
+// - env 固定 0(现网)
+// ============================================================================
+
+const (
+ xpayAPIBase = "https://api.weixin.qq.com"
+ xpayURIPayRequest = "requestVirtualPayment" // C 端下单 uri(不带问号参数)
+ xpayURIQueryOrder = "/xpay/query_order" // B 端查单 uri
+)
+
+// xpaySignData C 端 signData 结构。
+// 字段顺序即 JSON 序列化顺序,必须与签名时的字符串完全一致,切勿随意调整。
+type xpaySignData struct {
+ OfferId string `json:"offerId"`
+ BuyQuantity int `json:"buyQuantity"`
+ Env int `json:"env"`
+ CurrencyType string `json:"currencyType"`
+ ProductId string `json:"productId"`
+ GoodsPrice int64 `json:"goodsPrice"` // 单位:分
+ OutTradeNo string `json:"outTradeNo"`
+ Attach string `json:"attach"`
+}
+
+// XPayConfig 虚拟支付配置(manifest/config/config.yaml 的 wx.pay 段)
+type XPayConfig struct {
+ AppId string
+ OfferId string
+ AppKey string
+ Secret string // 微信 appSecret,取 access_token 用
+ Env int
+}
+
+// Configured 关键参数是否齐备。缺失时下单会直接报错,避免生成无法支付的签名。
+func (c *XPayConfig) Configured() bool {
+ return c.OfferId != "" && c.AppKey != ""
+}
+
+func xpayConfig(ctx context.Context) *XPayConfig {
+ appId := g.Cfg().MustGet(ctx, "wx.pay.appId", "").String()
+ if appId == "" {
+ appId = g.Cfg().MustGet(ctx, "wx.appId", "").String()
+ }
+ return &XPayConfig{
+ AppId: appId,
+ OfferId: g.Cfg().MustGet(ctx, "wx.pay.offerId", "").String(),
+ AppKey: g.Cfg().MustGet(ctx, "wx.pay.appKey", "").String(),
+ Secret: g.Cfg().MustGet(ctx, "wx.appSecret", "").String(),
+ Env: g.Cfg().MustGet(ctx, "wx.pay.env", consts.XPayEnvProd).Int(),
+ }
+}
+
+// ===== 签名 =====
+
+// CalcPaySig 支付签名:HMAC-SHA256(appKey, uri + "&" + postBody)
+func CalcPaySig(uri, postBody, appKey string) string {
+ mac := hmac.New(sha256.New, []byte(appKey))
+ mac.Write([]byte(uri + "&" + postBody))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+// CalcSignature 用户态签名:HMAC-SHA256(sessionKey, postBody)
+func CalcSignature(postBody, sessionKey string) string {
+ mac := hmac.New(sha256.New, []byte(sessionKey))
+ mac.Write([]byte(postBody))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+// BuildSignData 生成 signData 的 JSON 字符串(返回串必须原样用于签名与下发)
+func BuildSignData(offerId, productId string, goodsPrice int64, outTradeNo, attach string, env int) (string, error) {
+ data := xpaySignData{
+ OfferId: offerId,
+ BuyQuantity: 1, // 会员一次买一份
+ Env: env,
+ CurrencyType: consts.XPayCurrency,
+ ProductId: productId,
+ GoodsPrice: goodsPrice,
+ OutTradeNo: outTradeNo,
+ Attach: attach,
+ }
+ body, err := json.Marshal(data)
+ if err != nil {
+ return "", gerror.Newf("组装 signData 失败: %v", err)
+ }
+ return string(body), nil
+}
+
+// ===== access_token(B 端接口需要)=====
+
+var (
+ accessTokenMu sync.Mutex
+ accessTokenCache string
+ accessTokenExpire time.Time
+)
+
+// wxAccessToken 获取并缓存 access_token(stable_token 接口,有效期约 2 小时)
+func wxAccessToken(ctx context.Context) (string, error) {
+ cfg := xpayConfig(ctx)
+ if cfg.AppId == "" || cfg.Secret == "" {
+ return "", gerror.New("未配置 wx.appId / wx.appSecret,无法获取 access_token")
+ }
+ accessTokenMu.Lock()
+ defer accessTokenMu.Unlock()
+ if accessTokenCache != "" && time.Now().Before(accessTokenExpire) {
+ return accessTokenCache, nil
+ }
+ resp, err := g.Client().Post(ctx, xpayAPIBase+"/cgi-bin/stable_token", g.Map{
+ "grant_type": "client_credential",
+ "appid": cfg.AppId,
+ "secret": cfg.Secret,
+ "force_refresh": false,
+ })
+ if err != nil {
+ return "", gerror.Newf("请求 access_token 失败: %v", err)
+ }
+ defer resp.Close()
+ var out struct {
+ AccessToken string `json:"access_token"`
+ ExpiresIn int `json:"expires_in"`
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+ }
+ if err = json.Unmarshal(resp.ReadAll(), &out); err != nil {
+ return "", gerror.Newf("解析 access_token 响应失败: %v", err)
+ }
+ if out.ErrCode != 0 || out.AccessToken == "" {
+ return "", gerror.Newf("获取 access_token 失败: %d %s", out.ErrCode, out.ErrMsg)
+ }
+ accessTokenCache = out.AccessToken
+ // 提前 5 分钟过期,留出刷新余量
+ ttl := out.ExpiresIn - 300
+ if ttl < 60 {
+ ttl = 60
+ }
+ accessTokenExpire = time.Now().Add(time.Duration(ttl) * time.Second)
+ return accessTokenCache, nil
+}
+
+// ===== B 端:查询订单(兜底发货)=====
+
+// xpayOrderState 查单结果(只保留发货判定需要的字段)
+type xpayOrderState struct {
+ Paid bool // 是否已支付
+ WxOrderId string // 平台单号(若响应中可得)
+ RawJson string // 原始响应,排障用
+}
+
+// 查单响应体。字段名以官方文档为准;未在文档中明确的字段(wxOrderId 等)
+// 采用「多别名兼容 + 缺失不致命」策略,避免因平台临时调整字段名而误判。
+type xpayQueryOrderResp struct {
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+ Order struct {
+ OrderId string `json:"orderId"`
+ WxOrderId string `json:"wxOrderId"`
+ WxOrderId2 string `json:"wx_order_id"`
+ Token string `json:"token"`
+ OrderFee int64 `json:"orderFee"`
+ PaidFee int64 `json:"paidFee"`
+ PaidTime int64 `json:"paidTime"`
+ Status int `json:"status"`
+ OrderType int `json:"orderType"`
+ } `json:"order"`
+}
+
+// XPayQueryOrder 主动查单:推送丢失时的兜底路径。
+//
+// 已支付判定依据是 `paidTime > 0 || paidFee > 0`,而不是 order.status —— 原因是
+// status 的枚举在官方文档中未完整给出(社区可见 3 表示已发货,orderType 还新增了
+// iOS 的 7/8),依赖枚举值有误判风险;而「有支付时间/实付金额」在任何渠道下都成立。
+func XPayQueryOrder(ctx context.Context, openid, outTradeNo string) (*xpayOrderState, error) {
+ cfg := xpayConfig(ctx)
+ if !cfg.Configured() {
+ return nil, gerror.New("虚拟支付未配置(缺少 wx.pay.offerId / wx.pay.appKey)")
+ }
+ token, err := wxAccessToken(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // 请求体字符串必须与实际发出的完全一致 —— 先序列化,再拿同一串去签名
+ body := g.Map{
+ "openid": openid,
+ "env": cfg.Env,
+ "order_id": outTradeNo,
+ }
+ bodyBytes, err := json.Marshal(body)
+ if err != nil {
+ return nil, gerror.Newf("组装查单请求体失败: %v", err)
+ }
+ postBody := string(bodyBytes)
+ paySig := CalcPaySig(xpayURIQueryOrder, postBody, cfg.AppKey)
+
+ url := fmt.Sprintf("%s%s?access_token=%s&pay_sig=%s", xpayAPIBase, xpayURIQueryOrder, token, paySig)
+ resp, err := g.Client().ContentJson().Post(ctx, url, postBody)
+ if err != nil {
+ return nil, gerror.Newf("调用 query_order 失败: %v", err)
+ }
+ defer resp.Close()
+ raw := string(resp.ReadAll())
+
+ out := &xpayQueryOrderResp{}
+ if err = json.Unmarshal([]byte(raw), out); err != nil {
+ return &xpayOrderState{RawJson: raw}, gerror.Newf("解析 query_order 响应失败: %v", err)
+ }
+ if out.ErrCode != 0 {
+ return &xpayOrderState{RawJson: raw}, gerror.Newf("query_order 返回错误: %d %s", out.ErrCode, out.ErrMsg)
+ }
+ wxOrderId := out.Order.WxOrderId
+ if wxOrderId == "" {
+ wxOrderId = out.Order.WxOrderId2
+ }
+ if wxOrderId == "" {
+ wxOrderId = out.Order.Token
+ }
+ state := &xpayOrderState{
+ Paid: out.Order.PaidTime > 0 || out.Order.PaidFee > 0,
+ WxOrderId: wxOrderId,
+ RawJson: raw,
+ }
+ g.Log().Debugf(ctx, "[xpay] query_order outTradeNo=%s paid=%v wxOrderId=%s raw=%s",
+ outTradeNo, state.Paid, state.WxOrderId, raw)
+ return state, nil
+}
+
+// ===== 发货推送解析 =====
+
+// XPayDeliverNotify 道具发货推送(XML)解析结构
+type XPayDeliverNotify struct {
+ XMLName xml.Name `xml:"xml"`
+ ToUserName string `xml:"ToUserName"`
+ FromUserName string `xml:"FromUserName"`
+ CreateTime int64 `xml:"CreateTime"`
+ MsgType string `xml:"MsgType"`
+ Event string `xml:"Event"`
+ OpenId string `xml:"OpenId"`
+ OutTradeNo string `xml:"OutTradeNo"`
+ Env int `xml:"Env"`
+ WeChatPayInfo struct {
+ MchOrderNo string `xml:"MchOrderNo"`
+ } `xml:"WeChatPayInfo"`
+ GoodsInfo struct {
+ ProductId string `xml:"ProductId"`
+ Quantity int `xml:"Quantity"`
+ } `xml:"GoodsInfo"`
+}
+
+// ParseDeliverNotify 解析发货推送 XML
+func ParseDeliverNotify(raw []byte) (*XPayDeliverNotify, error) {
+ notify := &XPayDeliverNotify{}
+ if err := xml.Unmarshal(raw, notify); err != nil {
+ return nil, gerror.Newf("解析发货推送 XML 失败: %v", err)
+ }
+ if notify.Event != consts.XPayNotifyEvent {
+ return nil, gerror.Newf("非发货推送事件: %s", notify.Event)
+ }
+ if notify.OutTradeNo == "" {
+ return nil, gerror.New("发货推送缺少 OutTradeNo")
+ }
+ return notify, nil
+}
+
+// XPayNotifyAck 发货推送成功应答(平台要求返回 0,否则最多重试 15 次)
+func XPayNotifyAck() string {
+ return "0"
+}
+
+// XPayNotifyAckFail 发货推送失败应答(触发平台重试)
+func XPayNotifyAckFail(msg string) string {
+ return fmt.Sprintf("-1", msg)
+}
diff --git a/internal/model/entity/entity_member_orders.go b/internal/model/entity/entity_member_orders.go
new file mode 100644
index 0000000..bea6a85
--- /dev/null
+++ b/internal/model/entity/entity_member_orders.go
@@ -0,0 +1,33 @@
+package entity
+
+import "github.com/gogf/gf/v2/os/gtime"
+
+// MemberOrders 订单(一笔微信虚拟支付道具直购)。
+// 发货、对账一律以 WxOrderId(平台单号)为准,OutTradeNo 只是商家侧业务单号。
+//
+// 同一张表承载两类商品,靠 OrderType 区分:
+// - OrderTypeMember(1):会员套餐。发货 = 延长 users.level_expire_at(用到 LevelKey/DurationDays)
+// - OrderTypeQuota(2) :次数包。 发货 = 增加 user_tool_quota 余额(用到 ToolKey/Times/DurationDays 作为有效期天数)
+type MemberOrders struct {
+ Id int64 `json:"id"`
+ OutTradeNo string `json:"out_trade_no"` // 业务单号,唯一,8-32 位且不能以下划线开头
+ WxOrderId string `json:"wx_order_id"` // 平台单号,发货幂等去重以此为准
+ UserId int64 `json:"user_id"`
+ Openid string `json:"openid"` // 支付时使用的 openid,查单需要
+ OrderType int `json:"order_type"`
+ PlanKey string `json:"plan_key"`
+ PackKey string `json:"pack_key"` // 次数包档位(OrderType=2)
+ ToolKey string `json:"tool_key"` // 次数包绑定的工具(OrderType=2)
+ Times int `json:"times"` // 次数包的次数(OrderType=2)
+ ProductId string `json:"product_id"`
+ PriceCents int64 `json:"price_cents"`
+ LevelKey string `json:"level_key"` // 下单时套餐对应的等级(快照)
+ DurationDays int `json:"duration_days"` // 下单时套餐天数 / 次数包有效期天数(快照)
+ Status int `json:"status"`
+ PayChannel string `json:"pay_channel"`
+ Attach string `json:"attach"` // 透传数据,发货时原样回传
+ PaidAt *gtime.Time `json:"paid_at"`
+ DeliveredAt *gtime.Time `json:"delivered_at"`
+ CreatedAt *gtime.Time `json:"created_at"`
+ UpdatedAt *gtime.Time `json:"updated_at"`
+}
diff --git a/internal/model/entity/entity_member_plans.go b/internal/model/entity/entity_member_plans.go
new file mode 100644
index 0000000..739332c
--- /dev/null
+++ b/internal/model/entity/entity_member_plans.go
@@ -0,0 +1,26 @@
+package entity
+
+import "github.com/gogf/gf/v2/os/gtime"
+
+// MemberPlans 会员套餐:一个套餐 = 一次「道具直购」+ 对应等级 + 有效天数。
+// PriceCents / ProductId 必须与 MP 后台【虚拟支付 → 道具管理】里的道具严格一致,
+// 否则 wx.requestVirtualPayment 会校验失败。
+type MemberPlans struct {
+ Id int64 `json:"id"`
+ PlanKey string `json:"plan_key"` // 套餐标识,如 vip-month
+ Name string `json:"name"` // 展示名,如 月卡
+ Subtitle string `json:"subtitle"` // 副标题,如 首月特惠
+ LevelKey string `json:"level_key"` // 购买后生效的等级
+ DurationDays int `json:"duration_days"` // 有效天数
+ PriceCents int64 `json:"price_cents"` // 价格(分),全链路不换算
+ ProductId string `json:"product_id"` // 微信虚拟支付道具 ID
+ Badge string `json:"badge"` // 角标文案,如 最划算
+ // QuotaPeriodDays 购买该套餐后生效的额度重置周期(天)。
+ // 0 表示不调整(沿用用户原值或全局设置)——发货时若 >0 会写入 users.quota_period_days。
+ QuotaPeriodDays int `json:"quota_period_days"`
+ Sort int `json:"sort"`
+ IsEnabled int `json:"is_enabled"`
+ Remark string `json:"remark"`
+ CreatedAt *gtime.Time `json:"created_at"`
+ UpdatedAt *gtime.Time `json:"updated_at"`
+}
diff --git a/internal/model/entity/entity_quota_packs.go b/internal/model/entity/entity_quota_packs.go
new file mode 100644
index 0000000..9358bea
--- /dev/null
+++ b/internal/model/entity/entity_quota_packs.go
@@ -0,0 +1,27 @@
+package entity
+
+import "github.com/gogf/gf/v2/os/gtime"
+
+// QuotaPacks 次数包档位(付费额度包)。
+//
+// 档位标识 pack_key 是跨端契约,与 MP 后台【虚拟支付 → 道具管理】的 ProductId 一一对应。
+//
+// ToolKey 的语义:
+// - 空串 → 通用档位:购买时由用户选择「买给哪个工具」,次数记到该工具名下
+// - 非空 → 专属档位:只对该工具出售,可用于给某个工具单独定价
+type QuotaPacks struct {
+ Id int64 `json:"id"`
+ PackKey string `json:"pack_key"` // 档位标识,如 quota_100
+ Name string `json:"name"` // 展示名,如 100 次
+ Times int `json:"times"` // 可用次数
+ PriceCents int64 `json:"price_cents"` // 价格(分),全链路不换算
+ ProductId string `json:"product_id"` // 微信虚拟支付道具 ID
+ ValidDays int `json:"valid_days"` // 有效期天数,默认 365(一年)
+ ToolKey string `json:"tool_key"` // 绑定工具,空 = 通用档位
+ Badge string `json:"badge"` // 角标文案,如 最划算
+ Sort int `json:"sort"`
+ IsEnabled int `json:"is_enabled"`
+ Remark string `json:"remark"`
+ CreatedAt *gtime.Time `json:"created_at"`
+ UpdatedAt *gtime.Time `json:"updated_at"`
+}
diff --git a/internal/model/entity/entity_settings.go b/internal/model/entity/entity_settings.go
new file mode 100644
index 0000000..275ea9b
--- /dev/null
+++ b/internal/model/entity/entity_settings.go
@@ -0,0 +1,14 @@
+package entity
+
+import "github.com/gogf/gf/v2/os/gtime"
+
+// Settings 轻量键值配置(后台可改的全局参数)。
+//
+// 目前只存全局默认额度周期(quota_period_days);之所以不放在 config.yaml,
+// 是因为它要能在管理端直接改,而 config.yaml 被 gitignore、只有运维能碰。
+type Settings struct {
+ K string `json:"k"`
+ V string `json:"v"`
+ Remark string `json:"remark"`
+ UpdatedAt *gtime.Time `json:"updated_at"`
+}
diff --git a/internal/model/entity/entity_tools.go b/internal/model/entity/entity_tools.go
index 52e9bc6..1e06958 100644
--- a/internal/model/entity/entity_tools.go
+++ b/internal/model/entity/entity_tools.go
@@ -3,6 +3,11 @@ package entity
import "github.com/gogf/gf/v2/os/gtime"
// Tools 工具目录
+//
+// 额度模型(三池):免费额度 → 会员额度 → 付费额度,依次扣减。
+// - FreeQuota 非会员每周期可用次数(周期由 settings/quota_period_days 决定,默认 7 天)
+// - MemberQuota 会员每周期可用次数(在免费额度用尽后启用)
+// - PayCost 使用付费额度时每次消耗的次数系数(重工具可设 2、3)
type Tools struct {
Id int64 `json:"id"`
ToolKey string `json:"tool_key"`
@@ -10,6 +15,9 @@ type Tools struct {
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
+ FreeQuota int `json:"free_quota"`
+ MemberQuota int `json:"member_quota"`
+ PayCost int `json:"pay_cost"`
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
IsHot int `json:"is_hot"`
diff --git a/internal/model/entity/entity_user_quota_usage.go b/internal/model/entity/entity_user_quota_usage.go
new file mode 100644
index 0000000..79f1d47
--- /dev/null
+++ b/internal/model/entity/entity_user_quota_usage.go
@@ -0,0 +1,19 @@
+package entity
+
+import "github.com/gogf/gf/v2/os/gtime"
+
+// UserQuotaUsage 用户在某工具上「当前周期」的免费/会员额度用量。
+//
+// 周期为滚动窗口:首次使用时把 PeriodStart 定为当时时间,
+// PeriodEnd = PeriodStart + 周期天数;now >= PeriodEnd 时归零并开启新周期。
+// 之所以不用自然周/自然月,是为了让「后台改周期天数」立刻按新长度重算,不必等到下个自然周期。
+type UserQuotaUsage struct {
+ Id int64 `json:"id"`
+ UserId int64 `json:"user_id"`
+ ToolKey string `json:"tool_key"`
+ PeriodStart *gtime.Time `json:"period_start"`
+ PeriodEnd *gtime.Time `json:"period_end"`
+ FreeUsed int `json:"free_used"` // 本周期已用免费额度
+ MemberUsed int `json:"member_used"` // 本周期已用会员额度
+ UpdatedAt *gtime.Time `json:"updated_at"`
+}
diff --git a/internal/model/entity/entity_user_tool_quota.go b/internal/model/entity/entity_user_tool_quota.go
new file mode 100644
index 0000000..a3a4ff6
--- /dev/null
+++ b/internal/model/entity/entity_user_tool_quota.go
@@ -0,0 +1,18 @@
+package entity
+
+import "github.com/gogf/gf/v2/os/gtime"
+
+// UserToolQuota 用户在某工具上的付费额度余额(次数包买断制,按工具绑定)。
+//
+// 一个用户对一个工具只有一行(UNIQUE(user_id, tool_key)),重复购买同工具时
+// 次数累加、有效期顺延(base = max(now, 当前到期时间),expire = base + valid_days)。
+type UserToolQuota struct {
+ Id int64 `json:"id"`
+ UserId int64 `json:"user_id"`
+ ToolKey string `json:"tool_key"`
+ TimesLeft int `json:"times_left"` // 剩余次数
+ TotalBought int `json:"total_bought"` // 累计购买次数(用于对账与展示)
+ ExpireAt *gtime.Time `json:"expire_at"` // 到期时间,NULL 视为已过期
+ CreatedAt *gtime.Time `json:"created_at"`
+ UpdatedAt *gtime.Time `json:"updated_at"`
+}
diff --git a/internal/model/entity/entity_users.go b/internal/model/entity/entity_users.go
index be8e705..bc44c9e 100644
--- a/internal/model/entity/entity_users.go
+++ b/internal/model/entity/entity_users.go
@@ -4,15 +4,25 @@ import "github.com/gogf/gf/v2/os/gtime"
// Users 小程序/H5 用户
type Users struct {
- Id int64 `json:"id"`
- Openid string `json:"openid"`
- Username string `json:"username"`
- PasswordHash string `json:"password_hash"`
- Nickname string `json:"nickname"`
- AvatarUrl string `json:"avatar_url"`
- LevelKey string `json:"level_key"`
- Status int `json:"status"`
- CreatedAt *gtime.Time `json:"created_at"`
- LastLoginAt *gtime.Time `json:"last_login_at"`
- UpdatedAt *gtime.Time `json:"updated_at"`
+ Id int64 `json:"id"`
+ Openid string `json:"openid"`
+ Username string `json:"username"`
+ PasswordHash string `json:"password_hash"`
+ Nickname string `json:"nickname"`
+ AvatarUrl string `json:"avatar_url"`
+ LevelKey string `json:"level_key"`
+ // LevelExpireAt 会员/等级到期时间,NULL 表示永不过期(如管理员手动分配的等级)。
+ // 已过期时由 EffectiveLevelKey 回落为 consts.DefaultLevelKey。
+ LevelExpireAt *gtime.Time `json:"level_expire_at"`
+ // SessionKey 微信 session_key,用于虚拟支付用户态签名(signature)。
+ // 每次 wx.login 换取的 session_key 会覆盖刷新。
+ // 注意:实体仅用于服务端内部,不会直接序列化给客户端(对外用 v1.UserInfo)。
+ SessionKey string `json:"session_key"`
+ // QuotaPeriodDays 该用户的额度重置周期(天)。0 表示跟随全局设置
+ // (settings.quota_period_days,默认 7 天);管理端可对单个用户单独调整。
+ QuotaPeriodDays int `json:"quota_period_days"`
+ Status int `json:"status"`
+ CreatedAt *gtime.Time `json:"created_at"`
+ LastLoginAt *gtime.Time `json:"last_login_at"`
+ UpdatedAt *gtime.Time `json:"updated_at"`
}
diff --git a/sql/member_pay.sql b/sql/member_pay.sql
new file mode 100644
index 0000000..26fa964
--- /dev/null
+++ b/sql/member_pay.sql
@@ -0,0 +1,79 @@
+-- ============================================================================
+-- 会员购买(微信小程序虚拟支付 · 道具直购)
+-- 适用:个人主体小程序(服务类目含「工具」),月支付限额 10 万元
+-- 执行前请确认已备份;本脚本幂等性由 `IF NOT EXISTS` / 存在性判断保证。
+-- ============================================================================
+
+-- 1. users:等级有效期 + session_key ----------------------------------------
+-- level_expire_at:会员到期时间,NULL = 永久(管理员手动分配的等级保持 NULL)
+-- session_key :微信 session_key,用户态签名 signature 需要
+SET @db := DATABASE();
+
+SET @sql := (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `users` ADD COLUMN `level_expire_at` datetime DEFAULT NULL COMMENT ''等级/会员到期时间,NULL=永久'' AFTER `level_key`',
+ 'SELECT ''users.level_expire_at already exists'' AS msg')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'level_expire_at'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `users` ADD COLUMN `session_key` varchar(128) DEFAULT NULL COMMENT ''微信 session_key(虚拟支付签名用)'' AFTER `openid`',
+ 'SELECT ''users.session_key already exists'' AS msg')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'session_key'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 2. member_plans:会员套餐 --------------------------------------------------
+CREATE TABLE IF NOT EXISTS `member_plans` (
+ `id` bigint NOT NULL AUTO_INCREMENT,
+ `plan_key` varchar(32) NOT NULL COMMENT '套餐标识,如 vip-month',
+ `name` varchar(64) NOT NULL COMMENT '展示名,如 月卡',
+ `subtitle` varchar(128) DEFAULT NULL COMMENT '副标题',
+ `level_key` varchar(32) NOT NULL COMMENT '购买后生效的等级',
+ `duration_days` int NOT NULL DEFAULT 30 COMMENT '有效天数',
+ `price_cents` bigint NOT NULL DEFAULT 0 COMMENT '价格(分),须与微信后台道具价格一致',
+ `product_id` varchar(64) NOT NULL COMMENT '微信虚拟支付道具 ID',
+ `badge` varchar(32) DEFAULT NULL COMMENT '角标文案',
+ `sort` int NOT NULL DEFAULT 0,
+ `is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '1 上架 / 0 下架',
+ `remark` varchar(255) DEFAULT NULL,
+ `created_at` datetime DEFAULT NULL,
+ `updated_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_plan_key` (`plan_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员套餐(道具直购)';
+
+-- 3. member_orders:会员订单 -------------------------------------------------
+CREATE TABLE IF NOT EXISTS `member_orders` (
+ `id` bigint NOT NULL AUTO_INCREMENT,
+ `out_trade_no` varchar(64) NOT NULL COMMENT '业务单号,唯一,8-32 位且不能以下划线开头',
+ `wx_order_id` varchar(64) DEFAULT NULL COMMENT '平台单号,发货/对账以此为准,用于幂等去重',
+ `user_id` bigint NOT NULL,
+ `openid` varchar(64) NOT NULL COMMENT '支付时使用的 openid,主动查单需要',
+ `plan_key` varchar(32) NOT NULL,
+ `product_id` varchar(64) NOT NULL,
+ `price_cents` bigint NOT NULL DEFAULT 0,
+ `level_key` varchar(32) NOT NULL COMMENT '等级快照',
+ `duration_days` int NOT NULL DEFAULT 0 COMMENT '天数快照',
+ `status` tinyint NOT NULL DEFAULT 0 COMMENT '0 待支付 / 1 已发货 / 2 已退款 / 3 已关闭',
+ `pay_channel` varchar(16) DEFAULT NULL COMMENT 'wxpay / apple',
+ `attach` varchar(128) DEFAULT NULL COMMENT '透传数据',
+ `paid_at` datetime DEFAULT NULL,
+ `delivered_at` datetime DEFAULT NULL,
+ `created_at` datetime DEFAULT NULL,
+ `updated_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_out_trade_no` (`out_trade_no`),
+ UNIQUE KEY `uk_wx_order_id` (`wx_order_id`),
+ KEY `idx_user_created` (`user_id`, `created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员订单(虚拟支付道具直购)';
+
+-- 4. 套餐数据 ----------------------------------------------------------------
+-- 套餐种子由服务端 internal/logic/seed.go 在启动时幂等写入(与其他种子数据一致),
+-- 请在那里修改 product_id / 价格后重启服务即可。
+-- 注意:product_id 必须先在 MP 后台【虚拟支付 → 道具管理】创建并发布,
+-- 价格(分)也要与后台道具价格严格一致,否则支付会被平台拒绝。
diff --git a/sql/quota.sql b/sql/quota.sql
new file mode 100644
index 0000000..2ddf986
--- /dev/null
+++ b/sql/quota.sql
@@ -0,0 +1,153 @@
+-- ============================================================================
+-- 工具额度体系(免费 / 会员 / 付费次数包)建表脚本
+--
+-- ⚠️ 这份脚本是「给人看的对照版」。服务端启动时会自动执行等价且幂等的迁移
+-- (见 internal/logic/migrate.go),所以正常升级只需要重启后端,不用手工跑。
+-- 仅当你需要手工核对、或在没有服务端的环境里初始化时,才执行本文件。
+--
+-- 幂等:可重复执行。表用 CREATE TABLE IF NOT EXISTS,列先用 information_schema
+-- 判存在再 ALTER(MySQL 不支持 ADD COLUMN IF NOT EXISTS)。
+-- ============================================================================
+
+-- ---------------------------------------------------------------------------
+-- 1. 工具额度配置(在「工具管理」里维护)
+-- ---------------------------------------------------------------------------
+SET @db := DATABASE();
+
+SET @sql := IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'tools' AND COLUMN_NAME = 'free_quota') = 0,
+ 'ALTER TABLE `tools` ADD COLUMN `free_quota` int NOT NULL DEFAULT 3 COMMENT ''免费额度(每周期次数)''',
+ 'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @sql := IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'tools' AND COLUMN_NAME = 'member_quota') = 0,
+ 'ALTER TABLE `tools` ADD COLUMN `member_quota` int NOT NULL DEFAULT 100 COMMENT ''会员额度(每周期次数)''',
+ 'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @sql := IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'tools' AND COLUMN_NAME = 'pay_cost') = 0,
+ 'ALTER TABLE `tools` ADD COLUMN `pay_cost` int NOT NULL DEFAULT 1 COMMENT ''付费额度每次消耗系数''',
+ 'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ---------------------------------------------------------------------------
+-- 2. 额度周期:用户与会员套餐各自的覆盖值(0 = 跟随全局设置)
+-- ---------------------------------------------------------------------------
+SET @sql := IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'quota_period_days') = 0,
+ 'ALTER TABLE `users` ADD COLUMN `quota_period_days` int NOT NULL DEFAULT 0 COMMENT ''额度重置周期(天),0=跟随全局''',
+ 'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @sql := IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_plans' AND COLUMN_NAME = 'quota_period_days') = 0,
+ 'ALTER TABLE `member_plans` ADD COLUMN `quota_period_days` int NOT NULL DEFAULT 0 COMMENT ''购买后生效的额度周期(天),0=不调整''',
+ 'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ---------------------------------------------------------------------------
+-- 3. 订单表承载两类商品(会员套餐 / 次数包)
+-- ---------------------------------------------------------------------------
+SET @sql := IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'order_type') = 0,
+ 'ALTER TABLE `member_orders` ADD COLUMN `order_type` tinyint NOT NULL DEFAULT 1 COMMENT ''1=会员套餐 2=次数包''',
+ 'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @sql := IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'pack_key') = 0,
+ 'ALTER TABLE `member_orders` ADD COLUMN `pack_key` varchar(64) NOT NULL DEFAULT '''' COMMENT ''次数包档位''',
+ 'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @sql := IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'tool_key') = 0,
+ 'ALTER TABLE `member_orders` ADD COLUMN `tool_key` varchar(64) NOT NULL DEFAULT '''' COMMENT ''次数包绑定工具''',
+ 'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @sql := IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'times') = 0,
+ 'ALTER TABLE `member_orders` ADD COLUMN `times` int NOT NULL DEFAULT 0 COMMENT ''次数包次数''',
+ 'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ---------------------------------------------------------------------------
+-- 4. 次数包档位(在「次数包管理」里维护;product_id 必须与 MP 后台道具一致)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `quota_packs` (
+ `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+ `pack_key` varchar(64) NOT NULL COMMENT '档位标识,跨端契约',
+ `name` varchar(64) NOT NULL DEFAULT '' COMMENT '展示名',
+ `times` int NOT NULL DEFAULT 0 COMMENT '可用次数',
+ `price_cents` bigint NOT NULL DEFAULT 0 COMMENT '价格(分)',
+ `product_id` varchar(64) NOT NULL DEFAULT '' COMMENT '微信虚拟支付道具 ID',
+ `valid_days` int NOT NULL DEFAULT 365 COMMENT '有效期天数,默认一年',
+ `tool_key` varchar(64) NOT NULL DEFAULT '' COMMENT '绑定工具,空=通用档位',
+ `badge` varchar(32) NOT NULL DEFAULT '' COMMENT '角标文案',
+ `sort` int NOT NULL DEFAULT 0,
+ `is_enabled` tinyint NOT NULL DEFAULT 1,
+ `remark` varchar(255) NOT NULL DEFAULT '',
+ `created_at` datetime DEFAULT NULL,
+ `updated_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_pack_key` (`pack_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='次数包档位(付费额度包)';
+
+-- ---------------------------------------------------------------------------
+-- 5. 用户付费额度余额(按工具绑定,买断制)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `user_tool_quota` (
+ `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+ `user_id` bigint NOT NULL,
+ `tool_key` varchar(64) NOT NULL,
+ `times_left` int NOT NULL DEFAULT 0 COMMENT '剩余次数',
+ `total_bought` int NOT NULL DEFAULT 0 COMMENT '累计购买次数',
+ `expire_at` datetime DEFAULT NULL COMMENT '到期时间,NULL 视为已过期',
+ `created_at` datetime DEFAULT NULL,
+ `updated_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_user_tool` (`user_id`,`tool_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户工具付费额度余额';
+
+-- ---------------------------------------------------------------------------
+-- 6. 用户周期用量(滚动窗口,归零由服务端在读写时判定)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `user_quota_usage` (
+ `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+ `user_id` bigint NOT NULL,
+ `tool_key` varchar(64) NOT NULL,
+ `period_start` datetime NOT NULL COMMENT '当前周期起点',
+ `period_end` datetime NOT NULL COMMENT '当前周期终点',
+ `free_used` int NOT NULL DEFAULT 0 COMMENT '本周期已用免费额度',
+ `member_used` int NOT NULL DEFAULT 0 COMMENT '本周期已用会员额度',
+ `updated_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_user_tool` (`user_id`,`tool_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户工具额度周期用量';
+
+-- ---------------------------------------------------------------------------
+-- 7. 后台可改的全局配置(额度周期等)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `settings` (
+ `k` varchar(64) NOT NULL,
+ `v` varchar(255) NOT NULL DEFAULT '',
+ `remark` varchar(255) NOT NULL DEFAULT '',
+ `updated_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`k`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台可改的全局配置';
+
+-- 种子数据(服务端启动时也会幂等写入同样的内容)
+INSERT IGNORE INTO `settings` (`k`, `v`, `remark`, `updated_at`)
+VALUES ('quota_period_days', '7', '全局默认额度重置周期(天)', NOW());