Merge branch 'emdash/wechat-official-account-payment-integration-30g'

# Conflicts:
#	README.md
#	cmd/api/main.go
#	internal/bootstrap/dependencies.go
#	pkg/config/config.go
#	pkg/config/defaults/config.yaml
This commit is contained in:
2026-01-30 17:32:33 +08:00
48 changed files with 9034 additions and 378 deletions

View File

@@ -23,7 +23,11 @@ type Config struct {
JWT JWTConfig `mapstructure:"jwt"`
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
Storage StorageConfig `mapstructure:"storage"`
<<<<<<< HEAD
Gateway GatewayConfig `mapstructure:"gateway"`
=======
Wechat WechatConfig `mapstructure:"wechat"`
>>>>>>> emdash/wechat-official-account-payment-integration-30g
}
// ServerConfig HTTP 服务器配置
@@ -156,6 +160,35 @@ type PresignConfig struct {
DownloadExpires time.Duration `mapstructure:"download_expires"` // 下载 URL 有效期默认24h
}
// WechatConfig 微信配置
type WechatConfig struct {
OfficialAccount OfficialAccountConfig `mapstructure:"official_account"`
Payment PaymentConfig `mapstructure:"payment"`
}
// OfficialAccountConfig 微信公众号配置
type OfficialAccountConfig struct {
AppID string `mapstructure:"app_id"`
AppSecret string `mapstructure:"app_secret"`
Token string `mapstructure:"token"`
AESKey string `mapstructure:"aes_key"`
OAuthRedirectURL string `mapstructure:"oauth_redirect_url"`
}
// PaymentConfig 微信支付配置
type PaymentConfig struct {
AppID string `mapstructure:"app_id"`
MchID string `mapstructure:"mch_id"`
APIV3Key string `mapstructure:"api_v3_key"`
APIV2Key string `mapstructure:"api_v2_key"`
CertPath string `mapstructure:"cert_path"`
KeyPath string `mapstructure:"key_path"`
SerialNo string `mapstructure:"serial_no"`
NotifyURL string `mapstructure:"notify_url"`
HttpDebug bool `mapstructure:"http_debug"`
Timeout time.Duration `mapstructure:"timeout"`
}
type requiredField struct {
value string
name string

View File

@@ -105,9 +105,31 @@ default_admin:
password: ""
phone: ""
<<<<<<< HEAD
# Gateway 服务配置
gateway:
base_url: "https://lplan.whjhft.com/openapi"
app_id: "60bgt1X8i7AvXqkd"
app_secret: "BZeQttaZQt0i73moF"
timeout: 30
=======
# 微信配置(必填项需通过环境变量设置)
wechat:
official_account:
app_id: "" # 必填JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID
app_secret: "" # 必填JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET敏感
token: "" # 可选JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN
aes_key: "" # 可选JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY敏感
oauth_redirect_url: "" # 可选JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL
payment:
app_id: "" # 必填JUNHONG_WECHAT_PAYMENT_APP_ID
mch_id: "" # 必填JUNHONG_WECHAT_PAYMENT_MCH_ID
api_v3_key: "" # 必填JUNHONG_WECHAT_PAYMENT_API_V3_KEY敏感
api_v2_key: "" # 可选JUNHONG_WECHAT_PAYMENT_API_V2_KEY敏感
cert_path: "" # 必填JUNHONG_WECHAT_PAYMENT_CERT_PATH证书文件路径
key_path: "" # 必填JUNHONG_WECHAT_PAYMENT_KEY_PATH私钥文件路径
serial_no: "" # 必填JUNHONG_WECHAT_PAYMENT_SERIAL_NO
notify_url: "" # 必填JUNHONG_WECHAT_PAYMENT_NOTIFY_URL
http_debug: false
timeout: "30s"
>>>>>>> emdash/wechat-official-account-payment-integration-30g

View File

@@ -39,10 +39,14 @@ const (
CodePermAlreadyAssigned = 1027 // 权限已分配
// 认证相关错误 (1040-1049)
CodeInvalidCredentials = 1040 // 用户名或密码错误
CodeAccountLocked = 1041 // 账号已锁定
CodePasswordExpired = 1042 // 密码已过期
CodeInvalidOldPassword = 1043 // 旧密码错误
CodeInvalidCredentials = 1040 // 用户名或密码错误
CodeAccountLocked = 1041 // 账号已锁定
CodePasswordExpired = 1042 // 密码已过期
CodeInvalidOldPassword = 1043 // 旧密码错误
CodeWechatOAuthFailed = 1044 // 微信 OAuth 授权失败
CodeWechatUserInfoFailed = 1045 // 获取微信用户信息失败
CodeWechatPayFailed = 1046 // 微信支付发起失败
CodeWechatCallbackInvalid = 1047 // 微信回调签名验证失败
// 组织相关错误 (1030-1049)
CodeShopNotFound = 1030 // 店铺不存在
@@ -150,6 +154,10 @@ var allErrorCodes = []int{
CodeAccountLocked,
CodePasswordExpired,
CodeInvalidOldPassword,
CodeWechatOAuthFailed,
CodeWechatUserInfoFailed,
CodeWechatPayFailed,
CodeWechatCallbackInvalid,
CodeInvalidStatus,
CodeInsufficientBalance,
CodeWithdrawalNotFound,
@@ -279,6 +287,10 @@ var errorMessages = map[int]string{
CodeAccountLocked: "账号已锁定",
CodePasswordExpired: "密码已过期",
CodeInvalidOldPassword: "旧密码错误",
CodeWechatOAuthFailed: "微信授权失败",
CodeWechatUserInfoFailed: "获取微信用户信息失败",
CodeWechatPayFailed: "微信支付发起失败",
CodeWechatCallbackInvalid: "微信回调验证失败",
CodeInternalError: "内部服务器错误",
CodeDatabaseError: "数据库错误",
CodeRedisError: "缓存服务错误",

View File

@@ -44,6 +44,6 @@ func BuildDocHandlers() *bootstrap.Handlers {
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil),
AdminOrder: admin.NewOrderHandler(nil),
H5Order: h5.NewOrderHandler(nil),
PaymentCallback: callback.NewPaymentHandler(nil),
PaymentCallback: callback.NewPaymentHandler(nil, nil),
}
}

85
pkg/wechat/config.go Normal file
View File

@@ -0,0 +1,85 @@
package wechat
import (
"fmt"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
"github.com/ArtisanCloud/PowerWeChat/v3/src/officialAccount"
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// NewRedisCache 使用项目现有的 Redis 客户端创建 PowerWeChat 的 Redis Cache
func NewRedisCache(rdb *redis.Client) kernel.CacheInterface {
return kernel.NewRedisClient(&kernel.UniversalOptions{
Addrs: []string{rdb.Options().Addr},
Password: rdb.Options().Password,
DB: rdb.Options().DB,
})
}
// NewOfficialAccountApp 创建微信公众号应用实例
func NewOfficialAccountApp(cfg *config.Config, cache kernel.CacheInterface, logger *zap.Logger) (*officialAccount.OfficialAccount, error) {
oaCfg := cfg.Wechat.OfficialAccount
if oaCfg.AppID == "" || oaCfg.AppSecret == "" {
return nil, fmt.Errorf("微信公众号配置不完整:缺少 AppID 或 AppSecret")
}
userConfig := &officialAccount.UserConfig{
AppID: oaCfg.AppID,
Secret: oaCfg.AppSecret,
Cache: cache,
}
// 可选配置:消息验证 Token 和 AESKey
if oaCfg.Token != "" {
userConfig.Token = oaCfg.Token
}
if oaCfg.AESKey != "" {
userConfig.AESKey = oaCfg.AESKey
}
app, err := officialAccount.NewOfficialAccount(userConfig)
if err != nil {
logger.Error("创建微信公众号应用失败", zap.Error(err))
return nil, fmt.Errorf("创建微信公众号应用失败: %w", err)
}
logger.Info("微信公众号应用初始化成功", zap.String("app_id", oaCfg.AppID))
return app, nil
}
// NewPaymentApp 创建微信支付应用实例
func NewPaymentApp(cfg *config.Config, cache kernel.CacheInterface, logger *zap.Logger) (*payment.Payment, error) {
payCfg := cfg.Wechat.Payment
if payCfg.AppID == "" || payCfg.MchID == "" {
return nil, fmt.Errorf("微信支付配置不完整:缺少 AppID 或 MchID")
}
userConfig := &payment.UserConfig{
AppID: payCfg.AppID,
MchID: payCfg.MchID,
MchApiV3Key: payCfg.APIV3Key,
Key: payCfg.APIV2Key,
CertPath: payCfg.CertPath,
KeyPath: payCfg.KeyPath,
SerialNo: payCfg.SerialNo,
NotifyURL: payCfg.NotifyURL,
HttpDebug: payCfg.HttpDebug,
Cache: cache,
}
app, err := payment.NewPayment(userConfig)
if err != nil {
logger.Error("创建微信支付应用失败", zap.Error(err))
return nil, fmt.Errorf("创建微信支付应用失败: %w", err)
}
logger.Info("微信支付应用初始化成功",
zap.String("app_id", payCfg.AppID),
zap.String("mch_id", payCfg.MchID),
)
return app, nil
}

View File

@@ -1,25 +0,0 @@
package wechat
import (
"context"
"fmt"
)
// MockService Mock 微信服务实现(用于开发和测试)
type MockService struct{}
// NewMockService 创建 Mock 微信服务
func NewMockService() *MockService {
return &MockService{}
}
// GetUserInfo Mock 实现:通过授权码获取用户信息
// 注意:这是一个 Mock 实现,实际生产环境需要对接微信 OAuth API
func (s *MockService) GetUserInfo(ctx context.Context, code string) (string, string, error) {
// TODO: 实际实现需要调用微信 OAuth2.0 接口
// 1. 使用 code 换取 access_token
// 2. 使用 access_token 获取用户信息
// 参考文档: https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html
return "", "", fmt.Errorf("微信服务暂未实现,待对接微信 SDK")
}

91
pkg/wechat/mock_test.go Normal file
View File

@@ -0,0 +1,91 @@
package wechat
import (
"context"
"net/http"
)
// MockOfficialAccountService Mock 微信公众号服务(实现 OfficialAccountServiceInterface
type MockOfficialAccountService struct {
GetUserInfoFn func(ctx context.Context, code string) (openID, unionID string, err error)
GetUserInfoDetailedFn func(ctx context.Context, code string) (*UserInfo, error)
GetUserInfoByTokenFn func(ctx context.Context, accessToken, openID string) (*UserInfo, error)
}
// GetUserInfo Mock 实现
func (m *MockOfficialAccountService) GetUserInfo(ctx context.Context, code string) (openID, unionID string, err error) {
if m.GetUserInfoFn != nil {
return m.GetUserInfoFn(ctx, code)
}
return "", "", nil
}
// GetUserInfoDetailed Mock 实现
func (m *MockOfficialAccountService) GetUserInfoDetailed(ctx context.Context, code string) (*UserInfo, error) {
if m.GetUserInfoDetailedFn != nil {
return m.GetUserInfoDetailedFn(ctx, code)
}
return nil, nil
}
// GetUserInfoByToken Mock 实现
func (m *MockOfficialAccountService) GetUserInfoByToken(ctx context.Context, accessToken, openID string) (*UserInfo, error) {
if m.GetUserInfoByTokenFn != nil {
return m.GetUserInfoByTokenFn(ctx, accessToken, openID)
}
return nil, nil
}
// MockPaymentService Mock 微信支付服务(实现 PaymentServiceInterface
type MockPaymentService struct {
CreateJSAPIOrderFn func(ctx context.Context, orderNo, description, openID string, amount int) (*JSAPIPayResult, error)
CreateH5OrderFn func(ctx context.Context, orderNo, description string, amount int, sceneInfo *H5SceneInfo) (*H5PayResult, error)
QueryOrderFn func(ctx context.Context, orderNo string) (*OrderInfo, error)
CloseOrderFn func(ctx context.Context, orderNo string) error
HandlePaymentNotifyFn func(r *http.Request, callback PaymentNotifyCallback) (*http.Response, error)
}
// CreateJSAPIOrder Mock 实现
func (m *MockPaymentService) CreateJSAPIOrder(ctx context.Context, orderNo, description, openID string, amount int) (*JSAPIPayResult, error) {
if m.CreateJSAPIOrderFn != nil {
return m.CreateJSAPIOrderFn(ctx, orderNo, description, openID, amount)
}
return nil, nil
}
// CreateH5Order Mock 实现
func (m *MockPaymentService) CreateH5Order(ctx context.Context, orderNo, description string, amount int, sceneInfo *H5SceneInfo) (*H5PayResult, error) {
if m.CreateH5OrderFn != nil {
return m.CreateH5OrderFn(ctx, orderNo, description, amount, sceneInfo)
}
return nil, nil
}
// QueryOrder Mock 实现
func (m *MockPaymentService) QueryOrder(ctx context.Context, orderNo string) (*OrderInfo, error) {
if m.QueryOrderFn != nil {
return m.QueryOrderFn(ctx, orderNo)
}
return nil, nil
}
// CloseOrder Mock 实现
func (m *MockPaymentService) CloseOrder(ctx context.Context, orderNo string) error {
if m.CloseOrderFn != nil {
return m.CloseOrderFn(ctx, orderNo)
}
return nil
}
// HandlePaymentNotify Mock 实现(简化版)
func (m *MockPaymentService) HandlePaymentNotify(r *http.Request, callback PaymentNotifyCallback) (*http.Response, error) {
if m.HandlePaymentNotifyFn != nil {
return m.HandlePaymentNotifyFn(r, callback)
}
return &http.Response{StatusCode: 200}, nil
}
var (
_ OfficialAccountServiceInterface = (*MockOfficialAccountService)(nil)
_ PaymentServiceInterface = (*MockPaymentService)(nil)
)

View File

@@ -0,0 +1,185 @@
package wechat
import (
"context"
"github.com/ArtisanCloud/PowerWeChat/v3/src/officialAccount"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"go.uber.org/zap"
)
// OfficialAccountService 微信公众号服务实现
type OfficialAccountService struct {
app *officialAccount.OfficialAccount
logger *zap.Logger
}
// NewOfficialAccountService 创建微信公众号服务
func NewOfficialAccountService(app *officialAccount.OfficialAccount, logger *zap.Logger) *OfficialAccountService {
return &OfficialAccountService{
app: app,
logger: logger,
}
}
// GetUserInfo 通过授权码获取用户基本信息(静默授权)
// 返回 OpenID 和 UnionID如果有
func (s *OfficialAccountService) GetUserInfo(ctx context.Context, code string) (openID, unionID string, err error) {
if code == "" {
return "", "", errors.New(errors.CodeInvalidParam, "授权码不能为空")
}
// 设置为静默授权模式snsapi_base只能获取 OpenID
s.app.OAuth.SetScopes([]string{"snsapi_base"})
user, err := s.app.OAuth.UserFromCode(code)
if err != nil {
s.logger.Error("微信 OAuth 授权失败",
zap.String("code", code),
zap.Error(err),
)
return "", "", errors.Wrap(errors.CodeWechatOAuthFailed, err)
}
if user == nil {
s.logger.Error("微信 OAuth 返回空用户信息", zap.String("code", code))
return "", "", errors.New(errors.CodeWechatOAuthFailed, "获取用户信息失败")
}
openID = user.GetOpenID()
// 从原始数据中获取 UnionID
raw, _ := user.GetRaw()
if raw != nil {
if uid, ok := (*raw)["unionid"].(string); ok {
unionID = uid
}
}
s.logger.Debug("微信 OAuth 授权成功",
zap.String("open_id", openID),
zap.String("union_id", unionID),
)
return openID, unionID, nil
}
// GetUserInfoDetailed 通过授权码获取用户详细信息(用户授权)
// 需要用户点击授权,可以获取昵称、头像等信息
func (s *OfficialAccountService) GetUserInfoDetailed(ctx context.Context, code string) (*UserInfo, error) {
if code == "" {
return nil, errors.New(errors.CodeInvalidParam, "授权码不能为空")
}
// 设置为用户信息授权模式snsapi_userinfo
s.app.OAuth.SetScopes([]string{"snsapi_userinfo"})
user, err := s.app.OAuth.UserFromCode(code)
if err != nil {
s.logger.Error("微信 OAuth 授权失败",
zap.String("code", code),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeWechatOAuthFailed, err)
}
if user == nil {
s.logger.Error("微信 OAuth 返回空用户信息", zap.String("code", code))
return nil, errors.New(errors.CodeWechatOAuthFailed, "获取用户信息失败")
}
raw, _ := user.GetRaw()
userInfo := &UserInfo{
OpenID: user.GetOpenID(),
}
if raw != nil {
if uid, ok := (*raw)["unionid"].(string); ok {
userInfo.UnionID = uid
}
if nickname, ok := (*raw)["nickname"].(string); ok {
userInfo.Nickname = nickname
}
if headimgurl, ok := (*raw)["headimgurl"].(string); ok {
userInfo.Avatar = headimgurl
}
if sex, ok := (*raw)["sex"].(float64); ok {
userInfo.Sex = int(sex)
}
if province, ok := (*raw)["province"].(string); ok {
userInfo.Province = province
}
if city, ok := (*raw)["city"].(string); ok {
userInfo.City = city
}
if country, ok := (*raw)["country"].(string); ok {
userInfo.Country = country
}
}
s.logger.Debug("微信 OAuth 获取用户详细信息成功",
zap.String("open_id", userInfo.OpenID),
zap.String("nickname", userInfo.Nickname),
)
return userInfo, nil
}
// GetUserInfoByToken 通过 AccessToken 和 OpenID 获取用户详细信息
func (s *OfficialAccountService) GetUserInfoByToken(ctx context.Context, accessToken, openID string) (*UserInfo, error) {
if accessToken == "" || openID == "" {
return nil, errors.New(errors.CodeInvalidParam, "AccessToken 和 OpenID 不能为空")
}
user, err := s.app.OAuth.UserFromToken(accessToken, openID)
if err != nil {
s.logger.Error("通过 Token 获取微信用户信息失败",
zap.String("open_id", openID),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeWechatUserInfoFailed, err)
}
if user == nil {
s.logger.Error("微信返回空用户信息", zap.String("open_id", openID))
return nil, errors.New(errors.CodeWechatUserInfoFailed, "获取用户信息失败")
}
raw, _ := user.GetRaw()
userInfo := &UserInfo{
OpenID: user.GetOpenID(),
}
if raw != nil {
if uid, ok := (*raw)["unionid"].(string); ok {
userInfo.UnionID = uid
}
if nickname, ok := (*raw)["nickname"].(string); ok {
userInfo.Nickname = nickname
}
if headimgurl, ok := (*raw)["headimgurl"].(string); ok {
userInfo.Avatar = headimgurl
}
if sex, ok := (*raw)["sex"].(float64); ok {
userInfo.Sex = int(sex)
}
if province, ok := (*raw)["province"].(string); ok {
userInfo.Province = province
}
if city, ok := (*raw)["city"].(string); ok {
userInfo.City = city
}
if country, ok := (*raw)["country"].(string); ok {
userInfo.Country = country
}
}
s.logger.Debug("通过 Token 获取微信用户详细信息成功",
zap.String("open_id", userInfo.OpenID),
zap.String("nickname", userInfo.Nickname),
)
return userInfo, nil
}

View File

@@ -0,0 +1,76 @@
package wechat
import (
"context"
"testing"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestOfficialAccountService_ParameterValidation(t *testing.T) {
logger := zap.NewNop()
mockSvc := &MockOfficialAccountService{}
t.Run("GetUserInfo_空授权码", func(t *testing.T) {
mockSvc.GetUserInfoFn = func(ctx context.Context, code string) (string, string, error) {
if code == "" {
return "", "", errors.New(errors.CodeInvalidParam, "授权码不能为空")
}
return "openid_123", "unionid_123", nil
}
openID, unionID, err := mockSvc.GetUserInfo(context.Background(), "")
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
assert.Empty(t, openID)
assert.Empty(t, unionID)
})
t.Run("GetUserInfo_成功", func(t *testing.T) {
mockSvc.GetUserInfoFn = func(ctx context.Context, code string) (string, string, error) {
return "openid_123", "unionid_123", nil
}
openID, unionID, err := mockSvc.GetUserInfo(context.Background(), "valid_code")
require.NoError(t, err)
assert.Equal(t, "openid_123", openID)
assert.Equal(t, "unionid_123", unionID)
})
t.Run("GetUserInfoDetailed_空授权码", func(t *testing.T) {
mockSvc.GetUserInfoDetailedFn = func(ctx context.Context, code string) (*UserInfo, error) {
if code == "" {
return nil, errors.New(errors.CodeInvalidParam, "授权码不能为空")
}
return &UserInfo{OpenID: "openid_123"}, nil
}
userInfo, err := mockSvc.GetUserInfoDetailed(context.Background(), "")
require.Error(t, err)
assert.Nil(t, userInfo)
})
t.Run("GetUserInfoByToken_空参数", func(t *testing.T) {
mockSvc.GetUserInfoByTokenFn = func(ctx context.Context, accessToken, openID string) (*UserInfo, error) {
if accessToken == "" || openID == "" {
return nil, errors.New(errors.CodeInvalidParam, "AccessToken 和 OpenID 不能为空")
}
return &UserInfo{OpenID: openID}, nil
}
userInfo, err := mockSvc.GetUserInfoByToken(context.Background(), "", "openid_123")
require.Error(t, err)
assert.Nil(t, userInfo)
userInfo, err = mockSvc.GetUserInfoByToken(context.Background(), "token_123", "")
require.Error(t, err)
assert.Nil(t, userInfo)
})
_ = logger
}

282
pkg/wechat/payment.go Normal file
View File

@@ -0,0 +1,282 @@
package wechat
import (
"context"
"net/http"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/models"
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment"
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment/notify/request"
orderRequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/order/request"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"go.uber.org/zap"
)
// PaymentService 微信支付服务实现
type PaymentService struct {
app *payment.Payment
logger *zap.Logger
}
// NewPaymentService 创建微信支付服务
func NewPaymentService(app *payment.Payment, logger *zap.Logger) *PaymentService {
return &PaymentService{
app: app,
logger: logger,
}
}
// JSAPIPayResult JSAPI 支付结果
type JSAPIPayResult struct {
PrepayID string `json:"prepay_id"`
PayConfig interface{} `json:"pay_config"`
}
// H5PayResult H5 支付结果
type H5PayResult struct {
H5URL string `json:"h5_url"`
}
// OrderInfo 订单信息
type OrderInfo struct {
TransactionID string `json:"transaction_id"`
OutTradeNo string `json:"out_trade_no"`
TradeState string `json:"trade_state"`
TradeStateDesc string `json:"trade_state_desc"`
SuccessTime string `json:"success_time"`
TradeType string `json:"trade_type"`
BankType string `json:"bank_type"`
Attach string `json:"attach"`
PayerOpenID string `json:"payer_openid"`
TotalAmount int64 `json:"total_amount"`
PayerTotal int64 `json:"payer_total"`
Currency string `json:"currency"`
}
// PaymentNotifyResult 支付通知结果
type PaymentNotifyResult struct {
TransactionID string `json:"transaction_id"`
OutTradeNo string `json:"out_trade_no"`
TradeState string `json:"trade_state"`
SuccessTime string `json:"success_time"`
PayerOpenID string `json:"payer_openid"`
TotalAmount int64 `json:"total_amount"`
Attach string `json:"attach"`
}
// CreateJSAPIOrder 创建 JSAPI 支付订单
func (s *PaymentService) CreateJSAPIOrder(ctx context.Context, orderNo, description, openID string, amount int) (*JSAPIPayResult, error) {
if orderNo == "" || openID == "" || amount <= 0 {
return nil, errors.New(errors.CodeInvalidParam, "订单号、OpenID 和金额不能为空")
}
resp, err := s.app.Order.JSAPITransaction(ctx, &orderRequest.RequestJSAPIPrepay{
Description: description,
OutTradeNo: orderNo,
Amount: &orderRequest.JSAPIAmount{
Total: amount,
Currency: "CNY",
},
Payer: &orderRequest.JSAPIPayer{
OpenID: openID,
},
})
if err != nil {
s.logger.Error("创建 JSAPI 支付订单失败",
zap.String("order_no", orderNo),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeWechatPayFailed, err)
}
if resp == nil || resp.PrepayID == "" {
s.logger.Error("创建 JSAPI 支付订单失败:空 PrepayID", zap.String("order_no", orderNo))
return nil, errors.New(errors.CodeWechatPayFailed, "创建支付订单失败")
}
payConfig, err := s.app.JSSDK.BridgeConfig(resp.PrepayID, false)
if err != nil {
s.logger.Error("生成支付配置失败",
zap.String("order_no", orderNo),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeWechatPayFailed, err)
}
s.logger.Info("创建 JSAPI 支付订单成功",
zap.String("order_no", orderNo),
zap.String("prepay_id", resp.PrepayID),
)
return &JSAPIPayResult{
PrepayID: resp.PrepayID,
PayConfig: payConfig,
}, nil
}
// CreateH5Order 创建 H5 支付订单
func (s *PaymentService) CreateH5Order(ctx context.Context, orderNo, description string, amount int, sceneInfo *H5SceneInfo) (*H5PayResult, error) {
if orderNo == "" || amount <= 0 {
return nil, errors.New(errors.CodeInvalidParam, "订单号和金额不能为空")
}
req := &orderRequest.RequestH5Prepay{
Description: description,
OutTradeNo: orderNo,
Amount: &orderRequest.H5Amount{
Total: amount,
Currency: "CNY",
},
}
if sceneInfo != nil {
req.SceneInfo = &orderRequest.H5SceneInfo{
PayerClientIP: sceneInfo.PayerClientIP,
H5Info: &orderRequest.H5H5Info{
Type: sceneInfo.H5Type,
},
}
}
resp, err := s.app.Order.TransactionH5(ctx, req)
if err != nil {
s.logger.Error("创建 H5 支付订单失败",
zap.String("order_no", orderNo),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeWechatPayFailed, err)
}
if resp == nil || resp.H5URL == "" {
s.logger.Error("创建 H5 支付订单失败:空 H5URL", zap.String("order_no", orderNo))
return nil, errors.New(errors.CodeWechatPayFailed, "创建 H5 支付订单失败")
}
s.logger.Info("创建 H5 支付订单成功",
zap.String("order_no", orderNo),
zap.String("h5_url", resp.H5URL),
)
return &H5PayResult{
H5URL: resp.H5URL,
}, nil
}
// H5SceneInfo H5 支付场景信息
type H5SceneInfo struct {
PayerClientIP string `json:"payer_client_ip"`
H5Type string `json:"h5_type"`
}
// QueryOrder 查询订单
func (s *PaymentService) QueryOrder(ctx context.Context, orderNo string) (*OrderInfo, error) {
if orderNo == "" {
return nil, errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
resp, err := s.app.Order.QueryByOutTradeNumber(ctx, orderNo)
if err != nil {
s.logger.Error("查询订单失败",
zap.String("order_no", orderNo),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeWechatPayFailed, err)
}
if resp == nil {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
orderInfo := &OrderInfo{
TransactionID: resp.TransactionID,
OutTradeNo: resp.OutTradeNo,
TradeState: resp.TradeState,
TradeStateDesc: resp.TradeStateDesc,
SuccessTime: resp.SuccessTime,
TradeType: resp.TradeType,
BankType: resp.BankType,
Attach: resp.Attach,
}
if resp.Amount != nil {
orderInfo.TotalAmount = resp.Amount.Total
orderInfo.PayerTotal = resp.Amount.PayerTotal
orderInfo.Currency = resp.Amount.Currency
}
if resp.Payer != nil {
orderInfo.PayerOpenID = resp.Payer.OpenID
}
s.logger.Debug("查询订单成功",
zap.String("order_no", orderNo),
zap.String("trade_state", resp.TradeState),
)
return orderInfo, nil
}
// CloseOrder 关闭订单
func (s *PaymentService) CloseOrder(ctx context.Context, orderNo string) error {
if orderNo == "" {
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
_, err := s.app.Order.Close(ctx, orderNo)
if err != nil {
s.logger.Error("关闭订单失败",
zap.String("order_no", orderNo),
zap.Error(err),
)
return errors.Wrap(errors.CodeWechatPayFailed, err)
}
s.logger.Info("关闭订单成功", zap.String("order_no", orderNo))
return nil
}
// PaymentNotifyCallback 支付通知回调函数
type PaymentNotifyCallback func(result *PaymentNotifyResult) error
// HandlePaymentNotify 处理支付回调通知
func (s *PaymentService) HandlePaymentNotify(r *http.Request, callback PaymentNotifyCallback) (*http.Response, error) {
return s.app.HandlePaidNotify(r, func(notify *request.RequestNotify, transaction *models.Transaction, fail func(message string)) interface{} {
if transaction == nil {
s.logger.Error("支付通知数据为空")
fail("支付通知数据为空")
return nil
}
result := &PaymentNotifyResult{
OutTradeNo: transaction.OutTradeNo,
TradeState: transaction.TradeState,
SuccessTime: transaction.SuccessTime,
Attach: transaction.Attach,
}
result.TransactionID = transaction.TransactionID
if transaction.Payer != nil {
result.PayerOpenID = transaction.Payer.OpenID
}
if transaction.Amount != nil {
result.TotalAmount = transaction.Amount.Total
}
if err := callback(result); err != nil {
s.logger.Error("处理支付通知回调失败",
zap.String("out_trade_no", result.OutTradeNo),
zap.Error(err),
)
fail(err.Error())
return nil
}
s.logger.Info("支付通知处理成功",
zap.String("out_trade_no", result.OutTradeNo),
zap.String("transaction_id", result.TransactionID),
)
return true
})
}

View File

@@ -0,0 +1,93 @@
package wechat
import (
"context"
"testing"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestPaymentService_ParameterValidation(t *testing.T) {
logger := zap.NewNop()
mockSvc := &MockPaymentService{}
t.Run("CreateJSAPIOrder_参数验证", func(t *testing.T) {
mockSvc.CreateJSAPIOrderFn = func(ctx context.Context, orderNo, description, openID string, amount int) (*JSAPIPayResult, error) {
if orderNo == "" || openID == "" || amount <= 0 {
return nil, errors.New(errors.CodeInvalidParam, "订单号、OpenID 和金额不能为空")
}
return &JSAPIPayResult{PrepayID: "prepay_id_123"}, nil
}
_, err := mockSvc.CreateJSAPIOrder(context.Background(), "", "desc", "openid", 100)
require.Error(t, err)
_, err = mockSvc.CreateJSAPIOrder(context.Background(), "order_123", "desc", "", 100)
require.Error(t, err)
_, err = mockSvc.CreateJSAPIOrder(context.Background(), "order_123", "desc", "openid", 0)
require.Error(t, err)
result, err := mockSvc.CreateJSAPIOrder(context.Background(), "order_123", "desc", "openid", 100)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "prepay_id_123", result.PrepayID)
})
t.Run("CreateH5Order_参数验证", func(t *testing.T) {
mockSvc.CreateH5OrderFn = func(ctx context.Context, orderNo, description string, amount int, sceneInfo *H5SceneInfo) (*H5PayResult, error) {
if orderNo == "" || amount <= 0 {
return nil, errors.New(errors.CodeInvalidParam, "订单号和金额不能为空")
}
return &H5PayResult{H5URL: "https://wx.tenpay.com/..."}, nil
}
_, err := mockSvc.CreateH5Order(context.Background(), "", "desc", 100, nil)
require.Error(t, err)
_, err = mockSvc.CreateH5Order(context.Background(), "order_123", "desc", 0, nil)
require.Error(t, err)
result, err := mockSvc.CreateH5Order(context.Background(), "order_123", "desc", 100, nil)
require.NoError(t, err)
assert.NotNil(t, result)
assert.NotEmpty(t, result.H5URL)
})
t.Run("QueryOrder_参数验证", func(t *testing.T) {
mockSvc.QueryOrderFn = func(ctx context.Context, orderNo string) (*OrderInfo, error) {
if orderNo == "" {
return nil, errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
return &OrderInfo{OutTradeNo: orderNo}, nil
}
_, err := mockSvc.QueryOrder(context.Background(), "")
require.Error(t, err)
result, err := mockSvc.QueryOrder(context.Background(), "order_123")
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "order_123", result.OutTradeNo)
})
t.Run("CloseOrder_参数验证", func(t *testing.T) {
mockSvc.CloseOrderFn = func(ctx context.Context, orderNo string) error {
if orderNo == "" {
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
return nil
}
err := mockSvc.CloseOrder(context.Background(), "")
require.Error(t, err)
err = mockSvc.CloseOrder(context.Background(), "order_123")
require.NoError(t, err)
})
_ = logger
}

View File

@@ -1,21 +1,46 @@
package wechat
import "context"
import (
"context"
"net/http"
)
// Service 微信服务接口
// Service 微信服务接口(向后兼容)
type Service interface {
// GetUserInfo 通过授权码获取用户信息
GetUserInfo(ctx context.Context, code string) (openID, unionID string, err error)
}
// OfficialAccountServiceInterface 微信公众号服务接口
type OfficialAccountServiceInterface interface {
Service
GetUserInfoDetailed(ctx context.Context, code string) (*UserInfo, error)
GetUserInfoByToken(ctx context.Context, accessToken, openID string) (*UserInfo, error)
}
// PaymentServiceInterface 微信支付服务接口
type PaymentServiceInterface interface {
CreateJSAPIOrder(ctx context.Context, orderNo, description, openID string, amount int) (*JSAPIPayResult, error)
CreateH5Order(ctx context.Context, orderNo, description string, amount int, sceneInfo *H5SceneInfo) (*H5PayResult, error)
QueryOrder(ctx context.Context, orderNo string) (*OrderInfo, error)
CloseOrder(ctx context.Context, orderNo string) error
HandlePaymentNotify(r *http.Request, callback PaymentNotifyCallback) (*http.Response, error)
}
// UserInfo 微信用户信息
type UserInfo struct {
OpenID string `json:"open_id"` // 微信 OpenID
UnionID string `json:"union_id"` // 微信 UnionID开放平台统一ID
Nickname string `json:"nickname"` // 昵称
Avatar string `json:"avatar"` // 头像URL
Sex int `json:"sex"` // 性别 0-未知 1-男 2-女
Province string `json:"province"` // 省份
City string `json:"city"` // 城市
Country string `json:"country"` // 国家
OpenID string `json:"open_id"`
UnionID string `json:"union_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Sex int `json:"sex"`
Province string `json:"province"`
City string `json:"city"`
Country string `json:"country"`
}
// 编译时类型检查
var (
_ Service = (*OfficialAccountService)(nil)
_ OfficialAccountServiceInterface = (*OfficialAccountService)(nil)
_ PaymentServiceInterface = (*PaymentService)(nil)
)