This commit is contained in:
Mrx
2026-06-06 11:52:06 +08:00
parent a79c464329
commit a85436950e
16 changed files with 864 additions and 25 deletions

View File

@@ -22,6 +22,15 @@ service main {
// 微信退款回调 // 微信退款回调
@handler WechatPayRefundCallback @handler WechatPayRefundCallback
post /pay/wechat/refund_callback post /pay/wechat/refund_callback
// 微信小程序虚拟支付发货推送GET 验签 + POST 事件)
@handler XpayPush
get /pay/xpay/push
post /pay/xpay/push
// 运维xpay 手动补发货
@handler XpayAdminDeliver
post /pay/xpay/admin/deliver (XpayAdminDeliverReq) returns (XpayAdminDeliverResp)
} }
@server ( @server (
@@ -70,3 +79,15 @@ type (
} }
) )
type (
XpayAdminDeliverReq {
OrderNo string `json:"order_no" validate:"required"`
}
XpayAdminDeliverResp {
Credited bool `json:"credited"`
Notified bool `json:"notified"`
WechatDetail string `json:"wechat_detail"`
Errors []string `json:"errors"`
}
)

View File

@@ -75,6 +75,15 @@ WechatH5:
WechatMini: WechatMini:
AppID: "wx5bacc94add2da981" # 小程序的AppID AppID: "wx5bacc94add2da981" # 小程序的AppID
AppSecret: "48a2c1e8ff1b7d4c0ff82fbefa64d2d0" # 小程序的AppSecret AppSecret: "48a2c1e8ff1b7d4c0ff82fbefa64d2d0" # 小程序的AppSecret
WechatXpay:
Enabled: true
Env: 1
OfferId: "1450552691"
AppKey: "n1SSzeMiitPjiQwLlBn1s4Hn7SpkG3qD"
ProdAppKey: "oYv2wooYzRmPDLdXkpBpqaml8cFaY0Bb"
MessagePushToken: "qncXpayPush2026"
AdminToken: "qncXpayAdmin2026"
SessionKeyTTL: 2592000
Query: Query:
ShareLinkExpire: 604800 # 7天 = 7 * 24 * 60 * 60 = 604800秒 ShareLinkExpire: 604800 # 7天 = 7 * 24 * 60 * 60 = 604800秒
AdminConfig: AdminConfig:

View File

@@ -64,6 +64,15 @@ WechatH5:
WechatMini: WechatMini:
AppID: "wx5bacc94add2da981" # 小程序的AppID AppID: "wx5bacc94add2da981" # 小程序的AppID
AppSecret: "48a2c1e8ff1b7d4c0ff82fbefa64d2d0" # 小程序的AppSecret AppSecret: "48a2c1e8ff1b7d4c0ff82fbefa64d2d0" # 小程序的AppSecret
WechatXpay:
Enabled: true
Env: 1 # 0 现网 / 1 沙箱(当前:沙箱联调)
OfferId: "1450552691"
AppKey: "n1SSzeMiitPjiQwLlBn1s4Hn7SpkG3qD" # 沙箱 AppKey
ProdAppKey: "oYv2wooYzRmPDLdXkpBpqaml8cFaY0Bb" # 现网 AppKey上线时 Env 改 0 并切换 AppKey
MessagePushToken: "qncXpayPush2026" # 与 mp 后台消息推送 Token 保持一致
AdminToken: "qncXpayAdmin2026" # 运维手动补发货接口 Bearer Token
SessionKeyTTL: 2592000 # 30 天,与 JwtAuth.AccessExpire 对齐
Query: Query:
ShareLinkExpire: 604800 # 7天 = 7 * 24 * 60 * 60 = 604800秒 ShareLinkExpire: 604800 # 7天 = 7 * 24 * 60 * 60 = 604800秒
AdminConfig: AdminConfig:

View File

@@ -21,6 +21,7 @@ type Config struct {
WechatH5 WechatH5Config WechatH5 WechatH5Config
Authorization AuthorizationConfig // 授权书配置 Authorization AuthorizationConfig // 授权书配置
WechatMini WechatMiniConfig WechatMini WechatMiniConfig
WechatXpay WechatXpayConfig
Query QueryConfig Query QueryConfig
AdminConfig AdminConfig AdminConfig AdminConfig
TaxConfig TaxConfig TaxConfig TaxConfig
@@ -98,6 +99,18 @@ type WechatMiniConfig struct {
AppID string AppID string
AppSecret string AppSecret string
} }
// WechatXpayConfig 微信小程序虚拟支付xpay
type WechatXpayConfig struct {
Enabled bool
Env int // 0 现网 / 1 沙箱
OfferId string
AppKey string // 与 Env 配套的 AppKey
ProdAppKey string // 现网 AppKey切换 env=0 时使用,可选)
MessagePushToken string
AdminToken string
SessionKeyTTL int64
}
type QueryConfig struct { type QueryConfig struct {
ShareLinkExpire int64 ShareLinkExpire int64
} }

