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