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