View File

@@ -0,0 +1,30 @@
package pay
import (
"net/http"
"qnc-server/app/main/api/internal/logic/pay"
"qnc-server/app/main/api/internal/svc"
"qnc-server/app/main/api/internal/types"
"qnc-server/common/result"
"qnc-server/pkg/lzkit/validator"
"github.com/zeromicro/go-zero/rest/httpx"
)
func XpayAdminDeliverHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.XpayAdminDeliverReq
if err := httpx.Parse(r, &req); err != nil {
result.ParamErrorResult(r, w, err)
return
}
if err := validator.Validate(req); err != nil {
result.ParamValidateErrorResult(r, w, err)
return
}
l := pay.NewXpayAdminDeliverLogic(r.Context(), svcCtx)
resp, err := l.XpayAdminDeliver(&req, r)
result.HttpResult(r, w, resp, err)
}
}

View File

@@ -0,0 +1,22 @@
package pay
import (
"net/http"
"qnc-server/app/main/api/internal/logic/pay"
"qnc-server/app/main/api/internal/svc"
)
func XpayPushHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
l := pay.NewXpayPushLogic(svcCtx)
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
l.HandleGET(w, r)
case http.MethodPost:
l.HandlePOST(w, r)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
}

View File

@@ -937,6 +937,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/pay/wechat/refund_callback", Path: "/pay/wechat/refund_callback",
Handler: pay.WechatPayRefundCallbackHandler(serverCtx), Handler: pay.WechatPayRefundCallbackHandler(serverCtx),
}, },
{
Method: http.MethodGet,
Path: "/pay/xpay/push",
Handler: pay.XpayPushHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/pay/xpay/push",
Handler: pay.XpayPushHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/pay/xpay/admin/deliver",
Handler: pay.XpayAdminDeliverHandler(serverCtx),
},
}, },
rest.WithPrefix("/api/v1"), rest.WithPrefix("/api/v1"),
) )

View File

@@ -3,10 +3,13 @@ package pay
import ( import (
"context" "context"
"strings" "strings"
"qnc-server/app/main/api/internal/service"
"qnc-server/app/main/api/internal/svc" "qnc-server/app/main/api/internal/svc"
"qnc-server/app/main/api/internal/types" "qnc-server/app/main/api/internal/types"
"qnc-server/app/main/model"
"qnc-server/common/ctxdata"
"qnc-server/common/xerr" "qnc-server/common/xerr"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/zeromicro/go-zero/core/logx" "github.com/zeromicro/go-zero/core/logx"
) )
@@ -26,26 +29,72 @@ func NewPaymentCheckLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Paym
} }
func (l *PaymentCheckLogic) PaymentCheck(req *types.PaymentCheckReq) (resp *types.PaymentCheckResp, err error) { func (l *PaymentCheckLogic) PaymentCheck(req *types.PaymentCheckReq) (resp *types.PaymentCheckResp, err error) {
// 根据订单号前缀判断订单类型
if strings.HasPrefix(req.OrderNo, "U_") { if strings.HasPrefix(req.OrderNo, "U_") {
// 升级订单 order, findErr := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo)
order, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo) if findErr != nil {
if err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询升级订单失败: %v", findErr)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询升级订单失败: %v", err)
} }
return &types.PaymentCheckResp{ return &types.PaymentCheckResp{Type: "agent_upgrade", Status: order.Status}, nil
Type: "agent_upgrade",
Status: order.Status,
}, nil
} }
// 查询订单(包括代理订单) order, findErr := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo)
order, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo) if findErr != nil {
if err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询订单失败: %v", findErr)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询订单失败: %v", err)
} }
return &types.PaymentCheckResp{
Type: "query", // xpay 轮询pending 时主动查微信单
Status: order.Status, if order.Status == "pending" && order.PaymentScene == "wxmini" &&
}, nil l.svcCtx.XpayService != nil && l.svcCtx.XpayService.Enabled() {
if syncErr := l.syncXpayOrderStatus(order); syncErr != nil {
l.Errorf("[xpay] 轮询查单失败 order_no=%s err=%v", req.OrderNo, syncErr)
} else {
order, _ = l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo)
}
}
return &types.PaymentCheckResp{Type: "query", Status: order.Status}, nil
}
func (l *PaymentCheckLogic) syncXpayOrderStatus(order *model.Order) error {
userID, err := ctxdata.GetUidFromCtx(l.ctx)
if err != nil {
return err
}
if order.UserId != userID {
return errors.New("无权查询此订单")
}
openid, err := l.svcCtx.XpayService.GetWxMiniOpenID(l.ctx, l.svcCtx.UserAuthModel, userID)
if err != nil {
return err
}
sessionKey, err := l.svcCtx.XpayService.GetSessionKey(l.ctx, userID)
if err != nil {
return err
}
status, err := l.svcCtx.XpayService.QueryOrder(l.ctx, openid, order.OrderNo, sessionKey)
if err != nil {
return err
}
if service.IsXpayPaidStatus(status.Status) {
credited, fulfillErr := fulfillQueryOrderPaid(l.ctx, l.svcCtx, order, "", status.PaidFee)
if fulfillErr != nil {
return fulfillErr
}
if credited {
wxOrderID := ""
_ = l.svcCtx.XpayService.NotifyProvideGoods(l.ctx, openid, order.OrderNo, wxOrderID, sessionKey)
_ = l.svcCtx.XpayService.MarkNotified(l.ctx, order.OrderNo)
}
return nil
}
if service.IsXpayClosedStatus(status.Status) {
order.Status = "closed"
return l.svcCtx.OrderModel.UpdateWithVersion(l.ctx, nil, order)
}
return nil
} }

