Compare commits

10 Commits

Author SHA1 Message Date
2adbc87a52 Merge commit 'a03ba5d39645948f1c48ca0cfccc74c6edd1ccbf'
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m14s
2026-05-22 14:20:21 +08:00
6e564d1d1a Merge commit '599289a94ee0b8f36a09711122303a5bfacb265a' 2026-05-22 14:20:21 +08:00
3d28e29eaa Merge commit '58bdb2f18e5dd4eef84467961a1b55f3434b9ee5' 2026-05-22 14:20:20 +08:00
0948494b1c task: 修复支付宝C端入口构建失败
移除不存在的 alipay.CalcExpireAt 调用,改为按 cfg.AliPayExpireMinutes(默认 30 分钟)
内联计算 expireAt;将所有 BuildWapPayURL 调用从 5 参数改为 4 参数(移除 returnURL)。
修复范围:getOrBuildAlipayPaymentLink、createAlipayForceRechargeOrder、createAlipayRecharge。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:18:21 +08:00
599289a94e task: 修复支付宝C端入口构建失败
移除不存在的 alipay.CalcExpireAt 调用,改为按 cfg.AliPayExpireMinutes(默认 30 分钟)
内联计算 expireAt;将所有 BuildWapPayURL 调用从 5 参数改为 4 参数(移除 returnURL)。
修复范围:getOrBuildAlipayPaymentLink、createAlipayForceRechargeOrder、createAlipayRecharge。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:13:46 +08:00
b988f99a95 task: 完成支付宝回调安全(验签、多维度校验、业务分发) 2026-05-22 14:07:20 +08:00
56adc30409 task: 完成支付宝C端入口
新增支付宝支付方式支持:
- DTO:ClientPaymentLink、ClientPayOrderRequest/Response 增加 alipay;ClientCreateOrderRequest 增加 payment_method;ClientCreateOrderResponse 增加 payment_link;ClientCreateRechargeRequest 允许 alipay,app_type 改为微信必填;ClientRechargeResponse PayConfig 改为可选指针,增加 payment_link
- Service(client_order):PayOrder 新增 alipay 分支(不需要 app_type/OpenID,复用或新建 pending payment,生成 WAP URL);CreateOrder 强充路径按 payment_method 分支,alipay 不需要 app_type/OpenID;幂等命中时支付宝强充返回同一充值单对应的 payment_link;新增 getOrBuildAlipayPaymentLink(过期 payment 自动续建);新增 createAlipayForceRechargeOrder(事务创建充值单+支付单,WAP URL 生成失败则标记 failed)
- Handler(client_wallet):CreateRecharge 移除微信唯一限制,按 payment_method 分发到 createWechatRecharge / createAlipayRecharge;alipay 分支事务内创建充值单+支付单,然后生成 WAP URL,失败标记 payment failed
- 依赖 worker-1 共享 API:pkg/alipay.BuildWapPayURL/CalcExpireAt、Payment.ExpireAt、WechatConfig.AliReturnURL/AliPayExpireMinutes、PaymentStore.FindLatestPendingByOrderAndMethod;本 worktree 暂不能独立构建,待 leader 集成

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:07:20 +08:00
53e95751b1 task: 完成支付宝共享基础(迁移/WechatConfig/PaymentStore/pkg/alipay) 2026-05-22 14:07:20 +08:00
58bdb2f18e task: 完成支付宝共享基础(迁移/WechatConfig/PaymentStore/pkg/alipay) 2026-05-22 12:09:55 +08:00
a03ba5d396 task: 完成支付宝回调安全(验签、多维度校验、业务分发) 2026-05-22 12:09:50 +08:00
18 changed files with 910 additions and 77 deletions

4
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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 充值记录列表

View File

@@ -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")

View File

@@ -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 订单支付文档参数(路径参数 + 请求体,仅供文档生成器使用)

View File

@@ -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 充值信息

View File

@@ -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"),
}

View File

@@ -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"`

View File

@@ -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 指定表名

View File

@@ -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, "不支持的支付方式")
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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
View 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
View 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
View 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
View 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_nopayment.Amount转为元字符串
// payment.ExpireAt 若非空则转换为支付宝 TimeExpire 格式yyyy-MM-dd HH:mm
// 返回签名后的完整 WAP 支付 URLqr_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
}