Files
qitongxue-api/internal/logic/member.go
2026-09-17 17:47:09 +08:00

735 lines
24 KiB
Go
Raw Permalink 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 创建会员订单,返回前端拉支付所需的 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 申请,开发者无法主动退款",
}
}