package logic import ( "context" "strings" "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" v1 "tool-api/api/admin/v1" "tool-api/internal/consts" "tool-api/internal/model/entity" ) // ============================================================================ // 管理端「订单管理 + 退款」逻辑 // // 数据源:member_orders(order_type 1=会员套餐 2=次数包,同一张表)。 // // 退款语义(本次核心): // - 仅允许 status=1(已发货) → 2(已退款);其它状态给可读业务错误(4015)。 // - 幂等:status=2 再调 → 成功返回,但**不重复回收权益**(幂等由行锁 + status 判定保证)。 // - revokeService=true → 回收所购服务: // 会员套餐:回退 users.level_expire_at(减去本单购买的天数),回收后若已到期则同时回落默认等级; // level_expire_at IS NULL(永久)**同样真取消**:置为当前时刻(已过期,用户裁定 2026-09-23), // level_key 保留不清空,失效判定交给 EffectiveLevelKey。 // 次数包: 扣回 user_tool_quota 的付费次数(扣到 0 为止,不出负数)。 // revokeService=false → 仅改状态,权益保留。 // - 释放券:退款时释放该订单占用的免单券 / 优惠码(仅当状态确实 1→2 时执行)。 // - 审计:记录操作人(审计上下文)、订单号、是否回收服务。 // // 幂等/一致性设计说明: // 回收权益与状态变更在**同一事务**内完成(按 out_trade_no 行锁),任一失败整体回滚 → // 「已退款必然已按 revokeService 处理完毕」。新增列 service_revoked 记录**实际回收结果** // (勾选「取消服务」且确有生效中的授权被解除才为 1;勾了但无可回收授权为 0,避免「列表显示已回收 // 但实际什么都没回收」的不诚实记录),用于列表展示与审计留痕(即便将来放开「退款后再回收」也据此判重)。 // ============================================================================ // AdminOrderList 订单列表(分页 + 关键字 + 状态筛选,默认创建时间倒序)。 func AdminOrderList(ctx context.Context, req *v1.OrderListReq) (*v1.OrderListRes, error) { page, pageSize := normalizePage(req.Page, req.PageSize, 20) m := g.Model(consts.TableMemberOrders) if req.Status != nil { m = m.Where("status", *req.Status) } if kw := strings.TrimSpace(req.Keyword); kw != "" { like := "%" + kw + "%" // 关键字:订单号 直接匹配;用户名/昵称 通过 users 子查询匹配归属用户 m = m.Where( "out_trade_no LIKE ? OR user_id IN (SELECT id FROM "+consts.TableUsers+" WHERE nickname LIKE ? OR username LIKE ?)", like, like, like, ) } total, err := m.Count() if err != nil { return nil, err } records, err := m.Page(page, pageSize).OrderDesc("created_at").OrderDesc("id").All() if err != nil { return nil, err } userInfo := orderUsersMap(ctx, records) planNames, _ := planNameMap(ctx) packNames, _ := quotaPackNameMap(ctx) toolNames, _ := toolNameMap(ctx) list := make([]v1.OrderItem, 0, len(records)) for _, r := range records { uid := r["user_id"].Int64() orderType := r["order_type"].Int() if orderType == 0 { orderType = consts.OrderTypeMember // 兼容加列之前的历史订单 } item := v1.OrderItem{ Id: r["id"].Int64(), OutTradeNo: r["out_trade_no"].String(), UserId: uid, OrderType: orderType, PlanKey: r["plan_key"].String(), PackKey: r["pack_key"].String(), ToolKey: r["tool_key"].String(), Times: r["times"].Int(), OriginPriceCents: r["origin_price_cents"].Int64(), DiscountCents: r["discount_cents"].Int64(), PaidPriceCents: r["paid_price_cents"].Int64(), PromoKind: r["promo_kind"].Int(), Status: r["status"].Int(), StatusText: orderStatusText(r["status"].Int()), ServiceRevoked: r["service_revoked"].Int(), PayChannel: r["pay_channel"].String(), RefundStatus: r["refund_status"].Int(), RefundNo: r["refund_no"].String(), RefundAmount: r["refund_amount"].Int64(), RefundReason: r["refund_reason"].String(), CreatedAt: timeStr(r["created_at"].GTime()), PaidAt: timeStr(r["paid_at"].GTime()), RefundedAt: timeStr(r["refunded_at"].GTime()), } if u, ok := userInfo[uid]; ok { item.Nickname = u["nickname"].String() item.Username = u["username"].String() item.AvatarUrl = u["avatar_url"].String() } item.Title = orderTitle(orderType, r["plan_key"].String(), r["pack_key"].String(), r["tool_key"].String(), planNames, packNames, toolNames) list = append(list, item) } return &v1.OrderListRes{List: list, Total: total}, nil } // orderUsersMap 批量取订单归属用户信息(一次查询,避免 N+1)。 func orderUsersMap(ctx context.Context, records gdb.Result) map[int64]gdb.Record { out := map[int64]gdb.Record{} idSet := map[int64]struct{}{} for _, r := range records { if uid := r["user_id"].Int64(); uid > 0 { idSet[uid] = struct{}{} } } if len(idSet) == 0 { return out } ids := make([]int64, 0, len(idSet)) for id := range idSet { ids = append(ids, id) } rows, err := g.Model(consts.TableUsers).WhereIn("id", ids).All() if err != nil { return out } for _, u := range rows { out[u["id"].Int64()] = u } return out } // orderTitle 计算订单商品展示名。 // // 会员套餐 → 套餐名;次数包 → 工具名 · 档位名(工具名缺失时仅档位名)。 func orderTitle( orderType int, planKey, packKey, toolKey string, planNames, packNames, toolNames map[string]string, ) string { if orderType == consts.OrderTypeQuota { title := packNames[packKey] if tn := toolNames[toolKey]; tn != "" && title != "" { title = tn + " · " + title } return title } return planNames[planKey] } // AdminOrderRefund 已迁移至 order_refund.go(真退款状态机: // 调微信 /xpay/refund_order + 回调分流 + 查单轮询,权益回收移到确认成功时)。 // revokeOrderServiceTx 回收订单所购服务(仅已知订单行快照,事务内、行锁已持有)。 // // 返回 revoked = 是否确有「生效中的授权」被解除,供 service_revoked 按**实际结果**落库。 func revokeOrderServiceTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) (bool, error) { if o.OrderType == consts.OrderTypeQuota { return revokeQuotaServiceTx(ctx, tx, o) } return revokeMemberServiceTx(ctx, tx, o) } // revokeQuotaServiceTx 次数包回收:扣回对应工具付费次数(扣到 0 为止,不出负数)。 // // 不变量:total_bought >= times_left(扣减 total_bought 时不低于扣减后的 times_left), // 使看板「付费额度累计消耗 = Σ(total_bought - times_left)」保持自洽。 // // 返回 revoked = 实际扣回了「生效中的付费额度」(原 times_left > 0): // 无额度记录 / 额度已耗尽(=0) 时不产生实际回收,返回 false。 func revokeQuotaServiceTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) (bool, error) { if o.ToolKey == "" || o.Times <= 0 { // 数据异常(正常发货要求 toolKey 非空且 times>0):无可回收项,跳过并告警 g.Log().Warningf(ctx, "[refund] 次数包订单缺少工具/次数,跳过回收 outTradeNo=%s toolKey=%q times=%d", o.OutTradeNo, o.ToolKey, o.Times) return false, nil } row, err := tx.Model(consts.TableUserToolQuota). Where("user_id", o.UserId).Where("tool_key", o.ToolKey).LockUpdate().One() if err != nil { return false, err } if row.IsEmpty() { // 该用户在该工具上已无付费额度记录(可能已过期清理):无可回收项 return false, nil } id := row["id"].Int64() left := row["times_left"].Int() bought := row["total_bought"].Int() newLeft := left - o.Times if newLeft < 0 { newLeft = 0 // 扣到 0 为止,绝不为负 } newBought := bought - o.Times if newBought < newLeft { newBought = newLeft } if _, err = tx.Model(consts.TableUserToolQuota).Where("id", id).Data(g.Map{ "times_left": newLeft, "total_bought": newBought, // ⚠️ 库内 NOW():避免 Go 侧传参 datetime 的 TZ-01 8h 偏差(新代码不得引入)。 "updated_at": gdb.Raw("NOW()"), }).Update(); err != nil { return false, err } g.Log().Infof(ctx, "[refund] 次数包回收 userId=%d tool=%s -%d 次(剩余 %d→%d)outTradeNo=%s", o.UserId, o.ToolKey, o.Times, left, newLeft, o.OutTradeNo) // 原本就有可用额度才构成「生效中的授权被解除」;额度已为 0 时扣减不产生实际回收。 return left > 0, nil } // revokeMemberServiceTx 会员套餐回收:回退 users.level_expire_at。 // // 回收模型(重放算账,修复「退 A 误扣 B」): // - 旧行为 `GREATEST(level_expire_at - INTERVAL days DAY, now)` 是对「当前到期时间」盲目减 // 本单天数:若本单到期后用户又买了 B,退 A 会把 **B 的天数一起减掉**。 // - 新行为:按发货同款顺延规则(extendMembership),重放该用户所有「已发货未退款」的会员单 // (本单在进入本函数前已被置为 status=2 → 自然排除),得到理论到期时间,再把当前到期时间 // 收敛过去。审计例:T 买 A(30d)→到期→T+60 买 B(30d)→退 A:重放只剩 B → 到期仍为 T+90, // 一分不减。 // - level_expire_at 为 NULL(永久)→ 维持用户裁定(2026-09-23)**真取消、置为已过期**: // 写入 level_expire_at = NOW();level_key 保留不清空,失效判定交给 EffectiveLevelKey。 // - 等级回落**只经 EffectiveLevelKey 收敛**(与「自然到期」口径一致),本函数不改 level_key。 // // ⚠️ TZ-01 铁律:本库 DSN 未指定 loc,Go 侧传参 datetime 落库会早一个时区偏移。 // 重放用的 paid_at 全部**读自 DB**(同一时区口径),差值 deltaMinutes 由两个 DB 值相减 // (偏移相消),最终用 `level_expire_at - INTERVAL ? MINUTE` 在**库内**完成算术, // GREATEST(..., NOW()) 兜底「已过期哨兵」(与旧实现同款豁免);updated_at 用库内 NOW()。 // // 返回 revoked = 回收**前**是否为「生效中的会员」(唯一判定走 EffectiveLevelKey), // 供 service_revoked 按实际结果落库(已过期 / 免费用户退款时不记「已回收」)。 func revokeMemberServiceTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) (bool, error) { row, err := tx.Model(consts.TableUsers).Where("id", o.UserId).LockUpdate().One() if err != nil { return false, err } if row.IsEmpty() { return false, nil } expire := row["level_expire_at"].GTime() // 回收前是否为「生效中的会员」:唯一判定走 EffectiveLevelKey(永久 NULL 与未过期同为有效,已过期回落默认)。 wasEffective := EffectiveLevelKey(&entity.Users{ LevelKey: row["level_key"].String(), LevelExpireAt: expire, }) != consts.DefaultLevelKey // 永久会员:真取消,置为已过期(用户裁定 2026-09-23)。 // NOW() 在库内取,规避 Go 侧传参的时区偏差(TZ-01);WHERE 限定 IS NULL,绝不误动有限期。 if expire == nil || expire.IsZero() { if _, err = tx.Exec( "UPDATE "+consts.TableUsers+" SET level_expire_at = NOW(), updated_at = NOW() "+ "WHERE id = ? AND level_expire_at IS NULL", o.UserId); err != nil { return false, err } if err = rollbackQuotaPeriodTx(ctx, tx, o.UserId); err != nil { return false, err } g.Log().Infof(ctx, "[refund] 永久会员回收置为已过期 userId=%d outTradeNo=%s", o.UserId, o.OutTradeNo) return wasEffective, nil } // ===== 重放算账:理论到期时间 ===== // ⚠️ 必须排除本单:手工路径在本函数执行时订单尚未翻转 status(仍为 1), // 仅按 status 过滤会把本单算进重放;API 确认路径同样适用(本单即将置为已退款)。 orders, err := tx.Model(consts.TableMemberOrders). Where("user_id", o.UserId). Where("order_type", consts.OrderTypeMember). Where("status", consts.OrderStatusDelivered). Where("id <> ?", o.Id). OrderAsc("paid_at").OrderAsc("id"). All() if err != nil { return false, err } var runExpire time.Time // 零值 = 尚无任何会员时长 runLevel := "" for _, r := range orders { days := r["duration_days"].Int() if days <= 0 { continue } paid := r["paid_at"].GTime() if paid == nil || paid.IsZero() { paid = r["created_at"].GTime() // 老订单兜底 } if paid == nil || paid.IsZero() { continue } level := r["level_key"].String() // 与 extendMembership 同款:同档且未到期 → 顺延;跨档/更高档 → 从购买时刻起算。 // (简化:不重放「高等级不降权」的 level_key 归属,到期时间口径一致;level_key 本就不由本函数改写。) base := paid.Time if !runExpire.IsZero() && runExpire.After(paid.Time) && runLevel == level { base = runExpire } runExpire = base.AddDate(0, 0, days) runLevel = level } if runExpire.IsZero() { // 退款后已无任何剩余会员单:沿用旧口径——从当前到期时间减去本单天数、夹到当前时刻。 // (用户可能在本单之前还有「无订单可考」的剩余时长,减法比直接置 NOW() 更不误伤。) days := o.DurationDays if days <= 0 { days = 0 } // 「已过期哨兵」下限沿用旧实现的 Go 侧传参豁免(与库内 NOW() 在 EffectiveLevelKey // 下用户可见行为一致;且规避 DB 与进程秒级时钟差导致的判定抖动)。 if _, err = tx.Exec( "UPDATE "+consts.TableUsers+" SET level_expire_at = GREATEST(level_expire_at - INTERVAL ? MINUTE, ?), "+ "updated_at = NOW() WHERE id = ? AND level_expire_at IS NOT NULL", int64(days)*24*60, gtime.Now(), o.UserId); err != nil { return false, err } if err = rollbackQuotaPeriodTx(ctx, tx, o.UserId); err != nil { return false, err } g.Log().Infof(ctx, "[refund] 会员回收(重放后无剩余会员单,减本单 %d 天夹到当前)userId=%d outTradeNo=%s", days, o.UserId, o.OutTradeNo) return wasEffective, nil } // 差值收敛:delta = 当前到期 − 理论到期(分钟)。delta<=0 说明重放不短于现状 // (退款绝不该「延长」权益)→ 不动库。算术全部发生在库内(INTERVAL ? MINUTE)。 deltaMinutes := int64(expire.Time.Sub(runExpire).Minutes() + 0.5) if deltaMinutes <= 0 { g.Log().Warningf(ctx, "[refund] 会员回收重放结果不短于当前到期,跳过减法 userId=%d delta=%dmin outTradeNo=%s", o.UserId, deltaMinutes, o.OutTradeNo) return wasEffective, nil } result, err := tx.Exec( "UPDATE "+consts.TableUsers+" SET level_expire_at = GREATEST(level_expire_at - INTERVAL ? MINUTE, ?), "+ "updated_at = NOW() WHERE id = ? AND level_expire_at IS NOT NULL", deltaMinutes, gtime.Now(), o.UserId) if err != nil { return false, err } if n, _ := result.RowsAffected(); n == 0 { g.Log().Warningf(ctx, "[refund] 会员回收未命中(用户已无有限期)userId=%d outTradeNo=%s", o.UserId, o.OutTradeNo) return false, nil } if err = rollbackQuotaPeriodTx(ctx, tx, o.UserId); err != nil { return false, err } g.Log().Infof(ctx, "[refund] 会员回收(重放算账)userId=%d 收敛 -%d 分钟 outTradeNo=%s", o.UserId, deltaMinutes, o.OutTradeNo) return wasEffective, nil } // rollbackQuotaPeriodTx 回滚发货时写的 users.quota_period_days: // 取该用户**剩余**最新一笔已发货会员单的套餐周期;没有 → 0(跟随全局)。 // 幂等:重复调用结果一致;绝不回写本单所属套餐的周期(本单已置为退款态,不在查询集内)。 func rollbackQuotaPeriodTx(ctx context.Context, tx gdb.TX, userId int64) error { value, err := tx.Model(consts.TableMemberOrders+" o"). LeftJoin(consts.TableMemberPlans+" p", "p.plan_key = o.plan_key"). Where("o.user_id", userId). Where("o.order_type", consts.OrderTypeMember). Where("o.status", consts.OrderStatusDelivered). OrderDesc("o.id"). Limit(1). Value("p.quota_period_days") if err != nil { return err } days := 0 // 0 = 跟随全局(users.quota_period_days 的语义) if value != nil { days = value.Int() } if days < 0 { days = 0 } if _, err = tx.Exec( "UPDATE "+consts.TableUsers+" SET quota_period_days = ?, updated_at = NOW() WHERE id = ?", days, userId); err != nil { return err } g.Log().Infof(ctx, "[refund] 回滚额度周期 userId=%d → %d 天", userId, days) return nil }