task: 完成支付宝共享基础(迁移/WechatConfig/PaymentStore/pkg/alipay)

This commit is contained in:
2026-05-22 12:09:55 +08:00
parent 2768deb0b6
commit 58bdb2f18e
13 changed files with 342 additions and 4 deletions

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
}