From a85436950e696d1a6696d2d36e64a332ae1c3676 Mon Sep 17 00:00:00 2001 From: Mrx <18278715334@163.com> Date: Sat, 6 Jun 2026 11:52:06 +0800 Subject: [PATCH] f --- app/main/api/desc/front/pay.api | 21 ++ app/main/api/etc/main.dev.yaml | 9 + app/main/api/etc/main.yaml | 9 + app/main/api/internal/config/config.go | 13 + .../handler/pay/xpayadmindeliverhandler.go | 30 ++ .../internal/handler/pay/xpaypushhandler.go | 22 ++ app/main/api/internal/handler/routes.go | 15 + .../internal/logic/pay/paymentchecklogic.go | 87 ++++- .../api/internal/logic/pay/paymentlogic.go | 65 +++- .../logic/pay/xpayadmindeliverlogic.go | 99 +++++ .../logic/pay/xpayorderfulfilllogic.go | 48 +++ .../api/internal/logic/pay/xpaypushlogic.go | 97 +++++ .../internal/logic/user/wxminiauthlogic.go | 11 +- app/main/api/internal/service/xpayService.go | 349 ++++++++++++++++++ app/main/api/internal/svc/servicecontext.go | 3 + app/main/api/internal/types/types.go | 11 + 16 files changed, 864 insertions(+), 25 deletions(-) create mode 100644 app/main/api/internal/handler/pay/xpayadmindeliverhandler.go create mode 100644 app/main/api/internal/handler/pay/xpaypushhandler.go create mode 100644 app/main/api/internal/logic/pay/xpayadmindeliverlogic.go create mode 100644 app/main/api/internal/logic/pay/xpayorderfulfilllogic.go create mode 100644 app/main/api/internal/logic/pay/xpaypushlogic.go create mode 100644 app/main/api/internal/service/xpayService.go diff --git a/app/main/api/desc/front/pay.api b/app/main/api/desc/front/pay.api index 36b688d..c642a63 100644 --- a/app/main/api/desc/front/pay.api +++ b/app/main/api/desc/front/pay.api @@ -22,6 +22,15 @@ service main { // 微信退款回调 @handler WechatPayRefundCallback 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 ( @@ -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"` + } +) + diff --git a/app/main/api/etc/main.dev.yaml b/app/main/api/etc/main.dev.yaml index 144f12d..b82781d 100644 --- a/app/main/api/etc/main.dev.yaml +++ b/app/main/api/etc/main.dev.yaml @@ -75,6 +75,15 @@ WechatH5: WechatMini: AppID: "wx5bacc94add2da981" # 小程序的AppID AppSecret: "48a2c1e8ff1b7d4c0ff82fbefa64d2d0" # 小程序的AppSecret +WechatXpay: + Enabled: true + Env: 1 + OfferId: "1450552691" + AppKey: "n1SSzeMiitPjiQwLlBn1s4Hn7SpkG3qD" + ProdAppKey: "oYv2wooYzRmPDLdXkpBpqaml8cFaY0Bb" + MessagePushToken: "qncXpayPush2026" + AdminToken: "qncXpayAdmin2026" + SessionKeyTTL: 2592000 Query: ShareLinkExpire: 604800 # 7天 = 7 * 24 * 60 * 60 = 604800秒 AdminConfig: diff --git a/app/main/api/etc/main.yaml b/app/main/api/etc/main.yaml index f7620e1..f109b4c 100644 --- a/app/main/api/etc/main.yaml +++ b/app/main/api/etc/main.yaml @@ -64,6 +64,15 @@ WechatH5: WechatMini: AppID: "wx5bacc94add2da981" # 小程序的AppID 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: ShareLinkExpire: 604800 # 7天 = 7 * 24 * 60 * 60 = 604800秒 AdminConfig: diff --git a/app/main/api/internal/config/config.go b/app/main/api/internal/config/config.go index 8b685aa..ceafac1 100644 --- a/app/main/api/internal/config/config.go +++ b/app/main/api/internal/config/config.go @@ -21,6 +21,7 @@ type Config struct { WechatH5 WechatH5Config Authorization AuthorizationConfig // 授权书配置 WechatMini WechatMiniConfig + WechatXpay WechatXpayConfig Query QueryConfig AdminConfig AdminConfig TaxConfig TaxConfig @@ -98,6 +99,18 @@ type WechatMiniConfig struct { AppID 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 { ShareLinkExpire int64 } diff --git a/app/main/api/internal/handler/pay/xpayadmindeliverhandler.go b/app/main/api/internal/handler/pay/xpayadmindeliverhandler.go new file mode 100644 index 0000000..51e26f5 --- /dev/null +++ b/app/main/api/internal/handler/pay/xpayadmindeliverhandler.go @@ -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) + } +} diff --git a/app/main/api/internal/handler/pay/xpaypushhandler.go b/app/main/api/internal/handler/pay/xpaypushhandler.go new file mode 100644 index 0000000..19e2970 --- /dev/null +++ b/app/main/api/internal/handler/pay/xpaypushhandler.go @@ -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) + } + } +} diff --git a/app/main/api/internal/handler/routes.go b/app/main/api/internal/handler/routes.go index 40ccba8..51ae009 100644 --- a/app/main/api/internal/handler/routes.go +++ b/app/main/api/internal/handler/routes.go @@ -937,6 +937,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/pay/wechat/refund_callback", 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"), ) diff --git a/app/main/api/internal/logic/pay/paymentchecklogic.go b/app/main/api/internal/logic/pay/paymentchecklogic.go index 2d813ac..27b3937 100644 --- a/app/main/api/internal/logic/pay/paymentchecklogic.go +++ b/app/main/api/internal/logic/pay/paymentchecklogic.go @@ -3,10 +3,13 @@ package pay import ( "context" "strings" + + "qnc-server/app/main/api/internal/service" "qnc-server/app/main/api/internal/svc" "qnc-server/app/main/api/internal/types" + "qnc-server/app/main/model" + "qnc-server/common/ctxdata" "qnc-server/common/xerr" - "github.com/pkg/errors" "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) { - // 根据订单号前缀判断订单类型 if strings.HasPrefix(req.OrderNo, "U_") { - // 升级订单 - order, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo) - if err != nil { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询升级订单失败: %v", err) + order, findErr := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo) + if findErr != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询升级订单失败: %v", findErr) } - return &types.PaymentCheckResp{ - Type: "agent_upgrade", - Status: order.Status, - }, nil + return &types.PaymentCheckResp{Type: "agent_upgrade", Status: order.Status}, nil } - - // 查询订单(包括代理订单) - order, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo) - if err != nil { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询订单失败: %v", err) + + order, findErr := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo) + if findErr != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询订单失败: %v", findErr) } - return &types.PaymentCheckResp{ - Type: "query", - Status: order.Status, - }, nil + + // xpay 轮询:pending 时主动查微信单 + if order.Status == "pending" && order.PaymentScene == "wxmini" && + 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 } diff --git a/app/main/api/internal/logic/pay/paymentlogic.go b/app/main/api/internal/logic/pay/paymentlogic.go index 664900a..a5407df 100644 --- a/app/main/api/internal/logic/pay/paymentlogic.go +++ b/app/main/api/internal/logic/pay/paymentlogic.go @@ -33,6 +33,7 @@ type PaymentTypeResp struct { outTradeNo string description string orderID string // 订单ID,用于开发环境测试支付模式 + productEn string // 产品英文名,xpay 道具 ID } // enableDevTestPayment 测试支付功能开关,设置为 true 以启用测试支付功能 @@ -50,6 +51,7 @@ func (l *PaymentLogic) Payment(req *types.PaymentReq) (resp *types.PaymentResp, var paymentTypeResp *PaymentTypeResp var prepayData interface{} var orderID string + useXpay := l.shouldUseXpay(req) // 检查是否为开发环境的测试支付模式 env := os.Getenv("ENV") @@ -95,9 +97,11 @@ func (l *PaymentLogic) Payment(req *types.PaymentReq) (resp *types.PaymentResp, return nil } - // 正常支付流程 + // 正常支付流程(微信小程序 xpay 在事务外构造支付参数) 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) } else if req.PayMethod == "alipay" { 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 } + // 微信小程序虚拟支付:构造 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 != "" { // 使用 goroutine 异步处理,确保事务已完全提交 @@ -257,7 +277,7 @@ func (l *PaymentLogic) QueryOrderPayment(req *types.PaymentReq, session sqlx.Ses UserId: userID, ProductId: product.Id, PaymentPlatform: req.PayMethod, - PaymentScene: "app", + PaymentScene: resolvePaymentScene(l.ctx), Amount: amount, 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 &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 代理会员充值订单(已废弃,新系统使用升级功能替代) diff --git a/app/main/api/internal/logic/pay/xpayadmindeliverlogic.go b/app/main/api/internal/logic/pay/xpayadmindeliverlogic.go new file mode 100644 index 0000000..f2e6243 --- /dev/null +++ b/app/main/api/internal/logic/pay/xpayadmindeliverlogic.go @@ -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 +} diff --git a/app/main/api/internal/logic/pay/xpayorderfulfilllogic.go b/app/main/api/internal/logic/pay/xpayorderfulfilllogic.go new file mode 100644 index 0000000..295050d --- /dev/null +++ b/app/main/api/internal/logic/pay/xpayorderfulfilllogic.go @@ -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 +} diff --git a/app/main/api/internal/logic/pay/xpaypushlogic.go b/app/main/api/internal/logic/pay/xpaypushlogic.go new file mode 100644 index 0000000..6df15ac --- /dev/null +++ b/app/main/api/internal/logic/pay/xpaypushlogic.go @@ -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, ¬ify); 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, + }) +} diff --git a/app/main/api/internal/logic/user/wxminiauthlogic.go b/app/main/api/internal/logic/user/wxminiauthlogic.go index 65a6492..596968a 100644 --- a/app/main/api/internal/logic/user/wxminiauthlogic.go +++ b/app/main/api/internal/logic/user/wxminiauthlogic.go @@ -65,13 +65,20 @@ func (l *WxMiniAuthLogic) WxMiniAuth(req *types.WXMiniAuthReq) (resp *types.WXMi userID = user.Id } - // 4. 生成JWT Token(动态计算userType) + // 4. 缓存 session_key(xpay 虚拟支付签名需要) + 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) if err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "生成JWT Token失败: %v", err) } - // 5. 返回登录结果 + // 6. 返回登录结果 now := time.Now().Unix() return &types.WXMiniAuthResp{ AccessToken: token, diff --git a/app/main/api/internal/service/xpayService.go b/app/main/api/internal/service/xpayService.go new file mode 100644 index 0000000..75e5c71 --- /dev/null +++ b/app/main/api/internal/service/xpayService.go @@ -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 +} diff --git a/app/main/api/internal/svc/servicecontext.go b/app/main/api/internal/svc/servicecontext.go index 28f4249..03223cd 100644 --- a/app/main/api/internal/svc/servicecontext.go +++ b/app/main/api/internal/svc/servicecontext.go @@ -90,6 +90,7 @@ type ServiceContext struct { // 服务 AlipayService *service.AliPayService WechatPayService *service.WechatPayService + XpayService *service.XpayService ApplePayService *service.ApplePayService ApiRequestService *service.ApiRequestService WhitelistService *service.WhitelistService @@ -190,6 +191,7 @@ func NewServiceContext(c config.Config) *ServiceContext { // ============================== 业务服务初始化 ============================== alipayService := service.NewAliPayService(c) wechatPayService := service.NewWechatPayService(c, userAuthModel, service.InitTypeWxPayPubKey) + xpayService := service.NewXpayService(c, redisClient) applePayService := service.NewApplePayService(c) tianyuanapiCallLogService := service.NewTianyuanapiCallLogService(tianyuanapiCallLogModel, featureModel) whitelistService := service.NewWhitelistService(c, userFeatureWhitelistModel, whitelistOrderModel, whitelistOrderItemModel, queryModel, featureModel) @@ -307,6 +309,7 @@ func NewServiceContext(c config.Config) *ServiceContext { // 服务 AlipayService: alipayService, WechatPayService: wechatPayService, + XpayService: xpayService, ApplePayService: applePayService, ApiRequestService: apiRequestService, WhitelistService: whitelistService, diff --git a/app/main/api/internal/types/types.go b/app/main/api/internal/types/types.go index e852720..827b61e 100644 --- a/app/main/api/internal/types/types.go +++ b/app/main/api/internal/types/types.go @@ -1933,6 +1933,17 @@ type PaymentResp struct { 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 { PeriodLabel string `json:"period_label"` // 时间段标签(如:今日、昨日、前日) QueryUsers int64 `json:"query_users"` // 查询订单数(订单数量,不是用户数)