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 }