Files
qitongxue-api/internal/logic/member.go
2026-09-29 15:48:13 +08:00

745 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 创建会员订单(**只建单**,不返回支付参数;N1-1/2)。
//
// 同一用户 + 同一套餐 + 待支付 的订单存在则复用(reused=true),保证待支付单恒为 1 条。
// 支付参数由 POST /member/order/pay 单独构建(见 order_pay.go 的 buildPayParams)。
// platform:客户端平台标识,用于判定 pay_channel(ios → apple,其余 → wxpay)。
func MemberOrderCreate(ctx context.Context, planKey, platform string) (*v1.MemberOrderCreateRes, error) {
userId := CtxUserId(ctx)
if !xpayConfig(ctx).Configured() {
return nil, gerror.NewCode(gcodePayFail(),
"虚拟支付尚未配置,请在 MP 后台【虚拟支付 → 基本配置】获取 OfferID 与现网 AppKey 后填入 config.yaml")
}
plan, err := enabledPlanByKey(ctx, planKey)
if err != nil {
return nil, err
}
res, err := createOrder(ctx, userId, consts.OrderTypeMember, plan.ProductId, plan.PriceCents,
g.Map{"plan_key": plan.PlanKey},
g.Map{
"order_type": consts.OrderTypeMember,
"plan_key": plan.PlanKey,
"level_key": plan.LevelKey,
"duration_days": plan.DurationDays,
"pay_channel": payChannelFromRequest(ctx, platform),
})
if err != nil {
return nil, err
}
return &v1.MemberOrderCreateRes{
OutTradeNo: res.OutTradeNo,
Reused: res.Reused,
OriginPriceCents: res.OriginPriceCents,
PaidPriceCents: res.PaidPriceCents,
Status: res.Status,
}, nil
}
// enabledPlanByKey 取启用的会员套餐;不存在 / 已下架 / 未配置道具 ID 或价格 → 支付失败错误。
func enabledPlanByKey(ctx context.Context, planKey string) (*entity.MemberPlans, error) {
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 或价格,请先在后台道具管理中核对")
}
return plan, nil
}
// MemberOrderDetail 订单详情(O3 / N1-3):非本人单 → 4015(与「不存在」统一文案,防枚举)。
func MemberOrderDetail(ctx context.Context, outTradeNo string) (*v1.MemberOrderDetailRes, error) {
o, err := mustOwnOrder(ctx, CtxUserId(ctx), outTradeNo)
if err != nil {
return nil, err
}
// 老订单(加列前)origin/paid 为 0 → 回退「原价 = 实付 = price_cents」(S14/R4)。
origin, paid := o.OriginPriceCents, o.PaidPriceCents
if origin <= 0 {
origin = o.PriceCents
}
if paid <= 0 {
paid = o.PriceCents
}
res := &v1.MemberOrderDetailRes{
OutTradeNo: o.OutTradeNo,
OrderType: o.OrderType,
PlanKey: o.PlanKey,
PackKey: o.PackKey,
ToolKey: o.ToolKey,
Times: o.Times,
OriginPriceCents: origin,
PaidPriceCents: paid,
DiscountCents: o.DiscountCents,
PromoKind: o.PromoKind,
PromoCode: o.PromoCode,
UserCouponId: o.UserCouponId,
Status: o.Status,
StatusText: orderStatusText(o.Status),
CreatedAt: timeStr(o.CreatedAt),
DeliveredAt: timeStr(o.DeliveredAt),
}
planNames, _ := planNameMap(ctx)
packNames, _ := quotaPackNameMap(ctx)
toolNames, _ := toolNameMap(ctx)
if o.OrderType == consts.OrderTypeQuota {
res.ToolName = toolNames[o.ToolKey]
res.Title = packNames[o.PackKey]
if res.ToolName != "" && res.Title != "" {
res.Title = res.ToolName + " · " + res.Title
}
} else {
res.PlanName = planNames[o.PlanKey]
res.Title = res.PlanName
}
return res, nil
}
// ===== 支付链路的公共部分 =====
//
// 两步式改造(N1)后,原「一步式」的 xpayPayData / prepareXpayOrder(建单 + 拼签名耦合、必传 code)
// 已拆分并迁移到 order_pay.go:
// createOrder —— 只建单 / 复用(不校验支付人)
// buildPayParams —— 只建签名(此处才校验「支付人 = 登录人」)
// 会员套餐与次数包两条链路共用这两个函数,保证行为一致(C-6)。
// 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()],
PackKey: r["pack_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(),
// T04.6:优惠凭证快照(老订单无此列时读回 0/空串,展示层按原价=实付处理)
OriginPriceCents: r["origin_price_cents"].Int64(),
PaidPriceCents: r["paid_price_cents"].Int64(),
PromoCode: r["promo_code"].String(),
DiscountCents: r["discount_cents"].Int64(),
PromoKind: r["promo_kind"].Int(),
}
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 处理 /pay/notify 推送(唯一入口,按 Event 分流):
// - xpay_goods_deliver_notify → 发货(deliverNotifyHandle)
// - xpay_refund_notify → 退款结果确认(refundNotifyHandle,见 order_refund.go)
// - 其他事件 → 告警并回成功(避免平台对不认识的事件无意义重试 15 次)
func PayNotifyHandle(ctx context.Context, raw []byte) (ack string, err error) {
event, err := ParseNotifyEvent(raw)
if err != nil {
g.Log().Warningf(ctx, "[xpay] 推送解析失败: %v raw=%s", err, string(raw))
return XPayNotifyAckFail("bad payload"), nil
}
switch event {
case consts.XPayNotifyEvent:
return deliverNotifyHandle(ctx, raw)
case consts.XPayRefundNotifyEvent:
return refundNotifyHandle(ctx, raw)
default:
g.Log().Warningf(ctx, "[xpay] 未处理的推送事件,忽略: %s", event)
return XPayNotifyAck(), nil
}
}
// deliverNotifyHandle 处理发货推送。
//
// 安全策略:平台推送不保证携带可用于验签的字段,因此当虚拟支付已配置时,
// 一律「先主动查单复核,确认已支付才发货」,查单结果即最可靠的验签;
// 未配置时**拒绝发货**(回失败应答)——否则任何人 POST /pay/notify 都能给自己发货。
// 确需无平台联调时,显式打开 wx.pay.allowUnverifiedNotify=true(默认 false,仅限本机联调)。
func deliverNotifyHandle(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
}
switch order.Status {
case consts.OrderStatusDelivered:
return XPayNotifyAck(), nil // 幂等:重复推送直接成功
case consts.OrderStatusRefunded, consts.OrderStatusClosed:
// 已退款/已关闭的订单绝不允许被推送「复活」重新发货
g.Log().Warningf(ctx, "[xpay] 订单已终态(status=%d),拒绝发货: %s", order.Status, order.OutTradeNo)
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 if g.Cfg().MustGet(ctx, "wx.pay.allowUnverifiedNotify", false).Bool() {
g.Log().Warningf(ctx, "[xpay] 虚拟支付未配置且已开启 allowUnverifiedNotify,按推送内容发货(仅限本机联调): %s", order.OutTradeNo)
} else {
g.Log().Warningf(ctx, "[xpay] 虚拟支付未配置,拒绝按推送发货(如需联调请开 wx.pay.allowUnverifiedNotify): %s", order.OutTradeNo)
return XPayNotifyAckFail("xpay not configured"), nil
}
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) {
switch order.Status {
case consts.OrderStatusDelivered:
return true, nil
case consts.OrderStatusRefunded, consts.OrderStatusClosed:
// 已退款/已关闭的订单不允许再触发发货
return false, 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)
}
// 状态守卫:只有待支付(0)可发货;已发货幂等返回;已退款/已关闭绝不允许被重复发货
switch row["status"].Int() {
case consts.OrderStatusDelivered:
return nil // 幂等:已发货直接返回
case consts.OrderStatusRefunded:
return gerror.Newf("订单 %s 已退款,拒绝发货", outTradeNo)
case consts.OrderStatusClosed:
return gerror.Newf("订单 %s 已关闭,拒绝发货", outTradeNo)
}
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 申请,开发者无法主动退款",
}
}