Compare commits
10 Commits
2768deb0b6
...
2adbc87a52
| Author | SHA1 | Date | |
|---|---|---|---|
| 2adbc87a52 | |||
| 6e564d1d1a | |||
| 3d28e29eaa | |||
| 0948494b1c | |||
| 599289a94e | |||
| b988f99a95 | |||
| 56adc30409 | |||
| 53e95751b1 | |||
| 58bdb2f18e | |||
| a03ba5d396 |
4
go.mod
4
go.mod
@@ -69,6 +69,10 @@ require (
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.13.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/smartwalle/alipay/v3 v3.2.29 // indirect
|
||||
github.com/smartwalle/ncrypto v1.0.4 // indirect
|
||||
github.com/smartwalle/ngx v1.1.0 // indirect
|
||||
github.com/smartwalle/nsign v1.0.9 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
|
||||
8
go.sum
8
go.sum
@@ -208,6 +208,14 @@ github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dI
|
||||
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/smartwalle/alipay/v3 v3.2.29 h1:roGFqlml8hDa//0TpFmlyxZhndTYs7rbYLu/HlNFNJo=
|
||||
github.com/smartwalle/alipay/v3 v3.2.29/go.mod h1:XarBLuAkwK3ah7mYjVtghRu+ysxzlex9sRkgqNMzMRU=
|
||||
github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8=
|
||||
github.com/smartwalle/ncrypto v1.0.4/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk=
|
||||
github.com/smartwalle/ngx v1.1.0 h1:q8nANgWSPRGeI/u+ixBoA4mf68DrUq6vZ+n9L5UKv9I=
|
||||
github.com/smartwalle/ngx v1.1.0/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0=
|
||||
github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E=
|
||||
github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge"
|
||||
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/alipay"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
|
||||
@@ -262,10 +263,6 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
if req.PaymentMethod != constants.RechargeMethodWechat {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -289,6 +286,23 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeWechatConfigUnavailable)
|
||||
}
|
||||
|
||||
switch req.PaymentMethod {
|
||||
case constants.RechargeMethodAlipay:
|
||||
return h.createAlipayRecharge(c, resolved, config, wallet, req)
|
||||
default:
|
||||
// 微信充值(默认):app_type 必填
|
||||
return h.createWechatRecharge(c, resolved, config, wallet, req)
|
||||
}
|
||||
}
|
||||
|
||||
// createWechatRecharge 微信充值分支
|
||||
func (h *ClientWalletHandler) createWechatRecharge(
|
||||
c *fiber.Ctx,
|
||||
resolved *resolvedWalletAssetContext,
|
||||
config *model.WechatConfig,
|
||||
wallet *model.AssetWallet,
|
||||
req dto.ClientCreateRechargeRequest,
|
||||
) error {
|
||||
appID, err := pickAppIDByType(config, req.AppType)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -350,17 +364,103 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
|
||||
}
|
||||
|
||||
resp := &dto.ClientRechargeResponse{
|
||||
return response.Success(c, &dto.ClientRechargeResponse{
|
||||
Recharge: dto.ClientRechargeResult{
|
||||
RechargeID: rechargeOrder.ID,
|
||||
RechargeNo: rechargeOrder.RechargeOrderNo,
|
||||
Amount: rechargeOrder.Amount,
|
||||
Status: rechargeOrder.Status,
|
||||
},
|
||||
PayConfig: payConfig,
|
||||
PayConfig: &payConfig,
|
||||
})
|
||||
}
|
||||
|
||||
// createAlipayRecharge 支付宝充值分支。
|
||||
// WAP URL 为本地签名生成,事务内先写 DB,再生成链接;链接失败则标记 payment failed。
|
||||
func (h *ClientWalletHandler) createAlipayRecharge(
|
||||
c *fiber.Ctx,
|
||||
resolved *resolvedWalletAssetContext,
|
||||
config *model.WechatConfig,
|
||||
wallet *model.AssetWallet,
|
||||
req dto.ClientCreateRechargeRequest,
|
||||
) error {
|
||||
rechargeNo := generateClientRechargeNo()
|
||||
paymentNo := generateClientPaymentNo()
|
||||
expireMinutes := config.AliPayExpireMinutes
|
||||
if expireMinutes <= 0 {
|
||||
expireMinutes = 30
|
||||
}
|
||||
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
|
||||
|
||||
rechargeOrder := &model.RechargeOrder{
|
||||
RechargeOrderNo: rechargeNo,
|
||||
UserID: resolved.CustomerID,
|
||||
AssetWalletID: wallet.ID,
|
||||
ResourceType: resolved.ResourceType,
|
||||
ResourceID: resolved.Asset.AssetID,
|
||||
Amount: req.Amount,
|
||||
Status: model.RechargeOrderStatusPending,
|
||||
PaymentConfigID: &config.ID,
|
||||
ShopIDTag: wallet.ShopIDTag,
|
||||
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
OperatorType: constants.OperatorTypePersonalCustomer,
|
||||
Generation: resolved.Generation,
|
||||
}
|
||||
payment := &model.Payment{
|
||||
PaymentNo: paymentNo,
|
||||
OrderType: model.PaymentOrderTypeRecharge,
|
||||
PaymentMethod: model.PaymentByAlipay,
|
||||
Amount: req.Amount,
|
||||
Status: model.PaymentRecordStatusPending,
|
||||
PaymentConfigID: &config.ID,
|
||||
ExpireAt: &expireAt,
|
||||
}
|
||||
|
||||
return response.Success(c, resp)
|
||||
// WAP URL 是本地签名,不需要先调第三方,在事务内创建充值单和支付单
|
||||
if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
|
||||
}
|
||||
payment.OrderID = rechargeOrder.ID
|
||||
return h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wapURL, err := alipay.BuildWapPayURL(resolved.SkipPermissionCtx, config, payment, "资产钱包充值")
|
||||
if err != nil {
|
||||
if updateErr := h.paymentStore.UpdateStatus(resolved.SkipPermissionCtx, payment.ID, model.PaymentRecordStatusFailed); updateErr != nil {
|
||||
h.logger.Warn("标记支付宝支付单 failed 失败",
|
||||
zap.String("payment_no", paymentNo),
|
||||
zap.Error(updateErr),
|
||||
)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
h.logger.Info("支付宝充值支付单已创建",
|
||||
zap.String("payment_no", paymentNo),
|
||||
zap.String("recharge_no", rechargeNo),
|
||||
zap.Int64("amount", req.Amount),
|
||||
zap.Time("expire_at", expireAt),
|
||||
zap.Uint("config_id", config.ID),
|
||||
)
|
||||
|
||||
expireStr := expireAt.Format(time.RFC3339)
|
||||
return response.Success(c, &dto.ClientRechargeResponse{
|
||||
Recharge: dto.ClientRechargeResult{
|
||||
RechargeID: rechargeOrder.ID,
|
||||
RechargeNo: rechargeOrder.RechargeOrderNo,
|
||||
Amount: rechargeOrder.Amount,
|
||||
Status: rechargeOrder.Status,
|
||||
},
|
||||
PaymentLink: &dto.ClientPaymentLink{
|
||||
PaymentNo: paymentNo,
|
||||
QRLink: wapURL,
|
||||
CopyLink: wapURL,
|
||||
PayExpireAt: expireStr,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetRechargeList C5 充值记录列表
|
||||
|
||||
@@ -3,8 +3,8 @@ package callback
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
rechargeService "github.com/break/junhong_cmp_fiber/internal/service/recharge"
|
||||
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/alipay"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
|
||||
@@ -184,74 +185,180 @@ func (h *PaymentHandler) wechatV2SuccessResponse(c *fiber.Ctx) error {
|
||||
return c.SendString(`<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>`)
|
||||
}
|
||||
|
||||
// parseYuanToFen 将支付宝元为单位的金额字符串转换为分
|
||||
// 支付宝回调金额字段(buyer_pay_amount / total_amount)为字符串格式的元,如 "2.00"
|
||||
// 使用 math.Round 避免浮点运算精度误差
|
||||
func parseYuanToFen(yuan string) int64 {
|
||||
if yuan == "" {
|
||||
return 0
|
||||
}
|
||||
f, err := strconv.ParseFloat(yuan, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(math.Round(f * 100))
|
||||
}
|
||||
|
||||
type AlipayCallbackRequest struct {
|
||||
OrderNo string `json:"out_trade_no" form:"out_trade_no"`
|
||||
BuyerPayAmount string `json:"buyer_pay_amount" form:"buyer_pay_amount"`
|
||||
TotalAmount string `json:"total_amount" form:"total_amount"`
|
||||
}
|
||||
|
||||
// AlipayCallback 支付宝回调
|
||||
// AlipayCallback 支付宝异步回调(带验签与多维度安全校验)
|
||||
// POST /api/callback/alipay
|
||||
func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
|
||||
var req AlipayCallbackRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
ctx := c.UserContext()
|
||||
|
||||
// 从原始请求体解析 form 参数(x-www-form-urlencoded)
|
||||
values, err := url.ParseQuery(string(c.Body()))
|
||||
if err != nil {
|
||||
h.logger.Error("支付宝回调:解析 form 参数失败", zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam, "回调数据格式错误")
|
||||
}
|
||||
|
||||
if req.OrderNo == "" {
|
||||
// 未验签前只读取 out_trade_no,用于回溯创建支付单时所用的配置
|
||||
outTradeNo := values.Get("out_trade_no")
|
||||
if outTradeNo == "" {
|
||||
h.logger.Error("支付宝回调:out_trade_no 为空")
|
||||
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
|
||||
}
|
||||
|
||||
ctx := c.UserContext()
|
||||
|
||||
// 优先取 buyer_pay_amount(买家实付,扣除优惠券后),回退到 total_amount
|
||||
rawAmount := req.BuyerPayAmount
|
||||
if rawAmount == "" {
|
||||
rawAmount = req.TotalAmount
|
||||
// 按 payment_config_id 加载创建支付单时所用的配置(支持已停用配置)
|
||||
cfg, err := h.wechatConfigService.GetConfigForCallback(ctx, outTradeNo)
|
||||
if err != nil || cfg == nil {
|
||||
h.logger.Error("支付宝回调:加载支付配置失败",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付配置不可用")
|
||||
}
|
||||
paidAmount := parseYuanToFen(rawAmount)
|
||||
|
||||
handled, err := h.dispatchPaymentRecordCallback(ctx, req.OrderNo, model.PaymentMethodAlipay, "", paidAmount)
|
||||
// 使用支付宝公钥验签(DecodeNotification 内部完成签名校验)
|
||||
notification, err := alipay.DecodeNotification(ctx, cfg, values)
|
||||
if err != nil {
|
||||
return err
|
||||
h.logger.Error("支付宝回调:验签失败",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.Uint("config_id", cfg.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付宝回调验签失败")
|
||||
}
|
||||
if handled {
|
||||
|
||||
// 校验 app_id 与配置一致,防止跨商户混用
|
||||
if notification.AppId != cfg.AliAppID {
|
||||
h.logger.Error("支付宝回调:app_id 不匹配",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("notify_app_id", notification.AppId),
|
||||
zap.String("config_app_id", cfg.AliAppID),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付宝 app_id 校验失败")
|
||||
}
|
||||
|
||||
// 非终态交易(如 WAIT_BUYER_PAY、TRADE_CLOSED)直接返回 success,不推进业务
|
||||
tradeStatus := string(notification.TradeStatus)
|
||||
if tradeStatus != "TRADE_SUCCESS" && tradeStatus != "TRADE_FINISHED" {
|
||||
h.logger.Info("支付宝回调:非成功交易状态,忽略",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("trade_status", tradeStatus),
|
||||
)
|
||||
return c.SendString("success")
|
||||
}
|
||||
|
||||
// 按订单号前缀分发
|
||||
switch {
|
||||
case strings.HasPrefix(req.OrderNo, "ORD"):
|
||||
if err := h.orderService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay, paidAmount); err != nil {
|
||||
return err
|
||||
// 查询支付记录
|
||||
payment, err := h.paymentStore.GetByPaymentNo(ctx, outTradeNo)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
h.logger.Error("支付宝回调:支付记录不存在",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付记录不存在")
|
||||
}
|
||||
case strings.HasPrefix(req.OrderNo, constants.AssetRechargeOrderPrefix):
|
||||
if h.rechargeOrderService != nil {
|
||||
return h.rechargeOrderService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentByAlipay, "")
|
||||
h.logger.Error("支付宝回调:查询支付记录失败",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询支付记录失败")
|
||||
}
|
||||
|
||||
// 校验支付方式必须为 alipay,防止其他通道的 payment_no 被误用
|
||||
if payment.PaymentMethod != model.PaymentByAlipay {
|
||||
h.logger.Error("支付宝回调:支付方式不匹配",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("payment_method", payment.PaymentMethod),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付通道校验失败")
|
||||
}
|
||||
|
||||
// 校验 payment_config_id 与当前配置一致;旧数据为空时兼容并记录 warn
|
||||
if payment.PaymentConfigID == nil {
|
||||
h.logger.Warn("支付宝回调:payment_config_id 为空,跳过配置 ID 校验(旧数据兼容)",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.Uint("config_id", cfg.ID),
|
||||
)
|
||||
} else if *payment.PaymentConfigID != cfg.ID {
|
||||
h.logger.Error("支付宝回调:payment_config_id 不匹配",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.Uint("payment_config_id", *payment.PaymentConfigID),
|
||||
zap.Uint("callback_config_id", cfg.ID),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付配置 ID 校验失败")
|
||||
}
|
||||
|
||||
// 校验金额:使用 total_amount(原始订单金额)而非 buyer_pay_amount,
|
||||
// 避免优惠券/积分场景导致内部订单金额被错误记低
|
||||
notifyAmountFen, err := alipay.YuanToFen(notification.TotalAmount)
|
||||
if err != nil {
|
||||
h.logger.Error("支付宝回调:total_amount 转换失败",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("total_amount", notification.TotalAmount),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付金额格式错误")
|
||||
}
|
||||
if notifyAmountFen != payment.Amount {
|
||||
h.logger.Error("支付宝回调:金额不一致",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.Int64("notify_amount_fen", notifyAmountFen),
|
||||
zap.Int64("payment_amount_fen", payment.Amount),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付金额校验失败")
|
||||
}
|
||||
|
||||
// 写入支付宝交易流水号
|
||||
if err := h.paymentStore.UpdatePaymentInfo(ctx, payment.ID, notification.TradeNo, nil); err != nil {
|
||||
h.logger.Error("支付宝回调:写入 third_party_trade_no 失败",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("trade_no", notification.TradeNo),
|
||||
zap.Error(err),
|
||||
)
|
||||
// 不中断业务,继续分发(幂等业务层会处理状态)
|
||||
}
|
||||
|
||||
// 按支付单 order_type 分发业务
|
||||
switch payment.OrderType {
|
||||
case model.PaymentOrderTypePackage:
|
||||
if err := h.orderService.HandlePaymentRecordCallback(ctx, outTradeNo, model.PaymentByAlipay, notification.TradeNo, payment.Amount); err != nil {
|
||||
h.logger.Error("支付宝回调:推进套餐订单失败",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("trade_no", notification.TradeNo),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "处理支付宝支付回调失败")
|
||||
}
|
||||
return fmt.Errorf("充值订单服务未配置,无法处理订单: %s", req.OrderNo)
|
||||
case strings.HasPrefix(req.OrderNo, constants.AgentRechargeOrderPrefix):
|
||||
if h.agentRechargeService != nil {
|
||||
if err := h.agentRechargeService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
h.logger.Info("支付宝回调:套餐订单支付成功",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("trade_no", notification.TradeNo),
|
||||
zap.String("order_type", payment.OrderType),
|
||||
)
|
||||
|
||||
case model.PaymentOrderTypeRecharge:
|
||||
if h.rechargeOrderService == nil {
|
||||
h.logger.Error("支付宝回调:充值订单服务未配置",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
)
|
||||
return errors.New(errors.CodeInternalError, "充值订单服务未配置")
|
||||
}
|
||||
if err := h.rechargeOrderService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentByAlipay, notification.TradeNo); err != nil {
|
||||
h.logger.Error("支付宝回调:推进充值订单失败",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("trade_no", notification.TradeNo),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "处理支付宝充值回调失败")
|
||||
}
|
||||
h.logger.Info("支付宝回调:充值订单支付成功",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("trade_no", notification.TradeNo),
|
||||
zap.String("order_type", payment.OrderType),
|
||||
)
|
||||
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "未知订单号前缀")
|
||||
h.logger.Error("支付宝回调:未知支付记录类型",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("order_type", payment.OrderType),
|
||||
)
|
||||
return errors.New(errors.CodeInternalError, "未知支付记录类型")
|
||||
}
|
||||
|
||||
return c.SendString("success")
|
||||
|
||||
@@ -8,8 +8,10 @@ package dto
|
||||
type ClientCreateOrderRequest struct {
|
||||
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||
PackageIDs []uint `json:"package_ids" validate:"required,min=1,dive,gt=0" required:"true" description:"套餐ID列表"`
|
||||
// app_type 仅强充场景需要(服务端检测到强充时会直接发起微信支付);普通下单时可不传
|
||||
AppType string `json:"app_type" validate:"omitempty,oneof=official_account miniapp" description:"应用类型(强充必传)(official_account:公众号, miniapp:小程序)"`
|
||||
// PaymentMethod 指定支付方式;强充场景必传。wechat 时 app_type 也必传,alipay 时不需要 app_type
|
||||
PaymentMethod string `json:"payment_method" validate:"omitempty,oneof=wechat alipay" description:"支付方式(强充必传)(wechat:微信, alipay:支付宝)"`
|
||||
// AppType 仅强充 + 微信支付场景必传;支付宝强充无需传
|
||||
AppType string `json:"app_type" validate:"omitempty,oneof=official_account miniapp" description:"应用类型(微信强充必传)(official_account:公众号, miniapp:小程序)"`
|
||||
}
|
||||
|
||||
// ClientCreateOrderResponse D1 客户端创建订单响应
|
||||
@@ -17,7 +19,8 @@ type ClientCreateOrderResponse struct {
|
||||
OrderType string `json:"order_type" description:"订单类型 (package:套餐订单, recharge:充值订单)"`
|
||||
Order *ClientOrderInfo `json:"order,omitempty" description:"套餐订单信息(普通下单时返回,无支付参数,需单独调支付接口)"`
|
||||
Recharge *ClientRechargeInfo `json:"recharge,omitempty" description:"充值订单信息"`
|
||||
PayConfig *ClientPayConfig `json:"pay_config,omitempty" description:"微信支付配置(仅强充场景返回)"`
|
||||
PayConfig *ClientPayConfig `json:"pay_config,omitempty" description:"微信支付配置(强充微信支付时返回)"`
|
||||
PaymentLink *ClientPaymentLink `json:"payment_link,omitempty" description:"支付宝支付链接(强充支付宝支付时返回)"`
|
||||
LinkedPackageInfo *LinkedPackageInfo `json:"linked_package_info,omitempty" description:"关联套餐信息"`
|
||||
Idempotent bool `json:"idempotent,omitempty" description:"幂等标志(true 表示返回的是已存在的待支付充值订单)"`
|
||||
}
|
||||
@@ -122,17 +125,26 @@ type ClientOrderPackageItem struct {
|
||||
// D4 订单支付
|
||||
// ========================================
|
||||
|
||||
// ClientPaymentLink C 端支付宝支付链接
|
||||
type ClientPaymentLink struct {
|
||||
PaymentNo string `json:"payment_no" description:"支付单号"`
|
||||
QRLink string `json:"qr_link" description:"二维码内容链接,前端用该链接生成二维码"`
|
||||
CopyLink string `json:"copy_link" description:"复制到普通浏览器打开的支付链接(与 qr_link 相同)"`
|
||||
PayExpireAt string `json:"pay_expire_at" description:"支付链接过期时间(RFC3339 格式)"`
|
||||
}
|
||||
|
||||
// ClientPayOrderRequest D4 订单支付请求
|
||||
type ClientPayOrderRequest struct {
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet wechat" required:"true" description:"支付方式 (wallet:钱包, wechat:微信)"`
|
||||
// AppType 仅 payment_method=wechat 时必填
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet wechat alipay" required:"true" description:"支付方式 (wallet:钱包, wechat:微信, alipay:支付宝)"`
|
||||
// AppType 仅 payment_method=wechat 时必填,alipay 无需传
|
||||
AppType string `json:"app_type" validate:"omitempty,oneof=official_account miniapp" description:"应用类型(微信支付必传)(official_account:公众号, miniapp:小程序)"`
|
||||
}
|
||||
|
||||
// ClientPayOrderResponse D4 订单支付响应
|
||||
type ClientPayOrderResponse struct {
|
||||
PaymentMethod string `json:"payment_method" description:"支付方式"`
|
||||
PayConfig *ClientPayConfig `json:"pay_config,omitempty" description:"微信支付配置(payment_method=wechat 时返回)"`
|
||||
PaymentMethod string `json:"payment_method" description:"支付方式"`
|
||||
PayConfig *ClientPayConfig `json:"pay_config,omitempty" description:"微信支付配置(payment_method=wechat 时返回)"`
|
||||
PaymentLink *ClientPaymentLink `json:"payment_link,omitempty" description:"支付宝支付链接(payment_method=alipay 时返回)"`
|
||||
}
|
||||
|
||||
// ClientPayOrderParams D4 订单支付文档参数(路径参数 + 请求体,仅供文档生成器使用)
|
||||
|
||||
@@ -77,15 +77,17 @@ type ClientRechargeCheckResponse struct {
|
||||
// ClientCreateRechargeRequest C4 创建充值订单请求
|
||||
type ClientCreateRechargeRequest struct {
|
||||
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||
Amount int64 `json:"amount" validate:"required,min=100,max=10000000" required:"true" minimum:"100" maximum:"10000000" description:"充值金额(分)"`
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat" required:"true" description:"支付方式 (wechat:微信支付)"`
|
||||
AppType string `json:"app_type" validate:"required,oneof=official_account miniapp" required:"true" description:"应用类型 (official_account:公众号, miniapp:小程序)"`
|
||||
Amount int64 `json:"amount" validate:"required,min=1,max=10000000" required:"true" minimum:"1" maximum:"10000000" description:"充值金额(分)"`
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay" required:"true" description:"支付方式 (wechat:微信支付, alipay:支付宝)"`
|
||||
// AppType 仅 payment_method=wechat 时必填,alipay 无需传
|
||||
AppType string `json:"app_type" validate:"omitempty,oneof=official_account miniapp" description:"应用类型(微信支付必传)(official_account:公众号, miniapp:小程序)"`
|
||||
}
|
||||
|
||||
// ClientRechargeResponse C4 创建充值订单响应
|
||||
type ClientRechargeResponse struct {
|
||||
Recharge ClientRechargeResult `json:"recharge" description:"充值信息"`
|
||||
PayConfig ClientRechargePayConfig `json:"pay_config" description:"支付配置"`
|
||||
Recharge ClientRechargeResult `json:"recharge" description:"充值信息"`
|
||||
PayConfig *ClientRechargePayConfig `json:"pay_config,omitempty" description:"微信支付配置(payment_method=wechat 时返回)"`
|
||||
PaymentLink *ClientPaymentLink `json:"payment_link,omitempty" description:"支付宝支付链接(payment_method=alipay 时返回)"`
|
||||
}
|
||||
|
||||
// ClientRechargeResult C4 充值信息
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
type CreateWechatConfigRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"配置名称"`
|
||||
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置描述"`
|
||||
ProviderType string `json:"provider_type" validate:"required,oneof=wechat fuiou" required:"true" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"`
|
||||
ProviderType string `json:"provider_type" validate:"required,oneof=wechat wechat_v2 fuiou" required:"true" description:"支付渠道类型 (wechat:微信直连v3, wechat_v2:微信直连v2, fuiou:富友)"`
|
||||
|
||||
OaAppID string `json:"oa_app_id" validate:"omitempty,max=100" maxLength:"100" description:"公众号AppID"`
|
||||
OaAppSecret string `json:"oa_app_secret" validate:"omitempty,max=200" maxLength:"200" description:"公众号AppSecret"`
|
||||
@@ -36,13 +36,22 @@ type CreateWechatConfigRequest struct {
|
||||
FyPublicKey string `json:"fy_public_key" validate:"omitempty" description:"富友公钥(PEM格式)"`
|
||||
FyAPIURL string `json:"fy_api_url" validate:"omitempty,max=500" maxLength:"500" description:"富友API地址"`
|
||||
FyNotifyURL string `json:"fy_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"富友支付回调地址"`
|
||||
|
||||
// 支付宝配置字段(并存能力,不受 provider_type 限制)
|
||||
AliAppID string `json:"ali_app_id" validate:"omitempty,max=100" maxLength:"100" description:"支付宝应用ID"`
|
||||
AliPrivateKey string `json:"ali_private_key" validate:"omitempty" description:"支付宝应用私钥(PEM格式)"`
|
||||
AliPublicKey string `json:"ali_public_key" validate:"omitempty" description:"支付宝公钥(PEM格式,用于验签)"`
|
||||
AliNotifyURL string `json:"ali_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"支付宝异步通知地址"`
|
||||
AliReturnURL string `json:"ali_return_url" validate:"omitempty,max=500" maxLength:"500" description:"支付宝同步跳转地址"`
|
||||
AliProduction bool `json:"ali_production" description:"是否生产环境 (true:生产, false:沙箱)"`
|
||||
AliPayExpireMinutes int `json:"ali_pay_expire_minutes" validate:"omitempty,min=1,max=1440" minimum:"1" maximum:"1440" description:"支付过期分钟数 (默认30分钟)"`
|
||||
}
|
||||
|
||||
// UpdateWechatConfigRequest 更新微信参数配置请求
|
||||
type UpdateWechatConfigRequest struct {
|
||||
Name *string `json:"name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"配置名称"`
|
||||
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置描述"`
|
||||
ProviderType *string `json:"provider_type" validate:"omitempty,oneof=wechat fuiou" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"`
|
||||
ProviderType *string `json:"provider_type" validate:"omitempty,oneof=wechat wechat_v2 fuiou" description:"支付渠道类型 (wechat:微信直连v3, wechat_v2:微信直连v2, fuiou:富友)"`
|
||||
|
||||
OaAppID *string `json:"oa_app_id" validate:"omitempty,max=100" maxLength:"100" description:"公众号AppID"`
|
||||
OaAppSecret *string `json:"oa_app_secret" validate:"omitempty,max=200" maxLength:"200" description:"公众号AppSecret"`
|
||||
@@ -68,6 +77,15 @@ type UpdateWechatConfigRequest struct {
|
||||
FyPublicKey *string `json:"fy_public_key" validate:"omitempty" description:"富友公钥(PEM格式)"`
|
||||
FyAPIURL *string `json:"fy_api_url" validate:"omitempty,max=500" maxLength:"500" description:"富友API地址"`
|
||||
FyNotifyURL *string `json:"fy_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"富友支付回调地址"`
|
||||
|
||||
// 支付宝配置字段(并存能力,不受 provider_type 限制)
|
||||
AliAppID *string `json:"ali_app_id" validate:"omitempty,max=100" maxLength:"100" description:"支付宝应用ID"`
|
||||
AliPrivateKey *string `json:"ali_private_key" validate:"omitempty" description:"支付宝应用私钥(PEM格式)"`
|
||||
AliPublicKey *string `json:"ali_public_key" validate:"omitempty" description:"支付宝公钥(PEM格式,用于验签)"`
|
||||
AliNotifyURL *string `json:"ali_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"支付宝异步通知地址"`
|
||||
AliReturnURL *string `json:"ali_return_url" validate:"omitempty,max=500" maxLength:"500" description:"支付宝同步跳转地址"`
|
||||
AliProduction *bool `json:"ali_production" description:"是否生产环境 (true:生产, false:沙箱)"`
|
||||
AliPayExpireMinutes *int `json:"ali_pay_expire_minutes" validate:"omitempty,min=1,max=1440" minimum:"1" maximum:"1440" description:"支付过期分钟数"`
|
||||
}
|
||||
|
||||
// UpdateWechatConfigParams 更新微信参数配置聚合参数 (用于文档生成)
|
||||
@@ -80,7 +98,7 @@ type UpdateWechatConfigParams struct {
|
||||
type WechatConfigListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
ProviderType *string `json:"provider_type" query:"provider_type" validate:"omitempty,oneof=wechat fuiou" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"`
|
||||
ProviderType *string `json:"provider_type" query:"provider_type" validate:"omitempty,oneof=wechat wechat_v2 fuiou" description:"支付渠道类型 (wechat:微信直连v3, wechat_v2:微信直连v2, fuiou:富友)"`
|
||||
IsActive *bool `json:"is_active" query:"is_active" description:"是否激活 (true:已激活, false:未激活)"`
|
||||
}
|
||||
|
||||
@@ -89,7 +107,7 @@ type WechatConfigResponse struct {
|
||||
ID uint `json:"id" description:"配置ID"`
|
||||
Name string `json:"name" description:"配置名称"`
|
||||
Description string `json:"description" description:"配置描述"`
|
||||
ProviderType string `json:"provider_type" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"`
|
||||
ProviderType string `json:"provider_type" description:"支付渠道类型 (wechat:微信直连v3, wechat_v2:微信直连v2, fuiou:富友)"`
|
||||
IsActive bool `json:"is_active" description:"是否激活"`
|
||||
|
||||
OaAppID string `json:"oa_app_id" description:"公众号AppID"`
|
||||
@@ -117,6 +135,15 @@ type WechatConfigResponse struct {
|
||||
FyAPIURL string `json:"fy_api_url" description:"富友API地址"`
|
||||
FyNotifyURL string `json:"fy_notify_url" description:"富友支付回调地址"`
|
||||
|
||||
// 支付宝配置字段(敏感字段已脱敏)
|
||||
AliAppID string `json:"ali_app_id" description:"支付宝应用ID"`
|
||||
AliPrivateKey string `json:"ali_private_key" description:"支付宝应用私钥(配置状态)"`
|
||||
AliPublicKey string `json:"ali_public_key" description:"支付宝公钥(配置状态)"`
|
||||
AliNotifyURL string `json:"ali_notify_url" description:"支付宝异步通知地址"`
|
||||
AliReturnURL string `json:"ali_return_url" description:"支付宝同步跳转地址"`
|
||||
AliProduction bool `json:"ali_production" description:"是否生产环境"`
|
||||
AliPayExpireMinutes int `json:"ali_pay_expire_minutes" description:"支付过期分钟数"`
|
||||
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
@@ -180,6 +207,14 @@ func FromWechatConfigModel(m *model.WechatConfig) *WechatConfigResponse {
|
||||
FyAPIURL: m.FyAPIURL,
|
||||
FyNotifyURL: m.FyNotifyURL,
|
||||
|
||||
AliAppID: m.AliAppID,
|
||||
AliPrivateKey: MaskLongSecret(m.AliPrivateKey),
|
||||
AliPublicKey: MaskLongSecret(m.AliPublicKey),
|
||||
AliNotifyURL: m.AliNotifyURL,
|
||||
AliReturnURL: m.AliReturnURL,
|
||||
AliProduction: m.AliProduction,
|
||||
AliPayExpireMinutes: m.AliPayExpireMinutes,
|
||||
|
||||
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: m.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type Payment struct {
|
||||
PaymentConfigID *uint `gorm:"column:payment_config_id" json:"payment_config_id,omitempty"`
|
||||
PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500)" json:"payment_voucher_key,omitempty"`
|
||||
PaidAt *time.Time `gorm:"column:paid_at" json:"paid_at,omitempty"`
|
||||
ExpireAt *time.Time `gorm:"column:expire_at" json:"expire_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
|
||||
|
||||
@@ -9,6 +9,9 @@ const (
|
||||
ProviderTypeFuiou = "fuiou" // 富友
|
||||
)
|
||||
|
||||
// 支付宝支付过期默认分钟数
|
||||
const DefaultAliPayExpireMinutes = 30
|
||||
|
||||
// WechatConfig 微信参数配置模型
|
||||
// 管理微信公众号 OAuth、小程序、微信直连支付、富友支付等多套配置
|
||||
// 同一时间只有一条记录处于 is_active=true(全局唯一生效配置)
|
||||
@@ -49,6 +52,15 @@ type WechatConfig struct {
|
||||
FyPublicKey string `gorm:"column:fy_public_key;type:text;default:'';comment:富友公钥" json:"fy_public_key"`
|
||||
FyAPIURL string `gorm:"column:fy_api_url;type:varchar(500);default:'';comment:富友API地址" json:"fy_api_url"`
|
||||
FyNotifyURL string `gorm:"column:fy_notify_url;type:varchar(500);default:'';comment:富友支付回调地址" json:"fy_notify_url"`
|
||||
|
||||
// 支付-支付宝(与微信/富友并存,provider_type 不新增 alipay)
|
||||
AliAppID string `gorm:"column:ali_app_id;type:varchar(100);not null;default:'';comment:支付宝应用ID" json:"ali_app_id"`
|
||||
AliPrivateKey string `gorm:"column:ali_private_key;type:text;not null;default:'';comment:支付宝应用私钥(敏感)" json:"ali_private_key"`
|
||||
AliPublicKey string `gorm:"column:ali_public_key;type:text;not null;default:'';comment:支付宝公钥(用于验签)" json:"ali_public_key"`
|
||||
AliNotifyURL string `gorm:"column:ali_notify_url;type:varchar(500);not null;default:'';comment:支付宝异步通知地址" json:"ali_notify_url"`
|
||||
AliReturnURL string `gorm:"column:ali_return_url;type:varchar(500);not null;default:'';comment:支付宝同步跳转地址" json:"ali_return_url"`
|
||||
AliProduction bool `gorm:"column:ali_production;type:boolean;not null;default:false;comment:是否生产环境" json:"ali_production"`
|
||||
AliPayExpireMinutes int `gorm:"column:ali_pay_expire_minutes;type:integer;not null;default:30;comment:支付过期分钟数" json:"ali_pay_expire_minutes"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/alipay"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/bytedance/sonic"
|
||||
@@ -178,7 +179,7 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
|
||||
if err == nil && strings.HasPrefix(existingValue, "CRCH") {
|
||||
existingOrder, queryErr := s.rechargeOrderStore.GetByRechargeOrderNo(skipCtx, existingValue)
|
||||
if queryErr == nil && existingOrder.Status == model.RechargeOrderStatusPending {
|
||||
return &dto.ClientCreateOrderResponse{
|
||||
resp := &dto.ClientCreateOrderResponse{
|
||||
OrderType: "recharge",
|
||||
Recharge: &dto.ClientRechargeInfo{
|
||||
RechargeID: existingOrder.ID,
|
||||
@@ -189,7 +190,23 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
|
||||
AutoPurchaseStatus: existingOrder.AutoPurchaseStatus,
|
||||
},
|
||||
Idempotent: true,
|
||||
}, nil
|
||||
}
|
||||
// 支付宝强充幂等:复用已有待支付 payment 或生成新链接
|
||||
if req.PaymentMethod == model.PaymentMethodAlipay {
|
||||
paymentLink, linkErr := s.getOrBuildAlipayPaymentLink(
|
||||
skipCtx, existingOrder.ID, model.PaymentOrderTypeRecharge,
|
||||
existingOrder.Amount, "余额充值",
|
||||
)
|
||||
if linkErr != nil {
|
||||
s.logger.Warn("强充幂等复用支付宝链接失败",
|
||||
zap.String("recharge_no", existingOrder.RechargeOrderNo),
|
||||
zap.Error(linkErr),
|
||||
)
|
||||
} else {
|
||||
resp.PaymentLink = paymentLink
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
if queryErr == nil {
|
||||
_ = s.redis.Del(skipCtx, redisKey).Err()
|
||||
@@ -209,7 +226,18 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
|
||||
}()
|
||||
|
||||
if forceRecharge.NeedForceRecharge {
|
||||
// 强充场景需要微信支付,此时 app_type 必须传入
|
||||
if req.PaymentMethod == model.PaymentMethodAlipay {
|
||||
// 支付宝强充:不需要 app_type / OpenID
|
||||
activeConfig, err := s.wechatConfigService.GetActiveConfig(skipCtx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付配置失败")
|
||||
}
|
||||
if activeConfig == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "未找到生效的支付配置")
|
||||
}
|
||||
return s.createAlipayForceRechargeOrder(skipCtx, customerID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, &created)
|
||||
}
|
||||
// 微信强充:app_type 必须传入
|
||||
activeConfig, appID, err := s.resolveWechatConfig(skipCtx, req.AppType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -813,6 +841,222 @@ func generateClientPaymentNo() string {
|
||||
return fmt.Sprintf("PAY%d%06d", time.Now().UnixNano()/1e6, rand.Intn(1000000))
|
||||
}
|
||||
|
||||
// getOrBuildAlipayPaymentLink 查找未过期的支付宝待支付单并返回链接;
|
||||
// 过期则标记 failed 并创建新单;新建失败则整体返回错误。
|
||||
func (s *Service) getOrBuildAlipayPaymentLink(
|
||||
ctx context.Context,
|
||||
orderID uint,
|
||||
orderType string,
|
||||
amount int64,
|
||||
subject string,
|
||||
) (*dto.ClientPaymentLink, error) {
|
||||
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付配置失败")
|
||||
}
|
||||
if activeConfig == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "未找到生效的支付配置")
|
||||
}
|
||||
|
||||
// 查找最新的 alipay 待支付单
|
||||
existing, _ := s.paymentStore.FindLatestPendingByOrderAndMethod(
|
||||
ctx, orderID, orderType, model.PaymentByAlipay,
|
||||
)
|
||||
|
||||
var payment *model.Payment
|
||||
now := time.Now()
|
||||
if existing != nil && existing.ExpireAt != nil && existing.ExpireAt.After(now) {
|
||||
// 复用未过期的支付单
|
||||
payment = existing
|
||||
} else {
|
||||
// 过期或不存在,标记旧单 failed 并新建
|
||||
if existing != nil {
|
||||
if updateErr := s.paymentStore.UpdateStatus(ctx, existing.ID, model.PaymentRecordStatusFailed); updateErr != nil {
|
||||
s.logger.Warn("标记过期支付宝支付单 failed 失败",
|
||||
zap.Uint("payment_id", existing.ID),
|
||||
zap.Error(updateErr),
|
||||
)
|
||||
}
|
||||
}
|
||||
expireMinutes := activeConfig.AliPayExpireMinutes
|
||||
if expireMinutes <= 0 {
|
||||
expireMinutes = 30
|
||||
}
|
||||
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
|
||||
newPayment := &model.Payment{
|
||||
PaymentNo: generateClientPaymentNo(),
|
||||
OrderID: orderID,
|
||||
OrderType: orderType,
|
||||
PaymentMethod: model.PaymentByAlipay,
|
||||
Amount: amount,
|
||||
Status: model.PaymentRecordStatusPending,
|
||||
PaymentConfigID: &activeConfig.ID,
|
||||
ExpireAt: &expireAt,
|
||||
}
|
||||
if err := s.paymentStore.Create(ctx, newPayment); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建支付宝支付单失败")
|
||||
}
|
||||
payment = newPayment
|
||||
s.logger.Info("创建支付宝支付单",
|
||||
zap.String("payment_no", payment.PaymentNo),
|
||||
zap.String("order_type", orderType),
|
||||
zap.Uint("order_id", orderID),
|
||||
zap.Int64("amount", amount),
|
||||
zap.Time("expire_at", expireAt),
|
||||
zap.Uint("config_id", activeConfig.ID),
|
||||
)
|
||||
}
|
||||
|
||||
wapURL, err := alipay.BuildWapPayURL(ctx, activeConfig, payment, subject)
|
||||
if err != nil {
|
||||
// 新建的 payment 生成链接失败,标记 failed
|
||||
if existing == nil || payment.ID != existing.ID {
|
||||
_ = s.paymentStore.UpdateStatus(ctx, payment.ID, model.PaymentRecordStatusFailed)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
expireStr := ""
|
||||
if payment.ExpireAt != nil {
|
||||
expireStr = payment.ExpireAt.Format(time.RFC3339)
|
||||
}
|
||||
return &dto.ClientPaymentLink{
|
||||
PaymentNo: payment.PaymentNo,
|
||||
QRLink: wapURL,
|
||||
CopyLink: wapURL,
|
||||
PayExpireAt: expireStr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createAlipayForceRechargeOrder 强充场景下创建支付宝充值单并返回支付链接。
|
||||
func (s *Service) createAlipayForceRechargeOrder(
|
||||
ctx context.Context,
|
||||
customerID uint,
|
||||
assetInfo *dto.AssetResolveResponse,
|
||||
validationResult *purchase_validation.PurchaseValidationResult,
|
||||
activeConfig *model.WechatConfig,
|
||||
forceRecharge *ForceRechargeRequirement,
|
||||
redisKey string,
|
||||
created *bool,
|
||||
) (*dto.ClientCreateOrderResponse, error) {
|
||||
resourceType, resourceID, err := resolveWalletResource(validationResult)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wallet, err := s.getOrCreateWallet(ctx, resourceType, resourceID, resolveSellerShopID(validationResult))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
linkedPackageIDs, err := sonic.Marshal(extractPackageIDs(validationResult.Packages))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化关联套餐失败")
|
||||
}
|
||||
|
||||
rechargeOrderNo := generateClientRechargeNo()
|
||||
var iotCardID *uint
|
||||
var deviceID *uint
|
||||
if validationResult.Card != nil {
|
||||
iotCardID = &validationResult.Card.ID
|
||||
}
|
||||
if validationResult.Device != nil {
|
||||
deviceID = &validationResult.Device.ID
|
||||
}
|
||||
|
||||
rechargeOrder := &model.RechargeOrder{
|
||||
RechargeOrderNo: rechargeOrderNo,
|
||||
UserID: customerID,
|
||||
AssetWalletID: wallet.ID,
|
||||
ResourceType: resourceType,
|
||||
ResourceID: resourceID,
|
||||
IotCardID: iotCardID,
|
||||
DeviceID: deviceID,
|
||||
Amount: forceRecharge.ForceRechargeAmount,
|
||||
Status: model.RechargeOrderStatusPending,
|
||||
ShopIDTag: wallet.ShopIDTag,
|
||||
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
OperatorType: "personal_customer",
|
||||
Generation: resolveGeneration(validationResult),
|
||||
LinkedPackageIDs: datatypes.JSON(linkedPackageIDs),
|
||||
LinkedOrderType: resolveOrderType(validationResult),
|
||||
LinkedCarrierType: assetInfo.AssetType,
|
||||
LinkedCarrierID: &resourceID,
|
||||
AutoPurchaseStatus: model.AutoPurchaseStatusPending,
|
||||
PaymentConfigID: &activeConfig.ID,
|
||||
}
|
||||
|
||||
expireMinutesForce := activeConfig.AliPayExpireMinutes
|
||||
if expireMinutesForce <= 0 {
|
||||
expireMinutesForce = 30
|
||||
}
|
||||
expireAt := time.Now().Add(time.Duration(expireMinutesForce) * time.Minute)
|
||||
paymentNo := generateClientPaymentNo()
|
||||
payment := &model.Payment{
|
||||
PaymentNo: paymentNo,
|
||||
OrderType: model.PaymentOrderTypeRecharge,
|
||||
PaymentMethod: model.PaymentByAlipay,
|
||||
Amount: forceRecharge.ForceRechargeAmount,
|
||||
Status: model.PaymentRecordStatusPending,
|
||||
PaymentConfigID: &activeConfig.ID,
|
||||
ExpireAt: &expireAt,
|
||||
}
|
||||
|
||||
// 事务创建充值单 + 支付单(WAP URL 本地签名,不需要先调第三方)
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.rechargeOrderStore.CreateWithTx(ctx, tx, rechargeOrder); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
|
||||
}
|
||||
payment.OrderID = rechargeOrder.ID
|
||||
return s.paymentStore.CreateWithTx(ctx, tx, payment)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wapURL, err := alipay.BuildWapPayURL(ctx, activeConfig, payment, "余额充值")
|
||||
if err != nil {
|
||||
if updateErr := s.paymentStore.UpdateStatus(ctx, payment.ID, model.PaymentRecordStatusFailed); updateErr != nil {
|
||||
s.logger.Warn("标记支付宝支付单 failed 失败",
|
||||
zap.String("payment_no", paymentNo),
|
||||
zap.Error(updateErr),
|
||||
)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("支付宝强充支付单已创建",
|
||||
zap.String("payment_no", paymentNo),
|
||||
zap.String("recharge_no", rechargeOrderNo),
|
||||
zap.Int64("amount", forceRecharge.ForceRechargeAmount),
|
||||
zap.Time("expire_at", expireAt),
|
||||
zap.Uint("config_id", activeConfig.ID),
|
||||
)
|
||||
|
||||
s.markClientPurchaseCreated(ctx, redisKey, rechargeOrderNo)
|
||||
*created = true
|
||||
|
||||
clientStatus := rechargeStatusToClientStatus(int(rechargeOrder.Status))
|
||||
expireStr := expireAt.Format(time.RFC3339)
|
||||
return &dto.ClientCreateOrderResponse{
|
||||
OrderType: "recharge",
|
||||
Recharge: &dto.ClientRechargeInfo{
|
||||
RechargeID: rechargeOrder.ID,
|
||||
RechargeNo: rechargeOrderNo,
|
||||
Amount: rechargeOrder.Amount,
|
||||
Status: clientStatus,
|
||||
StatusName: clientRechargeStatusName(clientStatus),
|
||||
AutoPurchaseStatus: rechargeOrder.AutoPurchaseStatus,
|
||||
},
|
||||
PaymentLink: &dto.ClientPaymentLink{
|
||||
PaymentNo: paymentNo,
|
||||
QRLink: wapURL,
|
||||
CopyLink: wapURL,
|
||||
PayExpireAt: expireStr,
|
||||
},
|
||||
LinkedPackageInfo: buildLinkedPackageInfo(validationResult, forceRecharge),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListOrders 查询 C 端订单列表。
|
||||
func (s *Service) ListOrders(ctx context.Context, customerID uint, req *dto.ClientOrderListRequest) ([]dto.ClientOrderListItem, int64, error) {
|
||||
skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
|
||||
@@ -1058,6 +1302,36 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
|
||||
PayConfig: buildClientPayConfigFromResult(paymentResult),
|
||||
}, nil
|
||||
|
||||
case model.PaymentMethodAlipay:
|
||||
// 支付宝支付:不需要 app_type / OpenID
|
||||
activeConfig, err := s.wechatConfigService.GetActiveConfig(skipCtx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付配置失败")
|
||||
}
|
||||
if activeConfig == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "未找到生效的支付配置")
|
||||
}
|
||||
|
||||
paymentLink, err := s.getOrBuildAlipayPaymentLink(
|
||||
skipCtx, orderID, model.PaymentOrderTypePackage, order.TotalAmount, "套餐购买",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 更新订单支付配置 ID,兼容回调配置回溯
|
||||
if dbErr := s.db.WithContext(skipCtx).Model(&model.Order{}).
|
||||
Where("id = ?", orderID).
|
||||
Update("payment_config_id", activeConfig.ID).Error; dbErr != nil {
|
||||
s.logger.Warn("更新订单支付配置 ID 失败",
|
||||
zap.Uint("order_id", orderID),
|
||||
zap.Error(dbErr),
|
||||
)
|
||||
}
|
||||
return &dto.ClientPayOrderResponse{
|
||||
PaymentMethod: model.PaymentMethodAlipay,
|
||||
PaymentLink: paymentLink,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidParam, "不支持的支付方式")
|
||||
}
|
||||
|
||||
@@ -103,6 +103,18 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateWechatConfigRequest
|
||||
FyPublicKey: req.FyPublicKey,
|
||||
FyAPIURL: req.FyAPIURL,
|
||||
FyNotifyURL: req.FyNotifyURL,
|
||||
|
||||
AliAppID: req.AliAppID,
|
||||
AliPrivateKey: req.AliPrivateKey,
|
||||
AliPublicKey: req.AliPublicKey,
|
||||
AliNotifyURL: req.AliNotifyURL,
|
||||
AliReturnURL: req.AliReturnURL,
|
||||
AliProduction: req.AliProduction,
|
||||
AliPayExpireMinutes: req.AliPayExpireMinutes,
|
||||
}
|
||||
// 支付宝过期分钟数默认值
|
||||
if config.AliPayExpireMinutes == 0 {
|
||||
config.AliPayExpireMinutes = model.DefaultAliPayExpireMinutes
|
||||
}
|
||||
config.Creator = middleware.GetUserIDFromContext(ctx)
|
||||
|
||||
@@ -229,6 +241,19 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateWechatConf
|
||||
s.mergeStringField(&config.FyAPIURL, req.FyAPIURL)
|
||||
s.mergeStringField(&config.FyNotifyURL, req.FyNotifyURL)
|
||||
|
||||
// 支付宝支付
|
||||
s.mergeStringField(&config.AliAppID, req.AliAppID)
|
||||
s.mergeSensitiveField(&config.AliPrivateKey, req.AliPrivateKey)
|
||||
s.mergeSensitiveField(&config.AliPublicKey, req.AliPublicKey)
|
||||
s.mergeStringField(&config.AliNotifyURL, req.AliNotifyURL)
|
||||
s.mergeStringField(&config.AliReturnURL, req.AliReturnURL)
|
||||
if req.AliProduction != nil {
|
||||
config.AliProduction = *req.AliProduction
|
||||
}
|
||||
if req.AliPayExpireMinutes != nil && *req.AliPayExpireMinutes > 0 {
|
||||
config.AliPayExpireMinutes = *req.AliPayExpireMinutes
|
||||
}
|
||||
|
||||
config.Updater = middleware.GetUserIDFromContext(ctx)
|
||||
|
||||
if err := s.store.Update(ctx, config); err != nil {
|
||||
|
||||
@@ -150,3 +150,18 @@ func (s *PaymentStore) GetByOrderIDAndStatus(ctx context.Context, orderID uint,
|
||||
}
|
||||
return &payment, nil
|
||||
}
|
||||
|
||||
// FindLatestPendingByOrderAndMethod 按业务单、支付方式查找最新一条待支付记录
|
||||
// 用于支付宝支付单复用:同一业务单同一支付方式只允许一个有效 pending 支付单
|
||||
func (s *PaymentStore) FindLatestPendingByOrderAndMethod(ctx context.Context, orderID uint, orderType string, paymentMethod string) (*model.Payment, error) {
|
||||
var payment model.Payment
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("order_id = ? AND order_type = ? AND payment_method = ? AND status = ?",
|
||||
orderID, orderType, paymentMethod, model.PaymentRecordStatusPending).
|
||||
Order("created_at DESC").
|
||||
First(&payment).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &payment, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 回滚:删除新增的索引
|
||||
DROP INDEX IF EXISTS idx_tb_payment_order_method_status;
|
||||
|
||||
-- 回滚:删除 tb_payment 的 expire_at 字段
|
||||
ALTER TABLE tb_payment
|
||||
DROP COLUMN IF EXISTS expire_at;
|
||||
|
||||
-- 回滚:删除 tb_wechat_config 的支付宝字段
|
||||
ALTER TABLE tb_wechat_config
|
||||
DROP COLUMN IF EXISTS ali_app_id,
|
||||
DROP COLUMN IF EXISTS ali_private_key,
|
||||
DROP COLUMN IF EXISTS ali_public_key,
|
||||
DROP COLUMN IF EXISTS ali_notify_url,
|
||||
DROP COLUMN IF EXISTS ali_return_url,
|
||||
DROP COLUMN IF EXISTS ali_production,
|
||||
DROP COLUMN IF EXISTS ali_pay_expire_minutes;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 新增支付宝配置字段到 tb_wechat_config
|
||||
ALTER TABLE tb_wechat_config
|
||||
ADD COLUMN IF NOT EXISTS ali_app_id varchar(100) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS ali_private_key text NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS ali_public_key text NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS ali_notify_url varchar(500) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS ali_return_url varchar(500) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS ali_production boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS ali_pay_expire_minutes integer NOT NULL DEFAULT 30;
|
||||
|
||||
-- 新增支付单过期时间字段
|
||||
ALTER TABLE tb_payment
|
||||
ADD COLUMN IF NOT EXISTS expire_at timestamptz;
|
||||
|
||||
-- 新增索引:按业务单+支付方式+状态查询
|
||||
CREATE INDEX IF NOT EXISTS idx_tb_payment_order_method_status
|
||||
ON tb_payment (order_id, order_type, payment_method, status, created_at DESC);
|
||||
88
pkg/alipay/amount.go
Normal file
88
pkg/alipay/amount.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Package alipay 支付宝支付能力封装
|
||||
// 提供 client 构建、金额转换、WAP 支付链接生成、回调验签等辅助函数
|
||||
package alipay
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FenToYuan 将分(整数)转换为元字符串,精确到小数点后两位。
|
||||
// 例如:123 -> "1.23",100 -> "1.00",1 -> "0.01"
|
||||
func FenToYuan(amountFen int64) string {
|
||||
// 整除得元,取余得分
|
||||
yuan := amountFen / 100
|
||||
fen := amountFen % 100
|
||||
if fen < 0 {
|
||||
fen = -fen
|
||||
}
|
||||
return fmt.Sprintf("%d.%02d", yuan, fen)
|
||||
}
|
||||
|
||||
// YuanToFen 将元字符串转换为分(整数)。
|
||||
// 只接受合法金额格式:最多两位小数,纯数字加可选小数点,拒绝科学计数法和负数。
|
||||
// 例如:"1.23" -> 123,"1.00" -> 100,"1" -> 100
|
||||
func YuanToFen(value string) (int64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, fmt.Errorf("金额字符串不能为空")
|
||||
}
|
||||
// 禁止负数
|
||||
if strings.HasPrefix(value, "-") {
|
||||
return 0, fmt.Errorf("金额不能为负数: %s", value)
|
||||
}
|
||||
// 禁止科学计数法
|
||||
if strings.ContainsAny(value, "eE") {
|
||||
return 0, fmt.Errorf("金额格式非法,禁止科学计数法: %s", value)
|
||||
}
|
||||
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) > 2 {
|
||||
return 0, fmt.Errorf("金额格式非法: %s", value)
|
||||
}
|
||||
|
||||
// 校验整数部分
|
||||
yuanStr := parts[0]
|
||||
if !isDigits(yuanStr) {
|
||||
return 0, fmt.Errorf("金额格式非法: %s", value)
|
||||
}
|
||||
|
||||
// 解析整数部分
|
||||
var yuan int64
|
||||
for _, c := range yuanStr {
|
||||
yuan = yuan*10 + int64(c-'0')
|
||||
}
|
||||
|
||||
var fen int64
|
||||
if len(parts) == 2 {
|
||||
fenStr := parts[1]
|
||||
if len(fenStr) == 0 || len(fenStr) > 2 {
|
||||
return 0, fmt.Errorf("金额小数位数非法(最多两位): %s", value)
|
||||
}
|
||||
if !isDigits(fenStr) {
|
||||
return 0, fmt.Errorf("金额格式非法: %s", value)
|
||||
}
|
||||
// 补齐到两位
|
||||
if len(fenStr) == 1 {
|
||||
fenStr += "0"
|
||||
}
|
||||
for _, c := range fenStr {
|
||||
fen = fen*10 + int64(c-'0')
|
||||
}
|
||||
}
|
||||
|
||||
return yuan*100 + fen, nil
|
||||
}
|
||||
|
||||
// isDigits 判断字符串是否全为数字字符
|
||||
func isDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
36
pkg/alipay/client.go
Normal file
36
pkg/alipay/client.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package alipay
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/smartwalle/alipay/v3"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
)
|
||||
|
||||
// NewClientFromConfig 从 WechatConfig 构建支付宝 SDK client。
|
||||
// 校验 ali_app_id、ali_private_key、ali_public_key 必须非空。
|
||||
// ali_production=true 时使用生产环境,否则使用沙箱环境。
|
||||
func NewClientFromConfig(cfg *model.WechatConfig) (*alipay.Client, error) {
|
||||
if cfg.AliAppID == "" {
|
||||
return nil, fmt.Errorf("支付宝配置缺失:ali_app_id 不能为空")
|
||||
}
|
||||
if cfg.AliPrivateKey == "" {
|
||||
return nil, fmt.Errorf("支付宝配置缺失:ali_private_key 不能为空")
|
||||
}
|
||||
if cfg.AliPublicKey == "" {
|
||||
return nil, fmt.Errorf("支付宝配置缺失:ali_public_key 不能为空")
|
||||
}
|
||||
|
||||
client, err := alipay.New(cfg.AliAppID, cfg.AliPrivateKey, cfg.AliProduction)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建支付宝客户端失败: %w", err)
|
||||
}
|
||||
|
||||
// 加载支付宝公钥(公钥模式验签)
|
||||
if err := client.LoadAliPayPublicKey(cfg.AliPublicKey); err != nil {
|
||||
return nil, fmt.Errorf("加载支付宝公钥失败: %w", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
34
pkg/alipay/notify.go
Normal file
34
pkg/alipay/notify.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package alipay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/smartwalle/alipay/v3"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
)
|
||||
|
||||
// DecodeNotification 对支付宝异步回调进行验签并解析通知内容。
|
||||
// values 为原始 form body 构建的 url.Values,调用方负责解析请求体。
|
||||
// 验签使用创建支付单时的 WechatConfig 中的 ali_public_key。
|
||||
// 验签失败时返回 error,不推进业务。
|
||||
func DecodeNotification(ctx context.Context, cfg *model.WechatConfig, values url.Values) (*alipay.Notification, error) {
|
||||
client, err := NewClientFromConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构建支付宝客户端失败: %w", err)
|
||||
}
|
||||
|
||||
notification, err := client.DecodeNotification(ctx, values)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("支付宝回调验签失败: %w", err)
|
||||
}
|
||||
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
// TradeStatusSuccess 判断交易状态是否为支付成功(TRADE_SUCCESS 或 TRADE_FINISHED)
|
||||
func TradeStatusSuccess(status alipay.TradeStatus) bool {
|
||||
return status == alipay.TradeStatusSuccess || status == alipay.TradeStatusFinished
|
||||
}
|
||||
47
pkg/alipay/wap.go
Normal file
47
pkg/alipay/wap.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package alipay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/smartwalle/alipay/v3"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
)
|
||||
|
||||
// BuildWapPayURL 生成支付宝手机网站支付 URL。
|
||||
// payment.PaymentNo 作为 out_trade_no,payment.Amount(分)转为元字符串,
|
||||
// payment.ExpireAt 若非空则转换为支付宝 TimeExpire 格式(yyyy-MM-dd HH:mm)。
|
||||
// 返回签名后的完整 WAP 支付 URL,qr_link 与 copy_link 可使用同一个 URL。
|
||||
func BuildWapPayURL(ctx context.Context, cfg *model.WechatConfig, payment *model.Payment, subject string) (string, error) {
|
||||
client, err := NewClientFromConfig(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
totalAmount := FenToYuan(payment.Amount)
|
||||
|
||||
param := alipay.TradeWapPay{
|
||||
Trade: alipay.Trade{
|
||||
NotifyURL: cfg.AliNotifyURL,
|
||||
ReturnURL: cfg.AliReturnURL,
|
||||
Subject: subject,
|
||||
OutTradeNo: payment.PaymentNo,
|
||||
TotalAmount: totalAmount,
|
||||
ProductCode: "QUICK_WAP_WAY",
|
||||
},
|
||||
}
|
||||
|
||||
// 设置支付链接过期时间
|
||||
if payment.ExpireAt != nil && payment.ExpireAt.After(time.Now()) {
|
||||
param.TimeExpire = payment.ExpireAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
payURL, err := client.TradeWapPay(param)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成支付宝 WAP 支付链接失败: %w", err)
|
||||
}
|
||||
|
||||
return payURL.String(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user