View File

@@ -33,6 +33,7 @@ type PaymentTypeResp struct {
outTradeNo string outTradeNo string
description string description string
orderID string // 订单ID用于开发环境测试支付模式 orderID string // 订单ID用于开发环境测试支付模式
productEn string // 产品英文名xpay 道具 ID
} }
// enableDevTestPayment 测试支付功能开关,设置为 true 以启用测试支付功能 // enableDevTestPayment 测试支付功能开关,设置为 true 以启用测试支付功能
@@ -50,6 +51,7 @@ func (l *PaymentLogic) Payment(req *types.PaymentReq) (resp *types.PaymentResp,
var paymentTypeResp *PaymentTypeResp var paymentTypeResp *PaymentTypeResp
var prepayData interface{} var prepayData interface{}
var orderID string var orderID string
useXpay := l.shouldUseXpay(req)
// 检查是否为开发环境的测试支付模式 // 检查是否为开发环境的测试支付模式
env := os.Getenv("ENV") env := os.Getenv("ENV")
@@ -95,9 +97,11 @@ func (l *PaymentLogic) Payment(req *types.PaymentReq) (resp *types.PaymentResp,
return nil return nil
} }
// 正常支付流程 // 正常支付流程(微信小程序 xpay 在事务外构造支付参数)
var createOrderErr error var createOrderErr error
if req.PayMethod == "wechat" { if useXpay {
// 订单已在 QueryOrderPayment 中创建,此处跳过 JSAPI 预下单
} else if req.PayMethod == "wechat" {
prepayData, createOrderErr = l.svcCtx.WechatPayService.CreateWechatOrder(l.ctx, paymentTypeResp.amount, paymentTypeResp.description, paymentTypeResp.outTradeNo, req.Code) prepayData, createOrderErr = l.svcCtx.WechatPayService.CreateWechatOrder(l.ctx, paymentTypeResp.amount, paymentTypeResp.description, paymentTypeResp.outTradeNo, req.Code)
} else if req.PayMethod == "alipay" { } else if req.PayMethod == "alipay" {
prepayData, createOrderErr = l.svcCtx.AlipayService.CreateAlipayOrder(l.ctx, paymentTypeResp.amount, paymentTypeResp.description, paymentTypeResp.outTradeNo) prepayData, createOrderErr = l.svcCtx.AlipayService.CreateAlipayOrder(l.ctx, paymentTypeResp.amount, paymentTypeResp.description, paymentTypeResp.outTradeNo)
@@ -113,6 +117,22 @@ func (l *PaymentLogic) Payment(req *types.PaymentReq) (resp *types.PaymentResp,
return nil, err return nil, err
} }
// 微信小程序虚拟支付:构造 signData / paySig / signature
if useXpay && paymentTypeResp != nil {
userID, uidErr := ctxdata.GetUidFromCtx(l.ctx)
if uidErr != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "获取用户信息失败")
}
xpayParams, buildErr := l.svcCtx.XpayService.BuildPayParams(l.ctx, userID, paymentTypeResp.outTradeNo, paymentTypeResp.productEn, paymentTypeResp.amount)
if buildErr != nil {
if strings.Contains(buildErr.Error(), "过期") || strings.Contains(buildErr.Error(), "会话") {
return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, buildErr.Error()), "")
}
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "构造虚拟支付参数失败: %v", buildErr)
}
prepayData = xpayParams
}
// 开发环境测试支付模式:事务提交后处理订单状态更新和后续流程 // 开发环境测试支付模式:事务提交后处理订单状态更新和后续流程
if isDevTestPayment && paymentTypeResp != nil && paymentTypeResp.orderID != "" { if isDevTestPayment && paymentTypeResp != nil && paymentTypeResp.orderID != "" {
// 使用 goroutine 异步处理,确保事务已完全提交 // 使用 goroutine 异步处理,确保事务已完全提交
@@ -257,7 +277,7 @@ func (l *PaymentLogic) QueryOrderPayment(req *types.PaymentReq, session sqlx.Ses
UserId: userID, UserId: userID,
ProductId: product.Id, ProductId: product.Id,
PaymentPlatform: req.PayMethod, PaymentPlatform: req.PayMethod,
PaymentScene: "app", PaymentScene: resolvePaymentScene(l.ctx),
Amount: amount, Amount: amount,
Status: "pending", Status: "pending",
} }
@@ -330,7 +350,44 @@ func (l *PaymentLogic) QueryOrderPayment(req *types.PaymentReq, session sqlx.Ses
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "生成订单, 保存代理订单失败: %+v", agentOrderInsert) return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "生成订单, 保存代理订单失败: %+v", agentOrderInsert)
} }
} }
return &PaymentTypeResp{amount: amount, outTradeNo: outTradeNo, description: product.ProductName, orderID: orderID}, nil return &PaymentTypeResp{
amount: amount,
outTradeNo: outTradeNo,
description: product.ProductName,
orderID: orderID,
productEn: product.ProductEn,
}, nil
}
func (l *PaymentLogic) shouldUseXpay(req *types.PaymentReq) bool {
if req.PayMethod != "wechat" || req.PayType != "query" {
return false
}
if l.svcCtx.XpayService == nil || !l.svcCtx.XpayService.Enabled() {
return false
}
platform, err := ctxdata.GetPlatformFromCtx(l.ctx)
if err != nil || platform != model.PlatformWxMini {
return false
}
return true
}
func resolvePaymentScene(ctx context.Context) string {
platform, err := ctxdata.GetPlatformFromCtx(ctx)
if err != nil {
return "app"
}
switch platform {
case model.PlatformWxMini:
return "wxmini"
case model.PlatformWxH5:
return "wxh5"
case model.PlatformH5:
return "h5"
default:
return "app"
}
} }
// AgentVipOrderPayment 代理会员充值订单(已废弃,新系统使用升级功能替代) // AgentVipOrderPayment 代理会员充值订单(已废弃,新系统使用升级功能替代)

