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

410 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"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/grand"
v1 "tool-api/api/admin/v1"
"tool-api/internal/consts"
)
// ============================================================================
// 真退款链路:调微信 /xpay/refund_order + 退款回调分流 + 查单轮询兜底
//
// 状态机(member_orders.status × refund_status):
// status=1(已发货) + refund_status=0 → 可发起退款
// 发起(API 路径) → refund_status=1(退款中),refund_no/refund_amount/reason 落库
// → 平台 xpay_refund_notify 推送 / RefundStatusPoll 查单轮询确认
// → 确认成功:status=2 + refund_status=2 + 回收权益(在此刻才回收)
// → 确认失败/超时:refund_status=3(status 保持 1,可重新发起)
// 发起(手工路径) → 手工登记(Manual / iOS apple 渠道 / 平台未配置)当场 status=2 +
// refund_status=2 + 回收权益(无异步确认环节,与旧版语义一致)
// refund_status=2 → 幂等返回;refund_status=3 → 允许重新发起(换新 refund_no)
//
// 权益回收时机:API 路径下从「发起时」移到「确认成功时」(finalize 统一执行),
// 杜绝「退款失败但权益已回收」。回收意图在发起时落 refund_revoke 列,回调/轮询据此执行。
//
// 时区铁律(TZ-01):所有 datetime 写入一律 gdb.Raw("NOW()")(库内取值),
// 严禁本文件引入 Go 侧 datetime 传参写库。
// ============================================================================
// refundWindowDays 平台退款窗口:支付 365 天内可退(180 天内退手续费,见 doc/会员购买接入说明.md)。
const refundWindowDays = 365
// OrderRefundOptions 后台「确认退款」的选项。
type OrderRefundOptions struct {
RevokeService bool // 同时回收所购服务(意图落 refund_revoke,确认成功后执行)
Reason string // 退款原因(手工登记必填;API 退款选填随单报平台)
Manual bool // 手工退款登记:不调平台接口,仅落库(iOS / 平台后台已手工退的场景)
}
// AdminOrderRefund 订单退款(真退款状态机,替换旧「点了就置已退款」实现)。
func AdminOrderRefund(ctx context.Context, outTradeNo string, opt OrderRefundOptions) (*v1.OrderRefundRes, error) {
outTradeNo = strings.TrimSpace(outTradeNo)
if outTradeNo == "" {
return nil, errOrderInvalid("订单号不能为空")
}
opt.Reason = strings.TrimSpace(opt.Reason)
res := &v1.OrderRefundRes{OutTradeNo: outTradeNo}
var beforeStatus int
// 出网参数(事务内组装、事务提交后才发起资金动作,不占行锁)
var apiOpenid, apiOutTradeNo, apiRefundNo string
var apiAmount int64
apiStarted := false
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
refundStatus := row["refund_status"].Int()
res.RefundStatus = refundStatus
// ===== 幂等分支 =====
if o.Status == consts.OrderStatusRefunded || refundStatus == consts.RefundStatusSuccess {
res.AlreadyRefunded = true
res.ServiceRevoked = row["service_revoked"].Int() == 1
res.Status = consts.OrderStatusRefunded
res.StatusText = orderStatusText(consts.OrderStatusRefunded)
return nil
}
if refundStatus == consts.RefundStatusProcessing {
res.Refunding = true
res.Status = o.Status
res.StatusText = "退款中"
return nil
}
switch o.Status {
case consts.OrderStatusPending:
return errOrderInvalid("订单尚未支付,无法退款")
case consts.OrderStatusClosed:
return errOrderInvalid("订单已关闭,无法退款")
case consts.OrderStatusDelivered:
// 唯一允许发起退款的起点状态
default:
return errOrderInvalid("订单状态异常,无法退款")
}
// 平台退款窗口:支付超 365 天不可退(超窗只能走手工登记,如 App Store 退款)
if paidAt := row["paid_at"].GTime(); paidAt != nil && !paidAt.IsZero() &&
time.Since(paidAt.Time) > refundWindowDays*24*time.Hour {
return errOrderInvalid("订单支付已超过 365 天,平台不再支持退款;如已线下退款请勾选「手工退款登记」")
}
amount := o.PaidPriceCents
if amount <= 0 {
amount = o.PriceCents // 老订单 paid_price_cents 为 0 时回退原价(S14 口径)
}
// 手工退款判定:显式 Manual / iOS 渠道(开发者无法主动退款,用户向 App Store 申请)/
// 平台未配置(无法出网)。显式手工与 iOS 渠道必须留原因(审计要求);
// 「平台未配置」的降级路径不强制(兼容既有联调/测试流程)。
platformUnconfigured := !xpayConfig(ctx).Configured()
manual := opt.Manual || o.PayChannel == consts.PayChannelIOS || platformUnconfigured
if manual && (opt.Manual || o.PayChannel == consts.PayChannelIOS) && opt.Reason == "" {
return errOrderInvalid("手工退款登记必须填写退款原因")
}
if manual {
// ===== 手工路径:当场回收 + 落「已退款」(旧版语义 + 退款列补记)=====
serviceRevoked := 0
if opt.RevokeService {
revoked, rerr := revokeOrderServiceTx(ctx, tx, o)
if rerr != nil {
return rerr
}
if revoked {
serviceRevoked = 1
}
}
if err = releaseVoucherForOrderTx(ctx, tx, o); err != nil {
return err
}
// refunded_at / refund_finished_at / updated_at 用库内 NOW()(TZ-01)
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()"),
"refund_status": consts.RefundStatusSuccess,
"refund_amount": amount,
"refund_reason": opt.Reason,
"refund_finished_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.RefundStatus = consts.RefundStatusSuccess
res.StatusText = orderStatusText(consts.OrderStatusRefunded)
return nil
}
// ===== API 路径:事务内落「退款中」,提交后出网调 /xpay/refund_order =====
refundNo := newRefundNo(o.Id)
if _, err = tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).Data(g.Map{
"refund_no": refundNo,
"refund_status": consts.RefundStatusProcessing,
"refund_amount": amount,
"refund_reason": opt.Reason,
"refund_revoke": boolToInt(opt.RevokeService),
"refunded_at": nil, // 确认成功时才写(避免「退款中」却显示退款时间)
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return err
}
apiOpenid, apiOutTradeNo, apiRefundNo, apiAmount = o.Openid, o.OutTradeNo, refundNo, amount
apiStarted = true
res.Refunding = true
res.RefundNo = refundNo
res.Status = o.Status
res.RefundStatus = consts.RefundStatusProcessing
res.StatusText = "退款中"
return nil
})
if err != nil {
return nil, err
}
switch {
case res.AlreadyRefunded:
res.Message = "订单已是退款状态,未重复执行"
case res.Refunding && !apiStarted:
res.Message = "退款已在处理中,等待平台确认"
case res.Refunding && apiStarted:
// 出网在事务提交后(资金动作不占行锁);同步失败 → 置「失败」并报错给管理员
if rerr := XPayRefundOrder(ctx, apiOpenid, apiOutTradeNo, apiRefundNo, apiAmount, opt.Reason); rerr != nil {
markRefundFailed(ctx, outTradeNo, apiRefundNo)
WriteAudit(ctx, AuditEntry{
Action: "order.refund", TargetType: "order", TargetId: outTradeNo,
Before: g.Map{"status": beforeStatus, "refund_status": consts.RefundStatusProcessing},
After: g.Map{"refund_status": consts.RefundStatusFailed},
Result: consts.AuditResultFail, Remark: rerr.Error(),
})
return nil, gerror.Newf("发起平台退款失败:%v(订单已标记退款失败,可修正后重试)", rerr)
}
res.Message = "已发起平台退款,确认后自动回收权益"
if opt.RevokeService {
res.Message += "(refund_revoke=1,收到平台成功推送即回收)"
}
}
if res.Message == "" {
res.Message = "退款已受理"
}
// 审计(旁路):发起动作本身
if !res.AlreadyRefunded {
WriteAudit(ctx, AuditEntry{
Action: "order.refund",
TargetType: "order",
TargetId: outTradeNo,
Before: g.Map{"status": beforeStatus, "refund_status": consts.RefundStatusNone},
After: g.Map{
"status": res.Status,
"refund_status": res.RefundStatus,
"revoke_intent": opt.RevokeService,
"manual": opt.Manual,
"reason": opt.Reason,
},
Result: consts.AuditResultSuccess,
Remark: res.Message,
})
}
return res, nil
}
// newRefundNo 生成商户退款单号:R + 订单id + 'x' + 8 位随机字母数字。
// 微信 out_refund_no 要求 6-32 位、仅字母数字(含 -_|*),此处最长 1+19+1+8=29 位。
// 每次发起(含失败重试)都生成新号,避免与历史退款单号冲突。
func newRefundNo(orderId int64) string {
return fmt.Sprintf("R%dx%s", orderId, grand.S(8))
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// markRefundFailed 平台退款同步失败 / 轮询确认超时:置 refund_status=3(status 不动,可重试)。
func markRefundFailed(ctx context.Context, outTradeNo, refundNo string) {
if _, err := g.Model(consts.TableMemberOrders).
Where("out_trade_no", outTradeNo).
Where("refund_no", refundNo).
Where("refund_status", consts.RefundStatusProcessing).
Data(g.Map{
"refund_status": consts.RefundStatusFailed,
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
g.Log().Errorf(ctx, "[refund] 标记退款失败落库异常 outTradeNo=%s err=%v", outTradeNo, err)
}
}
// refundNotifyHandle 处理平台退款结果推送(/pay/notify 按 Event 分流进入)。
func refundNotifyHandle(ctx context.Context, raw []byte) (string, error) {
notify, err := ParseRefundNotify(raw)
if err != nil {
g.Log().Warningf(ctx, "[xpay] 退款推送解析失败: %v raw=%s", err, string(raw))
return XPayNotifyAckFail("bad payload"), nil
}
order, err := entityOrderByOutTradeNo(ctx, notify.OutTradeNo)
if err != nil {
// 订单不存在多为脏数据,回成功避免平台无意义重试
g.Log().Warningf(ctx, "[xpay] 退款推送对应订单不存在: %s", notify.OutTradeNo)
return XPayNotifyAck(), nil
}
if order.RefundStatus != consts.RefundStatusProcessing {
return XPayNotifyAck(), nil // 幂等:非「退款中」一律忽略重复推送
}
// 退款单号交叉校验:推送单号与本地不一致(如旧号的迟到推送)→ 忽略并告警
pushNo := notify.OutRefundNo
if pushNo == "" {
pushNo = notify.RefundInfo.OutRefundNo
}
if pushNo != "" && order.RefundNo != "" && pushNo != order.RefundNo {
g.Log().Warningf(ctx, "[xpay] 退款推送单号不匹配,忽略: outTradeNo=%s local=%s push=%s",
notify.OutTradeNo, order.RefundNo, pushNo)
return XPayNotifyAck(), nil
}
switch {
case notify.RefundNotifySuccess():
confirmed, ferr := confirmRefund(ctx, notify.OutTradeNo)
if ferr != nil {
g.Log().Errorf(ctx, "[xpay] 退款确认落库失败 outTradeNo=%s err=%v", notify.OutTradeNo, ferr)
return XPayNotifyAckFail("finalize failed"), nil // 让平台重试
}
g.Log().Infof(ctx, "[xpay] 退款确认成功(推送) outTradeNo=%s confirmed=%v", notify.OutTradeNo, confirmed)
return XPayNotifyAck(), nil
case pushNo != "" || notify.RefundInfo.RefundFee > 0:
// 携带了退款信息但判定为非成功 → 显式失败,置 refund_status=3 供管理员重试
markRefundFailed(ctx, notify.OutTradeNo, order.RefundNo)
g.Log().Warningf(ctx, "[xpay] 退款推送判定为失败 outTradeNo=%s raw=%s", notify.OutTradeNo, string(raw))
return XPayNotifyAck(), nil
default:
// 字段判不动(文档外枚举/字段缺失):保持「退款中」交查单轮询收敛;回成功停止重试
g.Log().Warningf(ctx, "[xpay] 退款推送结果不可判定,留待轮询 outTradeNo=%s raw=%s", notify.OutTradeNo, string(raw))
return XPayNotifyAck(), nil
}
}
// confirmRefund 退款确认成功(推送 / 轮询共用):状态 1→2 + 按发起时的意图回收权益 + 释放券。
// 返回 confirmed = 本次调用是否真正完成了状态翻转(幂等:重复确认返回 false)。
func confirmRefund(ctx context.Context, outTradeNo string) (bool, error) {
confirmed := false
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() || row["status"].Int() != consts.OrderStatusDelivered {
return nil // 并发已确认 / 状态异常 → 幂等无操作
}
o := orderSnapshotFromRecord(row)
revokeService := row["refund_revoke"].Int() == 1
serviceRevoked := 0
if revokeService {
revoked, rerr := revokeOrderServiceTx(ctx, tx, o)
if rerr != nil {
return rerr
}
if revoked {
serviceRevoked = 1
}
}
if err = releaseVoucherForOrderTx(ctx, tx, o); err != nil {
return err
}
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()"),
"refund_status": consts.RefundStatusSuccess,
"refund_finished_at": gdb.Raw("NOW()"),
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return err
}
confirmed = true
return nil
})
if err != nil {
return false, err
}
if confirmed {
WriteAudit(ctx, AuditEntry{
Action: "order.refund.confirm",
TargetType: "order",
TargetId: outTradeNo,
After: g.Map{"status": consts.OrderStatusRefunded, "refund_status": consts.RefundStatusSuccess},
Result: consts.AuditResultSuccess,
Remark: "退款确认成功(平台推送/查单轮询)",
})
}
return confirmed, nil
}
// RefundStatusPoll 退款轮询兜底:扫「退款中」的订单主动查单确认。
// 推送丢失时的兜底路径;结果不可判定的单超过 24h 置「失败」告警,交管理员重试。
// 注册于 cmd.go(cronPatternRefundPoll,每 2 分钟)。
func RefundStatusPoll(ctx context.Context) {
if !xpayConfig(ctx).Configured() {
return
}
records, err := g.Model(consts.TableMemberOrders).
Where("refund_status", consts.RefundStatusProcessing).
Where("status", consts.OrderStatusDelivered).
WhereLT("updated_at", gtime.Now().Add(-2*time.Minute)).
OrderAsc("id").
Limit(50).
All()
if err != nil {
g.Log().Warningf(ctx, "[refund] 轮询扫单失败: %v", err)
return
}
for _, r := range records {
outTradeNo := r["out_trade_no"].String()
state, qerr := XPayQueryOrder(ctx, r["openid"].String(), outTradeNo)
if qerr != nil {
g.Log().Debugf(ctx, "[refund] 轮询查单跳过 outTradeNo=%s err=%v", outTradeNo, qerr)
continue
}
if state.Refund.Known && state.Refund.Success {
confirmed, ferr := confirmRefund(ctx, outTradeNo)
if ferr != nil {
g.Log().Errorf(ctx, "[refund] 轮询确认落库失败 outTradeNo=%s err=%v", outTradeNo, ferr)
continue
}
g.Log().Infof(ctx, "[refund] 轮询确认成功 outTradeNo=%s confirmed=%v", outTradeNo, confirmed)
continue
}
// 发起超 24h 仍无法确认 → 置失败并告警(管理员核对后重试 / 手工登记)
updated := r["updated_at"].GTime()
if updated != nil && !updated.IsZero() && time.Since(updated.Time) > 24*time.Hour {
markRefundFailed(ctx, outTradeNo, r["refund_no"].String())
g.Log().Errorf(ctx, "[refund] 退款超 24h 未确认,置为失败待人工核对 outTradeNo=%s raw=%s",
outTradeNo, state.RawJson)
}
}
}