小程序虚拟支付

This commit is contained in:
李琦
2026-09-17 17:38:45 +08:00
parent 7c106b69d2
commit 7ea8cdd982
32 changed files with 3499 additions and 88 deletions

3
.gitignore vendored
View File

@@ -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

View File

@@ -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"`

125
api/admin/v1/quota.go Normal file
View File

@@ -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{}

107
api/user/v1/member.go Normal file
View File

@@ -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"`
}

108
api/user/v1/quota.go Normal file
View File

@@ -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"`
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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" // 发货推送事件名
)

View File

@@ -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)
}

View File

@@ -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成功 <ErrCode>0</ErrCode>,失败非 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)
}

View File

@@ -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)
}

View File

@@ -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 只读,不修改

View File

@@ -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
}

72
internal/logic/level.go Normal file
View File

@@ -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)
}

712
internal/logic/member.go Normal file
View File

@@ -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_keyRefreshSessionKey 可能只更新了库,这里从库里读回)
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 申请,开发者无法主动退款",
}
}

139
internal/logic/migrate.go Normal file
View File

@@ -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 '次数包次数'"},
}
}

608
internal/logic/quota.go Normal file
View File

@@ -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 &quotaUsageState{
Id: lastId,
PeriodStart: now,
PeriodEnd: now.AddDate(0, 0, periodDays),
PeriodDaysSnapshot: periodDays,
}, nil
}
}
state := &quotaUsageState{
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 := &quotaUsageState{}
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()] = &quotaUsageState{
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 = &quotaUsageState{}
}
// 只读路径不写库:过期即按归零展示
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)
}

248
internal/logic/quota_api.go Normal file
View File

@@ -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
}

View File

@@ -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"},
}
}

View File

@@ -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
}

View File

@@ -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
}

302
internal/logic/xpay.go Normal file
View File

@@ -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 固定 requestVirtualPaymentB 端为接口路径 /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_tokenB 端接口需要)=====
var (
accessTokenMu sync.Mutex
accessTokenCache string
accessTokenExpire time.Time
)
// wxAccessToken 获取并缓存 access_tokenstable_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 "<xml><ErrCode>0</ErrCode><ErrMsg><![CDATA[success]]></ErrMsg></xml>"
}
// XPayNotifyAckFail 发货推送失败应答(触发平台重试)
func XPayNotifyAckFail(msg string) string {
return fmt.Sprintf("<xml><ErrCode>-1</ErrCode><ErrMsg><![CDATA[%s]]></ErrMsg></xml>", msg)
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

79
sql/member_pay.sql Normal file
View File

@@ -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 后台虚拟支付 道具管理创建并发布
-- 价格也要与后台道具价格严格一致否则支付会被平台拒绝

153
sql/quota.sql Normal file
View File

@@ -0,0 +1,153 @@
-- ============================================================================
-- 工具额度体系免费 / 会员 / 付费次数包建表脚本
--
-- 这份脚本是给人看的对照版服务端启动时会自动执行等价且幂等的迁移
-- internal/logic/migrate.go所以正常升级只需要重启后端不用手工跑
-- 仅当你需要手工核对或在没有服务端的环境里初始化时才执行本文件
--
-- 幂等可重复执行表用 CREATE TABLE IF NOT EXISTS列先用 information_schema
-- 判存在再 ALTERMySQL 不支持 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());