View File

@@ -0,0 +1,99 @@
package pay
import (
"context"
"net/http"
"strings"
"qnc-server/app/main/api/internal/svc"
"qnc-server/app/main/api/internal/types"
"qnc-server/common/xerr"
"github.com/pkg/errors"
"github.com/zeromicro/go-zero/core/logx"
)
type XpayAdminDeliverLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewXpayAdminDeliverLogic(ctx context.Context, svcCtx *svc.ServiceContext) *XpayAdminDeliverLogic {
return &XpayAdminDeliverLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
type XpayAdminDeliverResp struct {
Credited bool `json:"credited"`
Notified bool `json:"notified"`
WechatDetail string `json:"wechat_detail"`
Errors []string `json:"errors"`
}
func (l *XpayAdminDeliverLogic) XpayAdminDeliver(req *types.XpayAdminDeliverReq, r *http.Request) (*XpayAdminDeliverResp, error) {
auth := r.Header.Get("Authorization")
token := strings.TrimPrefix(auth, "Bearer ")
if token == "" || token != l.svcCtx.Config.WechatXpay.AdminToken {
return nil, errors.Wrapf(xerr.NewErrMsg("未授权"), "")
}
order, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrMsg("订单不存在"), "")
}
resp := &XpayAdminDeliverResp{WechatDetail: "ok"}
if order.Status == "paid" {
resp.Credited = false
return resp, nil
}
openid, err := l.svcCtx.XpayService.GetWxMiniOpenID(l.ctx, l.svcCtx.UserAuthModel, order.UserId)
if err != nil {
resp.Errors = append(resp.Errors, err.Error())
return resp, nil
}
sessionKey, err := l.svcCtx.XpayService.GetSessionKey(l.ctx, order.UserId)
if err != nil {
resp.Errors = append(resp.Errors, "session_key 不可用: "+err.Error())
return resp, nil
}
status, qErr := l.svcCtx.XpayService.QueryOrder(l.ctx, openid, order.OrderNo, sessionKey)
if qErr != nil {
resp.Errors = append(resp.Errors, qErr.Error())
resp.WechatDetail = qErr.Error()
return resp, nil
}
if !serviceIsPaid(status.Status) {
resp.Errors = append(resp.Errors, "微信侧订单未支付")
return resp, nil
}
credited, fulfillErr := fulfillQueryOrderPaid(l.ctx, l.svcCtx, order, "", status.PaidFee)
resp.Credited = credited
if fulfillErr != nil {
resp.Errors = append(resp.Errors, fulfillErr.Error())
}
if notifyErr := l.svcCtx.XpayService.NotifyProvideGoods(l.ctx, openid, order.OrderNo, "", sessionKey); notifyErr != nil {
resp.Notified = false
resp.WechatDetail = notifyErr.Error()
resp.Errors = append(resp.Errors, notifyErr.Error())
} else {
resp.Notified = true
_ = l.svcCtx.XpayService.MarkNotified(l.ctx, order.OrderNo)
}
return resp, nil
}
func serviceIsPaid(status int) bool {
return status == 2 || status == 3 || status == 4
}

View File

