From 53e95751b103bf16115f1750a5a55ecb56e125a5 Mon Sep 17 00:00:00 2001 From: huang Date: Fri, 22 May 2026 12:09:55 +0800 Subject: [PATCH] =?UTF-8?q?task:=20=E5=AE=8C=E6=88=90=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E5=AE=9D=E5=85=B1=E4=BA=AB=E5=9F=BA=E7=A1=80=EF=BC=88=E8=BF=81?= =?UTF-8?q?=E7=A7=BB/WechatConfig/PaymentStore/pkg/alipay=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 4 + go.sum | 8 ++ internal/model/dto/wechat_config_dto.go | 43 ++++++++- internal/model/payment.go | 1 + internal/model/wechat_config.go | 12 +++ internal/service/wechat_config/service.go | 25 ++++++ internal/store/postgres/payment_store.go | 15 ++++ ..._alipay_config_and_payment_expire.down.sql | 16 ++++ ...dd_alipay_config_and_payment_expire.up.sql | 17 ++++ pkg/alipay/amount.go | 88 +++++++++++++++++++ pkg/alipay/client.go | 36 ++++++++ pkg/alipay/notify.go | 34 +++++++ pkg/alipay/wap.go | 47 ++++++++++ 13 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 migrations/000146_add_alipay_config_and_payment_expire.down.sql create mode 100644 migrations/000146_add_alipay_config_and_payment_expire.up.sql create mode 100644 pkg/alipay/amount.go create mode 100644 pkg/alipay/client.go create mode 100644 pkg/alipay/notify.go create mode 100644 pkg/alipay/wap.go diff --git a/go.mod b/go.mod index 070e6ed..3e90e04 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 5a0a6d2..4bb1395 100644 --- a/go.sum +++ b/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= diff --git a/internal/model/dto/wechat_config_dto.go b/internal/model/dto/wechat_config_dto.go index 2cee1da..de584b9 100644 --- a/internal/model/dto/wechat_config_dto.go +++ b/internal/model/dto/wechat_config_dto.go @@ -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"), } diff --git a/internal/model/payment.go b/internal/model/payment.go index 1fe26bc..de4eb16 100644 --- a/internal/model/payment.go +++ b/internal/model/payment.go @@ -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"` diff --git a/internal/model/wechat_config.go b/internal/model/wechat_config.go index 1f374f7..5b70dd2 100644 --- a/internal/model/wechat_config.go +++ b/internal/model/wechat_config.go @@ -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 指定表名 diff --git a/internal/service/wechat_config/service.go b/internal/service/wechat_config/service.go index 42f1d59..f351b89 100644 --- a/internal/service/wechat_config/service.go +++ b/internal/service/wechat_config/service.go @@ -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 { diff --git a/internal/store/postgres/payment_store.go b/internal/store/postgres/payment_store.go index 698e4df..8ebe254 100644 --- a/internal/store/postgres/payment_store.go +++ b/internal/store/postgres/payment_store.go @@ -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 +} diff --git a/migrations/000146_add_alipay_config_and_payment_expire.down.sql b/migrations/000146_add_alipay_config_and_payment_expire.down.sql new file mode 100644 index 0000000..025a195 --- /dev/null +++ b/migrations/000146_add_alipay_config_and_payment_expire.down.sql @@ -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; diff --git a/migrations/000146_add_alipay_config_and_payment_expire.up.sql b/migrations/000146_add_alipay_config_and_payment_expire.up.sql new file mode 100644 index 0000000..f83c148 --- /dev/null +++ b/migrations/000146_add_alipay_config_and_payment_expire.up.sql @@ -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); diff --git a/pkg/alipay/amount.go b/pkg/alipay/amount.go new file mode 100644 index 0000000..170960d --- /dev/null +++ b/pkg/alipay/amount.go @@ -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 +} diff --git a/pkg/alipay/client.go b/pkg/alipay/client.go new file mode 100644 index 0000000..9217e08 --- /dev/null +++ b/pkg/alipay/client.go @@ -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 +} diff --git a/pkg/alipay/notify.go b/pkg/alipay/notify.go new file mode 100644 index 0000000..6907a7c --- /dev/null +++ b/pkg/alipay/notify.go @@ -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 +} diff --git a/pkg/alipay/wap.go b/pkg/alipay/wap.go new file mode 100644 index 0000000..fd7e516 --- /dev/null +++ b/pkg/alipay/wap.go @@ -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 +}