diff --git a/app/main/api/desc/admin/order.api b/app/main/api/desc/admin/order.api index 2bfdf3f..6012d69 100644 --- a/app/main/api/desc/admin/order.api +++ b/app/main/api/desc/admin/order.api @@ -39,6 +39,10 @@ service main { @doc "重新执行代理处理" @handler AdminRetryAgentProcess post /retry-agent-process/:id (AdminRetryAgentProcessReq) returns (AdminRetryAgentProcessResp) + + @doc "xpay补发货(查微信单并到账)" + @handler AdminXpayDeliver + post /xpay-deliver/:id (AdminXpayDeliverReq) returns (AdminXpayDeliverResp) } type ( @@ -169,4 +173,15 @@ type ( Message string `json:"message"` // 执行结果消息 ProcessedAt string `json:"processed_at"` // 处理时间 } + // xpay 补发货 + AdminXpayDeliverReq { + Id string `path:"id"` // 订单ID + } + AdminXpayDeliverResp { + Credited bool `json:"credited"` + Notified bool `json:"notified"` + WechatDetail string `json:"wechat_detail"` + Errors []string `json:"errors"` + Message string `json:"message"` + } ) diff --git a/app/main/api/desc/front/pay.api b/app/main/api/desc/front/pay.api index c642a63..783c4ac 100644 --- a/app/main/api/desc/front/pay.api +++ b/app/main/api/desc/front/pay.api @@ -27,10 +27,6 @@ service main { @handler XpayPush get /pay/xpay/push post /pay/xpay/push - - // 运维:xpay 手动补发货 - @handler XpayAdminDeliver - post /pay/xpay/admin/deliver (XpayAdminDeliverReq) returns (XpayAdminDeliverResp) } @server ( @@ -79,15 +75,3 @@ 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 b82781d..e42dafe 100644 --- a/app/main/api/etc/main.dev.yaml +++ b/app/main/api/etc/main.dev.yaml @@ -77,13 +77,18 @@ WechatMini: AppSecret: "48a2c1e8ff1b7d4c0ff82fbefa64d2d0" # 小程序的AppSecret WechatXpay: Enabled: true - Env: 1 + Env: 1 # 本地联调用沙箱;现网单请改 0 + 现网 AppKey OfferId: "1450552691" - AppKey: "n1SSzeMiitPjiQwLlBn1s4Hn7SpkG3qD" - ProdAppKey: "oYv2wooYzRmPDLdXkpBpqaml8cFaY0Bb" - MessagePushToken: "qncXpayPush2026" - AdminToken: "qncXpayAdmin2026" - SessionKeyTTL: 2592000 + AppKey: "n1SSzeMiitPjiQwLlBn1s4Hn7SpkG3qD" # 沙箱 AppKey(仅 env=1) + MessagePushToken: "qncXpayPush2026" # 与 mp 后台消息推送 Token 保持一致 + SessionKeyTTL: 2592000 # 30 天,与 JwtAuth.AccessExpire 对齐 + + # Enabled: true + # Env: 0 # 0 现网 / 1 沙箱(须与 AppKey 配套) + # OfferId: "1450552691" + # AppKey: "oYv2wooYzRmPDLdXkpBpqaml8cFaY0Bb" # 现网 AppKey(env=0 必填现网密钥) + # MessagePushToken: "qncXpayPush2026" # 与 mp 后台消息推送 Token 保持一致 + # SessionKeyTTL: 2592000 # 30 天,与 JwtAuth.AccessExpire 对齐 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 f109b4c..f2db701 100644 --- a/app/main/api/etc/main.yaml +++ b/app/main/api/etc/main.yaml @@ -66,13 +66,12 @@ WechatMini: AppSecret: "48a2c1e8ff1b7d4c0ff82fbefa64d2d0" # 小程序的AppSecret WechatXpay: Enabled: true - Env: 1 # 0 现网 / 1 沙箱(当前:沙箱联调) + Env: 1 # 0 现网 / 1 沙箱(须与 AppKey 配套) OfferId: "1450552691" - AppKey: "n1SSzeMiitPjiQwLlBn1s4Hn7SpkG3qD" # 沙箱 AppKey - ProdAppKey: "oYv2wooYzRmPDLdXkpBpqaml8cFaY0Bb" # 现网 AppKey(上线时 Env 改 0 并切换 AppKey) + AppKey: "n1SSzeMiitPjiQwLlBn1s4Hn7SpkG3qD" # 现网 AppKey(env=0 必填现网密钥) 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 ceafac1..b767524 100644 --- a/app/main/api/internal/config/config.go +++ b/app/main/api/internal/config/config.go @@ -103,12 +103,10 @@ type WechatMiniConfig struct { // WechatXpayConfig 微信小程序虚拟支付(xpay) type WechatXpayConfig struct { Enabled bool - Env int // 0 现网 / 1 沙箱 + Env int // 0 现网 / 1 沙箱 OfferId string - AppKey string // 与 Env 配套的 AppKey - ProdAppKey string // 现网 AppKey(切换 env=0 时使用,可选) + AppKey string // 与 Env 配套的 AppKey(沙箱/现网各自在 yaml 中配置) MessagePushToken string - AdminToken string SessionKeyTTL int64 } type QueryConfig struct { diff --git a/app/main/api/internal/handler/pay/xpayadmindeliverhandler.go b/app/main/api/internal/handler/admin_order/adminxpaydeliverhandler.go similarity index 65% rename from app/main/api/internal/handler/pay/xpayadmindeliverhandler.go rename to app/main/api/internal/handler/admin_order/adminxpaydeliverhandler.go index 51e26f5..a2b13e3 100644 --- a/app/main/api/internal/handler/pay/xpayadmindeliverhandler.go +++ b/app/main/api/internal/handler/admin_order/adminxpaydeliverhandler.go @@ -1,9 +1,9 @@ -package pay +package admin_order import ( "net/http" - "qnc-server/app/main/api/internal/logic/pay" + "qnc-server/app/main/api/internal/logic/admin_order" "qnc-server/app/main/api/internal/svc" "qnc-server/app/main/api/internal/types" "qnc-server/common/result" @@ -12,9 +12,9 @@ import ( "github.com/zeromicro/go-zero/rest/httpx" ) -func XpayAdminDeliverHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { +func AdminXpayDeliverHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var req types.XpayAdminDeliverReq + var req types.AdminXpayDeliverReq if err := httpx.Parse(r, &req); err != nil { result.ParamErrorResult(r, w, err) return @@ -23,8 +23,8 @@ func XpayAdminDeliverHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { result.ParamValidateErrorResult(r, w, err) return } - l := pay.NewXpayAdminDeliverLogic(r.Context(), svcCtx) - resp, err := l.XpayAdminDeliver(&req, r) + l := admin_order.NewAdminXpayDeliverLogic(r.Context(), svcCtx) + resp, err := l.AdminXpayDeliver(&req) result.HttpResult(r, w, resp, err) } } diff --git a/app/main/api/internal/handler/routes.go b/app/main/api/internal/handler/routes.go index 51ae009..3a408cc 100644 --- a/app/main/api/internal/handler/routes.go +++ b/app/main/api/internal/handler/routes.go @@ -368,6 +368,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/retry-agent-process/:id", Handler: admin_order.AdminRetryAgentProcessHandler(serverCtx), }, + { + // xpay补发货(查微信单并到账) + Method: http.MethodPost, + Path: "/xpay-deliver/:id", + Handler: admin_order.AdminXpayDeliverHandler(serverCtx), + }, { // 更新订单 Method: http.MethodPut, @@ -947,11 +953,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { 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/admin_order/adminrefundorderlogic.go b/app/main/api/internal/logic/admin_order/adminrefundorderlogic.go index 7be661a..a394479 100644 --- a/app/main/api/internal/logic/admin_order/adminrefundorderlogic.go +++ b/app/main/api/internal/logic/admin_order/adminrefundorderlogic.go @@ -1,20 +1,21 @@ package admin_order import ( - "context" - "database/sql" - "fmt" - "time" + "context" + "database/sql" + "fmt" + "time" - "qnc-server/app/main/api/internal/svc" - "qnc-server/app/main/api/internal/types" - "qnc-server/app/main/model" - "qnc-server/common/xerr" + paylogic "qnc-server/app/main/api/internal/logic/pay" + "qnc-server/app/main/api/internal/svc" + "qnc-server/app/main/api/internal/types" + "qnc-server/app/main/model" + "qnc-server/common/xerr" - "github.com/pkg/errors" - "github.com/zeromicro/go-zero/core/logx" - "github.com/zeromicro/go-zero/core/stores/sqlx" - "github.com/google/uuid" + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/core/stores/sqlx" ) const ( @@ -44,6 +45,10 @@ func (l *AdminRefundOrderLogic) AdminRefundOrder(req *types.AdminRefundOrderReq) return nil, err } + if model.IsXpayOrder(order) { + return l.handleXpayRefund(order, req) + } + // 根据支付平台处理退款 switch order.PaymentPlatform { case PaymentPlatformAlipay: @@ -107,6 +112,23 @@ func (l *AdminRefundOrderLogic) handleAlipayRefund(order *model.Order, req *type } } +// handleXpayRefund 处理小程序虚拟支付退款 +func (l *AdminRefundOrderLogic) handleXpayRefund(order *model.Order, req *types.AdminRefundOrderReq) (*types.AdminRefundOrderResp, error) { + if req.RefundAmount != order.Amount { + return nil, errors.Wrapf(xerr.NewErrMsg("虚拟支付订单仅支持全额退款"), "") + } + if err := paylogic.RefundXpayQueryOrder(l.ctx, l.svcCtx, order); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "虚拟支付退款失败: %v", err) + } + refundNo := l.generateRefundNo(order.OrderNo) + _ = l.createRefundRecordOnly(order, req, refundNo, "", model.OrderRefundStatusSuccess) + return &types.AdminRefundOrderResp{ + Status: model.OrderStatusRefunded, + RefundNo: refundNo, + Amount: req.RefundAmount, + }, nil +} + // handleWechatRefund 处理微信退款 func (l *AdminRefundOrderLogic) handleWechatRefund(order *model.Order, req *types.AdminRefundOrderReq) (*types.AdminRefundOrderResp, error) { // 调用微信退款接口 @@ -133,18 +155,18 @@ func (l *AdminRefundOrderLogic) handleWechatRefund(order *model.Order, req *type func (l *AdminRefundOrderLogic) createRefundRecordAndUpdateOrder(order *model.Order, req *types.AdminRefundOrderReq, refundNo, platformRefundId, orderStatus, refundStatus string) error { return l.svcCtx.OrderModel.Trans(l.ctx, func(ctx context.Context, session sqlx.Session) error { // 创建退款记录 - refund := &model.OrderRefund{ - Id: uuid.NewString(), - RefundNo: refundNo, - PlatformRefundId: l.createNullString(platformRefundId), - OrderId: order.Id, - UserId: order.UserId, - ProductId: order.ProductId, - RefundAmount: req.RefundAmount, - RefundReason: l.createNullString(req.RefundReason), - Status: refundStatus, // 使用传入的状态,不再硬编码 - RefundTime: sql.NullTime{Time: time.Now(), Valid: true}, - } + refund := &model.OrderRefund{ + Id: uuid.NewString(), + RefundNo: refundNo, + PlatformRefundId: l.createNullString(platformRefundId), + OrderId: order.Id, + UserId: order.UserId, + ProductId: order.ProductId, + RefundAmount: req.RefundAmount, + RefundReason: l.createNullString(req.RefundReason), + Status: refundStatus, // 使用传入的状态,不再硬编码 + RefundTime: sql.NullTime{Time: time.Now(), Valid: true}, + } if _, err := l.svcCtx.OrderRefundModel.Insert(ctx, session, refund); err != nil { return fmt.Errorf("创建退款记录失败: %v", err) @@ -169,18 +191,18 @@ func (l *AdminRefundOrderLogic) createRefundRecordAndUpdateOrder(order *model.Or // createRefundRecordOnly 仅创建退款记录,不更新订单状态(用于退款失败的情况) func (l *AdminRefundOrderLogic) createRefundRecordOnly(order *model.Order, req *types.AdminRefundOrderReq, refundNo, platformRefundId, refundStatus string) error { - refund := &model.OrderRefund{ - Id: uuid.NewString(), - RefundNo: refundNo, - PlatformRefundId: l.createNullString(platformRefundId), - OrderId: order.Id, - UserId: order.UserId, - ProductId: order.ProductId, - RefundAmount: req.RefundAmount, - RefundReason: l.createNullString(req.RefundReason), - Status: refundStatus, - RefundTime: sql.NullTime{Time: time.Now(), Valid: true}, - } + refund := &model.OrderRefund{ + Id: uuid.NewString(), + RefundNo: refundNo, + PlatformRefundId: l.createNullString(platformRefundId), + OrderId: order.Id, + UserId: order.UserId, + ProductId: order.ProductId, + RefundAmount: req.RefundAmount, + RefundReason: l.createNullString(req.RefundReason), + Status: refundStatus, + RefundTime: sql.NullTime{Time: time.Now(), Valid: true}, + } _, err := l.svcCtx.OrderRefundModel.Insert(l.ctx, nil, refund) if err != nil { diff --git a/app/main/api/internal/logic/admin_order/adminxpaydeliverlogic.go b/app/main/api/internal/logic/admin_order/adminxpaydeliverlogic.go new file mode 100644 index 0000000..885258d --- /dev/null +++ b/app/main/api/internal/logic/admin_order/adminxpaydeliverlogic.go @@ -0,0 +1,56 @@ +package admin_order + +import ( + "context" + + "qnc-server/app/main/api/internal/logic/pay" + "qnc-server/app/main/api/internal/svc" + "qnc-server/app/main/api/internal/types" + "qnc-server/app/main/model" + "qnc-server/common/xerr" + + "github.com/pkg/errors" + "github.com/zeromicro/go-zero/core/logx" +) + +type AdminXpayDeliverLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewAdminXpayDeliverLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AdminXpayDeliverLogic { + return &AdminXpayDeliverLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *AdminXpayDeliverLogic) AdminXpayDeliver(req *types.AdminXpayDeliverReq) (resp *types.AdminXpayDeliverResp, err error) { + order, findErr := l.svcCtx.OrderModel.FindOne(l.ctx, req.Id) + if findErr != nil { + return nil, errors.Wrapf(xerr.NewErrMsg("订单不存在"), "%v", findErr) + } + + if !model.IsXpayOrder(order) { + return nil, errors.Wrapf(xerr.NewErrMsg("仅支持小程序虚拟支付订单"), "") + } + + if l.svcCtx.XpayService == nil || !l.svcCtx.XpayService.Enabled() { + return nil, errors.Wrapf(xerr.NewErrMsg("虚拟支付未启用"), "") + } + + result, deliverErr := pay.DeliverXpayQueryOrder(l.ctx, l.svcCtx, order.OrderNo) + if deliverErr != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "补发货失败: %v", deliverErr) + } + + return &types.AdminXpayDeliverResp{ + Credited: result.Credited, + Notified: result.Notified, + WechatDetail: result.WechatDetail, + Errors: result.Errors, + Message: result.Message, + }, nil +} diff --git a/app/main/api/internal/logic/pay/paymentchecklogic.go b/app/main/api/internal/logic/pay/paymentchecklogic.go index 27b3937..7e14e01 100644 --- a/app/main/api/internal/logic/pay/paymentchecklogic.go +++ b/app/main/api/internal/logic/pay/paymentchecklogic.go @@ -42,8 +42,8 @@ func (l *PaymentCheckLogic) PaymentCheck(req *types.PaymentCheckReq) (resp *type return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询订单失败: %v", findErr) } - // xpay 轮询:pending 时主动查微信单 - if order.Status == "pending" && order.PaymentScene == "wxmini" && + // xpay 轮询:pending 时主动查微信单并到账(不通知微信发货) + if order.Status == model.OrderStatusPending && model.IsXpayOrder(order) && 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) @@ -68,27 +68,14 @@ func (l *PaymentCheckLogic) syncXpayOrderStatus(order *model.Order) error { 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) + status, err := l.svcCtx.XpayService.QueryOrder(l.ctx, openid, order.OrderNo, "") 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 + _, fulfillErr := fulfillQueryOrderPaid(l.ctx, l.svcCtx, order, status.WxOrderID, status.PaidFee) + return fulfillErr } if service.IsXpayClosedStatus(status.Status) { diff --git a/app/main/api/internal/logic/pay/paymentlogic.go b/app/main/api/internal/logic/pay/paymentlogic.go index a5407df..761d246 100644 --- a/app/main/api/internal/logic/pay/paymentlogic.go +++ b/app/main/api/internal/logic/pay/paymentlogic.go @@ -67,7 +67,7 @@ func (l *PaymentLogic) Payment(req *types.PaymentReq) (resp *types.PaymentResp, } case "query": - paymentTypeResp, err = l.QueryOrderPayment(req, session) + paymentTypeResp, err = l.QueryOrderPayment(req, session, useXpay) if err != nil { return err } @@ -225,7 +225,7 @@ func (l *PaymentLogic) Payment(req *types.PaymentReq) (resp *types.PaymentResp, } } -func (l *PaymentLogic) QueryOrderPayment(req *types.PaymentReq, session sqlx.Session) (resp *PaymentTypeResp, err error) { +func (l *PaymentLogic) QueryOrderPayment(req *types.PaymentReq, session sqlx.Session, useXpay bool) (resp *PaymentTypeResp, err error) { userID, getUidErr := ctxdata.GetUidFromCtx(l.ctx) if getUidErr != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "生成订单, 获取用户信息失败, %+v", getUidErr) @@ -271,12 +271,43 @@ func (l *PaymentLogic) QueryOrderPayment(req *types.PaymentReq, session sqlx.Ses if user.Inside == 1 { amount = 0.01 } + + // 同一 queryId 重复发起支付:复用已有订单,避免 order_no 唯一键冲突导致二次支付失败 + if existingOrder, findOrderErr := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, outTradeNo); findOrderErr == nil { + switch existingOrder.Status { + case model.OrderStatusPaid: + return nil, errors.Wrapf(xerr.NewErrMsg("订单已支付,请前往报告查看"), "订单号: %s", outTradeNo) + case model.OrderStatusPending: + if existingOrder.Status != model.OrderStatusPending { + existingOrder.Status = model.OrderStatusPending + if updateErr := l.svcCtx.OrderModel.UpdateWithVersion(l.ctx, session, existingOrder); updateErr != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "重置订单状态失败: %+v", updateErr) + } + } + return &PaymentTypeResp{ + amount: existingOrder.Amount, + outTradeNo: outTradeNo, + description: product.ProductName, + orderID: existingOrder.Id, + productEn: product.ProductEn, + }, nil + default: + return nil, errors.Wrapf(xerr.NewErrMsg("订单状态异常,请重新发起查询"), "status=%s", existingOrder.Status) + } + } else if !errors.Is(findOrderErr, model.ErrNotFound) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询订单失败: %+v", findOrderErr) + } + + paymentPlatform := req.PayMethod + if useXpay { + paymentPlatform = model.PaymentPlatformWechatXpay + } order := model.Order{ Id: uuid.NewString(), OrderNo: outTradeNo, UserId: userID, ProductId: product.Id, - PaymentPlatform: req.PayMethod, + PaymentPlatform: paymentPlatform, PaymentScene: resolvePaymentScene(l.ctx), Amount: amount, Status: "pending", @@ -376,18 +407,9 @@ func (l *PaymentLogic) shouldUseXpay(req *types.PaymentReq) bool { 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" + return model.PaymentSceneApp } + return model.PaymentSceneFromPlatform(platform) } // AgentVipOrderPayment 代理会员充值订单(已废弃,新系统使用升级功能替代) diff --git a/app/main/api/internal/logic/pay/xpay_deliver.go b/app/main/api/internal/logic/pay/xpay_deliver.go new file mode 100644 index 0000000..66dd05f --- /dev/null +++ b/app/main/api/internal/logic/pay/xpay_deliver.go @@ -0,0 +1,87 @@ +package pay + +import ( + "context" + "strings" + + "qnc-server/app/main/api/internal/service" + "qnc-server/app/main/api/internal/svc" + "qnc-server/app/main/model" +) + +// XpayDeliverResult 同步 xpay 支付状态(查微信单 → 本地到账 → 异步查报告) +type XpayDeliverResult struct { + Credited bool `json:"credited"` + Notified bool `json:"notified"` + WechatDetail string `json:"wechat_detail"` + Errors []string `json:"errors"` + Message string `json:"message"` +} + +// DeliverXpayQueryOrder 按商户订单号同步微信虚拟支付到账(不通知微信发货,发货在报告成功后) +func DeliverXpayQueryOrder(ctx context.Context, svcCtx *svc.ServiceContext, orderNo string) (*XpayDeliverResult, error) { + order, err := svcCtx.OrderModel.FindOneByOrderNo(ctx, orderNo) + if err != nil { + return nil, err + } + + resp := &XpayDeliverResult{WechatDetail: "ok"} + + if order.Status == model.OrderStatusPaid { + resp.Message = "订单已支付,无需同步" + return resp, nil + } + + if !model.IsXpayOrder(order) { + resp.Errors = append(resp.Errors, "非小程序虚拟支付订单") + resp.Message = "非小程序虚拟支付订单" + return resp, nil + } + + if svcCtx.XpayService == nil || !svcCtx.XpayService.Enabled() { + resp.Errors = append(resp.Errors, "虚拟支付未启用") + resp.Message = "虚拟支付未启用" + return resp, nil + } + + openid, err := svcCtx.XpayService.GetWxMiniOpenID(ctx, svcCtx.UserAuthModel, order.UserId) + if err != nil { + resp.Errors = append(resp.Errors, err.Error()) + resp.Message = "获取用户 openid 失败" + return resp, nil + } + + status, qErr := svcCtx.XpayService.QueryOrder(ctx, openid, order.OrderNo, "") + if qErr != nil { + resp.Errors = append(resp.Errors, qErr.Error()) + resp.WechatDetail = qErr.Error() + resp.Message = "查询微信订单失败" + return resp, nil + } + + if !service.IsXpayPaidStatus(status.Status) { + resp.Errors = append(resp.Errors, "微信侧订单未支付") + resp.Message = "微信侧订单未支付" + return resp, nil + } + + platformOrderID := status.WxOrderID + credited, fulfillErr := fulfillQueryOrderPaid(ctx, svcCtx, order, platformOrderID, status.PaidFee) + resp.Credited = credited + if fulfillErr != nil { + resp.Errors = append(resp.Errors, fulfillErr.Error()) + } + + resp.Message = buildXpayDeliverMessage(resp) + return resp, nil +} + +func buildXpayDeliverMessage(r *XpayDeliverResult) string { + if len(r.Errors) > 0 { + return strings.Join(r.Errors, ";") + } + if r.Credited { + return "同步成功,订单已到账并开始查询报告" + } + return "未执行到账" +} diff --git a/app/main/api/internal/logic/pay/xpay_notify.go b/app/main/api/internal/logic/pay/xpay_notify.go new file mode 100644 index 0000000..3dc6acd --- /dev/null +++ b/app/main/api/internal/logic/pay/xpay_notify.go @@ -0,0 +1,48 @@ +package pay + +import ( + "context" + + "qnc-server/app/main/api/internal/svc" + "qnc-server/app/main/model" + + "github.com/zeromicro/go-zero/core/logx" +) + +// NotifyXpayGoodsAfterReport 报告生成成功后通知微信虚拟支付已发货(合规/结算) +func NotifyXpayGoodsAfterReport(ctx context.Context, svcCtx *svc.ServiceContext, order *model.Order) { + if order == nil || !model.IsXpayOrder(order) { + return + } + if svcCtx.XpayService == nil || !svcCtx.XpayService.Enabled() { + return + } + + already, err := svcCtx.XpayService.AlreadyNotified(ctx, order.OrderNo) + if err != nil { + logx.WithContext(ctx).Errorf("[xpay] 检查发货通知状态失败 order_no=%s err=%v", order.OrderNo, err) + return + } + if already { + return + } + + openid, err := svcCtx.XpayService.GetWxMiniOpenID(ctx, svcCtx.UserAuthModel, order.UserId) + if err != nil { + logx.WithContext(ctx).Errorf("[xpay] 报告成功后通知发货:获取 openid 失败 order_no=%s err=%v", order.OrderNo, err) + return + } + + sessionKey, _ := svcCtx.XpayService.GetSessionKey(ctx, order.UserId) + wxOrderID := "" + if order.PlatformOrderId.Valid { + wxOrderID = order.PlatformOrderId.String + } + + if notifyErr := svcCtx.XpayService.NotifyProvideGoods(ctx, openid, order.OrderNo, wxOrderID, sessionKey); notifyErr != nil { + logx.WithContext(ctx).Errorf("[xpay] 报告成功后 notify_provide_goods 失败 order_no=%s err=%v", order.OrderNo, notifyErr) + return + } + _ = svcCtx.XpayService.MarkNotified(ctx, order.OrderNo) + logx.WithContext(ctx).Infof("[xpay] 报告成功后已通知微信发货 order_no=%s", order.OrderNo) +} diff --git a/app/main/api/internal/logic/pay/xpay_refund.go b/app/main/api/internal/logic/pay/xpay_refund.go new file mode 100644 index 0000000..f1eab10 --- /dev/null +++ b/app/main/api/internal/logic/pay/xpay_refund.go @@ -0,0 +1,83 @@ +package pay + +import ( + "context" + "fmt" + + "qnc-server/app/main/api/internal/svc" + "qnc-server/app/main/model" + "qnc-server/pkg/lzkit/lzUtils" + + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/core/stores/sqlx" +) + +// RefundXpayQueryOrder 对 xpay 查询订单发起全额退款并更新本地订单状态 +func RefundXpayQueryOrder(ctx context.Context, svcCtx *svc.ServiceContext, order *model.Order) error { + if svcCtx.XpayService == nil || !svcCtx.XpayService.Enabled() { + return fmt.Errorf("虚拟支付未启用") + } + openid, err := svcCtx.XpayService.GetWxMiniOpenID(ctx, svcCtx.UserAuthModel, order.UserId) + if err != nil { + return fmt.Errorf("获取 openid 失败: %w", err) + } + + refundFeeFen := lzUtils.ToWechatAmount(order.Amount) + refundOrderID := buildXpayRefundOrderID(order.OrderNo) + if refundErr := svcCtx.XpayService.RefundOrder(ctx, openid, order.OrderNo, refundOrderID, refundFeeFen); refundErr != nil { + return refundErr + } + + return svcCtx.OrderModel.Trans(ctx, func(transCtx context.Context, session sqlx.Session) error { + order.Status = model.OrderStatusRefunded + if updateErr := svcCtx.OrderModel.UpdateWithVersion(transCtx, session, order); updateErr != nil { + return updateErr + } + return svcCtx.AgentService.ReverseAgentSettlementOnOrderRefund(transCtx, session, order.Id) + }) +} + +func buildXpayRefundOrderID(orderNo string) string { + const prefix = "RF_" + id := prefix + orderNo + if len(id) > 32 { + id = id[:32] + } + if len(id) < 8 { + id = id + "00000000" + id = id[:8] + } + return id +} + +// TryRefundOnQueryFailure 查询失败时按支付渠道退款 +func TryRefundOnQueryFailure(ctx context.Context, svcCtx *svc.ServiceContext, order *model.Order) { + var refundErr error + switch { + case model.IsXpayOrder(order): + refundErr = RefundXpayQueryOrder(ctx, svcCtx, order) + case order.PaymentPlatform == model.PaymentPlatformWechat: + refundErr = svcCtx.WechatPayService.WeChatRefund(ctx, order.OrderNo, order.Amount, order.Amount) + default: + refund, err := svcCtx.AlipayService.AliRefund(ctx, order.OrderNo, order.Amount) + if err != nil { + refundErr = err + } else if !refund.IsSuccess() { + refundErr = fmt.Errorf("支付宝退款失败: %s", refund.Msg) + } else { + transErr := svcCtx.OrderModel.Trans(ctx, func(transCtx context.Context, session sqlx.Session) error { + order.Status = model.OrderStatusRefunded + if err := svcCtx.OrderModel.UpdateWithVersion(transCtx, session, order); err != nil { + return err + } + return svcCtx.AgentService.ReverseAgentSettlementOnOrderRefund(transCtx, session, order.Id) + }) + if transErr != nil { + refundErr = transErr + } + } + } + if refundErr != nil { + logx.WithContext(ctx).Errorf("[refund] 查询失败自动退款未成功 order_no=%s platform=%s err=%v", order.OrderNo, order.PaymentPlatform, refundErr) + } +} diff --git a/app/main/api/internal/logic/pay/xpayadmindeliverlogic.go b/app/main/api/internal/logic/pay/xpayadmindeliverlogic.go deleted file mode 100644 index f2e6243..0000000 --- a/app/main/api/internal/logic/pay/xpayadmindeliverlogic.go +++ /dev/null @@ -1,99 +0,0 @@ -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 index 295050d..f94bf1d 100644 --- a/app/main/api/internal/logic/pay/xpayorderfulfilllogic.go +++ b/app/main/api/internal/logic/pay/xpayorderfulfilllogic.go @@ -21,8 +21,14 @@ func fulfillQueryOrderPaid(ctx context.Context, svcCtx *svc.ServiceContext, orde 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 + if model.IsXpayOrder(order) { + order.Amount = lzUtils.RoundMoney(float64(wechatPaidFen) / 100) + orderFen = wechatPaidFen + logx.WithContext(ctx).Infof("[xpay] 同步微信实付金额 order_no=%s amount=%.2f fen=%d", order.OrderNo, order.Amount, wechatPaidFen) + } else { + logx.WithContext(ctx).Errorf("[xpay] 金额不一致 order_no=%s order_fen=%d wechat_fen=%d", order.OrderNo, orderFen, wechatPaidFen) + return false, nil + } } order.Status = "paid" @@ -30,8 +36,8 @@ func fulfillQueryOrderPaid(ctx context.Context, svcCtx *svc.ServiceContext, orde if platformOrderID != "" { order.PlatformOrderId = sql.NullString{String: platformOrderID, Valid: true} } - if order.PaymentScene == "" || order.PaymentScene == "app" { - order.PaymentScene = "wxmini" + if order.PaymentScene == "" || order.PaymentScene == model.PaymentSceneApp { + order.PaymentScene = model.PaymentSceneMiniProgram } if updateErr := svcCtx.OrderModel.UpdateWithVersion(ctx, nil, order); updateErr != nil { diff --git a/app/main/api/internal/logic/user/wxminiauthlogic.go b/app/main/api/internal/logic/user/wxminiauthlogic.go index 596968a..4d96f2e 100644 --- a/app/main/api/internal/logic/user/wxminiauthlogic.go +++ b/app/main/api/internal/logic/user/wxminiauthlogic.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + "net/url" + "strings" "time" "qnc-server/app/main/api/internal/svc" @@ -33,8 +35,14 @@ func NewWxMiniAuthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *WxMini } func (l *WxMiniAuthLogic) WxMiniAuth(req *types.WXMiniAuthReq) (resp *types.WXMiniAuthResp, err error) { + code := strings.TrimSpace(req.Code) + if code == "" || code == "the code is a mock one" || strings.Contains(code, " ") { + return nil, errors.Wrapf(xerr.NewErrMsg("微信登录凭证无效,请重新打开小程序"), + "无效的微信 code: %q", req.Code) + } + // 1. 获取session_key和openid - sessionKeyResp, err := l.GetSessionKey(req.Code) + sessionKeyResp, err := l.GetSessionKey(code) if err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "获取session_key失败: %v", err) } @@ -104,10 +112,10 @@ func (l *WxMiniAuthLogic) GetSessionKey(code string) (*SessionKeyResp, error) { appID = l.svcCtx.Config.WechatMini.AppID appSecret = l.svcCtx.Config.WechatMini.AppSecret - url := fmt.Sprintf("https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", - appID, appSecret, code) + apiURL := fmt.Sprintf("https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", + url.QueryEscape(appID), url.QueryEscape(appSecret), url.QueryEscape(code)) - resp, err := http.Get(url) + resp, err := http.Get(apiURL) if err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "获取session_key失败: %v", err) } diff --git a/app/main/api/internal/queue/paySuccessNotify.go b/app/main/api/internal/queue/paySuccessNotify.go index 7dd5785..e87d0e5 100644 --- a/app/main/api/internal/queue/paySuccessNotify.go +++ b/app/main/api/internal/queue/paySuccessNotify.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "os" + "qnc-server/app/main/api/internal/logic/pay" "qnc-server/app/main/api/internal/svc" "qnc-server/app/main/api/internal/types" "qnc-server/app/main/model" @@ -17,7 +18,6 @@ import ( "github.com/google/uuid" "github.com/hibiken/asynq" "github.com/zeromicro/go-zero/core/logx" - "github.com/zeromicro/go-zero/core/stores/sqlx" ) type PaySuccessNotifyUserHandler struct { @@ -195,6 +195,9 @@ func (l *PaySuccessNotifyUserHandler) ProcessTask(ctx context.Context, t *asynq. logx.Errorf("发送代理处理任务失败,订单ID: %s, 错误: %v", order.Id, asyncErr) } + // xpay:报告成功后再通知微信虚拟支付已发货 + pay.NotifyXpayGoodsAfterReport(ctx, l.svcCtx, order) + _, delErr := l.svcCtx.Redis.DelCtx(ctx, redisKey) if delErr != nil { logx.Errorf("删除Redis缓存失败,但任务已成功处理,订单ID: %s, 错误: %v", order.Id, delErr) @@ -222,40 +225,7 @@ func (l *PaySuccessNotifyUserHandler) handleError(ctx context.Context, err error return asynq.SkipRetry } - // 退款 - if order.PaymentPlatform == "wechat" { - refundErr := l.svcCtx.WechatPayService.WeChatRefund(ctx, order.OrderNo, order.Amount, order.Amount) - if refundErr != nil { - logx.Error(refundErr) - return asynq.SkipRetry - } - } else { - refund, refundErr := l.svcCtx.AlipayService.AliRefund(ctx, order.OrderNo, order.Amount) - if refundErr != nil { - logx.Error(refundErr) - return asynq.SkipRetry - } - if refund.IsSuccess() { - logx.Errorf("支付宝退款成功, orderID: %s", order.Id) - transErr := l.svcCtx.OrderModel.Trans(ctx, func(transCtx context.Context, session sqlx.Session) error { - order.Status = "refunded" - if err := l.svcCtx.OrderModel.UpdateWithVersion(transCtx, session, order); err != nil { - return fmt.Errorf("更新订单状态失败: %v", err) - } - return l.svcCtx.AgentService.ReverseAgentSettlementOnOrderRefund(transCtx, session, order.Id) - }) - if transErr != nil { - logx.Errorf("支付宝退款后更新订单或冲正代理分账失败, orderID: %s, err: %v", order.Id, transErr) - return fmt.Errorf("更新订单状态或冲正代理分账失败: %v", transErr) - } - return asynq.SkipRetry - } else { - logx.Errorf("支付宝退款失败:%v", refundErr) - return asynq.SkipRetry - } - // 直接成功 - } - + pay.TryRefundOnQueryFailure(ctx, l.svcCtx, order) } return asynq.SkipRetry diff --git a/app/main/api/internal/service/verificationService.go b/app/main/api/internal/service/verificationService.go index d1f1cc6..ee5dc92 100644 --- a/app/main/api/internal/service/verificationService.go +++ b/app/main/api/internal/service/verificationService.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/url" "qnc-server/app/main/api/internal/config" tianyuanapi "qnc-server/app/main/api/internal/service/tianyuanapi_sdk" @@ -184,8 +185,9 @@ func (r *VerificationService) GetWechatH5OpenID(ctx context.Context, code string func (r *VerificationService) GetWechatMiniOpenID(ctx context.Context, code string) (string, error) { appID := r.c.WechatMini.AppID appSecret := r.c.WechatMini.AppSecret - url := fmt.Sprintf("https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", appID, appSecret, code) - resp, err := http.Get(url) + apiURL := fmt.Sprintf("https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", + url.QueryEscape(appID), url.QueryEscape(appSecret), url.QueryEscape(code)) + resp, err := http.Get(apiURL) if err != nil { return "", err } diff --git a/app/main/api/internal/service/wechatpayService.go b/app/main/api/internal/service/wechatpayService.go index f8b4168..a8b7fad 100644 --- a/app/main/api/internal/service/wechatpayService.go +++ b/app/main/api/internal/service/wechatpayService.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "qnc-server/app/main/api/internal/config" "qnc-server/app/main/model" "qnc-server/common/ctxdata" @@ -446,9 +447,10 @@ func (w *WechatPayService) GenerateOutTradeNo() string { func (w *WechatPayService) getWechatMiniOpenID(ctx context.Context, code string) (string, error) { appID := w.config.WechatMini.AppID appSecret := w.config.WechatMini.AppSecret - url := fmt.Sprintf("https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", appID, appSecret, code) + apiURL := fmt.Sprintf("https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", + url.QueryEscape(appID), url.QueryEscape(appSecret), url.QueryEscape(code)) - resp, err := http.Get(url) + resp, err := http.Get(apiURL) if err != nil { return "", fmt.Errorf("请求微信API失败: %v", err) } diff --git a/app/main/api/internal/service/xpayProductMapping.go b/app/main/api/internal/service/xpayProductMapping.go new file mode 100644 index 0000000..a4d749c --- /dev/null +++ b/app/main/api/internal/service/xpayProductMapping.go @@ -0,0 +1,37 @@ +package service + +// XpayProductIdMap 微信道具 productId(QCXG*)→ 本系统 product_en(toc_*) +// mp 后台道具 id 须与左侧 QCXG 编码一致;支付签名 productId 使用 QCXG,业务订单仍用 product_en。 +var XpayProductIdMap = map[string]string{ + "QCXG6B4E": "toc_VehicleClaimVerify", + "QCXGP00W": "toc_VehicleClaimDetail", + "QCXG3Z3L": "toc_VehicleMaintenanceDetail", + "QCXG3Y6B": "toc_VehicleMaintenanceSimple", + "QCXG4I1Z": "toc_VehicleTransferDetail", + "QCXG1H7Y": "toc_VehicleTransferSimple", + "QCXGY7F2": "toc_VehicleVinValuation", + "QCXG1U4U": "toc_VehicleMileageMixed", + "QCXG5U0Z": "toc_VehicleStaticInfo", + "QCXG4D2E": "toc_VehiclesUnderNameCount", + "QCXG9P1C": "toc_VehiclesUnderNamePlate", + "QCXGYTS2": "toc_PersonVehicleVerificationDetail", + "QCXGGB2Q": "toc_PersonVehicleVerification", + "QCXG7A2B": "toc_VehiclesUnderName", +} + +var xpayProductEnToId map[string]string + +func init() { + xpayProductEnToId = make(map[string]string, len(XpayProductIdMap)) + for xpayID, productEn := range XpayProductIdMap { + xpayProductEnToId[productEn] = xpayID + } +} + +// ResolveXpayProductId 将 product_en 转为 mp 道具 id;未配置时回退 product_en 本身。 +func ResolveXpayProductId(productEn string) string { + if id, ok := xpayProductEnToId[productEn]; ok { + return id + } + return productEn +} diff --git a/app/main/api/internal/service/xpayService.go b/app/main/api/internal/service/xpayService.go index 75e5c71..50756c4 100644 --- a/app/main/api/internal/service/xpayService.go +++ b/app/main/api/internal/service/xpayService.go @@ -23,10 +23,10 @@ import ( ) const ( - xpaySessionKeyFmt = "qnc:wx:xpay:session:%s" - xpayNotifiedKeyFmt = "qnc:xpay:notified:%s" - xpayAccessTokenKey = "qnc:wx:xpay:access_token" - xpayMode = "short_series_goods" + xpaySessionKeyFmt = "qnc:wx:xpay:session:%s" + xpayNotifiedKeyFmt = "qnc:xpay:notified:%s" + xpayAccessTokenKey = "qnc:wx:xpay:access_token" + xpayMode = "short_series_goods" xpayVirtualPaymentURI = "requestVirtualPayment" ) @@ -115,12 +115,13 @@ func (s *XpayService) BuildPayParams(ctx context.Context, userID, orderNo, produ } goodsPrice := lzUtils.ToWechatAmount(amount) + xpayProductID := ResolveXpayProductId(productEn) payload := signDataPayload{ OfferId: s.config.WechatXpay.OfferId, BuyQuantity: 1, Env: s.Env(), CurrencyType: "CNY", - ProductId: productEn, + ProductId: xpayProductID, GoodsPrice: goodsPrice, OutTradeNo: orderNo, Attach: fmt.Sprintf("query:%s", orderNo), @@ -134,8 +135,8 @@ func (s *XpayService) BuildPayParams(ctx context.Context, userID, orderNo, produ 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) + logx.WithContext(ctx).Infof("[xpay] create user=%s order_no=%s env=%d product_en=%s xpay_product_id=%s goodsPrice=%d", + userID, orderNo, s.Env(), productEn, xpayProductID, goodsPrice) return &XpayPrepayData{ Provider: "xpay", @@ -187,12 +188,15 @@ type xpayAPIResp struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` Data json.RawMessage `json:"data"` + Order json.RawMessage `json:"order"` // query_order 官方返回 order 字段 } type xpayOrderStatus struct { - Status int `json:"status"` - PaidFee int64 `json:"paid_fee"` - OrderID string `json:"order_id"` + Status int `json:"status"` + PaidFee int64 `json:"paid_fee"` + LeftFee int64 `json:"left_fee"` + OrderID string `json:"order_id"` + WxOrderID string `json:"wx_order_id"` } func (s *XpayService) callAPI(ctx context.Context, uri, bodyJSON, sessionKey string, needSignature bool) (*xpayAPIResp, error) { @@ -243,7 +247,8 @@ func (s *XpayService) QueryOrder(ctx context.Context, openid, orderNo, sessionKe return nil, err } - apiResp, err := s.callAPI(ctx, "/xpay/query_order", bodyJSON, sessionKey, true) + // 官方文档:query_order 仅需 pay_sig,不需用户态 signature + apiResp, err := s.callAPI(ctx, "/xpay/query_order", bodyJSON, "", false) if err != nil { return nil, err } @@ -251,13 +256,65 @@ func (s *XpayService) QueryOrder(ctx context.Context, openid, orderNo, sessionKe return nil, fmt.Errorf("query_order errcode=%d errmsg=%s", apiResp.ErrCode, apiResp.ErrMsg) } + orderRaw := apiResp.Order + if len(orderRaw) == 0 { + orderRaw = apiResp.Data + } var status xpayOrderStatus - if len(apiResp.Data) > 0 { - _ = json.Unmarshal(apiResp.Data, &status) + if len(orderRaw) > 0 { + _ = json.Unmarshal(orderRaw, &status) } return &status, nil } +// RefundOrder 启动 xpay 退款任务(全额退) +func (s *XpayService) RefundOrder(ctx context.Context, openid, orderNo, refundOrderID string, refundFeeFen int64) error { + if refundFeeFen <= 0 { + return fmt.Errorf("退款金额无效") + } + + status, err := s.QueryOrder(ctx, openid, orderNo, "") + if err != nil { + return fmt.Errorf("退款前查单失败: %w", err) + } + leftFee := status.LeftFee + if leftFee <= 0 { + leftFee = status.PaidFee + } + if leftFee <= 0 { + leftFee = refundFeeFen + } + if refundFeeFen > leftFee { + refundFeeFen = leftFee + } + + bodyObj := map[string]interface{}{ + "openid": openid, + "env": s.Env(), + "order_id": orderNo, + "refund_order_id": refundOrderID, + "left_fee": leftFee, + "refund_fee": refundFeeFen, + "biz_meta": fmt.Sprintf("refund:%s", orderNo), + "refund_reason": 5, + "req_from": 3, + } + bodyJSON, err := compactJSON(bodyObj) + if err != nil { + return err + } + + apiResp, err := s.callAPI(ctx, "/xpay/refund_order", bodyJSON, "", false) + if err != nil { + return err + } + if apiResp.ErrCode != 0 && apiResp.ErrCode != 268490004 && apiResp.ErrCode != 268490014 { + return fmt.Errorf("refund_order errcode=%d errmsg=%s", apiResp.ErrCode, apiResp.ErrMsg) + } + logx.WithContext(ctx).Infof("[xpay] refund_order OK order_no=%s refund_order_id=%s fee=%d", orderNo, refundOrderID, refundFeeFen) + return nil +} + // NotifyProvideGoods 主动通知微信已发货 func (s *XpayService) NotifyProvideGoods(ctx context.Context, openid, orderNo, wxOrderID, sessionKey string) error { bodyObj := map[string]interface{}{ @@ -305,10 +362,10 @@ func (s *XpayService) VerifyPushSignature(signature, timestamp, nonce string) bo // XpayDeliverNotify 推送消息体(明文 JSON) type XpayDeliverNotify struct { - Event string `json:"Event"` - OpenId string `json:"OpenId"` - OutTradeNo string `json:"OutTradeNo"` - Env int `json:"Env"` + 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"` diff --git a/app/main/api/internal/types/types.go b/app/main/api/internal/types/types.go index 827b61e..9413de9 100644 --- a/app/main/api/internal/types/types.go +++ b/app/main/api/internal/types/types.go @@ -816,6 +816,18 @@ type AdminRetryAgentProcessResp struct { ProcessedAt string `json:"processed_at"` // 处理时间 } +type AdminXpayDeliverReq struct { + Id string `path:"id"` // 订单ID +} + +type AdminXpayDeliverResp struct { + Credited bool `json:"credited"` + Notified bool `json:"notified"` + WechatDetail string `json:"wechat_detail"` + Errors []string `json:"errors"` + Message string `json:"message"` +} + type AdminRevenueStatistics struct { TodayAmount float64 `json:"today_amount"` // 今日营收 MonthAmount float64 `json:"month_amount"` // 当月营收 @@ -1933,17 +1945,6 @@ 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"` // 查询订单数(订单数量,不是用户数) diff --git a/app/main/api/main.go b/app/main/api/main.go index 1618a37..7a83abf 100644 --- a/app/main/api/main.go +++ b/app/main/api/main.go @@ -48,12 +48,12 @@ func main() { asynq := queue.NewCronJob(ctx, svcContext) mux := asynq.Register() - // 启动 asynq 消费者 + // Run 会阻塞;成功日志必须在 Run 之前打印 + fmt.Println("异步任务启动!!!") if err := svcContext.AsynqServer.Run(mux); err != nil { logx.WithContext(ctx).Errorf("异步任务启动失败: %v", err) os.Exit(1) } - fmt.Println("异步任务启动!!!") }() server := rest.MustNewServer(c.RestConf) diff --git a/app/main/model/vars.go b/app/main/model/vars.go index fc458e0..54b75db 100644 --- a/app/main/model/vars.go +++ b/app/main/model/vars.go @@ -9,13 +9,38 @@ import ( var ErrNotFound = sqlx.ErrNotFound var ErrNoRowsUpdate = errors.New("update db no rows change") -// 平台 +// 平台(请求头 X-Platform / JWT platform) var PlatformWxMini string = "wxmini" var PlatformWxH5 string = "wxh5" var PlatformApp string = "app" var PlatformH5 string = "h5" var PlatformAdmin string = "admin" +// 订单支付场景 order.payment_scene(MySQL ENUM) +const ( + PaymentSceneApp = "app" + PaymentSceneH5 = "h5" + PaymentSceneMiniProgram = "mini_program" + PaymentScenePublicAccount = "public_account" + PaymentSceneTest = "test" +) + +// PaymentSceneFromPlatform 将运行时平台标识映射为 order.payment_scene 枚举值 +func PaymentSceneFromPlatform(platform string) string { + switch platform { + case PlatformWxMini: + return PaymentSceneMiniProgram + case PlatformWxH5: + return PaymentScenePublicAccount + case PlatformH5: + return PaymentSceneH5 + case PlatformApp: + return PaymentSceneApp + default: + return PaymentSceneApp + } +} + // 用户授权类型 var UserAuthTypeMobile string = "mobile" var UserAuthTypeWxMiniOpenID string = "wxmini_openid" @@ -37,6 +62,28 @@ var AgentLeveNameNormal string = "normal" var AgentLeveNameVIP string = "VIP" var AgentLeveNameSVIP string = "SVIP" +// 订单支付平台 order.payment_platform(varchar,非 ENUM) +const ( + PaymentPlatformAlipay = "alipay" + PaymentPlatformWechat = "wechat" // 普通微信支付(JSAPI/H5/APP) + PaymentPlatformWechatXpay = "wechat_xpay" // 小程序虚拟支付 + PaymentPlatformAppleIAP = "appleiap" +) + +// IsXpayOrder 是否为小程序虚拟支付订单(含历史 wechat+mini_program 查询单) +func IsXpayOrder(order *Order) bool { + if order == nil { + return false + } + if order.PaymentPlatform == PaymentPlatformWechatXpay { + return true + } + // 兼容改造前:查询单 Q_ 前缀 + 小程序场景 + pay_method=wechat + return order.PaymentPlatform == PaymentPlatformWechat && + order.PaymentScene == PaymentSceneMiniProgram && + len(order.OrderNo) > 2 && order.OrderNo[:2] == "Q_" +} + const ( OrderStatusPending = "pending" OrderStatusPaid = "paid" diff --git a/dock/1.sql b/dock/1.sql new file mode 100644 index 0000000..472d9c9 --- /dev/null +++ b/dock/1.sql @@ -0,0 +1,16 @@ +SELECT + id, + create_time, + update_time, + delete_time, + del_state, + version, + product_name, + product_en, + description, + notes, + cost_price, + sell_price +FROM `product` +WHERE create_time > '2025-11-06 16:12:25' + AND del_state = 0; \ No newline at end of file diff --git a/dock/2.sql b/dock/2.sql new file mode 100644 index 0000000..e69de29 diff --git a/dock/gen.js b/dock/gen.js new file mode 100644 index 0000000..b6e2fb0 --- /dev/null +++ b/dock/gen.js @@ -0,0 +1,26 @@ +const XLSX = require("xlsx"); +const IMG = "https://www.quannengcha.com/banner.png"; +const products = [ + ["QCXG6B4E", "出险记录核验", IMG, 38, "toc_VehicleClaimVerify"], + ["QCXGP00W", "出险详版查询", IMG, 58, "toc_VehicleClaimDetail"], + ["QCXG3Z3L", "维保详版查询", IMG, 58, "toc_VehicleMaintenanceDetail"], + ["QCXG3Y6B", "维保简版查询", IMG, 38, "toc_VehicleMaintenanceSimple"], + ["QCXG4I1Z", "过户详版查询", IMG, 19.8, "toc_VehicleTransferDetail"], + ["QCXG1H7Y", "过户简版查询", IMG, 16.8, "toc_VehicleTransferSimple"], + ["QCXGY7F2", "二手车估值", IMG, 16.8, "toc_VehicleVinValuation"], + ["QCXG1U4U", "里程记录查询", IMG, 39.8, "toc_VehicleMileageMixed"], + ["QCXG5U0Z", "静态信息查询", IMG, 6, "toc_VehicleStaticInfo"], + ["QCXG4D2E", "名下车辆数量", IMG, 19.9, "toc_VehiclesUnderNameCount"], + ["QCXG9P1C", "名下车辆车牌", IMG, 19.9, "toc_VehiclesUnderNamePlate"], + ["QCXGYTS2", "人车核验详版", IMG, 19.8, "toc_PersonVehicleVerificationDetail"], + ["QCXGGB2Q", "人车核验简版", IMG, 16.8, "toc_PersonVehicleVerification"], + ["QCXG7A2B", "名下车辆", IMG, 19.9, "toc_VehiclesUnderName"], +]; +const header = ["道具id", "道具名称", "道具图片", "道具价格", "备注"]; +const ws = XLSX.utils.aoa_to_sheet([header, ...products]); +const wb = XLSX.utils.book_new(); +XLSX.utils.book_append_sheet(wb, ws, "Sheet1"); +const out = "c:/Users/a1726/Desktop/qnc/示例-QCXG道具导入.xls"; +XLSX.writeFile(wb, out, { bookType: "biff8" }); +console.log("written", out); +console.log("OK", products.length);