@@ -0,0 +1,48 @@
package pay
import (
"context"
"database/sql"
"time"
"qnc-server/app/main/api/internal/svc"
"qnc-server/app/main/model"
"qnc-server/pkg/lzkit/lzUtils"
"github.com/zeromicro/go-zero/core/logx"
)
// fulfillQueryOrderPaid 幂等将查询订单标记为已支付并触发报告生成
// wechatPaidFen微信侧实付金额>0 时须与订单 amount 一致
func fulfillQueryOrderPaid(ctx context.Context, svcCtx *svc.ServiceContext, order *model.Order, platformOrderID string, wechatPaidFen int64) (credited bool, err error) {
if order.Status == "paid" {
return false, nil
}
orderFen := lzUtils.ToWechatAmount(order.Amount)
if wechatPaidFen > 0 && wechatPaidFen != orderFen {
logx.WithContext(ctx).Errorf("[xpay] 金额不一致 order_no=%s order_fen=%d wechat_fen=%d", order.OrderNo, orderFen, wechatPaidFen)
return false, nil
}
order.Status = "paid"
order.PayTime = sql.NullTime{Time: time.Now(), Valid: true}
if platformOrderID != "" {
order.PlatformOrderId = sql.NullString{String: platformOrderID, Valid: true}
}
if order.PaymentScene == "" || order.PaymentScene == "app" {
order.PaymentScene = "wxmini"
}
if updateErr := svcCtx.OrderModel.UpdateWithVersion(ctx, nil, order); updateErr != nil {
return false, updateErr
}
if asyncErr := svcCtx.AsynqService.SendQueryTask(order.Id); asyncErr != nil {
logx.WithContext(ctx).Errorf("[xpay] SendQueryTask 失败 order_id=%s err=%v", order.Id, asyncErr)
return true, asyncErr
}
logx.WithContext(ctx).Infof("[xpay] 订单到账 order_no=%s order_id=%s", order.OrderNo, order.Id)
return true, nil
}

View File

@@ -0,0 +1,97 @@
package pay
import (
"encoding/json"
"io"
"net/http"
"qnc-server/app/main/api/internal/service"
"qnc-server/app/main/api/internal/svc"
"github.com/zeromicro/go-zero/core/logx"
)
type XpayPushLogic struct {
logx.Logger
svcCtx *svc.ServiceContext
}
func NewXpayPushLogic(svcCtx *svc.ServiceContext) *XpayPushLogic {
return &XpayPushLogic{svcCtx: svcCtx}
}
// HandleGET mp 后台配置消息推送时的验签
func (l *XpayPushLogic) HandleGET(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
signature := q.Get("signature")
timestamp := q.Get("timestamp")
nonce := q.Get("nonce")
echostr := q.Get("echostr")
if l.svcCtx.XpayService.VerifyPushSignature(signature, timestamp, nonce) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(echostr))
return
}
w.WriteHeader(http.StatusForbidden)
}
// HandlePOST 接收 xpay_goods_deliver_notify
func (l *XpayPushLogic) HandlePOST(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
l.writePushResp(w, 1, "read body failed")
return
}
var notify service.XpayDeliverNotify
if err := json.Unmarshal(body, &notify); err != nil {
l.writePushResp(w, 1, "parse failed")
return
}
if notify.Event != "" && notify.Event != "xpay_goods_deliver_notify" {
l.writePushResp(w, 0, "ignored")
return
}
orderNo := notify.OutTradeNo
if orderNo == "" {
l.writePushResp(w, 1, "missing OutTradeNo")
return
}
ctx := r.Context()
already, _ := l.svcCtx.XpayService.AlreadyNotified(ctx, orderNo)
if already {
l.writePushResp(w, 0, "already notified")
return
}
order, findErr := l.svcCtx.OrderModel.FindOneByOrderNo(ctx, orderNo)
if findErr != nil {
logx.WithContext(ctx).Errorf("[xpay push] 订单不存在 order_no=%s err=%v", orderNo, findErr)
l.writePushResp(w, 1, "order not found")
return
}
wxOrderID := notify.WeChatPayInfo.TransactionId
credited, fulfillErr := fulfillQueryOrderPaid(ctx, l.svcCtx, order, wxOrderID, notify.GoodsInfo.ActualPrice)
if fulfillErr != nil {
logx.WithContext(ctx).Errorf("[xpay push] 到账失败 order_no=%s err=%v", orderNo, fulfillErr)
l.writePushResp(w, 1, fulfillErr.Error())
return
}
_ = l.svcCtx.XpayService.MarkNotified(ctx, orderNo)
logx.WithContext(ctx).Infof("[xpay push] event=xpay_goods_deliver_notify order_no=%s credited=%v", orderNo, credited)
l.writePushResp(w, 0, "success")
}
func (l *XpayPushLogic) writePushResp(w http.ResponseWriter, errCode int, errMsg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ErrCode": errCode,
"ErrMsg": errMsg,
})
}

View File

