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) } } }