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

386 lines
16 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"
"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
}