小程序虚拟支付
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
@@ -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
125
api/admin/v1/quota.go
Normal 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
107
api/user/v1/member.go
Normal 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
108
api/user/v1/quota.go
Normal 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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" // 发货推送事件名
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
50
internal/controller/member.go
Normal file
50
internal/controller/member.go
Normal 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)
|
||||
}
|
||||
34
internal/controller/quota.go
Normal file
34
internal/controller/quota.go
Normal 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)
|
||||
}
|
||||
@@ -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 只读,不修改
|
||||
|
||||
217
internal/logic/admin_quota.go
Normal file
217
internal/logic/admin_quota.go
Normal 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
72
internal/logic/level.go
Normal 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
712
internal/logic/member.go
Normal 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_key(RefreshSessionKey 可能只更新了库,这里从库里读回)
|
||||
latest, err := getUserById(ctx, userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if latest.SessionKey == "" {
|
||||
return nil, gerror.NewCode(gcodePayFail(), "登录态已失效,请重新进入小程序后再试")
|
||||
}
|
||||
|
||||
outTradeNo, err := newOutTradeNo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attach := g.Map{"userId": userId}
|
||||
for k, v := range orderFields {
|
||||
attach[k] = v
|
||||
}
|
||||
attachBytes, _ := json.Marshal(attach)
|
||||
|
||||
order := g.Map{
|
||||
"out_trade_no": outTradeNo,
|
||||
"wx_order_id": nil,
|
||||
"user_id": userId,
|
||||
"openid": openid,
|
||||
"product_id": productId,
|
||||
"price_cents": priceCents,
|
||||
"status": consts.OrderStatusPending,
|
||||
"pay_channel": consts.PayChannelWx,
|
||||
"attach": string(attachBytes),
|
||||
"created_at": gtime.Now(),
|
||||
"updated_at": gtime.Now(),
|
||||
}
|
||||
for k, v := range orderFields {
|
||||
order[k] = v
|
||||
}
|
||||
if _, err = g.Model(consts.TableMemberOrders).Data(order).Insert(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signData, err := BuildSignData(cfg.OfferId, productId, priceCents, outTradeNo, string(attachBytes), cfg.Env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 这三个值必须与 MP 后台【虚拟支付 → 道具管理】里已发布的道具完全一致(区分大小写),
|
||||
// 否则客户端会报 PRODUCT_ID_NOT_PUBLISH。这里留痕,便于对着后台核对。
|
||||
g.Log().Infof(ctx, "[xpay] 下单 outTradeNo=%s type=%v productId=%s priceCents=%d offerId=%s env=%d",
|
||||
outTradeNo, orderFields["order_type"], productId, priceCents, cfg.OfferId, cfg.Env)
|
||||
return &xpayPayData{
|
||||
OutTradeNo: outTradeNo,
|
||||
SignData: signData,
|
||||
PaySig: CalcPaySig(xpayURIPayRequest, signData, cfg.AppKey),
|
||||
Signature: CalcSignature(signData, latest.SessionKey),
|
||||
Env: cfg.Env,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MemberOrderCheck 查询订单状态;未发货时主动查单兜底补发(前端支付成功回调后轮询此接口)
|
||||
func MemberOrderCheck(ctx context.Context, outTradeNo string) (*v1.MemberOrderCheckRes, error) {
|
||||
userId := CtxUserId(ctx)
|
||||
order, err := findOrder(ctx, userId, outTradeNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 未发货 → 走一次主动查单(推送到达前的高频场景)
|
||||
if order.Status != consts.OrderStatusDelivered && order.Status != consts.OrderStatusRefunded {
|
||||
_, _ = queryAndDeliver(ctx, order)
|
||||
order, err = findOrder(ctx, userId, outTradeNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
user, err := getUserById(ctx, userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v1.MemberOrderCheckRes{
|
||||
Status: order.Status,
|
||||
Delivered: order.Status == consts.OrderStatusDelivered,
|
||||
State: buildMemberState(ctx, user),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MemberOrders 我的订单列表
|
||||
func MemberOrders(ctx context.Context, limit int) (*v1.MemberOrdersRes, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
records, err := g.Model(consts.TableMemberOrders).
|
||||
Where("user_id", CtxUserId(ctx)).
|
||||
OrderDesc("id").
|
||||
Limit(limit).
|
||||
All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
planNames, err := planNameMap(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
packNames, err := quotaPackNameMap(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toolNames, err := toolNameMap(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := &v1.MemberOrdersRes{List: []v1.MemberOrderOut{}}
|
||||
for _, r := range records {
|
||||
orderType := r["order_type"].Int()
|
||||
if orderType == 0 {
|
||||
orderType = consts.OrderTypeMember // 兼容加列之前的历史订单
|
||||
}
|
||||
out := v1.MemberOrderOut{
|
||||
OutTradeNo: r["out_trade_no"].String(),
|
||||
OrderType: orderType,
|
||||
PlanKey: r["plan_key"].String(),
|
||||
PlanName: planNames[r["plan_key"].String()],
|
||||
ToolKey: r["tool_key"].String(),
|
||||
ToolName: toolNames[r["tool_key"].String()],
|
||||
Times: r["times"].Int(),
|
||||
PriceCents: r["price_cents"].Int64(),
|
||||
Status: r["status"].Int(),
|
||||
StatusText: orderStatusText(r["status"].Int()),
|
||||
PayChannel: r["pay_channel"].String(),
|
||||
CreatedAt: r["created_at"].String(),
|
||||
DeliveredAt: r["delivered_at"].String(),
|
||||
}
|
||||
if orderType == consts.OrderTypeQuota {
|
||||
out.Title = packNames[r["pack_key"].String()]
|
||||
if toolName := out.ToolName; toolName != "" && out.Title != "" {
|
||||
out.Title = toolName + " · " + out.Title
|
||||
}
|
||||
} else {
|
||||
out.Title = out.PlanName
|
||||
}
|
||||
res.List = append(res.List, out)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ===== 发货 =====
|
||||
|
||||
// PayNotifyHandle 处理虚拟支付发货推送。
|
||||
//
|
||||
// 安全策略:平台推送不保证携带可用于验签的字段,因此当虚拟支付已配置时,
|
||||
// 一律「先主动查单复核,确认已支付才发货」,查单结果即最可靠的验签;
|
||||
// 未配置(联调阶段)时按推送内容发货并打告警日志。
|
||||
func PayNotifyHandle(ctx context.Context, raw []byte) (ack string, err error) {
|
||||
notify, err := ParseDeliverNotify(raw)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[xpay] 发货推送解析失败: %v raw=%s", err, string(raw))
|
||||
return XPayNotifyAckFail("bad payload"), nil
|
||||
}
|
||||
g.Log().Infof(ctx, "[xpay] 收到发货推送 outTradeNo=%s openid=%s productId=%s qty=%d",
|
||||
notify.OutTradeNo, notify.OpenId, notify.GoodsInfo.ProductId, notify.GoodsInfo.Quantity)
|
||||
|
||||
order, err := entityOrderByOutTradeNo(ctx, notify.OutTradeNo)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[xpay] 发货推送对应订单不存在: %s", notify.OutTradeNo)
|
||||
// 订单不存在多为脏数据,回成功避免平台无意义重试 15 次
|
||||
return XPayNotifyAck(), nil
|
||||
}
|
||||
if order.Status == consts.OrderStatusDelivered {
|
||||
return XPayNotifyAck(), nil // 幂等:重复推送直接成功
|
||||
}
|
||||
|
||||
wxOrderId := notify.WeChatPayInfo.MchOrderNo
|
||||
if xpayConfig(ctx).Configured() {
|
||||
state, qErr := XPayQueryOrder(ctx, order.Openid, order.OutTradeNo)
|
||||
if qErr != nil {
|
||||
g.Log().Warningf(ctx, "[xpay] 发货推送触发查单复核失败: outTradeNo=%s err=%v", order.OutTradeNo, qErr)
|
||||
return XPayNotifyAckFail("query failed"), nil // 让平台重试
|
||||
}
|
||||
if !state.Paid {
|
||||
g.Log().Warningf(ctx, "[xpay] 查单显示未支付,拒绝发货: %s", order.OutTradeNo)
|
||||
return XPayNotifyAckFail("order not paid"), nil
|
||||
}
|
||||
if state.WxOrderId != "" {
|
||||
wxOrderId = state.WxOrderId
|
||||
}
|
||||
} else {
|
||||
g.Log().Warningf(ctx, "[xpay] 虚拟支付未配置,按推送内容直接发货(仅限联调): %s", order.OutTradeNo)
|
||||
}
|
||||
if wxOrderId == "" {
|
||||
wxOrderId = order.OutTradeNo // 极端兜底:保证唯一索引不冲突,幂等仍由 status 保证
|
||||
}
|
||||
|
||||
if err = deliverOrder(ctx, order.OutTradeNo, wxOrderId); err != nil {
|
||||
g.Log().Errorf(ctx, "[xpay] 发货失败 outTradeNo=%s err=%v", order.OutTradeNo, err)
|
||||
return XPayNotifyAckFail("deliver failed"), nil // 让平台重试
|
||||
}
|
||||
return XPayNotifyAck(), nil
|
||||
}
|
||||
|
||||
// SweepPendingOrders 兜底扫单:把超时未发货的待支付订单逐笔主动查单,已支付则补发货。
|
||||
// 建议每 5 分钟执行一次(见 cmd.go 的 gcron 注册)。
|
||||
func SweepPendingOrders(ctx context.Context) {
|
||||
if !xpayConfig(ctx).Configured() {
|
||||
return
|
||||
}
|
||||
// 只扫 2 小时内、未发货的订单,避免历史脏数据无限重试
|
||||
records, err := g.Model(consts.TableMemberOrders).
|
||||
Where("status", consts.OrderStatusPending).
|
||||
WhereGTE("created_at", gtime.Now().Add(-2*time.Hour)).
|
||||
OrderAsc("id").
|
||||
Limit(100).
|
||||
All()
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[xpay] 扫单失败: %v", err)
|
||||
return
|
||||
}
|
||||
for _, r := range records {
|
||||
order := &entity.MemberOrders{}
|
||||
if err = r.Struct(order); err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err = queryAndDeliver(ctx, order); err != nil {
|
||||
g.Log().Debugf(ctx, "[xpay] 扫单跳过 outTradeNo=%s err=%v", order.OutTradeNo, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// queryAndDeliver 主动查单 → 已支付则发货。返回是否完成发货。
|
||||
func queryAndDeliver(ctx context.Context, order *entity.MemberOrders) (bool, error) {
|
||||
if order.Status == consts.OrderStatusDelivered {
|
||||
return true, nil
|
||||
}
|
||||
if !xpayConfig(ctx).Configured() {
|
||||
return false, nil
|
||||
}
|
||||
state, err := XPayQueryOrder(ctx, order.Openid, order.OutTradeNo)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !state.Paid {
|
||||
return false, nil
|
||||
}
|
||||
wxOrderId := state.WxOrderId
|
||||
if wxOrderId == "" {
|
||||
wxOrderId = order.OutTradeNo
|
||||
}
|
||||
if err = deliverOrder(ctx, order.OutTradeNo, wxOrderId); err != nil {
|
||||
return false, err
|
||||
}
|
||||
g.Log().Infof(ctx, "[xpay] 查单兜底发货成功 outTradeNo=%s", order.OutTradeNo)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// deliverOrder 发货:置订单为已发货 + 延长会员有效期。全程在一个事务里,按 out_trade_no 加行锁保证幂等。
|
||||
func deliverOrder(ctx context.Context, outTradeNo, wxOrderId string) error {
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
row, err := tx.Model(consts.TableMemberOrders).
|
||||
Where("out_trade_no", outTradeNo).LockUpdate().One()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if row.IsEmpty() {
|
||||
return gerror.Newf("订单不存在: %s", outTradeNo)
|
||||
}
|
||||
if row["status"].Int() == consts.OrderStatusDelivered {
|
||||
return nil // 幂等:已发货直接返回
|
||||
}
|
||||
|
||||
userId := row["user_id"].Int64()
|
||||
days := row["duration_days"].Int()
|
||||
if days <= 0 {
|
||||
return gerror.Newf("订单 %s 有效天数异常: %d", outTradeNo, days)
|
||||
}
|
||||
|
||||
// 两类商品在同一事务里分支发货,幂等仍由上面的 status 判定保证
|
||||
switch row["order_type"].Int() {
|
||||
case consts.OrderTypeQuota:
|
||||
toolKey := row["tool_key"].String()
|
||||
times := row["times"].Int()
|
||||
expireAt, err := grantToolQuota(ctx, tx, userId, toolKey, times, days)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.Log().Infof(ctx, "[quota] 次数包发货完成 userId=%d outTradeNo=%s tool=%s times=%d expire=%s",
|
||||
userId, outTradeNo, toolKey, times, expireAt.String())
|
||||
default:
|
||||
levelKey := row["level_key"].String()
|
||||
expireAt, finalLevel, err := extendMembership(ctx, tx, userId, levelKey, days)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 套餐若配置了额度周期,则一并写入用户(「按会员套餐调整额度周期」的落地方式)
|
||||
if err = applyPlanQuotaPeriod(ctx, tx, userId, row["plan_key"].String()); err != nil {
|
||||
return err
|
||||
}
|
||||
g.Log().Infof(ctx, "[member] 发货完成 userId=%d outTradeNo=%s level=%s expire=%s",
|
||||
userId, outTradeNo, finalLevel, expireAt.String())
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
if _, err = tx.Model(consts.TableMemberOrders).
|
||||
Where("out_trade_no", outTradeNo).
|
||||
Data(g.Map{
|
||||
"wx_order_id": wxOrderId,
|
||||
"status": consts.OrderStatusDelivered,
|
||||
"paid_at": now,
|
||||
"delivered_at": now,
|
||||
"updated_at": now,
|
||||
}).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// applyPlanQuotaPeriod 把会员套餐配置的额度周期写到用户上。
|
||||
// 套餐未配置(0)时不动用户原值,避免「买了没配周期的套餐」把用户的自定义周期清掉。
|
||||
func applyPlanQuotaPeriod(ctx context.Context, tx gdb.TX, userId int64, planKey string) error {
|
||||
if planKey == "" {
|
||||
return nil
|
||||
}
|
||||
value, err := tx.Model(consts.TableMemberPlans).
|
||||
Where("plan_key", planKey).Value("quota_period_days")
|
||||
if err != nil || value == nil {
|
||||
return nil
|
||||
}
|
||||
days := value.Int()
|
||||
if days <= 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err = tx.Model(consts.TableUsers).Where("id", userId).Data(g.Map{
|
||||
"quota_period_days": days,
|
||||
"updated_at": gtime.Now(),
|
||||
}).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
g.Log().Infof(ctx, "[quota] 按套餐调整额度周期 userId=%d plan=%s periodDays=%d", userId, planKey, days)
|
||||
return nil
|
||||
}
|
||||
|
||||
// extendMembership 延长会员有效期。规则:
|
||||
// - 同档续费(当前等级 == 购买等级)且未过期 → 在原到期时间上顺延
|
||||
// - 其他情况 → 从当前时间起算
|
||||
// - 若当前未过期等级的权限高于所购等级,保留高等级(只延长有效期,不降权)
|
||||
// - level_expire_at 为 NULL(永久)时,不覆盖为有限期,避免把永久会员改成到期会员
|
||||
func extendMembership(ctx context.Context, tx gdb.TX, userId int64, levelKey string, days int) (*gtime.Time, string, error) {
|
||||
row, err := tx.Model(consts.TableUsers).Where("id", userId).LockUpdate().One()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if row.IsEmpty() {
|
||||
return nil, "", gerror.Newf("用户不存在: %d", userId)
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
curLevel := row["level_key"].String()
|
||||
curExpire := row["level_expire_at"].GTime()
|
||||
permanent := curExpire == nil || curExpire.IsZero()
|
||||
|
||||
// 永久会员:不因为一次购买而降级为有限期,仅按权重提升等级
|
||||
if permanent {
|
||||
finalLevel := curLevel
|
||||
if levelWeight(ctx, levelKey) > levelWeight(ctx, curLevel) {
|
||||
finalLevel = levelKey
|
||||
}
|
||||
if _, err = tx.Model(consts.TableUsers).Where("id", userId).Data(g.Map{
|
||||
"level_key": finalLevel,
|
||||
"updated_at": now,
|
||||
}).Update(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return nil, finalLevel, nil
|
||||
}
|
||||
|
||||
base := now
|
||||
if curExpire.After(now) && curLevel == levelKey {
|
||||
base = curExpire // 同档续费顺延
|
||||
}
|
||||
expireAt := base.AddDate(0, 0, days)
|
||||
|
||||
finalLevel := levelKey
|
||||
if curExpire.After(now) && levelWeight(ctx, curLevel) > levelWeight(ctx, levelKey) {
|
||||
finalLevel = curLevel // 不降权
|
||||
}
|
||||
if _, err = tx.Model(consts.TableUsers).Where("id", userId).Data(g.Map{
|
||||
"level_key": finalLevel,
|
||||
"level_expire_at": expireAt,
|
||||
"updated_at": now,
|
||||
}).Update(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return expireAt, finalLevel, nil
|
||||
}
|
||||
|
||||
// ===== 辅助 =====
|
||||
|
||||
func gcodePayFail() gcode.Code {
|
||||
return gcode.New(consts.CodePayFail, "", nil)
|
||||
}
|
||||
|
||||
func findOrder(ctx context.Context, userId int64, outTradeNo string) (*entity.MemberOrders, error) {
|
||||
record, err := g.Model(consts.TableMemberOrders).
|
||||
Where("user_id", userId).Where("out_trade_no", outTradeNo).One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record.IsEmpty() {
|
||||
return nil, gerror.New("订单不存在")
|
||||
}
|
||||
order := &entity.MemberOrders{}
|
||||
if err = record.Struct(order); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func entityOrderByOutTradeNo(ctx context.Context, outTradeNo string) (*entity.MemberOrders, error) {
|
||||
record, err := g.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record.IsEmpty() {
|
||||
return nil, gerror.Newf("订单不存在: %s", outTradeNo)
|
||||
}
|
||||
order := &entity.MemberOrders{}
|
||||
if err = record.Struct(order); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func listPlans(ctx context.Context) ([]v1.MemberPlanOut, error) {
|
||||
records, err := g.Model(consts.TableMemberPlans).
|
||||
Where("is_enabled", 1).OrderAsc("sort").All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := []v1.MemberPlanOut{}
|
||||
for _, r := range records {
|
||||
list = append(list, v1.MemberPlanOut{
|
||||
PlanKey: r["plan_key"].String(),
|
||||
Name: r["name"].String(),
|
||||
Subtitle: r["subtitle"].String(),
|
||||
LevelKey: r["level_key"].String(),
|
||||
DurationDays: r["duration_days"].Int(),
|
||||
PriceCents: r["price_cents"].Int64(),
|
||||
ProductId: r["product_id"].String(),
|
||||
Badge: r["badge"].String(),
|
||||
})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func planNameMap(ctx context.Context) (map[string]string, error) {
|
||||
records, err := g.Model(consts.TableMemberPlans).All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, r := range records {
|
||||
out[r["plan_key"].String()] = r["name"].String()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listLevelBriefs 全部启用等级(用于前端渲染权益对比与购买价值感知)
|
||||
func listLevelBriefs(ctx context.Context, user *entity.Users) ([]v1.MemberLevelOut, error) {
|
||||
records, err := g.Model(consts.TableLevels).Where("is_enabled", 1).OrderAsc("sort").All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modRecords, err := g.Model(consts.TableModules).Where("is_enabled", 1).All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
moduleNames := map[string]string{}
|
||||
for _, m := range modRecords {
|
||||
moduleNames[m["module_key"].String()] = m["name"].String()
|
||||
}
|
||||
current := EffectiveLevelKey(user)
|
||||
list := []v1.MemberLevelOut{}
|
||||
for _, r := range records {
|
||||
modules := []string{}
|
||||
_ = json.Unmarshal([]byte(r["modules"].String()), &modules)
|
||||
names := make([]string, 0, len(modules))
|
||||
for _, mk := range modules {
|
||||
if n := moduleNames[mk]; n != "" {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
list = append(list, v1.MemberLevelOut{
|
||||
LevelKey: r["level_key"].String(),
|
||||
Name: r["name"].String(),
|
||||
Modules: modules,
|
||||
ModuleNames: names,
|
||||
IsCurrent: r["level_key"].String() == current,
|
||||
})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func buildMemberState(ctx context.Context, user *entity.Users) v1.MemberStateOut {
|
||||
effectiveKey := EffectiveLevelKey(user)
|
||||
expired := IsLevelExpired(user)
|
||||
state := v1.MemberStateOut{
|
||||
LevelKey: effectiveKey,
|
||||
ExpireAt: "",
|
||||
Modules: []string{},
|
||||
IsMember: false,
|
||||
DaysLeft: 0,
|
||||
}
|
||||
|
||||
// 会员状态:有到期时间且未过期,且等级高于默认等级
|
||||
hasExpire := user.LevelExpireAt != nil && !user.LevelExpireAt.IsZero()
|
||||
if hasExpire {
|
||||
state.ExpireAt = user.LevelExpireAt.Format("Y-m-d H:i:s")
|
||||
}
|
||||
if hasExpire && !expired && effectiveKey != consts.DefaultLevelKey {
|
||||
state.IsMember = true
|
||||
left := user.LevelExpireAt.Sub(gtime.Now())
|
||||
days := int(left.Hours() / 24)
|
||||
if left.Hours() > float64(days*24) {
|
||||
days++
|
||||
}
|
||||
state.DaysLeft = days
|
||||
}
|
||||
|
||||
level, err := GetLevelByKey(ctx, effectiveKey)
|
||||
if err == nil && level != nil {
|
||||
state.LevelName = level.Name
|
||||
state.Modules = level.Modules
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// newOutTradeNo 生成业务单号:8-32 位、不能以下划线开头(官方约束)。
|
||||
// 形如 T20260917150345 + 6 位随机 = 21 位。
|
||||
func newOutTradeNo() (string, error) {
|
||||
return fmt.Sprintf("T%s%06d", gtime.Now().Format("YmdHis"), rand.Intn(1000000)), nil
|
||||
}
|
||||
|
||||
func orderStatusText(status int) string {
|
||||
switch status {
|
||||
case consts.OrderStatusPending:
|
||||
return "待支付"
|
||||
case consts.OrderStatusDelivered:
|
||||
return "已开通"
|
||||
case consts.OrderStatusRefunded:
|
||||
return "已退款"
|
||||
case consts.OrderStatusClosed:
|
||||
return "已关闭"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
// memberNotices 平台规则告知(技能要求:必须向用户说明退款、结算与费率)
|
||||
func memberNotices() []string {
|
||||
return []string{
|
||||
"个人主体小程序全终端月支付限额 10 万元",
|
||||
"Android 等:T+3 结算,腾讯技术服务费 1%",
|
||||
"iOS:约 45-60 天结算,Apple 佣金 12%",
|
||||
"支付时间 180 天内的退款,平台退还手续费;超过 180 天不退还",
|
||||
"iOS 端退款由用户向 App Store 申请,开发者无法主动退款",
|
||||
}
|
||||
}
|
||||
139
internal/logic/migrate.go
Normal file
139
internal/logic/migrate.go
Normal 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
608
internal/logic/quota.go
Normal 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 "aUsageState{
|
||||
Id: lastId,
|
||||
PeriodStart: now,
|
||||
PeriodEnd: now.AddDate(0, 0, periodDays),
|
||||
PeriodDaysSnapshot: periodDays,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
state := "aUsageState{
|
||||
Id: record["id"].Int64(),
|
||||
FreeUsed: record["free_used"].Int(),
|
||||
MemberUsed: record["member_used"].Int(),
|
||||
PeriodStart: record["period_start"].GTime(),
|
||||
PeriodEnd: record["period_end"].GTime(),
|
||||
}
|
||||
if state.PeriodStart == nil || state.PeriodStart.IsZero() {
|
||||
return state.reset(ctx, tx, now, periodDays)
|
||||
}
|
||||
|
||||
// 按「当前配置的周期天数」重算终点:后台改长/改短都能立刻对齐
|
||||
expectedEnd := state.PeriodStart.AddDate(0, 0, periodDays)
|
||||
if !now.Before(expectedEnd) {
|
||||
// 周期已结束 → 归零开新周期
|
||||
return state.reset(ctx, tx, now, periodDays)
|
||||
}
|
||||
if state.PeriodEnd == nil || !state.PeriodEnd.Equal(expectedEnd) {
|
||||
if _, err = tx.Model(consts.TableQuotaUsage).Where("id", state.Id).Data(g.Map{
|
||||
"period_end": expectedEnd,
|
||||
"updated_at": now,
|
||||
}).Update(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.PeriodEnd = expectedEnd
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *quotaUsageState) reset(ctx context.Context, tx gdb.TX, now *gtime.Time, periodDays int) (*quotaUsageState, error) {
|
||||
end := now.AddDate(0, 0, periodDays)
|
||||
if _, err := tx.Model(consts.TableQuotaUsage).Where("id", s.Id).Data(g.Map{
|
||||
"period_start": now,
|
||||
"period_end": end,
|
||||
"free_used": 0,
|
||||
"member_used": 0,
|
||||
"updated_at": now,
|
||||
}).Update(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.FreeUsed = 0
|
||||
s.MemberUsed = 0
|
||||
s.PeriodStart = now
|
||||
s.PeriodEnd = end
|
||||
s.ResetOccurred = true
|
||||
s.PeriodDaysSnapshot = periodDays
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ===== 付费额度余额 =====
|
||||
|
||||
type paidBalance struct {
|
||||
Id int64
|
||||
TimesLeft int
|
||||
ExpireAt *gtime.Time
|
||||
Exists bool
|
||||
}
|
||||
|
||||
func paidExpired(b *paidBalance, now *gtime.Time) bool {
|
||||
if b == nil || !b.Exists {
|
||||
return true
|
||||
}
|
||||
if b.ExpireAt == nil || b.ExpireAt.IsZero() {
|
||||
return true
|
||||
}
|
||||
return !now.Before(b.ExpireAt)
|
||||
}
|
||||
|
||||
// lockPaidBalance 行锁读取用户在某工具上的付费额度余额;为空时返回不存在(不创建)
|
||||
func lockPaidBalance(ctx context.Context, tx gdb.TX, userId int64, toolKey string) (*paidBalance, error) {
|
||||
record, err := tx.Model(consts.TableUserToolQuota).
|
||||
Where("user_id", userId).Where("tool_key", toolKey).LockUpdate().One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record.IsEmpty() {
|
||||
return &paidBalance{Exists: false}, nil
|
||||
}
|
||||
return &paidBalance{
|
||||
Id: record["id"].Int64(),
|
||||
TimesLeft: record["times_left"].Int(),
|
||||
ExpireAt: record["expire_at"].GTime(),
|
||||
Exists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// grantToolQuota 发货:给用户增加某工具的付费额度。
|
||||
// 同工具重复购买 → 次数累加、有效期顺延(base = max(now, 当前到期时间))。
|
||||
func grantToolQuota(
|
||||
ctx context.Context, tx gdb.TX, userId int64, toolKey string, times, validDays int,
|
||||
) (*gtime.Time, error) {
|
||||
if times <= 0 {
|
||||
return nil, gerror.New("次数包次数异常")
|
||||
}
|
||||
if validDays <= 0 {
|
||||
validDays = 365
|
||||
}
|
||||
now := gtime.Now()
|
||||
record, err := tx.Model(consts.TableUserToolQuota).
|
||||
Where("user_id", userId).Where("tool_key", toolKey).LockUpdate().One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
base := now
|
||||
curLeft := 0
|
||||
if !record.IsEmpty() {
|
||||
curExpire := record["expire_at"].GTime()
|
||||
curLeft = record["times_left"].Int()
|
||||
if curExpire != nil && curExpire.After(now) {
|
||||
base = curExpire
|
||||
} else {
|
||||
curLeft = 0 // 已过期:旧余额作废,重新计
|
||||
}
|
||||
}
|
||||
expireAt := base.AddDate(0, 0, validDays)
|
||||
|
||||
if record.IsEmpty() {
|
||||
if _, err = tx.Model(consts.TableUserToolQuota).Data(g.Map{
|
||||
"user_id": userId,
|
||||
"tool_key": toolKey,
|
||||
"times_left": times,
|
||||
"total_bought": times,
|
||||
"expire_at": expireAt,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}).Insert(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return expireAt, nil
|
||||
}
|
||||
if _, err = tx.Model(consts.TableUserToolQuota).Where("id", record["id"].Int64()).Data(g.Map{
|
||||
"times_left": curLeft + times,
|
||||
"total_bought": record["total_bought"].Int() + times,
|
||||
"expire_at": expireAt,
|
||||
"updated_at": now,
|
||||
}).Update(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return expireAt, nil
|
||||
}
|
||||
|
||||
// ===== 额度判定 =====
|
||||
|
||||
// judgeQuota 计算当前会命中哪个池,以及还有没有额度
|
||||
func judgeQuota(tool *entity.Tools, usage *quotaUsageState, paid *paidBalance, isMember bool, now *gtime.Time) quotaVerdict {
|
||||
payCost := tool.PayCost
|
||||
if payCost <= 0 {
|
||||
payCost = 1
|
||||
}
|
||||
if usage == nil || usage.FreeUsed < tool.FreeQuota {
|
||||
return quotaVerdict{Source: consts.QuotaSourceFree}
|
||||
}
|
||||
if isMember && usage.MemberUsed < tool.MemberQuota {
|
||||
return quotaVerdict{Source: consts.QuotaSourceMember}
|
||||
}
|
||||
if paid != nil && paid.Exists && !paidExpired(paid, now) && paid.TimesLeft >= payCost {
|
||||
return quotaVerdict{Source: consts.QuotaSourcePaid}
|
||||
}
|
||||
return quotaVerdict{Source: consts.QuotaSourceNone, Message: quotaExhaustedMessage(tool)}
|
||||
}
|
||||
|
||||
func quotaExhaustedMessage(tool *entity.Tools) string {
|
||||
return "「" + tool.Name + "」本周期额度已用完,开通会员可获得更多额度,或购买次数包继续使用"
|
||||
}
|
||||
|
||||
// ConsumeToolQuota 扣减一次额度(产出结果时调用)。
|
||||
//
|
||||
// 事务内按 out_trade_no 之外的另一把锁保证并发安全:行锁落在 user_quota_usage /
|
||||
// user_tool_quota 上,两个请求同时扣同一个人同一工具时串行执行,不会超发。
|
||||
func ConsumeToolQuota(ctx context.Context, userId int64, toolKey string) (*v1.QuotaToolOut, error) {
|
||||
user, err := getUserById(ctx, userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tool, err := toolRow(ctx, toolKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tool.IsEnabled != 1 {
|
||||
return nil, gerror.New("工具不存在或已下架")
|
||||
}
|
||||
isMember := EffectiveLevelKey(user) != consts.DefaultLevelKey
|
||||
periodDays := EffectiveQuotaPeriodDays(ctx, user)
|
||||
now := gtime.Now()
|
||||
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
usage, err := lockOrInitUsage(ctx, tx, userId, toolKey, periodDays)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paid, err := lockPaidBalance(ctx, tx, userId, toolKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verdict := judgeQuota(tool, usage, paid, isMember, now)
|
||||
switch verdict.Source {
|
||||
case consts.QuotaSourceFree:
|
||||
if _, err = tx.Model(consts.TableQuotaUsage).Where("id", usage.Id).Data(g.Map{
|
||||
"free_used": usage.FreeUsed + 1,
|
||||
"updated_at": now,
|
||||
}).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
case consts.QuotaSourceMember:
|
||||
if _, err = tx.Model(consts.TableQuotaUsage).Where("id", usage.Id).Data(g.Map{
|
||||
"member_used": usage.MemberUsed + 1,
|
||||
"updated_at": now,
|
||||
}).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
case consts.QuotaSourcePaid:
|
||||
payCost := tool.PayCost
|
||||
if payCost <= 0 {
|
||||
payCost = 1
|
||||
}
|
||||
if _, err = tx.Model(consts.TableUserToolQuota).Where("id", paid.Id).Data(g.Map{
|
||||
"times_left": paid.TimesLeft - payCost,
|
||||
"updated_at": now,
|
||||
}).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return gerror.NewCode(gcodeQuotaExhausted(), verdict.Message)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 扣减后回读一次真实状态(含周期已滚动的情形)
|
||||
status, err := ToolQuotaStatus(ctx, user, tool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// ===== 状态查询 =====
|
||||
|
||||
// ToolQuotaStatus 单个工具的额度状态
|
||||
func ToolQuotaStatus(ctx context.Context, user *entity.Users, tool *entity.Tools) (*v1.QuotaToolOut, error) {
|
||||
isMember := EffectiveLevelKey(user) != consts.DefaultLevelKey
|
||||
periodDays := EffectiveQuotaPeriodDays(ctx, user)
|
||||
now := gtime.Now()
|
||||
|
||||
usage := "aUsageState{}
|
||||
record, err := g.Model(consts.TableQuotaUsage).
|
||||
Where("user_id", user.Id).Where("tool_key", tool.ToolKey).One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !record.IsEmpty() {
|
||||
usage.Id = record["id"].Int64()
|
||||
usage.FreeUsed = record["free_used"].Int()
|
||||
usage.MemberUsed = record["member_used"].Int()
|
||||
usage.PeriodStart = record["period_start"].GTime()
|
||||
usage.PeriodEnd = record["period_end"].GTime()
|
||||
// 只读场景不写库:过期即视为用量归零
|
||||
if usage.PeriodEnd == nil || !now.Before(usage.PeriodEnd) {
|
||||
usage.FreeUsed = 0
|
||||
usage.MemberUsed = 0
|
||||
usage.PeriodStart = now
|
||||
usage.PeriodEnd = now.AddDate(0, 0, periodDays)
|
||||
} else if usage.PeriodStart != nil {
|
||||
expectedEnd := usage.PeriodStart.AddDate(0, 0, periodDays)
|
||||
if expectedEnd.After(now) {
|
||||
usage.PeriodEnd = expectedEnd
|
||||
}
|
||||
}
|
||||
} else {
|
||||
usage.PeriodStart = now
|
||||
usage.PeriodEnd = now.AddDate(0, 0, periodDays)
|
||||
}
|
||||
|
||||
paid := &paidBalance{}
|
||||
paidRecord, err := g.Model(consts.TableUserToolQuota).
|
||||
Where("user_id", user.Id).Where("tool_key", tool.ToolKey).One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !paidRecord.IsEmpty() {
|
||||
paid.Exists = true
|
||||
paid.Id = paidRecord["id"].Int64()
|
||||
paid.TimesLeft = paidRecord["times_left"].Int()
|
||||
paid.ExpireAt = paidRecord["expire_at"].GTime()
|
||||
}
|
||||
|
||||
return buildQuotaOut(tool, usage, paid, isMember, periodDays, now), nil
|
||||
}
|
||||
|
||||
// QuotaSnapshotForTools 批量计算多个工具的额度状态(2 次查询搞定,避免 11 个工具 11 次查库)
|
||||
func QuotaSnapshotForTools(
|
||||
ctx context.Context, user *entity.Users, tools []*entity.Tools,
|
||||
) (map[string]*v1.QuotaToolOut, error) {
|
||||
out := make(map[string]*v1.QuotaToolOut, len(tools))
|
||||
if len(tools) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
isMember := EffectiveLevelKey(user) != consts.DefaultLevelKey
|
||||
periodDays := EffectiveQuotaPeriodDays(ctx, user)
|
||||
now := gtime.Now()
|
||||
|
||||
usageRecords, err := g.Model(consts.TableQuotaUsage).Where("user_id", user.Id).All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usageMap := map[string]*quotaUsageState{}
|
||||
for _, r := range usageRecords {
|
||||
usageMap[r["tool_key"].String()] = "aUsageState{
|
||||
Id: r["id"].Int64(),
|
||||
FreeUsed: r["free_used"].Int(),
|
||||
MemberUsed: r["member_used"].Int(),
|
||||
PeriodStart: r["period_start"].GTime(),
|
||||
PeriodEnd: r["period_end"].GTime(),
|
||||
}
|
||||
}
|
||||
|
||||
paidRecords, err := g.Model(consts.TableUserToolQuota).Where("user_id", user.Id).All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paidMap := map[string]*paidBalance{}
|
||||
for _, r := range paidRecords {
|
||||
paidMap[r["tool_key"].String()] = &paidBalance{
|
||||
Id: r["id"].Int64(),
|
||||
TimesLeft: r["times_left"].Int(),
|
||||
ExpireAt: r["expire_at"].GTime(),
|
||||
Exists: true,
|
||||
}
|
||||
}
|
||||
|
||||
for _, tool := range tools {
|
||||
usage := usageMap[tool.ToolKey]
|
||||
if usage == nil {
|
||||
usage = "aUsageState{}
|
||||
}
|
||||
// 只读路径不写库:过期即按归零展示
|
||||
if usage.PeriodEnd == nil || !now.Before(usage.PeriodEnd) {
|
||||
usage.FreeUsed = 0
|
||||
usage.MemberUsed = 0
|
||||
usage.PeriodStart = now
|
||||
usage.PeriodEnd = now.AddDate(0, 0, periodDays)
|
||||
} else if usage.PeriodStart != nil {
|
||||
if expectedEnd := usage.PeriodStart.AddDate(0, 0, periodDays); expectedEnd.After(now) {
|
||||
usage.PeriodEnd = expectedEnd
|
||||
}
|
||||
}
|
||||
paid := paidMap[tool.ToolKey]
|
||||
if paid == nil {
|
||||
paid = &paidBalance{}
|
||||
}
|
||||
out[tool.ToolKey] = buildQuotaOut(tool, usage, paid, isMember, periodDays, now)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildQuotaOut(
|
||||
tool *entity.Tools, usage *quotaUsageState, paid *paidBalance,
|
||||
isMember bool, periodDays int, now *gtime.Time,
|
||||
) *v1.QuotaToolOut {
|
||||
payCost := tool.PayCost
|
||||
if payCost <= 0 {
|
||||
payCost = 1
|
||||
}
|
||||
freeLeft := clampMin(tool.FreeQuota-usage.FreeUsed, 0)
|
||||
memberLeft := 0
|
||||
if isMember {
|
||||
memberLeft = clampMin(tool.MemberQuota-usage.MemberUsed, 0)
|
||||
}
|
||||
paidLeft := 0
|
||||
paidExpireAt := ""
|
||||
if paid != nil && paid.Exists && !paidExpired(paid, now) {
|
||||
paidLeft = clampMax(paid.TimesLeft, 0)
|
||||
if paid.ExpireAt != nil && !paid.ExpireAt.IsZero() {
|
||||
paidExpireAt = paid.ExpireAt.Format("Y-m-d")
|
||||
}
|
||||
}
|
||||
|
||||
verdict := judgeQuota(tool, usage, paid, isMember, now)
|
||||
totalLeft := freeLeft + memberLeft + paidLeft/payCost
|
||||
|
||||
out := &v1.QuotaToolOut{
|
||||
ToolKey: tool.ToolKey,
|
||||
Name: tool.Name,
|
||||
Icon: tool.Icon,
|
||||
FreeTotal: tool.FreeQuota,
|
||||
FreeUsed: usage.FreeUsed,
|
||||
FreeLeft: freeLeft,
|
||||
MemberTotal: tool.MemberQuota,
|
||||
MemberUsed: usage.MemberUsed,
|
||||
MemberLeft: memberLeft,
|
||||
PaidLeft: paidLeft,
|
||||
PaidExpireAt: paidExpireAt,
|
||||
PayCost: payCost,
|
||||
IsMember: isMember,
|
||||
PeriodDays: periodDays,
|
||||
Source: verdict.Source,
|
||||
CanUse: verdict.Source != consts.QuotaSourceNone,
|
||||
TotalLeft: totalLeft,
|
||||
}
|
||||
if usage.PeriodEnd != nil && !usage.PeriodEnd.IsZero() {
|
||||
out.PeriodEnd = usage.PeriodEnd.Format("Y-m-d H:i:s")
|
||||
}
|
||||
if verdict.Source == consts.QuotaSourceNone {
|
||||
out.Message = verdict.Message
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clampMin(v, min int) int {
|
||||
if v < min {
|
||||
return min
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clampMax(v, max int) int {
|
||||
if v > max {
|
||||
return max
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func gcodeQuotaExhausted() gcode.Code {
|
||||
return gcode.New(consts.CodeQuotaExhausted, "", nil)
|
||||
}
|
||||
248
internal/logic/quota_api.go
Normal file
248
internal/logic/quota_api.go
Normal 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
|
||||
}
|
||||
@@ -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"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
302
internal/logic/xpay.go
Normal 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 固定 requestVirtualPayment;B 端为接口路径 /xpay/xxx
|
||||
// signature —— 服务端用「session_key」对 signData 做 HMAC-SHA256(用户态签名)
|
||||
//
|
||||
// 关键约束:
|
||||
// - post_body 必须与实际发出的请求体「完全一致」(不格式化、不改键顺序)
|
||||
// - 金额单位是「分」,全程不换算
|
||||
// - env 固定 0(现网)
|
||||
// ============================================================================
|
||||
|
||||
const (
|
||||
xpayAPIBase = "https://api.weixin.qq.com"
|
||||
xpayURIPayRequest = "requestVirtualPayment" // C 端下单 uri(不带问号参数)
|
||||
xpayURIQueryOrder = "/xpay/query_order" // B 端查单 uri
|
||||
)
|
||||
|
||||
// xpaySignData C 端 signData 结构。
|
||||
// 字段顺序即 JSON 序列化顺序,必须与签名时的字符串完全一致,切勿随意调整。
|
||||
type xpaySignData struct {
|
||||
OfferId string `json:"offerId"`
|
||||
BuyQuantity int `json:"buyQuantity"`
|
||||
Env int `json:"env"`
|
||||
CurrencyType string `json:"currencyType"`
|
||||
ProductId string `json:"productId"`
|
||||
GoodsPrice int64 `json:"goodsPrice"` // 单位:分
|
||||
OutTradeNo string `json:"outTradeNo"`
|
||||
Attach string `json:"attach"`
|
||||
}
|
||||
|
||||
// XPayConfig 虚拟支付配置(manifest/config/config.yaml 的 wx.pay 段)
|
||||
type XPayConfig struct {
|
||||
AppId string
|
||||
OfferId string
|
||||
AppKey string
|
||||
Secret string // 微信 appSecret,取 access_token 用
|
||||
Env int
|
||||
}
|
||||
|
||||
// Configured 关键参数是否齐备。缺失时下单会直接报错,避免生成无法支付的签名。
|
||||
func (c *XPayConfig) Configured() bool {
|
||||
return c.OfferId != "" && c.AppKey != ""
|
||||
}
|
||||
|
||||
func xpayConfig(ctx context.Context) *XPayConfig {
|
||||
appId := g.Cfg().MustGet(ctx, "wx.pay.appId", "").String()
|
||||
if appId == "" {
|
||||
appId = g.Cfg().MustGet(ctx, "wx.appId", "").String()
|
||||
}
|
||||
return &XPayConfig{
|
||||
AppId: appId,
|
||||
OfferId: g.Cfg().MustGet(ctx, "wx.pay.offerId", "").String(),
|
||||
AppKey: g.Cfg().MustGet(ctx, "wx.pay.appKey", "").String(),
|
||||
Secret: g.Cfg().MustGet(ctx, "wx.appSecret", "").String(),
|
||||
Env: g.Cfg().MustGet(ctx, "wx.pay.env", consts.XPayEnvProd).Int(),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 签名 =====
|
||||
|
||||
// CalcPaySig 支付签名:HMAC-SHA256(appKey, uri + "&" + postBody)
|
||||
func CalcPaySig(uri, postBody, appKey string) string {
|
||||
mac := hmac.New(sha256.New, []byte(appKey))
|
||||
mac.Write([]byte(uri + "&" + postBody))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// CalcSignature 用户态签名:HMAC-SHA256(sessionKey, postBody)
|
||||
func CalcSignature(postBody, sessionKey string) string {
|
||||
mac := hmac.New(sha256.New, []byte(sessionKey))
|
||||
mac.Write([]byte(postBody))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// BuildSignData 生成 signData 的 JSON 字符串(返回串必须原样用于签名与下发)
|
||||
func BuildSignData(offerId, productId string, goodsPrice int64, outTradeNo, attach string, env int) (string, error) {
|
||||
data := xpaySignData{
|
||||
OfferId: offerId,
|
||||
BuyQuantity: 1, // 会员一次买一份
|
||||
Env: env,
|
||||
CurrencyType: consts.XPayCurrency,
|
||||
ProductId: productId,
|
||||
GoodsPrice: goodsPrice,
|
||||
OutTradeNo: outTradeNo,
|
||||
Attach: attach,
|
||||
}
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", gerror.Newf("组装 signData 失败: %v", err)
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// ===== access_token(B 端接口需要)=====
|
||||
|
||||
var (
|
||||
accessTokenMu sync.Mutex
|
||||
accessTokenCache string
|
||||
accessTokenExpire time.Time
|
||||
)
|
||||
|
||||
// wxAccessToken 获取并缓存 access_token(stable_token 接口,有效期约 2 小时)
|
||||
func wxAccessToken(ctx context.Context) (string, error) {
|
||||
cfg := xpayConfig(ctx)
|
||||
if cfg.AppId == "" || cfg.Secret == "" {
|
||||
return "", gerror.New("未配置 wx.appId / wx.appSecret,无法获取 access_token")
|
||||
}
|
||||
accessTokenMu.Lock()
|
||||
defer accessTokenMu.Unlock()
|
||||
if accessTokenCache != "" && time.Now().Before(accessTokenExpire) {
|
||||
return accessTokenCache, nil
|
||||
}
|
||||
resp, err := g.Client().Post(ctx, xpayAPIBase+"/cgi-bin/stable_token", g.Map{
|
||||
"grant_type": "client_credential",
|
||||
"appid": cfg.AppId,
|
||||
"secret": cfg.Secret,
|
||||
"force_refresh": false,
|
||||
})
|
||||
if err != nil {
|
||||
return "", gerror.Newf("请求 access_token 失败: %v", err)
|
||||
}
|
||||
defer resp.Close()
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err = json.Unmarshal(resp.ReadAll(), &out); err != nil {
|
||||
return "", gerror.Newf("解析 access_token 响应失败: %v", err)
|
||||
}
|
||||
if out.ErrCode != 0 || out.AccessToken == "" {
|
||||
return "", gerror.Newf("获取 access_token 失败: %d %s", out.ErrCode, out.ErrMsg)
|
||||
}
|
||||
accessTokenCache = out.AccessToken
|
||||
// 提前 5 分钟过期,留出刷新余量
|
||||
ttl := out.ExpiresIn - 300
|
||||
if ttl < 60 {
|
||||
ttl = 60
|
||||
}
|
||||
accessTokenExpire = time.Now().Add(time.Duration(ttl) * time.Second)
|
||||
return accessTokenCache, nil
|
||||
}
|
||||
|
||||
// ===== B 端:查询订单(兜底发货)=====
|
||||
|
||||
// xpayOrderState 查单结果(只保留发货判定需要的字段)
|
||||
type xpayOrderState struct {
|
||||
Paid bool // 是否已支付
|
||||
WxOrderId string // 平台单号(若响应中可得)
|
||||
RawJson string // 原始响应,排障用
|
||||
}
|
||||
|
||||
// 查单响应体。字段名以官方文档为准;未在文档中明确的字段(wxOrderId 等)
|
||||
// 采用「多别名兼容 + 缺失不致命」策略,避免因平台临时调整字段名而误判。
|
||||
type xpayQueryOrderResp struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
Order struct {
|
||||
OrderId string `json:"orderId"`
|
||||
WxOrderId string `json:"wxOrderId"`
|
||||
WxOrderId2 string `json:"wx_order_id"`
|
||||
Token string `json:"token"`
|
||||
OrderFee int64 `json:"orderFee"`
|
||||
PaidFee int64 `json:"paidFee"`
|
||||
PaidTime int64 `json:"paidTime"`
|
||||
Status int `json:"status"`
|
||||
OrderType int `json:"orderType"`
|
||||
} `json:"order"`
|
||||
}
|
||||
|
||||
// XPayQueryOrder 主动查单:推送丢失时的兜底路径。
|
||||
//
|
||||
// 已支付判定依据是 `paidTime > 0 || paidFee > 0`,而不是 order.status —— 原因是
|
||||
// status 的枚举在官方文档中未完整给出(社区可见 3 表示已发货,orderType 还新增了
|
||||
// iOS 的 7/8),依赖枚举值有误判风险;而「有支付时间/实付金额」在任何渠道下都成立。
|
||||
func XPayQueryOrder(ctx context.Context, openid, outTradeNo string) (*xpayOrderState, error) {
|
||||
cfg := xpayConfig(ctx)
|
||||
if !cfg.Configured() {
|
||||
return nil, gerror.New("虚拟支付未配置(缺少 wx.pay.offerId / wx.pay.appKey)")
|
||||
}
|
||||
token, err := wxAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 请求体字符串必须与实际发出的完全一致 —— 先序列化,再拿同一串去签名
|
||||
body := g.Map{
|
||||
"openid": openid,
|
||||
"env": cfg.Env,
|
||||
"order_id": outTradeNo,
|
||||
}
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, gerror.Newf("组装查单请求体失败: %v", err)
|
||||
}
|
||||
postBody := string(bodyBytes)
|
||||
paySig := CalcPaySig(xpayURIQueryOrder, postBody, cfg.AppKey)
|
||||
|
||||
url := fmt.Sprintf("%s%s?access_token=%s&pay_sig=%s", xpayAPIBase, xpayURIQueryOrder, token, paySig)
|
||||
resp, err := g.Client().ContentJson().Post(ctx, url, postBody)
|
||||
if err != nil {
|
||||
return nil, gerror.Newf("调用 query_order 失败: %v", err)
|
||||
}
|
||||
defer resp.Close()
|
||||
raw := string(resp.ReadAll())
|
||||
|
||||
out := &xpayQueryOrderResp{}
|
||||
if err = json.Unmarshal([]byte(raw), out); err != nil {
|
||||
return &xpayOrderState{RawJson: raw}, gerror.Newf("解析 query_order 响应失败: %v", err)
|
||||
}
|
||||
if out.ErrCode != 0 {
|
||||
return &xpayOrderState{RawJson: raw}, gerror.Newf("query_order 返回错误: %d %s", out.ErrCode, out.ErrMsg)
|
||||
}
|
||||
wxOrderId := out.Order.WxOrderId
|
||||
if wxOrderId == "" {
|
||||
wxOrderId = out.Order.WxOrderId2
|
||||
}
|
||||
if wxOrderId == "" {
|
||||
wxOrderId = out.Order.Token
|
||||
}
|
||||
state := &xpayOrderState{
|
||||
Paid: out.Order.PaidTime > 0 || out.Order.PaidFee > 0,
|
||||
WxOrderId: wxOrderId,
|
||||
RawJson: raw,
|
||||
}
|
||||
g.Log().Debugf(ctx, "[xpay] query_order outTradeNo=%s paid=%v wxOrderId=%s raw=%s",
|
||||
outTradeNo, state.Paid, state.WxOrderId, raw)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// ===== 发货推送解析 =====
|
||||
|
||||
// XPayDeliverNotify 道具发货推送(XML)解析结构
|
||||
type XPayDeliverNotify struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
FromUserName string `xml:"FromUserName"`
|
||||
CreateTime int64 `xml:"CreateTime"`
|
||||
MsgType string `xml:"MsgType"`
|
||||
Event string `xml:"Event"`
|
||||
OpenId string `xml:"OpenId"`
|
||||
OutTradeNo string `xml:"OutTradeNo"`
|
||||
Env int `xml:"Env"`
|
||||
WeChatPayInfo struct {
|
||||
MchOrderNo string `xml:"MchOrderNo"`
|
||||
} `xml:"WeChatPayInfo"`
|
||||
GoodsInfo struct {
|
||||
ProductId string `xml:"ProductId"`
|
||||
Quantity int `xml:"Quantity"`
|
||||
} `xml:"GoodsInfo"`
|
||||
}
|
||||
|
||||
// ParseDeliverNotify 解析发货推送 XML
|
||||
func ParseDeliverNotify(raw []byte) (*XPayDeliverNotify, error) {
|
||||
notify := &XPayDeliverNotify{}
|
||||
if err := xml.Unmarshal(raw, notify); err != nil {
|
||||
return nil, gerror.Newf("解析发货推送 XML 失败: %v", err)
|
||||
}
|
||||
if notify.Event != consts.XPayNotifyEvent {
|
||||
return nil, gerror.Newf("非发货推送事件: %s", notify.Event)
|
||||
}
|
||||
if notify.OutTradeNo == "" {
|
||||
return nil, gerror.New("发货推送缺少 OutTradeNo")
|
||||
}
|
||||
return notify, nil
|
||||
}
|
||||
|
||||
// XPayNotifyAck 发货推送成功应答(平台要求返回 0,否则最多重试 15 次)
|
||||
func XPayNotifyAck() string {
|
||||
return "<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)
|
||||
}
|
||||
33
internal/model/entity/entity_member_orders.go
Normal file
33
internal/model/entity/entity_member_orders.go
Normal 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"`
|
||||
}
|
||||
26
internal/model/entity/entity_member_plans.go
Normal file
26
internal/model/entity/entity_member_plans.go
Normal 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"`
|
||||
}
|
||||
27
internal/model/entity/entity_quota_packs.go
Normal file
27
internal/model/entity/entity_quota_packs.go
Normal 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"`
|
||||
}
|
||||
14
internal/model/entity/entity_settings.go
Normal file
14
internal/model/entity/entity_settings.go
Normal 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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
19
internal/model/entity/entity_user_quota_usage.go
Normal file
19
internal/model/entity/entity_user_quota_usage.go
Normal 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"`
|
||||
}
|
||||
18
internal/model/entity/entity_user_tool_quota.go
Normal file
18
internal/model/entity/entity_user_tool_quota.go
Normal 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"`
|
||||
}
|
||||
@@ -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
79
sql/member_pay.sql
Normal 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
153
sql/quota.sql
Normal file
@@ -0,0 +1,153 @@
|
||||
-- ============================================================================
|
||||
-- 工具额度体系(免费 / 会员 / 付费次数包)建表脚本
|
||||
--
|
||||
-- ⚠️ 这份脚本是「给人看的对照版」。服务端启动时会自动执行等价且幂等的迁移
|
||||
-- (见 internal/logic/migrate.go),所以正常升级只需要重启后端,不用手工跑。
|
||||
-- 仅当你需要手工核对、或在没有服务端的环境里初始化时,才执行本文件。
|
||||
--
|
||||
-- 幂等:可重复执行。表用 CREATE TABLE IF NOT EXISTS,列先用 information_schema
|
||||
-- 判存在再 ALTER(MySQL 不支持 ADD COLUMN IF NOT EXISTS)。
|
||||
-- ============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. 工具额度配置(在「工具管理」里维护)
|
||||
-- ---------------------------------------------------------------------------
|
||||
SET @db := DATABASE();
|
||||
|
||||
SET @sql := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'tools' AND COLUMN_NAME = 'free_quota') = 0,
|
||||
'ALTER TABLE `tools` ADD COLUMN `free_quota` int NOT NULL DEFAULT 3 COMMENT ''免费额度(每周期次数)''',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
SET @sql := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'tools' AND COLUMN_NAME = 'member_quota') = 0,
|
||||
'ALTER TABLE `tools` ADD COLUMN `member_quota` int NOT NULL DEFAULT 100 COMMENT ''会员额度(每周期次数)''',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
SET @sql := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'tools' AND COLUMN_NAME = 'pay_cost') = 0,
|
||||
'ALTER TABLE `tools` ADD COLUMN `pay_cost` int NOT NULL DEFAULT 1 COMMENT ''付费额度每次消耗系数''',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. 额度周期:用户与会员套餐各自的覆盖值(0 = 跟随全局设置)
|
||||
-- ---------------------------------------------------------------------------
|
||||
SET @sql := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'quota_period_days') = 0,
|
||||
'ALTER TABLE `users` ADD COLUMN `quota_period_days` int NOT NULL DEFAULT 0 COMMENT ''额度重置周期(天),0=跟随全局''',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
SET @sql := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_plans' AND COLUMN_NAME = 'quota_period_days') = 0,
|
||||
'ALTER TABLE `member_plans` ADD COLUMN `quota_period_days` int NOT NULL DEFAULT 0 COMMENT ''购买后生效的额度周期(天),0=不调整''',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. 订单表承载两类商品(会员套餐 / 次数包)
|
||||
-- ---------------------------------------------------------------------------
|
||||
SET @sql := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'order_type') = 0,
|
||||
'ALTER TABLE `member_orders` ADD COLUMN `order_type` tinyint NOT NULL DEFAULT 1 COMMENT ''1=会员套餐 2=次数包''',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
SET @sql := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'pack_key') = 0,
|
||||
'ALTER TABLE `member_orders` ADD COLUMN `pack_key` varchar(64) NOT NULL DEFAULT '''' COMMENT ''次数包档位''',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
SET @sql := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'tool_key') = 0,
|
||||
'ALTER TABLE `member_orders` ADD COLUMN `tool_key` varchar(64) NOT NULL DEFAULT '''' COMMENT ''次数包绑定工具''',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
SET @sql := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'times') = 0,
|
||||
'ALTER TABLE `member_orders` ADD COLUMN `times` int NOT NULL DEFAULT 0 COMMENT ''次数包次数''',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. 次数包档位(在「次数包管理」里维护;product_id 必须与 MP 后台道具一致)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `quota_packs` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`pack_key` varchar(64) NOT NULL COMMENT '档位标识,跨端契约',
|
||||
`name` varchar(64) NOT NULL DEFAULT '' COMMENT '展示名',
|
||||
`times` int NOT NULL DEFAULT 0 COMMENT '可用次数',
|
||||
`price_cents` bigint NOT NULL DEFAULT 0 COMMENT '价格(分)',
|
||||
`product_id` varchar(64) NOT NULL DEFAULT '' COMMENT '微信虚拟支付道具 ID',
|
||||
`valid_days` int NOT NULL DEFAULT 365 COMMENT '有效期天数,默认一年',
|
||||
`tool_key` varchar(64) NOT NULL DEFAULT '' COMMENT '绑定工具,空=通用档位',
|
||||
`badge` varchar(32) NOT NULL DEFAULT '' COMMENT '角标文案',
|
||||
`sort` int NOT NULL DEFAULT 0,
|
||||
`is_enabled` tinyint NOT NULL DEFAULT 1,
|
||||
`remark` varchar(255) NOT NULL DEFAULT '',
|
||||
`created_at` datetime DEFAULT NULL,
|
||||
`updated_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_pack_key` (`pack_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='次数包档位(付费额度包)';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5. 用户付费额度余额(按工具绑定,买断制)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `user_tool_quota` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`user_id` bigint NOT NULL,
|
||||
`tool_key` varchar(64) NOT NULL,
|
||||
`times_left` int NOT NULL DEFAULT 0 COMMENT '剩余次数',
|
||||
`total_bought` int NOT NULL DEFAULT 0 COMMENT '累计购买次数',
|
||||
`expire_at` datetime DEFAULT NULL COMMENT '到期时间,NULL 视为已过期',
|
||||
`created_at` datetime DEFAULT NULL,
|
||||
`updated_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_tool` (`user_id`,`tool_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户工具付费额度余额';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. 用户周期用量(滚动窗口,归零由服务端在读写时判定)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `user_quota_usage` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`user_id` bigint NOT NULL,
|
||||
`tool_key` varchar(64) NOT NULL,
|
||||
`period_start` datetime NOT NULL COMMENT '当前周期起点',
|
||||
`period_end` datetime NOT NULL COMMENT '当前周期终点',
|
||||
`free_used` int NOT NULL DEFAULT 0 COMMENT '本周期已用免费额度',
|
||||
`member_used` int NOT NULL DEFAULT 0 COMMENT '本周期已用会员额度',
|
||||
`updated_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_tool` (`user_id`,`tool_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户工具额度周期用量';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7. 后台可改的全局配置(额度周期等)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `settings` (
|
||||
`k` varchar(64) NOT NULL,
|
||||
`v` varchar(255) NOT NULL DEFAULT '',
|
||||
`remark` varchar(255) NOT NULL DEFAULT '',
|
||||
`updated_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`k`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台可改的全局配置';
|
||||
|
||||
-- 种子数据(服务端启动时也会幂等写入同样的内容)
|
||||
INSERT IGNORE INTO `settings` (`k`, `v`, `remark`, `updated_at`)
|
||||
VALUES ('quota_period_days', '7', '全局默认额度重置周期(天)', NOW());
|
||||
Reference in New Issue
Block a user