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)。 func MemberOrderCreate(ctx context.Context, planKey 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, }) 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 处理虚拟支付发货推送。 // // 安全策略:平台推送不保证携带可用于验签的字段,因此当虚拟支付已配置时, // 一律「先主动查单复核,确认已支付才发货」,查单结果即最可靠的验签; // 未配置(联调阶段)时按推送内容发货并打告警日志。 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 申请,开发者无法主动退款", } }