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