@@ -65,13 +65,20 @@ func (l *WxMiniAuthLogic) WxMiniAuth(req *types.WXMiniAuthReq) (resp *types.WXMi
userID = user.Id userID = user.Id
} }
// 4. 生成JWT Token动态计算userType // 4. 缓存 session_keyxpay 虚拟支付签名需要
if l.svcCtx.XpayService != nil && l.svcCtx.XpayService.Enabled() {
if saveErr := l.svcCtx.XpayService.SaveSessionKey(l.ctx, userID, sessionKeyResp.SessionKey); saveErr != nil {
l.Errorf("缓存 xpay session_key 失败 userID=%s err=%v", userID, saveErr)
}
}
// 5. 生成JWT Token动态计算userType
token, err := l.svcCtx.UserService.GeneralUserToken(l.ctx, userID) token, err := l.svcCtx.UserService.GeneralUserToken(l.ctx, userID)
if err != nil { if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "生成JWT Token失败: %v", err) return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "生成JWT Token失败: %v", err)
} }
// 5. 返回登录结果 // 6. 返回登录结果
now := time.Now().Unix() now := time.Now().Unix()
return &types.WXMiniAuthResp{ return &types.WXMiniAuthResp{
AccessToken: token, AccessToken: token,

View File

@@ -0,0 +1,349 @@
package service
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"qnc-server/app/main/api/internal/config"
"qnc-server/app/main/model"
"qnc-server/pkg/lzkit/lzUtils"
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/core/stores/redis"
)
const (
xpaySessionKeyFmt = "qnc:wx:xpay:session:%s"
xpayNotifiedKeyFmt = "qnc:xpay:notified:%s"
xpayAccessTokenKey = "qnc:wx:xpay:access_token"
xpayMode = "short_series_goods"
xpayVirtualPaymentURI = "requestVirtualPayment"
)
// XpayPrepayData 前端 wx.requestVirtualPayment 参数
type XpayPrepayData struct {
Provider string `json:"provider"`
Mode string `json:"mode"`
SignData string `json:"signData"`
PaySig string `json:"paySig"`
Signature string `json:"signature"`
}
type XpayService struct {
config config.Config
redis *redis.Redis
}
func NewXpayService(c config.Config, r *redis.Redis) *XpayService {
return &XpayService{config: c, redis: r}
}
func (s *XpayService) Enabled() bool {
return s.config.WechatXpay.Enabled && s.config.WechatXpay.OfferId != "" && s.config.WechatXpay.AppKey != ""
}
func (s *XpayService) Env() int {
return s.config.WechatXpay.Env
}
func (s *XpayService) SessionKeyRedisKey(userID string) string {
return fmt.Sprintf(xpaySessionKeyFmt, userID)
}
func (s *XpayService) SaveSessionKey(ctx context.Context, userID, sessionKey string) error {
ttl := int(s.config.WechatXpay.SessionKeyTTL)
if ttl <= 0 {
ttl = int(s.config.JwtAuth.AccessExpire)
}
return s.redis.SetexCtx(ctx, s.SessionKeyRedisKey(userID), sessionKey, ttl)
}
func (s *XpayService) GetSessionKey(ctx context.Context, userID string) (string, error) {
val, err := s.redis.GetCtx(ctx, s.SessionKeyRedisKey(userID))
if err != nil {
return "", err
}
if val == "" {
return "", redis.Nil
}
return val, nil
}
func hmacSHA256Hex(key, data string) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(data))
return hex.EncodeToString(mac.Sum(nil))
}
func compactJSON(v interface{}) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
}
type signDataPayload struct {
OfferId string `json:"offerId"`
BuyQuantity int `json:"buyQuantity"`
Env int `json:"env"`
CurrencyType string `json:"currencyType"`
ProductId string `json:"productId"`
GoodsPrice int64 `json:"goodsPrice"`
OutTradeNo string `json:"outTradeNo"`
Attach string `json:"attach"`
}
// BuildPayParams 构造虚拟支付双签参数
func (s *XpayService) BuildPayParams(ctx context.Context, userID, orderNo, productEn string, amount float64) (*XpayPrepayData, error) {
sessionKey, err := s.GetSessionKey(ctx, userID)
if err != nil {
if err == redis.Nil {
return nil, fmt.Errorf("微信会话已过期,请重新打开小程序")
}
return nil, fmt.Errorf("获取微信会话失败: %w", err)
}
goodsPrice := lzUtils.ToWechatAmount(amount)
payload := signDataPayload{
OfferId: s.config.WechatXpay.OfferId,
BuyQuantity: 1,
Env: s.Env(),
CurrencyType: "CNY",
ProductId: productEn,
GoodsPrice: goodsPrice,
OutTradeNo: orderNo,
Attach: fmt.Sprintf("query:%s", orderNo),
}
signData, err := compactJSON(payload)
if err != nil {
return nil, err
}
appKey := s.config.WechatXpay.AppKey
paySig := hmacSHA256Hex(appKey, xpayVirtualPaymentURI+"&"+signData)
signature := hmacSHA256Hex(sessionKey, signData)
logx.WithContext(ctx).Infof("[xpay] create user=%s order_no=%s env=%d productId=%s goodsPrice=%d",
userID, orderNo, s.Env(), productEn, goodsPrice)
return &XpayPrepayData{
Provider: "xpay",
Mode: xpayMode,
SignData: signData,
PaySig: paySig,
Signature: signature,
}, nil
}
func (s *XpayService) getAccessToken(ctx context.Context) (string, error) {
cached, err := s.redis.GetCtx(ctx, xpayAccessTokenKey)
if err == nil && cached != "" {
return cached, nil
}
appID := s.config.WechatMini.AppID
secret := s.config.WechatMini.AppSecret
url := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", appID, secret)
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var tokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return "", err
}
if tokenResp.ErrCode != 0 || tokenResp.AccessToken == "" {
return "", fmt.Errorf("获取 access_token 失败: errcode=%d errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
}
ttl := tokenResp.ExpiresIn - 300
if ttl < 60 {
ttl = 60
}
_ = s.redis.SetexCtx(ctx, xpayAccessTokenKey, tokenResp.AccessToken, ttl)
return tokenResp.AccessToken, nil
}
type xpayAPIResp struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
Data json.RawMessage `json:"data"`
}
type xpayOrderStatus struct {
Status int `json:"status"`
PaidFee int64 `json:"paid_fee"`
OrderID string `json:"order_id"`
}
func (s *XpayService) callAPI(ctx context.Context, uri, bodyJSON, sessionKey string, needSignature bool) (*xpayAPIResp, error) {
accessToken, err := s.getAccessToken(ctx)
if err != nil {
return nil, err
}
appKey := s.config.WechatXpay.AppKey
paySig := hmacSHA256Hex(appKey, uri+"&"+bodyJSON)
reqURL := fmt.Sprintf("https://api.weixin.qq.com%s?access_token=%s&pay_sig=%s", uri, accessToken, paySig)
if needSignature && sessionKey != "" {
sig := hmacSHA256Hex(sessionKey, bodyJSON)
reqURL += "&signature=" + sig
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader([]byte(bodyJSON)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
httpResp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
var apiResp xpayAPIResp
if err := json.Unmarshal(respBody, &apiResp); err != nil {
return nil, fmt.Errorf("解析 xpay 响应失败: %w, body=%s", err, string(respBody))
}
logx.WithContext(ctx).Infof("[xpay] call uri=%s body=%s", uri, string(respBody))
return &apiResp, nil
}
// QueryOrder 调微信 query_order
func (s *XpayService) QueryOrder(ctx context.Context, openid, orderNo, sessionKey string) (*xpayOrderStatus, error) {
bodyObj := map[string]interface{}{
"openid": openid,
"env": s.Env(),
"order_id": orderNo,
}
bodyJSON, err := compactJSON(bodyObj)
if err != nil {
return nil, err
}
apiResp, err := s.callAPI(ctx, "/xpay/query_order", bodyJSON, sessionKey, true)
if err != nil {
return nil, err
}
if apiResp.ErrCode != 0 {
return nil, fmt.Errorf("query_order errcode=%d errmsg=%s", apiResp.ErrCode, apiResp.ErrMsg)
}
var status xpayOrderStatus
if len(apiResp.Data) > 0 {
_ = json.Unmarshal(apiResp.Data, &status)
}
return &status, nil
}
// NotifyProvideGoods 主动通知微信已发货
func (s *XpayService) NotifyProvideGoods(ctx context.Context, openid, orderNo, wxOrderID, sessionKey string) error {
bodyObj := map[string]interface{}{
"openid": openid,
"env": s.Env(),
"order_id": orderNo,
}
if wxOrderID != "" {
bodyObj["wx_order_id"] = wxOrderID
}
bodyJSON, err := compactJSON(bodyObj)
if err != nil {
return err
}
apiResp, err := s.callAPI(ctx, "/xpay/notify_provide_goods", bodyJSON, sessionKey, true)
if err != nil {
return err
}
if apiResp.ErrCode != 0 && apiResp.ErrCode != 268490004 {
return fmt.Errorf("notify_provide_goods errcode=%d errmsg=%s", apiResp.ErrCode, apiResp.ErrMsg)
}
logx.WithContext(ctx).Infof("[xpay] notify_provide_goods OK order_no=%s", orderNo)
return nil
}
// IsPaidStatus 微信订单状态 2/3/4 视为支付成功
func IsXpayPaidStatus(status int) bool {
return status == 2 || status == 3 || status == 4
}
// IsXpayClosedStatus 5~10 视为关闭
func IsXpayClosedStatus(status int) bool {
return status >= 5 && status <= 10
}
// VerifyPushSignature GET 验签(消息推送配置)
func (s *XpayService) VerifyPushSignature(signature, timestamp, nonce string) bool {
token := s.config.WechatXpay.MessagePushToken
arr := []string{token, timestamp, nonce}
sort.Strings(arr)
hash := sha1.Sum([]byte(strings.Join(arr, "")))
return signature == hex.EncodeToString(hash[:])
}
// XpayDeliverNotify 推送消息体(明文 JSON
type XpayDeliverNotify struct {
Event string `json:"Event"`
OpenId string `json:"OpenId"`
OutTradeNo string `json:"OutTradeNo"`
Env int `json:"Env"`
WeChatPayInfo struct {
TransactionId string `json:"TransactionId"`
PaidTime int64 `json:"PaidTime"`
} `json:"WeChatPayInfo"`
GoodsInfo struct {
ProductId string `json:"ProductId"`
Quantity int `json:"Quantity"`
OrigPrice int64 `json:"OrigPrice"`
ActualPrice int64 `json:"ActualPrice"`
Attach string `json:"Attach"`
} `json:"GoodsInfo"`
}
func (s *XpayService) AlreadyNotified(ctx context.Context, orderNo string) (bool, error) {
key := fmt.Sprintf(xpayNotifiedKeyFmt, orderNo)
val, err := s.redis.GetCtx(ctx, key)
if err == redis.Nil {
return false, nil
}
if err != nil {
return false, err
}
return val != "", nil
}
func (s *XpayService) MarkNotified(ctx context.Context, orderNo string) error {
key := fmt.Sprintf(xpayNotifiedKeyFmt, orderNo)
return s.redis.SetexCtx(ctx, key, "1", 7*24*3600)
}
// GetWxMiniOpenID 查询用户小程序 openid
func (s *XpayService) GetWxMiniOpenID(ctx context.Context, userAuthModel model.UserAuthModel, userID string) (string, error) {
auth, err := userAuthModel.FindOneByUserIdAuthType(ctx, userID, model.UserAuthTypeWxMiniOpenID)
if err != nil {
return "", err
}
return auth.AuthKey, nil
}

View File

@@ -90,6 +90,7 @@ type ServiceContext struct {
// 服务 // 服务
AlipayService *service.AliPayService AlipayService *service.AliPayService
WechatPayService *service.WechatPayService WechatPayService *service.WechatPayService
XpayService *service.XpayService
ApplePayService *service.ApplePayService ApplePayService *service.ApplePayService
ApiRequestService *service.ApiRequestService ApiRequestService *service.ApiRequestService
WhitelistService *service.WhitelistService WhitelistService *service.WhitelistService
@@ -190,6 +191,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
// ============================== 业务服务初始化 ============================== // ============================== 业务服务初始化 ==============================
alipayService := service.NewAliPayService(c) alipayService := service.NewAliPayService(c)
wechatPayService := service.NewWechatPayService(c, userAuthModel, service.InitTypeWxPayPubKey) wechatPayService := service.NewWechatPayService(c, userAuthModel, service.InitTypeWxPayPubKey)
xpayService := service.NewXpayService(c, redisClient)
applePayService := service.NewApplePayService(c) applePayService := service.NewApplePayService(c)
tianyuanapiCallLogService := service.NewTianyuanapiCallLogService(tianyuanapiCallLogModel, featureModel) tianyuanapiCallLogService := service.NewTianyuanapiCallLogService(tianyuanapiCallLogModel, featureModel)
whitelistService := service.NewWhitelistService(c, userFeatureWhitelistModel, whitelistOrderModel, whitelistOrderItemModel, queryModel, featureModel) whitelistService := service.NewWhitelistService(c, userFeatureWhitelistModel, whitelistOrderModel, whitelistOrderItemModel, queryModel, featureModel)
@@ -307,6 +309,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
// 服务 // 服务
AlipayService: alipayService, AlipayService: alipayService,
WechatPayService: wechatPayService, WechatPayService: wechatPayService,
XpayService: xpayService,
ApplePayService: applePayService, ApplePayService: applePayService,
ApiRequestService: apiRequestService, ApiRequestService: apiRequestService,
WhitelistService: whitelistService, WhitelistService: whitelistService,

View File

@@ -1933,6 +1933,17 @@ type PaymentResp struct {
OrderNo string `json:"order_no"` OrderNo string `json:"order_no"`
} }
type XpayAdminDeliverReq struct {
OrderNo string `json:"order_no" validate:"required"`
}
type XpayAdminDeliverResp struct {
Credited bool `json:"credited"`
Notified bool `json:"notified"`
WechatDetail string `json:"wechat_detail"`
Errors []string `json:"errors"`
}
type PeriodConversionData struct { type PeriodConversionData struct {
PeriodLabel string `json:"period_label"` // 时间段标签(如:今日、昨日、前日) PeriodLabel string `json:"period_label"` // 时间段标签(如:今日、昨日、前日)
QueryUsers int64 `json:"query_users"` // 查询订单数(订单数量,不是用户数) QueryUsers int64 `json:"query_users"` // 查询订单数(订单数量,不是用户数)