735 lines
24 KiB
Go
735 lines
24 KiB
Go
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
|
||
}
|
||
// plan_key / level_key 是会员专有列,但老表把它们建成了 NOT NULL 且无默认值
|
||
// (次数包订单没有这两个语义)→ 不补值会报
|
||
// `Error 1364: Field 'plan_key' doesn't have a default value`。
|
||
//
|
||
// 这里统一兜底成空串,而不是去把库列改成 DEFAULT '':
|
||
// level_key 一旦被静默填成空串,发货时 extendMembership 会把用户等级写成空,
|
||
// 属于「无声降级」。保持列严格、由唯一的下单入口显式补齐,才是安全的做法。
|
||
for _, k := range []string{"plan_key", "level_key"} {
|
||
if _, ok := order[k]; !ok {
|
||
order[k] = ""
|
||
}
|
||
}
|
||
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()
|
||
// 空 toolKey 会写出一条挂在空工具上的额度记录,times<=0 则是「发了等于没发」,
|
||
// 两者都属于数据异常,直接拒绝发货并告警,不要静默放过。
|
||
if toolKey == "" || times <= 0 {
|
||
return gerror.Newf("订单 %s 次数包信息异常: toolKey=%q times=%d", outTradeNo, toolKey, times)
|
||
}
|
||
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()
|
||
// 空等级会让 extendMembership 把 users.level_key 写成空串(等于无声降权),
|
||
// 这里拦住:宁可发货失败留一条告警日志,也不要污染用户数据。
|
||
if levelKey == "" {
|
||
return gerror.Newf("订单 %s 缺少等级信息(level_key 为空),拒绝发货以免清空用户等级", outTradeNo)
|
||
}
|
||
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 申请,开发者无法主动退款",
|
||
}
|
||
}
|