Files
qitongxue-api/internal/logic/admin_order.go
2026-09-29 10:57:01 +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"
"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(),
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 订单退款(可选回收所购服务)。
func AdminOrderRefund(ctx context.Context, outTradeNo string, revokeService bool) (*v1.OrderRefundRes, error) {
outTradeNo = strings.TrimSpace(outTradeNo)
if outTradeNo == "" {
return nil, errOrderInvalid("订单号不能为空")
}
res := &v1.OrderRefundRes{OutTradeNo: outTradeNo}
var beforeStatus int
err := 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 errOrderInvalid("订单不存在")
}
o := orderSnapshotFromRecord(row)
beforeStatus = o.Status
switch o.Status {
case consts.OrderStatusRefunded:
// 幂等:已退款直接成功返回,不重复回收权益
res.AlreadyRefunded = true
res.ServiceRevoked = row["service_revoked"].Int() == 1
res.Status = consts.OrderStatusRefunded
res.StatusText = orderStatusText(consts.OrderStatusRefunded)
return nil
case consts.OrderStatusPending:
return errOrderInvalid("订单尚未支付,无法退款")
case consts.OrderStatusClosed:
return errOrderInvalid("订单已关闭,无法退款")
case consts.OrderStatusDelivered:
// 唯一允许退款的起点状态,继续
default:
return errOrderInvalid("订单状态异常,无法退款")
}
// 1) 回收所购服务(可选)—— 按**实际回收结果**决定 service_revoked,
// 而非「管理员是否勾选」:勾了但用户当前无可回收的生效授权时记 0(避免误导管理员)。
serviceRevoked := 0
if revokeService {
revoked, rerr := revokeOrderServiceTx(ctx, tx, o)
if rerr != nil {
return rerr
}
if revoked {
serviceRevoked = 1
}
}
// 2) 释放该订单占用的免单券 / 优惠码(仅 1→2 时执行一次)
if err = releaseVoucherForOrderTx(ctx, tx, o); err != nil {
return err
}
// 3) 状态与回收标记落库。
// ⚠️ refunded_at / updated_at 用**库内 NOW()**(gdb.Raw)而非 Go 侧传参:
// 后台订单列表会展示退款时间,Go 传参落库会早一个时区(TZ-01,8h 可见偏差)。
if _, err = tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).Data(g.Map{
"status": consts.OrderStatusRefunded,
"service_revoked": serviceRevoked,
"refunded_at": gdb.Raw("NOW()"),
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return err
}
res.Revoked = true
res.ServiceRevoked = serviceRevoked == 1
res.Status = consts.OrderStatusRefunded
res.StatusText = orderStatusText(consts.OrderStatusRefunded)
return nil
})
if err != nil {
return nil, err
}
if res.AlreadyRefunded {
res.Message = "订单已是退款状态,未重复回收权益"
} else if revokeService && res.ServiceRevoked {
res.Message = "退款成功,已回收所购服务"
} else if revokeService {
res.Message = "退款成功,未发现可回收的生效授权"
} else {
res.Message = "退款成功,所购服务已保留"
}
// 审计(旁路:失败仅告警,不影响主流程)
WriteAudit(ctx, AuditEntry{
Action: "order.refund",
TargetType: "order",
TargetId: outTradeNo,
Before: g.Map{"status": beforeStatus},
After: g.Map{
"status": consts.OrderStatusRefunded,
"revoke_service": revokeService,
"service_revoked": res.ServiceRevoked,
"idempotent": res.AlreadyRefunded,
},
Result: consts.AuditResultSuccess,
Remark: res.Message,
})
return res, nil
}
// 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。
//
// 回收模型:发货时按「购买天数」顺延有效期(extendMembership),回收即**反向减去本单天数**。
// - level_expire_at 为 NULL(永久)→ 按用户裁定(2026-09-23)**同样真取消、置为已过期**:
// 写入 level_expire_at = NOW();level_key **保留不清空**(失效判定交给 EffectiveLevelKey)。
// - 有限期:减去本单天数;减完后若已到期(<= now)→ 夹到当前时刻(保持非 NULL),
// EffectiveLevelKey 随即回落默认等级。
// - 等级回落**只经 EffectiveLevelKey 收敛**(与「自然到期」口径一致),本函数不改 level_key。
//
// ⚠️ 时间取值遵循 TZ-01 铁律:本库 DSN 未指定 loc,Go 侧**传参** datetime 会按 UTC 落库
// (比真实墙钟早一个时区偏移),Go 读回又被按本地重解释,形成写入/读取不对称的 8h 偏差
// (本项目出过真事故)。故本函数**不把 Go 侧时间作为 datetime 列写入参数**:
// - 永久分支「置为已过期」用**库内 NOW()** 写入(DB 侧取值,写入/读取对称);
// - 限时分支的 `level_expire_at` 减法与 `updated_at` 均在库内完成(updated_at 用 NOW());
// **唯一豁免**:`GREATEST(level_expire_at - INTERVAL ? DAY, ?)` 的夹取下限 `?` 保留 Go 传参
// —— 它是「已过期哨兵值」,与库内 NOW() 在 EffectiveLevelKey 下用户可见行为完全一致。
//
// 返回 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
}
g.Log().Infof(ctx, "[refund] 永久会员回收置为已过期 userId=%d outTradeNo=%s", o.UserId, o.OutTradeNo)
return wasEffective, nil
}
days := o.DurationDays
if days <= 0 {
g.Log().Warningf(ctx, "[refund] 会员订单缺少有效天数,跳过会员回收 userId=%d outTradeNo=%s", o.UserId, o.OutTradeNo)
return false, nil
}
now := gtime.Now()
// GREATEST(expire - days, now):正常回退 days 天;若回收后已到期则夹到当前时刻(非 NULL)。
// 直连 SQL(不经过 Go 侧读回-改-写),规避 TZ-01 时区漂移;WHERE 限定非空,绝不误动永久会员。
//
// 注:`GREATEST` 的**夹取下限 `?`** 依裁定保留 Go 传参——它是「已过期哨兵值」,
// 与库内 NOW() 写法在 `EffectiveLevelKey` 下用户可见行为完全一致(严格 `<` ⇒ 立即回落默认);
// 而 `updated_at` 是**展示列**(后台用户列表/详情可见),改用库内 NOW(),避免 Go 传参的 8h 偏差。
result, err := tx.Exec(
"UPDATE "+consts.TableUsers+" SET level_expire_at = GREATEST(level_expire_at - INTERVAL ? DAY, ?), "+
"updated_at = NOW() WHERE id = ? AND level_expire_at IS NOT NULL",
days, 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
}
g.Log().Infof(ctx, "[refund] 会员回收 userId=%d -%d 天 outTradeNo=%s", o.UserId, days, o.OutTradeNo)
return wasEffective, nil
}