514 lines
16 KiB
Go
514 lines
16 KiB
Go
package pay
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"bindbox-game/configs"
|
||
"bindbox-game/internal/code"
|
||
"bindbox-game/internal/pkg/core"
|
||
"bindbox-game/internal/pkg/pay"
|
||
"bindbox-game/internal/pkg/wechat"
|
||
"bindbox-game/internal/repository/mysql/dao"
|
||
"bindbox-game/internal/repository/mysql/model"
|
||
|
||
"go.uber.org/zap"
|
||
|
||
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
|
||
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
|
||
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
|
||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
||
"github.com/wechatpay-apiv3/wechatpay-go/utils"
|
||
)
|
||
|
||
type notifyAck struct {
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// WechatNotify 微信支付回调通知处理
|
||
// 入参:微信官方通知,验签并解密resource,推进订单状态为已支付
|
||
// 幂等:若订单已为已支付则直接ACK
|
||
// @Summary 微信支付回调通知
|
||
// @Description 接收微信支付结果通知,验证签名并处理订单状态
|
||
// @Tags Pay
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Success 200 {object} notifyAck "处理成功"
|
||
// @Failure 400 {object} notifyAck "请求参数错误"
|
||
// @Failure 500 {object} notifyAck "服务器内部错误"
|
||
// @Router /pay/wechat/notify [post]
|
||
func (h *handler) WechatNotify() core.HandlerFunc {
|
||
return func(ctx core.Context) {
|
||
c := configs.Get()
|
||
if c.WechatPay.ApiV3Key == "" {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150000, "wechat pay config incomplete"))
|
||
return
|
||
}
|
||
var handler *notify.Handler
|
||
if c.WechatPay.PublicKeyID != "" && c.WechatPay.PublicKeyPath != "" {
|
||
pubKey, err := utils.LoadPublicKeyWithPath(c.WechatPay.PublicKeyPath)
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150001, err.Error()))
|
||
return
|
||
}
|
||
handler = notify.NewNotifyHandler(c.WechatPay.ApiV3Key, verifiers.NewSHA256WithRSAPubkeyVerifier(c.WechatPay.PublicKeyID, *pubKey))
|
||
} else {
|
||
if c.WechatPay.MchID == "" || c.WechatPay.SerialNo == "" || c.WechatPay.PrivateKeyPath == "" {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150000, "wechat pay config incomplete"))
|
||
return
|
||
}
|
||
mchPrivateKey, err := utils.LoadPrivateKeyWithPath(c.WechatPay.PrivateKeyPath)
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150002, err.Error()))
|
||
return
|
||
}
|
||
if err := downloader.MgrInstance().RegisterDownloaderWithPrivateKey(ctx.RequestContext(), mchPrivateKey, c.WechatPay.SerialNo, c.WechatPay.MchID, c.WechatPay.ApiV3Key); err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150003, err.Error()))
|
||
return
|
||
}
|
||
certificateVisitor := downloader.MgrInstance().GetCertificateVisitor(c.WechatPay.MchID)
|
||
handler = notify.NewNotifyHandler(c.WechatPay.ApiV3Key, verifiers.NewSHA256WithRSAVerifier(certificateVisitor))
|
||
}
|
||
|
||
var transaction payments.Transaction
|
||
notification, err := handler.ParseNotifyRequest(ctx.RequestContext(), ctx.Request(), &transaction)
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
|
||
return
|
||
}
|
||
// 事件去重处理
|
||
var existed *model.PaymentNotifyEvents
|
||
if notification != nil && notification.ID != "" {
|
||
existed, _ = h.readDB.PaymentNotifyEvents.WithContext(ctx.RequestContext()).Where(h.readDB.PaymentNotifyEvents.NotifyID.Eq(notification.ID)).First()
|
||
if existed != nil && existed.Processed {
|
||
ctx.Payload(¬ifyAck{Code: "SUCCESS", Message: "OK"})
|
||
return
|
||
}
|
||
}
|
||
if transaction.OutTradeNo == nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "missing out_trade_no"))
|
||
return
|
||
}
|
||
paidAt := time.Now()
|
||
if transaction.SuccessTime != nil {
|
||
if t, err := time.Parse(time.RFC3339, *transaction.SuccessTime); err == nil {
|
||
paidAt = t
|
||
}
|
||
}
|
||
rawStr := func() string { b, _ := json.Marshal(transaction); return string(b) }()
|
||
order, err := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.OrderNo.Eq(*transaction.OutTradeNo)).First()
|
||
if err != nil || order == nil {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "order not found for out_trade_no"))
|
||
return
|
||
}
|
||
// 7. 使用全局事务处理后续业务逻辑
|
||
err = h.writeDB.Transaction(func(tx *dao.Query) error {
|
||
// 1. 创建支付交易记录
|
||
payTx := &model.PaymentTransactions{
|
||
OrderID: order.ID,
|
||
OrderNo: *transaction.OutTradeNo,
|
||
Channel: "wechat_jsapi",
|
||
TransactionID: func() string {
|
||
if transaction.TransactionId != nil {
|
||
return *transaction.TransactionId
|
||
}
|
||
return ""
|
||
}(),
|
||
AmountTotal: func() int64 {
|
||
if transaction.Amount != nil && transaction.Amount.Total != nil {
|
||
return *transaction.Amount.Total
|
||
}
|
||
return 0
|
||
}(),
|
||
SuccessTime: paidAt,
|
||
Raw: rawStr,
|
||
}
|
||
if err := tx.PaymentTransactions.WithContext(ctx.RequestContext()).Create(payTx); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 2. 只有在此事务中将订单状态更新为已支付(2)
|
||
res, err := tx.Orders.WithContext(ctx.RequestContext()).Where(tx.Orders.OrderNo.Eq(*transaction.OutTradeNo), tx.Orders.Status.Eq(1)).Updates(map[string]any{
|
||
tx.Orders.Status.ColumnName().String(): 2,
|
||
tx.Orders.PaidAt.ColumnName().String(): paidAt,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
// 检查现有状态,如果是已支付,则可能是并发导致的,业务层面幂等跳过
|
||
cur, _ := tx.Orders.WithContext(ctx.RequestContext()).Where(tx.Orders.OrderNo.Eq(*transaction.OutTradeNo)).First()
|
||
if cur != nil && cur.Status >= 2 {
|
||
return nil // 幂等处理
|
||
}
|
||
return fmt.Errorf("order status update failed or order already processed")
|
||
}
|
||
|
||
// 3. 优惠券扣减 (传递 tx 给 service 或直接在这里处理)
|
||
// 注意:Service 内部也需要支持传入 tx 才能保证原子性,如果 Service 不支持,则核心逻辑应内联或重构
|
||
// 这里假设 h.user.DeductCouponsForPaidOrder 内部使用的是全局 DB 或有事务支持
|
||
// 如果无法确保 Service 事务一致,核心逻辑应由 tx 直接操作
|
||
_ = h.user.DeductCouponsForPaidOrder(ctx.RequestContext(), order.UserID, order.ID, paidAt)
|
||
|
||
// 4. 解析备注并核销优惠券 (补足 DeductCoupons 可能遗漏的备注片段逻辑)
|
||
remark := order.Remark
|
||
parts := strings.Split(remark, "|")
|
||
for _, seg := range parts {
|
||
if strings.HasPrefix(seg, "c:") {
|
||
xs := strings.Split(seg, ":")
|
||
if len(xs) == 3 {
|
||
ucID := parseInt64(xs[1])
|
||
applied := parseInt64(xs[2])
|
||
uc, _ := tx.UserCoupons.WithContext(ctx.RequestContext()).Where(tx.UserCoupons.ID.Eq(ucID), tx.UserCoupons.UserID.Eq(order.UserID)).First()
|
||
if uc != nil && !(uc.Status == 2 && uc.UsedOrderID == order.ID) {
|
||
sc, _ := tx.SystemCoupons.WithContext(ctx.RequestContext()).Where(tx.SystemCoupons.ID.Eq(uc.CouponID), tx.SystemCoupons.Status.Eq(1)).First()
|
||
if sc != nil {
|
||
if sc.DiscountType == 1 { // 金额券
|
||
newBal := uc.BalanceAmount - applied
|
||
if newBal < 0 {
|
||
newBal = 0
|
||
}
|
||
upd := map[string]any{"balance_amount": newBal, "used_order_id": order.ID, "used_at": paidAt}
|
||
if newBal == 0 {
|
||
upd["status"] = 2
|
||
}
|
||
_, _ = tx.UserCoupons.WithContext(ctx.RequestContext()).Where(tx.UserCoupons.ID.Eq(uc.ID)).Updates(upd)
|
||
} else { // 折扣券
|
||
_, _ = tx.UserCoupons.WithContext(ctx.RequestContext()).Where(tx.UserCoupons.ID.Eq(uc.ID)).Updates(map[string]any{"status": 2, "used_order_id": order.ID, "used_at": paidAt})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5. 积分奖励
|
||
func() {
|
||
cfg, _ := tx.SystemConfigs.WithContext(ctx.RequestContext()).Where(tx.SystemConfigs.ConfigKey.Eq("points_reward_per_cent")).First()
|
||
rate := int64(0)
|
||
if cfg != nil {
|
||
var r int64
|
||
_, _ = fmt.Sscanf(cfg.ConfigValue, "%d", &r)
|
||
rate = r
|
||
}
|
||
if rate > 0 && order.ActualAmount > 0 {
|
||
reward := order.ActualAmount * rate
|
||
_ = h.user.AddPointsWithAction(ctx.RequestContext(), order.UserID, reward, "pay_reward", order.OrderNo, "pay_reward", nil, nil)
|
||
}
|
||
}()
|
||
|
||
// 6. 一番赏占位 (事务内处理)
|
||
if order.SourceType == 2 {
|
||
aid := parseActivityIDFromRemark(order.Remark)
|
||
iss := parseIssueIDFromRemark(order.Remark)
|
||
dc := parseCountFromRemark(order.Remark)
|
||
act, _ := tx.Activities.WithContext(ctx.RequestContext()).Where(tx.Activities.ID.Eq(aid)).First()
|
||
if act != nil && act.PlayType == "ichiban" {
|
||
idxs, cnts := parseSlotsCountsFromRemark(order.Remark)
|
||
rem := make([]int64, len(cnts))
|
||
copy(rem, cnts)
|
||
cur := 0
|
||
for i := int64(0); i < dc; i++ {
|
||
slot := func() int64 {
|
||
if len(idxs) > 0 && len(idxs) == len(rem) {
|
||
for cur < len(rem) && rem[cur] == 0 {
|
||
cur++
|
||
}
|
||
if cur >= len(rem) {
|
||
return -1
|
||
}
|
||
rem[cur]--
|
||
return idxs[cur] - 1
|
||
}
|
||
return parseSlotFromRemark(order.Remark)
|
||
}()
|
||
if slot < 0 {
|
||
break
|
||
}
|
||
// 检查是否已被占用 (事务内)
|
||
cnt, _ := tx.IssuePositionClaims.WithContext(ctx.RequestContext()).Where(tx.IssuePositionClaims.IssueID.Eq(iss), tx.IssuePositionClaims.SlotIndex.Eq(slot)).Count()
|
||
if cnt > 0 {
|
||
return fmt.Errorf("slot_unavailable")
|
||
}
|
||
err := tx.IssuePositionClaims.WithContext(ctx.RequestContext()).Create(&model.IssuePositionClaims{IssueID: iss, SlotIndex: slot, UserID: order.UserID, OrderID: order.ID})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 7. 标记通知事件为已处理
|
||
if notification != nil && notification.ID != "" {
|
||
if existed == nil {
|
||
_ = tx.PaymentNotifyEvents.WithContext(ctx.RequestContext()).Create(&model.PaymentNotifyEvents{
|
||
NotifyID: notification.ID,
|
||
ResourceType: notification.ResourceType,
|
||
EventType: notification.EventType,
|
||
Summary: notification.Summary,
|
||
Raw: rawStr,
|
||
Processed: true,
|
||
})
|
||
} else {
|
||
_, _ = tx.PaymentNotifyEvents.WithContext(ctx.RequestContext()).Where(tx.PaymentNotifyEvents.NotifyID.Eq(notification.ID)).Update(tx.PaymentNotifyEvents.Processed, true)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
})
|
||
|
||
// 处理事务结果
|
||
if err != nil {
|
||
if err.Error() == "slot_unavailable" {
|
||
// 处理一番赏位置冲突退款 (事务外异步处理,避免长事务)
|
||
go h.handleRefund(context.Background(), order, "ichiban_slot_conflict")
|
||
ctx.Payload(¬ifyAck{Code: "SUCCESS", Message: "OK"})
|
||
return
|
||
}
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150005, err.Error()))
|
||
return
|
||
}
|
||
|
||
// 8. 异步触发外部同步逻辑 (无需事务)
|
||
go func() {
|
||
bgCtx := context.Background()
|
||
// 触发任务中心逻辑
|
||
_ = h.task.OnOrderPaid(bgCtx, order.UserID, order.ID)
|
||
|
||
// 抽奖或发货逻辑
|
||
ord, _ := h.readDB.Orders.WithContext(bgCtx).Where(h.readDB.Orders.ID.Eq(order.ID)).First()
|
||
if ord == nil {
|
||
return
|
||
}
|
||
actID := parseActivityIDFromRemark(ord.Remark)
|
||
act, _ := h.readDB.Activities.WithContext(bgCtx).Where(h.readDB.Activities.ID.Eq(actID)).First()
|
||
|
||
if ord.SourceType == 2 && act != nil && act.DrawMode == "instant" {
|
||
_ = h.activity.ProcessOrderLottery(bgCtx, ord.ID)
|
||
} else if ord.SourceType != 2 && ord.SourceType != 3 {
|
||
// 普通商品虚拟发货
|
||
payerOpenid := ""
|
||
if transaction.Payer != nil && transaction.Payer.Openid != nil {
|
||
payerOpenid = *transaction.Payer.Openid
|
||
}
|
||
itemsDesc := "订单" + ord.OrderNo
|
||
if txID := func() string {
|
||
if transaction.TransactionId != nil {
|
||
return *transaction.TransactionId
|
||
}
|
||
return ""
|
||
}(); txID != "" {
|
||
_ = wechat.UploadVirtualShippingWithFallback(ctx, &wechat.WechatConfig{AppID: configs.Get().Wechat.AppID, AppSecret: configs.Get().Wechat.AppSecret}, txID, ord.OrderNo, payerOpenid, itemsDesc, time.Now())
|
||
}
|
||
}
|
||
}()
|
||
|
||
ctx.Payload(¬ifyAck{Code: "SUCCESS", Message: "OK"})
|
||
}
|
||
}
|
||
|
||
// handleRefund 处理一番赏冲突退款
|
||
func (h *handler) handleRefund(ctx context.Context, ord *model.Orders, reason string) {
|
||
wc, e := pay.NewWechatPayClient(ctx)
|
||
if e != nil {
|
||
h.logger.Error("Failed to create wechat pay client for refund", zap.Error(e))
|
||
return
|
||
}
|
||
refundNo := fmt.Sprintf("R%s-%d", ord.OrderNo, time.Now().Unix())
|
||
iss := parseIssueIDFromRemark(ord.Remark)
|
||
refundID, status, e2 := wc.RefundOrder(ctx, ord.OrderNo, refundNo, ord.ActualAmount, ord.ActualAmount, reason)
|
||
if e2 != nil {
|
||
h.logger.Error("Refund failed", zap.Error(e2), zap.String("order_no", ord.OrderNo))
|
||
return
|
||
}
|
||
_ = h.writeDB.PaymentRefunds.WithContext(ctx).Create(&model.PaymentRefunds{OrderID: ord.ID, OrderNo: ord.OrderNo, RefundNo: refundNo, Channel: "wechat_jsapi", Status: status, AmountRefund: ord.ActualAmount, Reason: reason})
|
||
_ = h.writeDB.UserPointsLedger.WithContext(ctx).Create(&model.UserPointsLedger{UserID: ord.UserID, Action: "refund_amount", Points: ord.ActualAmount / 100, RefTable: "payment_refund", RefID: refundID})
|
||
_, _ = h.writeDB.Orders.WithContext(ctx).Where(h.writeDB.Orders.ID.Eq(ord.ID)).Update(h.writeDB.Orders.Status, 4)
|
||
_ = h.repo.GetDbW().Exec("INSERT INTO lottery_refund_logs(issue_id, order_id, user_id, amount, coupon_type, coupon_amount, reason, status) VALUES(?,?,?,?,?,?,?,?)", iss, ord.ID, ord.UserID, ord.ActualAmount, "", 0, reason, status).Error
|
||
}
|
||
|
||
func parseIssueIDFromRemark(remark string) int64 {
|
||
if remark == "" {
|
||
return 0
|
||
}
|
||
parts := strings.Split(remark, "|")
|
||
for _, p := range parts {
|
||
if strings.HasPrefix(p, "issue:") {
|
||
return parseInt64(p[6:])
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func parseActivityIDFromRemark(remark string) int64 {
|
||
if remark == "" {
|
||
return 0
|
||
}
|
||
parts := strings.Split(remark, "|")
|
||
for _, p := range parts {
|
||
if strings.HasPrefix(p, "lottery:activity:") {
|
||
return parseInt64(p[len("lottery:activity:"):])
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func parseCountFromRemark(remark string) int64 {
|
||
if remark == "" {
|
||
return 1
|
||
}
|
||
parts := strings.Split(remark, "|")
|
||
for _, p := range parts {
|
||
if strings.HasPrefix(p, "count:") {
|
||
n := parseInt64(p[6:])
|
||
if n <= 0 {
|
||
return 1
|
||
}
|
||
return n
|
||
}
|
||
}
|
||
return 1
|
||
}
|
||
|
||
func parseInt64(s string) int64 {
|
||
var n int64
|
||
for i := 0; i < len(s); i++ {
|
||
c := s[i]
|
||
if c < '0' || c > '9' {
|
||
break
|
||
}
|
||
n = n*10 + int64(c-'0')
|
||
}
|
||
return n
|
||
}
|
||
|
||
func parseSlotsCountsFromRemark(remark string) ([]int64, []int64) {
|
||
if remark == "" {
|
||
return nil, nil
|
||
}
|
||
parts := strings.Split(remark, "|")
|
||
for _, p := range parts {
|
||
if strings.HasPrefix(p, "slots:") {
|
||
pairs := p[6:]
|
||
var idxs []int64
|
||
var cnts []int64
|
||
start := 0
|
||
for start <= len(pairs) {
|
||
end := start
|
||
for end < len(pairs) && pairs[end] != ',' {
|
||
end++
|
||
}
|
||
if end > start {
|
||
a := pairs[start:end]
|
||
var x, y int64
|
||
j := 0
|
||
for j < len(a) && a[j] >= '0' && a[j] <= '9' {
|
||
x = x*10 + int64(a[j]-'0')
|
||
j++
|
||
}
|
||
if j < len(a) && a[j] == ':' {
|
||
j++
|
||
for j < len(a) && a[j] >= '0' && a[j] <= '9' {
|
||
y = y*10 + int64(a[j]-'0')
|
||
j++
|
||
}
|
||
}
|
||
if y > 0 {
|
||
idxs = append(idxs, x+1)
|
||
cnts = append(cnts, y)
|
||
}
|
||
}
|
||
start = end + 1
|
||
}
|
||
return idxs, cnts
|
||
}
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
func parseSlotFromRemark(remark string) int64 {
|
||
if remark == "" {
|
||
return -1
|
||
}
|
||
p := 0
|
||
for i := 0; i < len(remark); i++ {
|
||
if remark[i] == '|' {
|
||
seg := remark[p:i]
|
||
if len(seg) > 5 && seg[:5] == "slot:" {
|
||
var n int64
|
||
for j := 5; j < len(seg); j++ {
|
||
c := seg[j]
|
||
if c < '0' || c > '9' {
|
||
break
|
||
}
|
||
n = n*10 + int64(c-'0')
|
||
}
|
||
return n
|
||
}
|
||
p = i + 1
|
||
}
|
||
}
|
||
if p < len(remark) {
|
||
seg := remark[p:]
|
||
if len(seg) > 5 && seg[:5] == "slot:" {
|
||
var n int64
|
||
for j := 5; j < len(seg); j++ {
|
||
c := seg[j]
|
||
if c < '0' || c > '9' {
|
||
break
|
||
}
|
||
n = n*10 + int64(c-'0')
|
||
}
|
||
return n
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func parseItemCardIDFromRemark(remark string) int64 {
|
||
// remark segments separated by '|', find segment starting with "itemcard:"
|
||
p := 0
|
||
for i := 0; i < len(remark); i++ {
|
||
if remark[i] == '|' {
|
||
seg := remark[p:i]
|
||
if len(seg) > 9 && seg[:9] == "itemcard:" {
|
||
var n int64
|
||
for j := 9; j < len(seg); j++ {
|
||
c := seg[j]
|
||
if c < '0' || c > '9' {
|
||
break
|
||
}
|
||
n = n*10 + int64(c-'0')
|
||
}
|
||
if n > 0 {
|
||
return n
|
||
}
|
||
}
|
||
p = i + 1
|
||
}
|
||
}
|
||
// 检查最后一段
|
||
if p < len(remark) {
|
||
seg := remark[p:]
|
||
if len(seg) > 9 && seg[:9] == "itemcard:" {
|
||
var n int64
|
||
for j := 9; j < len(seg); j++ {
|
||
c := seg[j]
|
||
if c < '0' || c > '9' {
|
||
break
|
||
}
|
||
n = n*10 + int64(c-'0')
|
||
}
|
||
if n > 0 {
|
||
return n
|
||
}
|
||
}
|
||
}
|
||
return 0
|
||
}
|