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