diff --git a/README.md b/README.md index 64d2972..5837185 100644 --- a/README.md +++ b/README.md @@ -250,121 +250,102 @@ func NewServiceContext(c config.Config, db *gorm.DB) *ServiceContext { ``` -编辑2调用其它服务 `internal/svc/servicecontext.go`: +编辑调用其它服务 `internal/svc/servicecontext.go`: ```go -import ( -"fmt" -"net" -"strconv" -"sync" -"time" - -"lone-services/pkg/discovery" -"lone-services/pkg/utils" -"lone-services/services/order/internal/config" - -"github.com/zeromicro/go-zero/core/logx" -"github.com/zeromicro/go-zero/zrpc" -"gorm.io/gorm" -) - -// 缓存条目 type rpcClientCacheEntry struct { -cli zrpc.Client -target string -expireAt time.Time + cli zrpc.Client + target string + expireAt time.Time } const rpcClientCacheTTL = 30 * time.Second //缓存过期时间 var ( -rpcCacheLock sync.Mutex -rpcCache = make(map[string]*rpcClientCacheEntry) + rpcCacheLock sync.Mutex + rpcCache = make(map[string]*rpcClientCacheEntry) ) func GetRpcClient(serviceName string) (zrpc.Client, error) { -if serviceName == utils.StringEmpty { -err := fmt.Errorf("rpc serviceName is empty") -logx.Error(err) -return nil, err -} + if serviceName == utils.StringEmpty { + err := fmt.Errorf("rpc serviceName is empty") + logx.Error(err) + return nil, err + } -cacheKey := serviceName + cacheKey := serviceName -rpcCacheLock.Lock() -entry, ok := rpcCache[cacheKey] -if ok && time.Now().Before(entry.expireAt) { -rpcCacheLock.Unlock() -return entry.cli, nil -} -delete(rpcCache, cacheKey) -rpcCacheLock.Unlock() + rpcCacheLock.Lock() + entry, ok := rpcCache[cacheKey] + if ok && time.Now().Before(entry.expireAt) { + rpcCacheLock.Unlock() + return entry.cli, nil + } + delete(rpcCache, cacheKey) + rpcCacheLock.Unlock() -inst, err := discovery.Pick(serviceName) -if err != nil { -err = fmt.Errorf("discovery pick %s failed: %w", serviceName, err) -logx.Error(err) -return nil, err -} + inst, err := discovery.Pick(serviceName) + if err != nil { + err = fmt.Errorf("discovery pick %s failed: %w", serviceName, err) + logx.Error(err) + return nil, err + } -target := net.JoinHostPort(inst.IP, strconv.FormatUint(inst.Port, utils.NumberTen)) + target := net.JoinHostPort(inst.IP, strconv.FormatUint(inst.Port, utils.NumberTen)) -cli := zrpc.MustNewClient(zrpc.RpcClientConf{ -Target: target, -Timeout: utils.RpcTimeOut, //rpc调用超时5s -}) + cli := zrpc.MustNewClient(zrpc.RpcClientConf{ + Target: target, + Timeout: utils.RpcTimeOut, //rpc调用超时5s + }) -rpcCacheLock.Lock() -rpcCache[cacheKey] = &rpcClientCacheEntry{ -cli: cli, -target: target, -expireAt: time.Now().Add(rpcClientCacheTTL), -} -rpcCacheLock.Unlock() + rpcCacheLock.Lock() + rpcCache[cacheKey] = &rpcClientCacheEntry{ + cli: cli, + target: target, + expireAt: time.Now().Add(rpcClientCacheTTL), + } + rpcCacheLock.Unlock() -return cli, nil + return cli, nil } type ServiceContext struct { -Config config.Config -DB *gorm.DB -Prefix string - -ExpressSvcName string //服务名 + Config config.Config + DB *gorm.DB + Prefix string + ProductSvcName string //服务名 + ExpressSvcName string + ChoreSvcName string } func NewServiceContext(c config.Config, db *gorm.DB) *ServiceContext { -//启动仅读取配置,不建立rpc连接 多个服务就多个 -expressSvc := utils.GetConfigString("services.express") + //启动仅读取配置,不建立rpc连接 多个服务就多个 + productSvc := utils.GetConfigString("services.product") + ExpressSvc := utils.GetConfigString("services.express") + ChoreSvc := utils.GetConfigString("services.chore") -if expressSvc == utils.StringEmpty { -logx.Error("config services.express empty") + if productSvc == utils.StringEmpty { + logx.Error("config services.product empty") + } + if ExpressSvc == utils.StringEmpty { + logx.Error("config services.express empty") + } + if ChoreSvc == utils.StringEmpty { + logx.Error("config services.chore empty") + } + + svcCtx := &ServiceContext{ + Config: c, + DB: db, + Prefix: utils.GetConfigString("mysql.prefix"), + ProductSvcName: productSvc, + ExpressSvcName: ExpressSvc, + ChoreSvcName: ChoreSvc, + } + + return svcCtx } -svcCtx := &ServiceContext{ -Config: c, -DB: db, -Prefix: utils.GetConfigString("mysql.prefix"), -ExpressSvcName: expressSvc, -} - -return svcCtx -} - - -调用方法logic中 -//调用其它服务实例 -//cli, err := svc.GetRpcClient(l.svcCtx.ExpressSvcName) -//if err != nil { -// l.Logger.Errorf("get express rpc err: %v", err) -// //降级逻辑 -// return l.out(utils.ErrorInternalServer, "express"+utils.ErrorInternalServer.Msg) -//} -//expClient := express.NewExpressClient(cli.Conn()) -//res, _ := expClient.Ping(context.Background(), &express.Request{}) -//l.Logger.Error("res: %v", res) - ``` ### 7. 接入 Docker Compose @@ -390,7 +371,7 @@ return svcCtx ### 8. 在 BFF 配 Upstream(HTTP 路由写在 proto) -编辑 `bff/etc/bff.yaml`,在 **Upstreams** 增加一段(不必写 Mappings,gateway 会从 ProtoSet 里的 http option 注册路由): +编辑 `bff/etc/bff.yaml`,在 **Upstreams** 增加一段: ```yaml - Name: order diff --git a/pkg/payment/alipay/channel.go b/pkg/payment/alipay/channel.go new file mode 100644 index 0000000..e26e618 --- /dev/null +++ b/pkg/payment/alipay/channel.go @@ -0,0 +1,30 @@ +package alipay + +import ( + "context" + + pkgpay "lone-services/pkg/payment" +) + +// Channel is a placeholder for Alipay (to be implemented later). +type Channel struct{} + +func NewChannel() *Channel { + return &Channel{} +} + +func (c *Channel) Name() string { + return pkgpay.ChannelAlipay +} + +func (c *Channel) Prepay(context.Context, string, *pkgpay.PrepayRequest) (*pkgpay.PrepayResult, error) { + return nil, pkgpay.ErrNotImplemented +} + +func (c *Channel) Refund(context.Context, *pkgpay.RefundRequest) (*pkgpay.RefundResult, error) { + return nil, pkgpay.ErrNotImplemented +} + +func (c *Channel) ParsePayNotify(context.Context, map[string]string, []byte) (*pkgpay.NotifyResult, error) { + return nil, pkgpay.ErrNotImplemented +} diff --git a/pkg/payment/bank/channel.go b/pkg/payment/bank/channel.go new file mode 100644 index 0000000..cd0a4c1 --- /dev/null +++ b/pkg/payment/bank/channel.go @@ -0,0 +1,30 @@ +package bank + +import ( + "context" + + pkgpay "lone-services/pkg/payment" +) + +// Channel is a placeholder for bank-card / unionpay (to be implemented later). +type Channel struct{} + +func NewChannel() *Channel { + return &Channel{} +} + +func (c *Channel) Name() string { + return pkgpay.ChannelBank +} + +func (c *Channel) Prepay(context.Context, string, *pkgpay.PrepayRequest) (*pkgpay.PrepayResult, error) { + return nil, pkgpay.ErrNotImplemented +} + +func (c *Channel) Refund(context.Context, *pkgpay.RefundRequest) (*pkgpay.RefundResult, error) { + return nil, pkgpay.ErrNotImplemented +} + +func (c *Channel) ParsePayNotify(context.Context, map[string]string, []byte) (*pkgpay.NotifyResult, error) { + return nil, pkgpay.ErrNotImplemented +} diff --git a/pkg/payment/channel.go b/pkg/payment/channel.go new file mode 100644 index 0000000..bea0f7f --- /dev/null +++ b/pkg/payment/channel.go @@ -0,0 +1,85 @@ +package payment + +import ( + "context" + "errors" +) + +// Channel codes used in API body `channel`. +const ( + ChannelWechat = "wechat" + ChannelAlipay = "alipay" + ChannelBank = "bank" +) + +// Client scenes used in API body `client`. +const ( + ClientMiniProgram = "mini_program" + ClientApp = "app" + ClientH5 = "h5" +) + +var ( + ErrUnsupportedChannel = errors.New("unsupported payment channel") + ErrUnsupportedClient = errors.New("unsupported payment client for channel") + ErrNotImplemented = errors.New("payment channel not implemented") +) + +// PrepayRequest is channel-agnostic prepay input. +type PrepayRequest struct { + OutTradeNo string + Description string + AmountFen int64 + Currency string + NotifyURL string + Attach string + // Payer identity: WeChat uses OpenID; Alipay uses BuyerID. + OpenID string + BuyerID string + Extra map[string]string +} + +// PrepayResult holds client-side invoke params (JSON-serializable). +type PrepayResult struct { + PrepayID string `json:"prepay_id,omitempty"` + Payload map[string]any `json:"payload"` +} + +// RefundRequest is channel-agnostic refund input. +type RefundRequest struct { + OutTradeNo string + TransactionID string + OutRefundNo string + Reason string + NotifyURL string + RefundFen int64 + TotalFen int64 + Currency string +} + +// RefundResult is channel-agnostic refund output. +type RefundResult struct { + RefundID string `json:"refund_id"` + OutRefundNo string `json:"out_refund_no"` + TransactionID string `json:"transaction_id"` + OutTradeNo string `json:"out_trade_no"` + Status string `json:"status"` +} + +// NotifyResult is a normalized pay-success notify. +type NotifyResult struct { + OutTradeNo string `json:"out_trade_no"` + TransactionID string `json:"transaction_id"` + TradeState string `json:"trade_state"` + AmountFen int64 `json:"amount_fen"` + Success bool `json:"success"` + Attach string `json:"attach,omitempty"` +} + +// Channel abstracts WeChat / Alipay / bank card providers. +type Channel interface { + Name() string + Prepay(ctx context.Context, client string, req *PrepayRequest) (*PrepayResult, error) + Refund(ctx context.Context, req *RefundRequest) (*RefundResult, error) + ParsePayNotify(ctx context.Context, headers map[string]string, body []byte) (*NotifyResult, error) +} diff --git a/pkg/payment/wechat/channel.go b/pkg/payment/wechat/channel.go new file mode 100644 index 0000000..8316263 --- /dev/null +++ b/pkg/payment/wechat/channel.go @@ -0,0 +1,141 @@ +package wechat + +import ( + "bytes" + "context" + "fmt" + "net/http" + + pkgpay "lone-services/pkg/payment" + wxpay "lone-services/pkg/wechat/pay" +) + +// Channel wraps WeChat Pay APIv3 client. +type Channel struct { + client *wxpay.Client +} + +func NewChannel(cfg wxpay.Config) (*Channel, error) { + c, err := wxpay.NewClient(cfg) + if err != nil { + return nil, err + } + return &Channel{client: c}, nil +} + +func (c *Channel) Name() string { + return pkgpay.ChannelWechat +} + +func (c *Channel) Prepay(ctx context.Context, client string, req *pkgpay.PrepayRequest) (*pkgpay.PrepayResult, error) { + if req == nil { + return nil, fmt.Errorf("prepay request is required") + } + if client == "" { + client = pkgpay.ClientMiniProgram + } + + switch client { + case pkgpay.ClientMiniProgram: + return c.miniProgramPrepay(ctx, req) + default: + return nil, pkgpay.ErrUnsupportedClient + } +} + +func (c *Channel) miniProgramPrepay(ctx context.Context, req *pkgpay.PrepayRequest) (*pkgpay.PrepayResult, error) { + currency := req.Currency + if currency == "" { + currency = "CNY" + } + result, err := c.client.MiniProgramPay(ctx, &wxpay.JsapiPrepayRequest{ + Description: req.Description, + OutTradeNo: req.OutTradeNo, + Attach: req.Attach, + NotifyURL: req.NotifyURL, + Amount: wxpay.Amount{ + Total: req.AmountFen, + Currency: currency, + }, + Payer: wxpay.Payer{OpenID: req.OpenID}, + }) + if err != nil { + return nil, err + } + + payload := map[string]any{} + if result.Pay != nil { + payload["timeStamp"] = result.Pay.TimeStamp + payload["nonceStr"] = result.Pay.NonceStr + payload["package"] = result.Pay.Package + payload["signType"] = result.Pay.SignType + payload["paySign"] = result.Pay.PaySign + } + return &pkgpay.PrepayResult{ + PrepayID: result.PrepayID, + Payload: payload, + }, nil +} + +func (c *Channel) Refund(ctx context.Context, req *pkgpay.RefundRequest) (*pkgpay.RefundResult, error) { + if req == nil { + return nil, fmt.Errorf("refund request is required") + } + currency := req.Currency + if currency == "" { + currency = "CNY" + } + result, err := c.client.Refund(ctx, &wxpay.RefundRequest{ + OutTradeNo: req.OutTradeNo, + TransactionID: req.TransactionID, + OutRefundNo: req.OutRefundNo, + Reason: req.Reason, + NotifyURL: req.NotifyURL, + Amount: wxpay.RefundAmount{ + Refund: req.RefundFen, + Total: req.TotalFen, + Currency: currency, + }, + }) + if err != nil { + return nil, err + } + return &pkgpay.RefundResult{ + RefundID: result.RefundID, + OutRefundNo: result.OutRefundNo, + TransactionID: result.TransactionID, + OutTradeNo: result.OutTradeNo, + Status: result.Status, + }, nil +} + +func (c *Channel) ParsePayNotify(_ context.Context, headers map[string]string, body []byte) (*pkgpay.NotifyResult, error) { + httpReq, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + if err != nil { + return nil, err + } + for k, v := range headers { + httpReq.Header.Set(k, v) + } + + notify, err := c.client.ParseNotify(httpReq) + if err != nil { + return nil, err + } + if notify.EventType != "TRANSACTION.SUCCESS" { + return &pkgpay.NotifyResult{Success: false, TradeState: notify.EventType}, nil + } + + tx, err := c.client.DecryptNotify(notify) + if err != nil { + return nil, err + } + return &pkgpay.NotifyResult{ + OutTradeNo: tx.OutTradeNo, + TransactionID: tx.TransactionID, + TradeState: tx.TradeState, + AmountFen: tx.Amount.Total, + Success: tx.TradeState == "SUCCESS", + Attach: tx.Attach, + }, nil +} diff --git a/pkg/wechat/pay/certificate.go b/pkg/wechat/pay/certificate.go new file mode 100644 index 0000000..2d508fd --- /dev/null +++ b/pkg/wechat/pay/certificate.go @@ -0,0 +1,149 @@ +package pay + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +const certificatesPath = "/v3/certificates" + +type certificatesResponse struct { + Data []platformCertificateData `json:"data"` +} + +type platformCertificateData struct { + SerialNo string `json:"serial_no"` + EffectiveTime string `json:"effective_time"` + ExpireTime string `json:"expire_time"` + EncryptCertificate encryptCertificate `json:"encrypt_certificate"` +} + +type encryptCertificate struct { + Algorithm string `json:"algorithm"` + Nonce string `json:"nonce"` + AssociatedData string `json:"associated_data"` + Ciphertext string `json:"ciphertext"` +} + +func certSerialNumber(cert *x509.Certificate) string { + return strings.ToUpper(cert.SerialNumber.Text(16)) +} + +func loadPlatformCertificate(path string) (string, *rsa.PublicKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", nil, fmt.Errorf("read platform certificate %s: %w", path, err) + } + + block, _ := pem.Decode(data) + if block == nil { + return "", nil, fmt.Errorf("invalid platform certificate pem: %s", path) + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", nil, fmt.Errorf("parse platform certificate %s: %w", path, err) + } + + publicKey, ok := cert.PublicKey.(*rsa.PublicKey) + if !ok { + return "", nil, fmt.Errorf("platform certificate public key is not rsa: %s", path) + } + + return certSerialNumber(cert), publicKey, nil +} + +func (c *Client) addPlatformCert(serial string, publicKey *rsa.PublicKey) { + if c.platformCerts == nil { + c.platformCerts = make(map[string]*rsa.PublicKey) + } + c.platformCerts[strings.ToUpper(serial)] = publicKey + if c.platformSerial == "" { + c.platformSerial = strings.ToUpper(serial) + } +} + +func (c *Client) refreshPlatformCertificates(ctx context.Context) error { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Host+certificatesPath, nil) + if err != nil { + return err + } + + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("User-Agent", "lone-services-wechatpay") + + authorization, err := c.buildAuthorization(http.MethodGet, certificatesPath, nil) + if err != nil { + return err + } + httpReq.Header.Set("Authorization", authorization) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if !isSuccessStatus(resp.StatusCode) { + return newAPIError(resp.StatusCode, respBody) + } + + if len(c.platformCerts) > 0 { + if err := c.validateResponse(resp.Header, respBody); err != nil { + return fmt.Errorf("validate certificates response: %w", err) + } + } + + var result certificatesResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return err + } + + for _, item := range result.Data { + plaintext, err := decryptAES256GCM( + c.APIv3Key, + item.EncryptCertificate.AssociatedData, + item.EncryptCertificate.Nonce, + item.EncryptCertificate.Ciphertext, + ) + if err != nil { + return fmt.Errorf("decrypt platform certificate %s: %w", item.SerialNo, err) + } + + block, _ := pem.Decode(plaintext) + if block == nil { + return fmt.Errorf("invalid decrypted platform certificate pem: %s", item.SerialNo) + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return fmt.Errorf("parse platform certificate %s: %w", item.SerialNo, err) + } + + publicKey, ok := cert.PublicKey.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("platform certificate public key is not rsa: %s", item.SerialNo) + } + + c.addPlatformCert(item.SerialNo, publicKey) + } + + if len(c.platformCerts) == 0 { + return fmt.Errorf("no platform certificates available") + } + + return nil +} diff --git a/pkg/wechat/pay/client.go b/pkg/wechat/pay/client.go new file mode 100644 index 0000000..94d8231 --- /dev/null +++ b/pkg/wechat/pay/client.go @@ -0,0 +1,69 @@ +package pay + +import ( + "context" + "crypto/rsa" + "fmt" + "net/http" + "time" +) + +const defaultHost = "https://api.mch.weixin.qq.com" + +type Client struct { + AppID string + MchID string + CertificateSerialNo string + PrivateKey *rsa.PrivateKey + platformCerts map[string]*rsa.PublicKey + platformSerial string + APIv3Key string + NotifyURL string + Host string + httpClient *http.Client +} + +func NewClient(cfg Config) (*Client, error) { + privateKey, err := loadPrivateKey(cfg.PrivateKeyPath) + if err != nil { + return nil, err + } + if cfg.APIv3Key == "" { + return nil, fmt.Errorf("apiv3 key is required") + } + + c := &Client{ + AppID: cfg.AppID, + MchID: cfg.MchID, + CertificateSerialNo: cfg.CertificateSerialNo, + PrivateKey: privateKey, + APIv3Key: cfg.APIv3Key, + NotifyURL: cfg.NotifyURL, + Host: defaultHost, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } + + if cfg.PlatformCertPath != "" { + serial, publicKey, err := loadPlatformCertificate(cfg.PlatformCertPath) + if err != nil { + return nil, err + } + c.addPlatformCert(serial, publicKey) + c.platformSerial = serial + } + + if err := c.refreshPlatformCertificates(context.Background()); err != nil { + if len(c.platformCerts) == 0 { + return nil, fmt.Errorf("load platform certificates: %w", err) + } + } + + if c.platformSerial == "" { + for serial := range c.platformCerts { + c.platformSerial = serial + break + } + } + + return c, nil +} diff --git a/pkg/wechat/pay/config.go b/pkg/wechat/pay/config.go new file mode 100644 index 0000000..35689d4 --- /dev/null +++ b/pkg/wechat/pay/config.go @@ -0,0 +1,53 @@ +package pay + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "os" +) + +type Config struct { + AppID string + MchID string + CertificateSerialNo string + PrivateKeyPath string + PlatformCertPath string // 平台证书路径(可选,用于首次验签或离线启动) + APIv3Key string // 用于解密支付回调通知及拉取平台证书 + NotifyURL string +} + +func loadPrivateKey(path string) (*rsa.PrivateKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read private key %s: %w", path, err) + } + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("invalid private key pem: %s", path) + } + + switch block.Type { + case "PRIVATE KEY": + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse pkcs8 private key %s: %w", path, err) + } + privateKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("private key is not rsa") + } + return privateKey, nil + case "RSA PRIVATE KEY": + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse pkcs1 private key %s: %w", path, err) + } + return privateKey, nil + default: + return nil, fmt.Errorf("unsupported private key pem type %q in %s", block.Type, path) + } +} + diff --git a/pkg/wechat/pay/error.go b/pkg/wechat/pay/error.go new file mode 100644 index 0000000..1a064df --- /dev/null +++ b/pkg/wechat/pay/error.go @@ -0,0 +1,35 @@ +package pay + +import ( + "encoding/json" + "net/http" +) + +type APIError struct { + StatusCode int + Code string `json:"code"` + Message string `json:"message"` + RawBody string +} + +func (e *APIError) Error() string { + if e.Code != "" { + return e.Message + // return fmt.Sprintf("wechat pay api error: status=%d code=%s message=%s", e.StatusCode, e.Code, e.Message) + } + return e.RawBody + // return fmt.Sprintf("wechat pay api error: status=%d body=%s", e.StatusCode, e.RawBody) +} + +func newAPIError(statusCode int, body []byte) error { + err := &APIError{ + StatusCode: statusCode, + RawBody: string(body), + } + _ = json.Unmarshal(body, err) + return err +} + +func isSuccessStatus(code int) bool { + return code >= http.StatusOK && code < http.StatusMultipleChoices +} diff --git a/pkg/wechat/pay/jsapi.go b/pkg/wechat/pay/jsapi.go new file mode 100644 index 0000000..12f5ad0 --- /dev/null +++ b/pkg/wechat/pay/jsapi.go @@ -0,0 +1,144 @@ +package pay + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const jsapiPrepayPath = "/v3/pay/transactions/jsapi" + +type jsapiPrepayBody struct { + AppID string `json:"appid"` + MchID string `json:"mchid"` + Description string `json:"description"` + OutTradeNo string `json:"out_trade_no"` + TimeExpire interface{} `json:"time_expire,omitempty"` + Attach string `json:"attach,omitempty"` + NotifyURL string `json:"notify_url"` + GoodsTag string `json:"goods_tag,omitempty"` + SupportFapiao *bool `json:"support_fapiao,omitempty"` + Amount Amount `json:"amount"` + Payer Payer `json:"payer"` + Detail *Detail `json:"detail,omitempty"` + SceneInfo *SceneInfo `json:"scene_info,omitempty"` + SettleInfo *SettleInfo `json:"settle_info,omitempty"` +} + +func (c *Client) JsapiPrepay(ctx context.Context, req *JsapiPrepayRequest) (*JsapiPrepayResponse, error) { + if req == nil { + return nil, fmt.Errorf("request is required") + } + if req.OutTradeNo == "" || req.Description == "" || req.Payer.OpenID == "" { + return nil, fmt.Errorf("out_trade_no, description and payer.openid are required") + } + if req.Amount.Total <= 0 { + return nil, fmt.Errorf("amount.total must be greater than 0") + } + + notifyURL := req.NotifyURL + if notifyURL == "" { + notifyURL = c.NotifyURL + } + if notifyURL == "" { + return nil, fmt.Errorf("notify_url is required") + } + + currency := req.Amount.Currency + if currency == "" { + currency = "CNY" + } + + body := jsapiPrepayBody{ + AppID: c.AppID, + MchID: c.MchID, + Description: req.Description, + OutTradeNo: req.OutTradeNo, + Attach: req.Attach, + NotifyURL: notifyURL, + GoodsTag: req.GoodsTag, + SupportFapiao: req.SupportFapiao, + Amount: Amount{ + Total: req.Amount.Total, + Currency: currency, + }, + Payer: req.Payer, + Detail: req.Detail, + SceneInfo: req.SceneInfo, + SettleInfo: req.SettleInfo, + } + if req.TimeExpire != nil { + body.TimeExpire = req.TimeExpire + } + + reqBody, err := json.Marshal(body) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Host+jsapiPrepayPath, bytes.NewReader(reqBody)) + if err != nil { + return nil, err + } + + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Content-Type", "application/json") + if c.platformSerial != "" { + httpReq.Header.Set("Wechatpay-Serial", c.platformSerial) + } + + authorization, err := c.buildAuthorization(http.MethodPost, jsapiPrepayPath, reqBody) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", authorization) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if !isSuccessStatus(resp.StatusCode) { + return nil, newAPIError(resp.StatusCode, respBody) + } + + if err := c.validateResponse(resp.Header, respBody); err != nil { + return nil, err + } + + var result JsapiPrepayResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, err + } + if result.PrepayID == "" { + return nil, fmt.Errorf("empty prepay_id in response") + } + + return &result, nil +} + +func (c *Client) MiniProgramPay(ctx context.Context, req *JsapiPrepayRequest) (*MiniProgramPayResult, error) { + prepay, err := c.JsapiPrepay(ctx, req) + if err != nil { + return nil, err + } + + payParams, err := c.BuildMiniProgramPayParams(prepay.PrepayID) + if err != nil { + return nil, err + } + + return &MiniProgramPayResult{ + PrepayID: prepay.PrepayID, + Pay: payParams, + }, nil +} diff --git a/pkg/wechat/pay/notify.go b/pkg/wechat/pay/notify.go new file mode 100644 index 0000000..be80e49 --- /dev/null +++ b/pkg/wechat/pay/notify.go @@ -0,0 +1,109 @@ +package pay + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type NotifyRequest struct { + ID string `json:"id"` + CreateTime string `json:"create_time"` + EventType string `json:"event_type"` + ResourceType string `json:"resource_type"` + Summary string `json:"summary"` + Resource NotifyResource `json:"resource"` +} + +type NotifyResource struct { + Algorithm string `json:"algorithm"` + Ciphertext string `json:"ciphertext"` + AssociatedData string `json:"associated_data"` + Nonce string `json:"nonce"` + OriginalType string `json:"original_type"` +} + +type TransactionNotify struct { + AppID string `json:"appid"` + MchID string `json:"mchid"` + OutTradeNo string `json:"out_trade_no"` + TransactionID string `json:"transaction_id"` + TradeType string `json:"trade_type"` + TradeState string `json:"trade_state"` + TradeStateDesc string `json:"trade_state_desc"` + BankType string `json:"bank_type"` + Attach string `json:"attach"` + SuccessTime string `json:"success_time"` + Payer struct { + OpenID string `json:"openid"` + } `json:"payer"` + Amount struct { + Total int64 `json:"total"` + PayerTotal int64 `json:"payer_total"` + Currency string `json:"currency"` + PayerCurrency string `json:"payer_currency"` + } `json:"amount"` +} + +func (c *Client) ParseNotify(r *http.Request) (*NotifyRequest, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + + if err := c.validateResponse(r.Header, body); err != nil { + return nil, err + } + + var notify NotifyRequest + if err := json.Unmarshal(body, ¬ify); err != nil { + return nil, err + } + return ¬ify, nil +} + +func (c *Client) DecryptNotify(notify *NotifyRequest) (*TransactionNotify, error) { + if c.APIv3Key == "" { + return nil, fmt.Errorf("apiv3 key is required for notify decryption") + } + + plaintext, err := decryptAES256GCM( + c.APIv3Key, + notify.Resource.AssociatedData, + notify.Resource.Nonce, + notify.Resource.Ciphertext, + ) + if err != nil { + return nil, err + } + + var tx TransactionNotify + if err := json.Unmarshal(plaintext, &tx); err != nil { + return nil, err + } + return &tx, nil +} + +func decryptAES256GCM(apiV3Key, associatedData, nonce, ciphertext string) ([]byte, error) { + key := []byte(apiV3Key) + cipherBytes, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + return gcm.Open(nil, []byte(nonce), cipherBytes, []byte(associatedData)) +} diff --git a/pkg/wechat/pay/refund.go b/pkg/wechat/pay/refund.go new file mode 100644 index 0000000..16aa76f --- /dev/null +++ b/pkg/wechat/pay/refund.go @@ -0,0 +1,102 @@ +package pay + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const domesticRefundPath = "/v3/refund/domestic/refunds" + +func (c *Client) Refund(ctx context.Context, req *RefundRequest) (*RefundResponse, error) { + if req == nil { + return nil, fmt.Errorf("request is required") + } + if req.OutTradeNo == "" && req.TransactionID == "" { + return nil, fmt.Errorf("out_trade_no or transaction_id is required") + } + if req.OutRefundNo == "" { + return nil, fmt.Errorf("out_refund_no is required") + } + if req.Amount.Refund <= 0 { + return nil, fmt.Errorf("amount.refund must be greater than 0") + } + if req.Amount.Total <= 0 { + return nil, fmt.Errorf("amount.total must be greater than 0") + } + if req.Amount.Refund > req.Amount.Total { + return nil, fmt.Errorf("amount.refund cannot be greater than amount.total") + } + + currency := req.Amount.Currency + if currency == "" { + currency = "CNY" + } + + body := RefundRequest{ + OutTradeNo: req.OutTradeNo, + TransactionID: req.TransactionID, + OutRefundNo: req.OutRefundNo, + Reason: req.Reason, + NotifyURL: req.NotifyURL, + Amount: RefundAmount{ + Refund: req.Amount.Refund, + Total: req.Amount.Total, + Currency: currency, + }, + } + + reqBody, err := json.Marshal(body) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Host+domesticRefundPath, bytes.NewReader(reqBody)) + if err != nil { + return nil, err + } + + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Content-Type", "application/json") + if c.platformSerial != "" { + httpReq.Header.Set("Wechatpay-Serial", c.platformSerial) + } + + authorization, err := c.buildAuthorization(http.MethodPost, domesticRefundPath, reqBody) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", authorization) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if !isSuccessStatus(resp.StatusCode) { + return nil, newAPIError(resp.StatusCode, respBody) + } + + if err := c.validateResponse(resp.Header, respBody); err != nil { + return nil, err + } + + var result RefundResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, err + } + if result.RefundID == "" { + return nil, fmt.Errorf("empty refund_id in response") + } + + return &result, nil +} diff --git a/pkg/wechat/pay/sign.go b/pkg/wechat/pay/sign.go new file mode 100644 index 0000000..a6df91f --- /dev/null +++ b/pkg/wechat/pay/sign.go @@ -0,0 +1,107 @@ +package pay + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +func randomNonce(length int) (string, error) { + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + buf := make([]byte, length) + if _, err := rand.Read(buf); err != nil { + return "", err + } + for i, b := range buf { + buf[i] = chars[int(b)%len(chars)] + } + return string(buf), nil +} + +func signSHA256WithRSA(message string, privateKey *rsa.PrivateKey) (string, error) { + hashed := sha256.Sum256([]byte(message)) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed[:]) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(signature), nil +} + +func (c *Client) buildAuthorization(method, requestURI string, body []byte) (string, error) { + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + nonce, err := randomNonce(32) + if err != nil { + return "", err + } + + message := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n", method, requestURI, timestamp, nonce, string(body)) + signature, err := signSHA256WithRSA(message, c.PrivateKey) + if err != nil { + return "", err + } + + return fmt.Sprintf( + `WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"`, + c.MchID, nonce, timestamp, c.CertificateSerialNo, signature, + ), nil +} + +func (c *Client) validateResponse(headers http.Header, body []byte) error { + timestamp := headers.Get("Wechatpay-Timestamp") + nonce := headers.Get("Wechatpay-Nonce") + signature := headers.Get("Wechatpay-Signature") + serial := headers.Get("Wechatpay-Serial") + + if timestamp == "" || nonce == "" || signature == "" || serial == "" { + return fmt.Errorf("missing wechatpay response signature headers") + } + + publicKey, ok := c.platformCerts[strings.ToUpper(serial)] + if !ok { + return fmt.Errorf("unknown wechatpay platform certificate serial: %s", serial) + } + + message := fmt.Sprintf("%s\n%s\n%s\n", timestamp, nonce, string(body)) + sigBytes, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return err + } + + hashed := sha256.Sum256([]byte(message)) + return rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hashed[:], sigBytes) +} + +func (c *Client) BuildMiniProgramPayParams(prepayID string) (*MiniProgramPayParams, error) { + if prepayID == "" { + return nil, fmt.Errorf("prepay_id is required") + } + + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + nonce, err := randomNonce(32) + if err != nil { + return nil, err + } + + pkg := "prepay_id=" + prepayID + message := strings.Join([]string{c.AppID, timestamp, nonce, pkg, ""}, "\n") + + paySign, err := signSHA256WithRSA(message, c.PrivateKey) + if err != nil { + return nil, err + } + + return &MiniProgramPayParams{ + TimeStamp: timestamp, + NonceStr: nonce, + Package: pkg, + SignType: "RSA", + PaySign: paySign, + }, nil +} diff --git a/pkg/wechat/pay/types.go b/pkg/wechat/pay/types.go new file mode 100644 index 0000000..0a44863 --- /dev/null +++ b/pkg/wechat/pay/types.go @@ -0,0 +1,103 @@ +package pay + +import "time" + +type JsapiPrepayRequest struct { + Description string `json:"description"` + OutTradeNo string `json:"out_trade_no"` + TimeExpire *time.Time `json:"time_expire,omitempty"` + Attach string `json:"attach,omitempty"` + NotifyURL string `json:"notify_url,omitempty"` + GoodsTag string `json:"goods_tag,omitempty"` + SupportFapiao *bool `json:"support_fapiao,omitempty"` + Amount Amount `json:"amount"` + Payer Payer `json:"payer"` + Detail *Detail `json:"detail,omitempty"` + SceneInfo *SceneInfo `json:"scene_info,omitempty"` + SettleInfo *SettleInfo `json:"settle_info,omitempty"` +} + +type Amount struct { + Total int64 `json:"total"` + Currency string `json:"currency,omitempty"` +} + +type Payer struct { + OpenID string `json:"openid"` +} + +type Detail struct { + CostPrice int64 `json:"cost_price,omitempty"` + InvoiceID string `json:"invoice_id,omitempty"` + GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"` +} + +type GoodsDetail struct { + MerchantGoodsID string `json:"merchant_goods_id"` + WechatpayGoodsID string `json:"wechatpay_goods_id,omitempty"` + GoodsName string `json:"goods_name,omitempty"` + Quantity int64 `json:"quantity"` + UnitPrice int64 `json:"unit_price"` +} + +type SceneInfo struct { + PayerClientIP string `json:"payer_client_ip"` + DeviceID string `json:"device_id,omitempty"` + StoreInfo *StoreInfo `json:"store_info,omitempty"` +} + +type StoreInfo struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + AreaCode string `json:"area_code,omitempty"` + Address string `json:"address,omitempty"` +} + +type SettleInfo struct { + ProfitSharing *bool `json:"profit_sharing,omitempty"` +} + +type JsapiPrepayResponse struct { + PrepayID string `json:"prepay_id"` +} + +type MiniProgramPayParams struct { + TimeStamp string `json:"timeStamp"` + NonceStr string `json:"nonceStr"` + Package string `json:"package"` + SignType string `json:"signType"` + PaySign string `json:"paySign"` +} + +type MiniProgramPayResult struct { + PrepayID string `json:"prepay_id"` + Pay *MiniProgramPayParams `json:"pay"` +} + +type RefundRequest struct { + OutTradeNo string `json:"out_trade_no,omitempty"` + TransactionID string `json:"transaction_id,omitempty"` + OutRefundNo string `json:"out_refund_no"` + Reason string `json:"reason,omitempty"` + NotifyURL string `json:"notify_url,omitempty"` + Amount RefundAmount `json:"amount"` +} + +type RefundAmount struct { + Refund int64 `json:"refund"` + Total int64 `json:"total"` + Currency string `json:"currency,omitempty"` +} + +type RefundResponse struct { + RefundID string `json:"refund_id"` + OutRefundNo string `json:"out_refund_no"` + TransactionID string `json:"transaction_id"` + OutTradeNo string `json:"out_trade_no"` + Channel string `json:"channel"` + UserReceivedAccount string `json:"user_received_account"` + SuccessTime string `json:"success_time"` + CreateTime string `json:"create_time"` + Status string `json:"status"` + Amount RefundAmount `json:"amount"` +} diff --git a/rpc/payment/payment.pb b/rpc/payment/payment.pb new file mode 100644 index 0000000..a8ffce3 Binary files /dev/null and b/rpc/payment/payment.pb differ diff --git a/rpc/payment/payment.proto b/rpc/payment/payment.proto new file mode 100644 index 0000000..339cc56 --- /dev/null +++ b/rpc/payment/payment.proto @@ -0,0 +1,77 @@ +syntax = "proto3"; + +package payment; +option go_package = "lone-services/rpc/payment"; +import "google/api/annotations.proto"; + +service Payment { + rpc Pay(PayReq) returns (Response) { + option (google.api.http) = { + post: "/api/v3/payment/pay" + body: "*" + }; + } + + rpc Query(QueryReq) returns (Response) { + option (google.api.http) = { + post: "/api/v3/payment/query" + body: "*" + }; + } + + rpc Close(CloseReq) returns (Response) { + option (google.api.http) = { + post: "/api/v3/payment/close" + body: "*" + }; + } + + rpc Refund(RefundReq) returns (Response) { + option (google.api.http) = { + post: "/api/v3/payment/refund" + body: "*" + }; + } + + rpc Notify(NotifyReq) returns (Response) { + option (google.api.http) = { + post: "/api/v3/payment/notify/{channel}" + body: "*" + }; + } +} + +message Response { + int32 code = 1; + string msg = 2; + string data = 3; +} + +message PayReq { + string order_sn = 1; + string client = 2; + string channel = 3; +} + +message QueryReq { + string order_sn = 1; + string channel = 2; +} + +message CloseReq { + string order_sn = 1; + string channel = 2; +} + +message RefundReq { + string order_sn = 1; + string channel = 2; + string amount = 3; // 元,字符串避免浮点误差 + string reason = 4; +} + +message NotifyReq { + string channel = 1; + map headers = 2; + bytes body = 3; +} diff --git a/rpc/payment/pb/payment.pb.go b/rpc/payment/pb/payment.pb.go new file mode 100644 index 0000000..b6efbd5 --- /dev/null +++ b/rpc/payment/pb/payment.pb.go @@ -0,0 +1,480 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v3.19.4 +// source: payment/payment.proto + +package payment + +import ( + _ "google.golang.org/genproto/googleapis/api/annotations" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Response struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"` + Msg string `protobuf:"bytes,2,opt,name=msg,proto3" json:"msg,omitempty"` + Data string `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Response) Reset() { + *x = Response{} + mi := &file_payment_payment_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Response) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Response) ProtoMessage() {} + +func (x *Response) ProtoReflect() protoreflect.Message { + mi := &file_payment_payment_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Response.ProtoReflect.Descriptor instead. +func (*Response) Descriptor() ([]byte, []int) { + return file_payment_payment_proto_rawDescGZIP(), []int{0} +} + +func (x *Response) GetCode() int32 { + if x != nil { + return x.Code + } + return 0 +} + +func (x *Response) GetMsg() string { + if x != nil { + return x.Msg + } + return "" +} + +func (x *Response) GetData() string { + if x != nil { + return x.Data + } + return "" +} + +// client: mini_program / app / h5 +// channel: wechat / alipay / bank +type PayReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrderSn string `protobuf:"bytes,1,opt,name=order_sn,json=orderSn,proto3" json:"order_sn,omitempty"` + Client string `protobuf:"bytes,2,opt,name=client,proto3" json:"client,omitempty"` + Channel string `protobuf:"bytes,3,opt,name=channel,proto3" json:"channel,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PayReq) Reset() { + *x = PayReq{} + mi := &file_payment_payment_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PayReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PayReq) ProtoMessage() {} + +func (x *PayReq) ProtoReflect() protoreflect.Message { + mi := &file_payment_payment_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PayReq.ProtoReflect.Descriptor instead. +func (*PayReq) Descriptor() ([]byte, []int) { + return file_payment_payment_proto_rawDescGZIP(), []int{1} +} + +func (x *PayReq) GetOrderSn() string { + if x != nil { + return x.OrderSn + } + return "" +} + +func (x *PayReq) GetClient() string { + if x != nil { + return x.Client + } + return "" +} + +func (x *PayReq) GetChannel() string { + if x != nil { + return x.Channel + } + return "" +} + +type QueryReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrderSn string `protobuf:"bytes,1,opt,name=order_sn,json=orderSn,proto3" json:"order_sn,omitempty"` + Channel string `protobuf:"bytes,2,opt,name=channel,proto3" json:"channel,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QueryReq) Reset() { + *x = QueryReq{} + mi := &file_payment_payment_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QueryReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryReq) ProtoMessage() {} + +func (x *QueryReq) ProtoReflect() protoreflect.Message { + mi := &file_payment_payment_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryReq.ProtoReflect.Descriptor instead. +func (*QueryReq) Descriptor() ([]byte, []int) { + return file_payment_payment_proto_rawDescGZIP(), []int{2} +} + +func (x *QueryReq) GetOrderSn() string { + if x != nil { + return x.OrderSn + } + return "" +} + +func (x *QueryReq) GetChannel() string { + if x != nil { + return x.Channel + } + return "" +} + +type CloseReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrderSn string `protobuf:"bytes,1,opt,name=order_sn,json=orderSn,proto3" json:"order_sn,omitempty"` + Channel string `protobuf:"bytes,2,opt,name=channel,proto3" json:"channel,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloseReq) Reset() { + *x = CloseReq{} + mi := &file_payment_payment_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloseReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseReq) ProtoMessage() {} + +func (x *CloseReq) ProtoReflect() protoreflect.Message { + mi := &file_payment_payment_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseReq.ProtoReflect.Descriptor instead. +func (*CloseReq) Descriptor() ([]byte, []int) { + return file_payment_payment_proto_rawDescGZIP(), []int{3} +} + +func (x *CloseReq) GetOrderSn() string { + if x != nil { + return x.OrderSn + } + return "" +} + +func (x *CloseReq) GetChannel() string { + if x != nil { + return x.Channel + } + return "" +} + +type RefundReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrderSn string `protobuf:"bytes,1,opt,name=order_sn,json=orderSn,proto3" json:"order_sn,omitempty"` + Channel string `protobuf:"bytes,2,opt,name=channel,proto3" json:"channel,omitempty"` + Amount string `protobuf:"bytes,3,opt,name=amount,proto3" json:"amount,omitempty"` // 元,字符串避免浮点误差 + Reason string `protobuf:"bytes,4,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RefundReq) Reset() { + *x = RefundReq{} + mi := &file_payment_payment_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RefundReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RefundReq) ProtoMessage() {} + +func (x *RefundReq) ProtoReflect() protoreflect.Message { + mi := &file_payment_payment_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RefundReq.ProtoReflect.Descriptor instead. +func (*RefundReq) Descriptor() ([]byte, []int) { + return file_payment_payment_proto_rawDescGZIP(), []int{4} +} + +func (x *RefundReq) GetOrderSn() string { + if x != nil { + return x.OrderSn + } + return "" +} + +func (x *RefundReq) GetChannel() string { + if x != nil { + return x.Channel + } + return "" +} + +func (x *RefundReq) GetAmount() string { + if x != nil { + return x.Amount + } + return "" +} + +func (x *RefundReq) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type NotifyReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Channel string `protobuf:"bytes,1,opt,name=channel,proto3" json:"channel,omitempty"` + Headers map[string]string `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Body []byte `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotifyReq) Reset() { + *x = NotifyReq{} + mi := &file_payment_payment_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotifyReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotifyReq) ProtoMessage() {} + +func (x *NotifyReq) ProtoReflect() protoreflect.Message { + mi := &file_payment_payment_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NotifyReq.ProtoReflect.Descriptor instead. +func (*NotifyReq) Descriptor() ([]byte, []int) { + return file_payment_payment_proto_rawDescGZIP(), []int{5} +} + +func (x *NotifyReq) GetChannel() string { + if x != nil { + return x.Channel + } + return "" +} + +func (x *NotifyReq) GetHeaders() map[string]string { + if x != nil { + return x.Headers + } + return nil +} + +func (x *NotifyReq) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +var File_payment_payment_proto protoreflect.FileDescriptor + +const file_payment_payment_proto_rawDesc = "" + + "\n" + + "\x15payment/payment.proto\x12\apayment\x1a\x1cgoogle/api/annotations.proto\"D\n" + + "\bResponse\x12\x12\n" + + "\x04code\x18\x01 \x01(\x05R\x04code\x12\x10\n" + + "\x03msg\x18\x02 \x01(\tR\x03msg\x12\x12\n" + + "\x04data\x18\x03 \x01(\tR\x04data\"U\n" + + "\x06PayReq\x12\x19\n" + + "\border_sn\x18\x01 \x01(\tR\aorderSn\x12\x16\n" + + "\x06client\x18\x02 \x01(\tR\x06client\x12\x18\n" + + "\achannel\x18\x03 \x01(\tR\achannel\"?\n" + + "\bQueryReq\x12\x19\n" + + "\border_sn\x18\x01 \x01(\tR\aorderSn\x12\x18\n" + + "\achannel\x18\x02 \x01(\tR\achannel\"?\n" + + "\bCloseReq\x12\x19\n" + + "\border_sn\x18\x01 \x01(\tR\aorderSn\x12\x18\n" + + "\achannel\x18\x02 \x01(\tR\achannel\"p\n" + + "\tRefundReq\x12\x19\n" + + "\border_sn\x18\x01 \x01(\tR\aorderSn\x12\x18\n" + + "\achannel\x18\x02 \x01(\tR\achannel\x12\x16\n" + + "\x06amount\x18\x03 \x01(\tR\x06amount\x12\x16\n" + + "\x06reason\x18\x04 \x01(\tR\x06reason\"\xb0\x01\n" + + "\tNotifyReq\x12\x18\n" + + "\achannel\x18\x01 \x01(\tR\achannel\x129\n" + + "\aheaders\x18\x02 \x03(\v2\x1f.payment.NotifyReq.HeadersEntryR\aheaders\x12\x12\n" + + "\x04body\x18\x03 \x01(\fR\x04body\x1a:\n" + + "\fHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x012\xa8\x03\n" + + "\aPayment\x12I\n" + + "\x03Pay\x12\x0f.payment.PayReq\x1a\x11.payment.Response\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/api/v3/payment/pay\x12O\n" + + "\x05Query\x12\x11.payment.QueryReq\x1a\x11.payment.Response\" \x82\xd3\xe4\x93\x02\x1a:\x01*\"\x15/api/v3/payment/query\x12O\n" + + "\x05Close\x12\x11.payment.CloseReq\x1a\x11.payment.Response\" \x82\xd3\xe4\x93\x02\x1a:\x01*\"\x15/api/v3/payment/close\x12R\n" + + "\x06Refund\x12\x12.payment.RefundReq\x1a\x11.payment.Response\"!\x82\xd3\xe4\x93\x02\x1b:\x01*\"\x16/api/v3/payment/refund\x12\\\n" + + "\x06Notify\x12\x12.payment.NotifyReq\x1a\x11.payment.Response\"+\x82\xd3\xe4\x93\x02%:\x01*\" /api/v3/payment/notify/{channel}B\x1bZ\x19lone-services/rpc/paymentb\x06proto3" + +var ( + file_payment_payment_proto_rawDescOnce sync.Once + file_payment_payment_proto_rawDescData []byte +) + +func file_payment_payment_proto_rawDescGZIP() []byte { + file_payment_payment_proto_rawDescOnce.Do(func() { + file_payment_payment_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_payment_payment_proto_rawDesc), len(file_payment_payment_proto_rawDesc))) + }) + return file_payment_payment_proto_rawDescData +} + +var file_payment_payment_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_payment_payment_proto_goTypes = []any{ + (*Response)(nil), // 0: payment.Response + (*PayReq)(nil), // 1: payment.PayReq + (*QueryReq)(nil), // 2: payment.QueryReq + (*CloseReq)(nil), // 3: payment.CloseReq + (*RefundReq)(nil), // 4: payment.RefundReq + (*NotifyReq)(nil), // 5: payment.NotifyReq + nil, // 6: payment.NotifyReq.HeadersEntry +} +var file_payment_payment_proto_depIdxs = []int32{ + 6, // 0: payment.NotifyReq.headers:type_name -> payment.NotifyReq.HeadersEntry + 1, // 1: payment.Payment.Pay:input_type -> payment.PayReq + 2, // 2: payment.Payment.Query:input_type -> payment.QueryReq + 3, // 3: payment.Payment.Close:input_type -> payment.CloseReq + 4, // 4: payment.Payment.Refund:input_type -> payment.RefundReq + 5, // 5: payment.Payment.Notify:input_type -> payment.NotifyReq + 0, // 6: payment.Payment.Pay:output_type -> payment.Response + 0, // 7: payment.Payment.Query:output_type -> payment.Response + 0, // 8: payment.Payment.Close:output_type -> payment.Response + 0, // 9: payment.Payment.Refund:output_type -> payment.Response + 0, // 10: payment.Payment.Notify:output_type -> payment.Response + 6, // [6:11] is the sub-list for method output_type + 1, // [1:6] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_payment_payment_proto_init() } +func file_payment_payment_proto_init() { + if File_payment_payment_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_payment_payment_proto_rawDesc), len(file_payment_payment_proto_rawDesc)), + NumEnums: 0, + NumMessages: 7, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_payment_payment_proto_goTypes, + DependencyIndexes: file_payment_payment_proto_depIdxs, + MessageInfos: file_payment_payment_proto_msgTypes, + }.Build() + File_payment_payment_proto = out.File + file_payment_payment_proto_goTypes = nil + file_payment_payment_proto_depIdxs = nil +} diff --git a/rpc/payment/pb/payment_grpc.pb.go b/rpc/payment/pb/payment_grpc.pb.go new file mode 100644 index 0000000..ee1b468 --- /dev/null +++ b/rpc/payment/pb/payment_grpc.pb.go @@ -0,0 +1,283 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v3.19.4 +// source: payment/payment.proto + +package payment + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Payment_Pay_FullMethodName = "/payment.Payment/Pay" + Payment_Query_FullMethodName = "/payment.Payment/Query" + Payment_Close_FullMethodName = "/payment.Payment/Close" + Payment_Refund_FullMethodName = "/payment.Payment/Refund" + Payment_Notify_FullMethodName = "/payment.Payment/Notify" +) + +// PaymentClient is the client API for Payment service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type PaymentClient interface { + // 发起支付 + Pay(ctx context.Context, in *PayReq, opts ...grpc.CallOption) (*Response, error) + // 查询支付状态 + Query(ctx context.Context, in *QueryReq, opts ...grpc.CallOption) (*Response, error) + // 关闭支付 + Close(ctx context.Context, in *CloseReq, opts ...grpc.CallOption) (*Response, error) + // 退款 + Refund(ctx context.Context, in *RefundReq, opts ...grpc.CallOption) (*Response, error) + // 支付回调(由 BFF/网关转发原始 body + headers) + Notify(ctx context.Context, in *NotifyReq, opts ...grpc.CallOption) (*Response, error) +} + +type paymentClient struct { + cc grpc.ClientConnInterface +} + +func NewPaymentClient(cc grpc.ClientConnInterface) PaymentClient { + return &paymentClient{cc} +} + +func (c *paymentClient) Pay(ctx context.Context, in *PayReq, opts ...grpc.CallOption) (*Response, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Response) + err := c.cc.Invoke(ctx, Payment_Pay_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *paymentClient) Query(ctx context.Context, in *QueryReq, opts ...grpc.CallOption) (*Response, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Response) + err := c.cc.Invoke(ctx, Payment_Query_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *paymentClient) Close(ctx context.Context, in *CloseReq, opts ...grpc.CallOption) (*Response, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Response) + err := c.cc.Invoke(ctx, Payment_Close_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *paymentClient) Refund(ctx context.Context, in *RefundReq, opts ...grpc.CallOption) (*Response, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Response) + err := c.cc.Invoke(ctx, Payment_Refund_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *paymentClient) Notify(ctx context.Context, in *NotifyReq, opts ...grpc.CallOption) (*Response, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Response) + err := c.cc.Invoke(ctx, Payment_Notify_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// PaymentServer is the server API for Payment service. +// All implementations must embed UnimplementedPaymentServer +// for forward compatibility. +type PaymentServer interface { + // 发起支付 + Pay(context.Context, *PayReq) (*Response, error) + // 查询支付状态 + Query(context.Context, *QueryReq) (*Response, error) + // 关闭支付 + Close(context.Context, *CloseReq) (*Response, error) + // 退款 + Refund(context.Context, *RefundReq) (*Response, error) + // 支付回调(由 BFF/网关转发原始 body + headers) + Notify(context.Context, *NotifyReq) (*Response, error) + mustEmbedUnimplementedPaymentServer() +} + +// UnimplementedPaymentServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedPaymentServer struct{} + +func (UnimplementedPaymentServer) Pay(context.Context, *PayReq) (*Response, error) { + return nil, status.Error(codes.Unimplemented, "method Pay not implemented") +} +func (UnimplementedPaymentServer) Query(context.Context, *QueryReq) (*Response, error) { + return nil, status.Error(codes.Unimplemented, "method Query not implemented") +} +func (UnimplementedPaymentServer) Close(context.Context, *CloseReq) (*Response, error) { + return nil, status.Error(codes.Unimplemented, "method Close not implemented") +} +func (UnimplementedPaymentServer) Refund(context.Context, *RefundReq) (*Response, error) { + return nil, status.Error(codes.Unimplemented, "method Refund not implemented") +} +func (UnimplementedPaymentServer) Notify(context.Context, *NotifyReq) (*Response, error) { + return nil, status.Error(codes.Unimplemented, "method Notify not implemented") +} +func (UnimplementedPaymentServer) mustEmbedUnimplementedPaymentServer() {} +func (UnimplementedPaymentServer) testEmbeddedByValue() {} + +// UnsafePaymentServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to PaymentServer will +// result in compilation errors. +type UnsafePaymentServer interface { + mustEmbedUnimplementedPaymentServer() +} + +func RegisterPaymentServer(s grpc.ServiceRegistrar, srv PaymentServer) { + // If the following call panics, it indicates UnimplementedPaymentServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Payment_ServiceDesc, srv) +} + +func _Payment_Pay_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PayReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PaymentServer).Pay(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Payment_Pay_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PaymentServer).Pay(ctx, req.(*PayReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Payment_Query_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PaymentServer).Query(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Payment_Query_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PaymentServer).Query(ctx, req.(*QueryReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Payment_Close_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CloseReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PaymentServer).Close(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Payment_Close_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PaymentServer).Close(ctx, req.(*CloseReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Payment_Refund_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RefundReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PaymentServer).Refund(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Payment_Refund_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PaymentServer).Refund(ctx, req.(*RefundReq)) + } + return interceptor(ctx, in, info, handler) +} + +func _Payment_Notify_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(NotifyReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PaymentServer).Notify(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Payment_Notify_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PaymentServer).Notify(ctx, req.(*NotifyReq)) + } + return interceptor(ctx, in, info, handler) +} + +// Payment_ServiceDesc is the grpc.ServiceDesc for Payment service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Payment_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "payment.Payment", + HandlerType: (*PaymentServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Pay", + Handler: _Payment_Pay_Handler, + }, + { + MethodName: "Query", + Handler: _Payment_Query_Handler, + }, + { + MethodName: "Close", + Handler: _Payment_Close_Handler, + }, + { + MethodName: "Refund", + Handler: _Payment_Refund_Handler, + }, + { + MethodName: "Notify", + Handler: _Payment_Notify_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "payment/payment.proto", +} diff --git a/services/payment/etc/payment.yaml b/services/payment/etc/payment.yaml new file mode 100644 index 0000000..fc8e88a --- /dev/null +++ b/services/payment/etc/payment.yaml @@ -0,0 +1,7 @@ +Nacos: + Hosts: + - rnacos:8848 + NamespaceId: test + Group: LONE_SERVICES + RegisterIP: payment + ConfigID: payment \ No newline at end of file diff --git a/services/payment/internal/channel/factory.go b/services/payment/internal/channel/factory.go new file mode 100644 index 0000000..884a6ed --- /dev/null +++ b/services/payment/internal/channel/factory.go @@ -0,0 +1,62 @@ +package channel + +import ( + "fmt" + "sync" + + pkgpay "lone-services/pkg/payment" + "lone-services/pkg/payment/alipay" + "lone-services/pkg/payment/bank" + wxchannel "lone-services/pkg/payment/wechat" + "lone-services/pkg/utils" + "lone-services/services/payment/internal/config" +) + +var ( + cacheMu sync.Mutex + cache = map[string]pkgpay.Channel{} +) + +func Get(channelCode, clientCode string) (pkgpay.Channel, error) { + if channelCode == utils.StringEmpty { + return nil, pkgpay.ErrUnsupportedChannel + } + if clientCode == utils.StringEmpty { + clientCode = pkgpay.ClientMiniProgram + } + + cacheKey := channelCode + ":" + clientCode + cacheMu.Lock() + if ch, ok := cache[cacheKey]; ok { + cacheMu.Unlock() + return ch, nil + } + cacheMu.Unlock() + + ch, err := newChannel(channelCode, clientCode) + if err != nil { + return nil, err + } + + cacheMu.Lock() + cache[cacheKey] = ch + cacheMu.Unlock() + return ch, nil +} + +func newChannel(channelCode, clientCode string) (pkgpay.Channel, error) { + switch channelCode { + case pkgpay.ChannelWechat: + cfg, err := config.NewWechatPayConfig(clientCode) + if err != nil { + return nil, err + } + return wxchannel.NewChannel(cfg) + case pkgpay.ChannelAlipay: + return alipay.NewChannel(), nil + case pkgpay.ChannelBank: + return bank.NewChannel(), nil + default: + return nil, fmt.Errorf("%w: %s", pkgpay.ErrUnsupportedChannel, channelCode) + } +} diff --git a/services/payment/internal/config/config.go b/services/payment/internal/config/config.go new file mode 100644 index 0000000..a1979dd --- /dev/null +++ b/services/payment/internal/config/config.go @@ -0,0 +1,13 @@ +package config + +type Config struct { + Nacos NacosConf +} + +type NacosConf struct { + Hosts []string + NamespaceId string `json:",optional"` + Group string `json:",optional"` + RegisterIP string `json:",optional"` + ConfigID string `json:",optional"` +} diff --git a/services/payment/internal/config/wechatpay.go b/services/payment/internal/config/wechatpay.go new file mode 100644 index 0000000..b56b627 --- /dev/null +++ b/services/payment/internal/config/wechatpay.go @@ -0,0 +1,69 @@ +package config + +import ( + "fmt" + + "lone-services/pkg/utils" + wxpay "lone-services/pkg/wechat/pay" +) + +func NewWechatPayConfig(clientCode string) (wxpay.Config, error) { + if clientCode == utils.StringEmpty { + return wxpay.Config{}, fmt.Errorf("wechat pay client_code empty") + } + + prefix := "wechat." + clientCode + appID := utils.GetConfigString(prefix + ".app_id") + if appID == utils.StringEmpty { + appID = utils.GetConfigString("wechat.appid") + } + + mchID := firstNonEmpty( + utils.GetConfigString(prefix+".pay_mchid"), + utils.GetConfigString("wechat.pay_mchid"), + ) + serialNo := firstNonEmpty( + utils.GetConfigString(prefix+".pay_serial_no"), + utils.GetConfigString("wechat.pay_serial_no"), + ) + privateKeyPath := firstNonEmpty( + utils.GetConfigString(prefix+".pay_private_key_path"), + utils.GetConfigString("wechat.pay_private_key_path"), + ) + platformCertPath := firstNonEmpty( + utils.GetConfigString(prefix+".pay_platform_cert_path"), + utils.GetConfigString("wechat.pay_platform_cert_path"), + ) + apiV3Key := firstNonEmpty( + utils.GetConfigString(prefix+".pay_apiv3_key"), + utils.GetConfigString("wechat.pay_apiv3_key"), + ) + notifyURL := firstNonEmpty( + utils.GetConfigString(prefix+".pay_notify_url"), + utils.GetConfigString("wechat.pay_notify_url"), + ) + + if appID == utils.StringEmpty || mchID == utils.StringEmpty || serialNo == utils.StringEmpty || + privateKeyPath == utils.StringEmpty || apiV3Key == utils.StringEmpty { + return wxpay.Config{}, fmt.Errorf("wechat pay config missing for client %s", clientCode) + } + + return wxpay.Config{ + AppID: appID, + MchID: mchID, + CertificateSerialNo: serialNo, + PrivateKeyPath: privateKeyPath, + PlatformCertPath: platformCertPath, + APIv3Key: apiV3Key, + NotifyURL: notifyURL, + }, nil +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != utils.StringEmpty { + return v + } + } + return utils.StringEmpty +} diff --git a/services/payment/internal/dao/const.go b/services/payment/internal/dao/const.go new file mode 100644 index 0000000..8b5e871 --- /dev/null +++ b/services/payment/internal/dao/const.go @@ -0,0 +1,47 @@ +package dao + +const ( + PayStatusOk uint8 = 1 // 已支付 + PayStatusWait uint8 = 2 // 未支付 + PayStatusIn uint8 = 3 // 支付中 + PayStatusFail uint8 = 4 // 支付失败 + PayStatusLose uint8 = 5 // 已失效 + + PayTypeWechat uint8 = 1 + PayTypeAlipay uint8 = 2 + PayTypeBank uint8 = 4 + + CredentialTypeOpenid = "openid" + CredentialEnabled = 1 +) + +type OrderPayRow struct { + Id int64 `gorm:"column:id" json:"id"` + OrderSn string `gorm:"column:order_sn" json:"order_sn"` + UserId int64 `gorm:"column:user_id" json:"user_id"` + PayPrice float64 `gorm:"column:pay_price" json:"pay_price"` + PayType uint8 `gorm:"column:pay_type" json:"pay_type"` + Status uint8 `gorm:"column:status" json:"status"` + OutTradeNo string `gorm:"column:out_trade_no" json:"out_trade_no"` +} + +type UserCredential struct { + Id int64 `gorm:"column:id" json:"id"` + UserId int64 `gorm:"column:user_id" json:"user_id"` + CredentialType string `gorm:"column:credential_type" json:"credential_type"` + Identifier string `gorm:"column:identifier" json:"identifier"` + Status uint8 `gorm:"column:status" json:"status"` +} + +func ChannelToPayType(channel string) uint8 { + switch channel { + case "wechat": + return PayTypeWechat + case "alipay": + return PayTypeAlipay + case "bank": + return PayTypeBank + default: + return 0 + } +} diff --git a/services/payment/internal/logic/closeLogic.go b/services/payment/internal/logic/closeLogic.go new file mode 100644 index 0000000..1463190 --- /dev/null +++ b/services/payment/internal/logic/closeLogic.go @@ -0,0 +1,32 @@ +package logic + +import ( + "context" + + "lone-services/pkg/utils" + payment "lone-services/rpc/payment/pb" + "lone-services/services/payment/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CloseLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewCloseLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CloseLogic { + return &CloseLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +func (l *CloseLogic) Close(in *payment.CloseReq) (*payment.Response, error) { + if in == nil || in.OrderSn == utils.StringEmpty { + return failResponse(utils.ErrorParams), nil + } + return outResponse(utils.Fail, "close not implemented"), nil +} diff --git a/services/payment/internal/logic/notifyLogic.go b/services/payment/internal/logic/notifyLogic.go new file mode 100644 index 0000000..e13f379 --- /dev/null +++ b/services/payment/internal/logic/notifyLogic.go @@ -0,0 +1,96 @@ +package logic + +import ( + "context" + "errors" + + "lone-services/pkg/modelbase" + pkgpay "lone-services/pkg/payment" + "lone-services/pkg/utils" + payment "lone-services/rpc/payment/pb" + "lone-services/services/payment/internal/channel" + "lone-services/services/payment/internal/dao" + "lone-services/services/payment/internal/model" + "lone-services/services/payment/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type NotifyLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewNotifyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *NotifyLogic { + return &NotifyLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +func (l *NotifyLogic) Notify(in *payment.NotifyReq) (*payment.Response, error) { + if in == nil || len(in.Body) == 0 { + return failResponse(utils.ErrorParams), nil + } + + channelCode := resolveChannel(in.Channel) + ch, err := channel.Get(channelCode, pkgpay.ClientMiniProgram) + if err != nil { + l.Errorf("notify channel: %v", err) + return outResponse(utils.Fail, err.Error()), nil + } + + result, err := ch.ParsePayNotify(l.ctx, in.Headers, in.Body) + if err != nil { + l.Errorf("notify parse: %v", err) + return outResponse(utils.Fail, err.Error()), nil + } + if result == nil || !result.Success { + return outResponse(utils.Fail, "支付未成功"), nil + } + + if err := markOrderPaid(result); err != nil { + l.Errorf("notify mark paid: %v", err) + return outResponse(utils.Fail, err.Error()), nil + } + + return okResponse(result), nil +} + +func markOrderPaid(result *pkgpay.NotifyResult) error { + if result.OutTradeNo == utils.StringEmpty { + return errors.New("out_trade_no empty") + } + + var row dao.OrderPayRow + err := model.OrderModel{}.Init().GetOne(modelbase.Params{ + Eq: map[string]string{"order_sn": result.OutTradeNo}, + }, &row) + if err != nil { + return err + } + if row.Id < utils.NumberOne { + return errors.New("order not found") + } + if row.Status == dao.PayStatusOk { + return nil // idempotent + } + + expected := yuanToFen(row.PayPrice) + if expected > 0 && result.AmountFen > 0 && expected != result.AmountFen { + return errors.New("amount mismatch") + } + + updates := map[string]any{ + "status": dao.PayStatusOk, + } + if result.OutTradeNo != utils.StringEmpty { + updates["out_trade_no"] = result.OutTradeNo + } + _, err = model.OrderModel{}.Init().Edit(modelbase.Params{ + Eq: map[string]string{"order_sn": result.OutTradeNo}, + }, updates) + return err +} diff --git a/services/payment/internal/logic/payLogic.go b/services/payment/internal/logic/payLogic.go new file mode 100644 index 0000000..36b0c2a --- /dev/null +++ b/services/payment/internal/logic/payLogic.go @@ -0,0 +1,159 @@ +package logic + +import ( + "context" + "fmt" + "math" + "strconv" + + "lone-services/pkg/modelbase" + pkgpay "lone-services/pkg/payment" + "lone-services/pkg/utils" + payment "lone-services/rpc/payment/pb" + "lone-services/services/payment/internal/channel" + "lone-services/services/payment/internal/dao" + "lone-services/services/payment/internal/model" + "lone-services/services/payment/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PayLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewPayLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PayLogic { + return &PayLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +func (l *PayLogic) Pay(in *payment.PayReq) (*payment.Response, error) { + if in == nil || in.OrderSn == utils.StringEmpty { + return failResponse(utils.ErrorParams), nil + } + + loginUser := utils.GetUserFromCtx(l.ctx) + if loginUser.ID < utils.NumberOne { + return failResponse(utils.ErrorNoLoginInfo), nil + } + + client := resolveClient(in.Client, loginUser.ClientCode) + channelCode := resolveChannel(in.Channel) + + order, err := loadPayableOrder(in.OrderSn, loginUser.ID) + if err != nil { + l.Errorf("pay load order: %v", err) + return failResponse(utils.ErrorNotFund), nil + } + if order.Status == dao.PayStatusOk { + return &payment.Response{ + Code: int32(utils.ErrorRepeatPayment.GetCode()), + Msg: utils.ErrorRepeatPayment.GetMsg(), + }, nil + } + if order.Status != dao.PayStatusWait && order.Status != dao.PayStatusIn && order.Status != dao.PayStatusFail { + return outResponse(utils.Fail, "订单状态不可支付"), nil + } + + amountFen := yuanToFen(order.PayPrice) + if amountFen <= 0 { + return outResponse(utils.Fail, "支付金额无效"), nil + } + + ch, err := channel.Get(channelCode, client) + if err != nil { + l.Errorf("pay channel: %v", err) + return outResponse(utils.Fail, err.Error()), nil + } + + prepayReq := &pkgpay.PrepayRequest{ + OutTradeNo: in.OrderSn, + Description: "订单支付", + AmountFen: amountFen, + Currency: "CNY", + Attach: in.OrderSn, + } + + switch channelCode { + case pkgpay.ChannelWechat: + openid, oErr := loadOpenID(loginUser.ID) + if oErr != nil || openid == utils.StringEmpty { + l.Errorf("pay openid: %v", oErr) + return outResponse(utils.Fail, "未绑定微信openid"), nil + } + prepayReq.OpenID = openid + case pkgpay.ChannelAlipay, pkgpay.ChannelBank: + return outResponse(utils.Fail, "支付渠道暂未开通"), nil + } + + result, err := ch.Prepay(l.ctx, client, prepayReq) + if err != nil { + l.Errorf("pay prepay: %v", err) + return outResponse(utils.Fail, err.Error()), nil + } + + _ = markOrderPaying(in.OrderSn, channelCode, in.OrderSn) + + return okResponse(result), nil +} + +func loadPayableOrder(orderSn string, userID int64) (*dao.OrderPayRow, error) { + var row dao.OrderPayRow + err := model.OrderModel{}.Init().GetOne(modelbase.Params{ + Eq: map[string]string{ + "order_sn": orderSn, + }, + }, &row) + if err != nil { + return nil, err + } + if row.Id < utils.NumberOne { + return nil, fmt.Errorf("order not found") + } + if row.UserId > 0 && row.UserId != userID { + return nil, fmt.Errorf("order user mismatch") + } + return &row, nil +} + +func loadOpenID(userID int64) (string, error) { + var cred dao.UserCredential + err := model.UserCredentialModel{}.Init().GetOne(modelbase.Params{ + Eq: map[string]string{ + "user_id": strconv.FormatInt(userID, 10), + "credential_type": dao.CredentialTypeOpenid, + "status": strconv.Itoa(int(dao.CredentialEnabled)), + }, + }, &cred) + if err != nil { + return "", err + } + if cred.Id < utils.NumberOne { + return "", fmt.Errorf("openid credential not found") + } + return cred.Identifier, nil +} + +func markOrderPaying(orderSn, channelCode, outTradeNo string) error { + payType := dao.ChannelToPayType(channelCode) + updates := map[string]any{ + "status": dao.PayStatusIn, + "out_trade_no": outTradeNo, + } + if payType > 0 { + updates["pay_type"] = payType + } + _, err := model.OrderModel{}.Init().Edit(modelbase.Params{ + Eq: map[string]string{"order_sn": orderSn}, + }, updates) + return err +} + +func yuanToFen(yuan float64) int64 { + return int64(math.Round(yuan * 100)) +} diff --git a/services/payment/internal/logic/queryLogic.go b/services/payment/internal/logic/queryLogic.go new file mode 100644 index 0000000..7fe1806 --- /dev/null +++ b/services/payment/internal/logic/queryLogic.go @@ -0,0 +1,32 @@ +package logic + +import ( + "context" + + "lone-services/pkg/utils" + payment "lone-services/rpc/payment/pb" + "lone-services/services/payment/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type QueryLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewQueryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryLogic { + return &QueryLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +func (l *QueryLogic) Query(in *payment.QueryReq) (*payment.Response, error) { + if in == nil || in.OrderSn == utils.StringEmpty { + return failResponse(utils.ErrorParams), nil + } + return outResponse(utils.Fail, "query not implemented"), nil +} diff --git a/services/payment/internal/logic/refundLogic.go b/services/payment/internal/logic/refundLogic.go new file mode 100644 index 0000000..15af415 --- /dev/null +++ b/services/payment/internal/logic/refundLogic.go @@ -0,0 +1,94 @@ +package logic + +import ( + "context" + "fmt" + "strconv" + "time" + + pkgpay "lone-services/pkg/payment" + "lone-services/pkg/modelbase" + "lone-services/pkg/utils" + payment "lone-services/rpc/payment/pb" + "lone-services/services/payment/internal/channel" + "lone-services/services/payment/internal/dao" + "lone-services/services/payment/internal/model" + "lone-services/services/payment/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RefundLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewRefundLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RefundLogic { + return &RefundLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +func (l *RefundLogic) Refund(in *payment.RefundReq) (*payment.Response, error) { + if in == nil || in.OrderSn == utils.StringEmpty || in.Amount == utils.StringEmpty { + return failResponse(utils.ErrorParams), nil + } + + loginUser := utils.GetUserFromCtx(l.ctx) + if loginUser.ID < utils.NumberOne { + return failResponse(utils.ErrorNoLoginInfo), nil + } + + channelCode := resolveChannel(in.Channel) + client := resolveClient(utils.StringEmpty, loginUser.ClientCode) + + var row dao.OrderPayRow + err := model.OrderModel{}.Init().GetOne(modelbase.Params{ + Eq: map[string]string{"order_sn": in.OrderSn}, + }, &row) + if err != nil || row.Id < utils.NumberOne { + return failResponse(utils.ErrorNotFund), nil + } + if row.Status != dao.PayStatusOk { + return outResponse(utils.Fail, "订单未支付,无法退款"), nil + } + + refundYuan, err := strconv.ParseFloat(in.Amount, 64) + if err != nil || refundYuan <= 0 { + return failResponse(utils.ErrorParams), nil + } + refundFen := yuanToFen(refundYuan) + totalFen := yuanToFen(row.PayPrice) + if refundFen > totalFen { + return outResponse(utils.Fail, "退款金额不能大于支付金额"), nil + } + + ch, err := channel.Get(channelCode, client) + if err != nil { + return outResponse(utils.Fail, err.Error()), nil + } + + outTradeNo := row.OutTradeNo + if outTradeNo == utils.StringEmpty { + outTradeNo = row.OrderSn + } + outRefundNo := fmt.Sprintf("RF%s%d", row.OrderSn, time.Now().UnixNano()) + + result, err := ch.Refund(l.ctx, &pkgpay.RefundRequest{ + OutTradeNo: outTradeNo, + OutRefundNo: outRefundNo, + Reason: in.Reason, + RefundFen: refundFen, + TotalFen: totalFen, + Currency: "CNY", + }) + if err != nil { + l.Errorf("refund: %v", err) + return outResponse(utils.Fail, err.Error()), nil + } + + return okResponse(result), nil +} diff --git a/services/payment/internal/logic/response.go b/services/payment/internal/logic/response.go new file mode 100644 index 0000000..90a6296 --- /dev/null +++ b/services/payment/internal/logic/response.go @@ -0,0 +1,49 @@ +package logic + +import ( + pkgpay "lone-services/pkg/payment" + "lone-services/pkg/utils" + payment "lone-services/rpc/payment/pb" + + jsoniter "github.com/json-iterator/go" +) + +func okResponse(data any) *payment.Response { + buf, _ := jsoniter.Marshal(data) + return &payment.Response{ + Code: utils.Ok.Code, + Msg: utils.Ok.Msg, + Data: string(buf), + } +} + +func failResponse(status utils.Status) *payment.Response { + return &payment.Response{ + Code: status.Code, + Msg: status.Msg, + } +} + +func outResponse(status utils.Status, msg string) *payment.Response { + return &payment.Response{ + Code: status.Code, + Msg: msg, + } +} + +func resolveClient(reqClient, headerClient string) string { + if reqClient != utils.StringEmpty { + return reqClient + } + if headerClient != utils.StringEmpty { + return headerClient + } + return pkgpay.ClientMiniProgram +} + +func resolveChannel(channel string) string { + if channel == utils.StringEmpty { + return pkgpay.ChannelWechat + } + return channel +} diff --git a/services/payment/internal/server/paymentServer.go b/services/payment/internal/server/paymentServer.go new file mode 100644 index 0000000..7d39eb9 --- /dev/null +++ b/services/payment/internal/server/paymentServer.go @@ -0,0 +1,54 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl 1.10.1 +// Source: payment.proto + +package server + +import ( + "context" + + "lone-services/rpc/payment/pb" + "lone-services/services/payment/internal/logic" + "lone-services/services/payment/internal/svc" +) + +type PaymentServer struct { + svcCtx *svc.ServiceContext + payment.UnimplementedPaymentServer +} + +func NewPaymentServer(svcCtx *svc.ServiceContext) *PaymentServer { + return &PaymentServer{ + svcCtx: svcCtx, + } +} + +// 发起支付 +func (s *PaymentServer) Pay(ctx context.Context, in *payment.PayReq) (*payment.Response, error) { + l := logic.NewPayLogic(ctx, s.svcCtx) + return l.Pay(in) +} + +// 查询支付状态 +func (s *PaymentServer) Query(ctx context.Context, in *payment.QueryReq) (*payment.Response, error) { + l := logic.NewQueryLogic(ctx, s.svcCtx) + return l.Query(in) +} + +// 关闭支付 +func (s *PaymentServer) Close(ctx context.Context, in *payment.CloseReq) (*payment.Response, error) { + l := logic.NewCloseLogic(ctx, s.svcCtx) + return l.Close(in) +} + +// 退款 +func (s *PaymentServer) Refund(ctx context.Context, in *payment.RefundReq) (*payment.Response, error) { + l := logic.NewRefundLogic(ctx, s.svcCtx) + return l.Refund(in) +} + +// 支付回调(由 BFF/网关转发原始 body + headers) +func (s *PaymentServer) Notify(ctx context.Context, in *payment.NotifyReq) (*payment.Response, error) { + l := logic.NewNotifyLogic(ctx, s.svcCtx) + return l.Notify(in) +} diff --git a/services/payment/internal/svc/servicecontext.go b/services/payment/internal/svc/servicecontext.go new file mode 100644 index 0000000..be9d4b3 --- /dev/null +++ b/services/payment/internal/svc/servicecontext.go @@ -0,0 +1,99 @@ +package svc + +import ( + "fmt" + "lone-services/pkg/discovery" + "lone-services/pkg/utils" + "lone-services/services/payment/internal/config" + "net" + "strconv" + "sync" + "time" + + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/zrpc" + "gorm.io/gorm" +) + +type rpcClientCacheEntry struct { + cli zrpc.Client + target string + expireAt time.Time +} + +const rpcClientCacheTTL = 30 * time.Second + +var ( + rpcCacheLock sync.Mutex + rpcCache = make(map[string]*rpcClientCacheEntry) +) + +func GetRpcClient(serviceName string) (zrpc.Client, error) { + if serviceName == utils.StringEmpty { + err := fmt.Errorf("rpc serviceName is empty") + logx.Error(err) + return nil, err + } + + cacheKey := serviceName + + rpcCacheLock.Lock() + entry, ok := rpcCache[cacheKey] + if ok && time.Now().Before(entry.expireAt) { + rpcCacheLock.Unlock() + return entry.cli, nil + } + delete(rpcCache, cacheKey) + rpcCacheLock.Unlock() + + inst, err := discovery.Pick(serviceName) + if err != nil { + err = fmt.Errorf("discovery pick %s failed: %w", serviceName, err) + logx.Error(err) + return nil, err + } + + target := net.JoinHostPort(inst.IP, strconv.FormatUint(inst.Port, utils.NumberTen)) + + cli := zrpc.MustNewClient(zrpc.RpcClientConf{ + Target: target, + Timeout: utils.RpcTimeOut, + }) + + rpcCacheLock.Lock() + rpcCache[cacheKey] = &rpcClientCacheEntry{ + cli: cli, + target: target, + expireAt: time.Now().Add(rpcClientCacheTTL), + } + rpcCacheLock.Unlock() + + return cli, nil +} + +type ServiceContext struct { + Config config.Config + DB *gorm.DB + Prefix string + OrderSvcName string + UserSvcName string +} + +func NewServiceContext(c config.Config, db *gorm.DB) *ServiceContext { + orderSvc := utils.GetConfigString("services.order") + userSvc := utils.GetConfigString("services.user") + if orderSvc == utils.StringEmpty { + logx.Error("config services.order empty") + } + if userSvc == utils.StringEmpty { + logx.Error("config services.user empty") + } + + return &ServiceContext{ + Config: c, + DB: db, + Prefix: utils.GetConfigString("mysql.prefix"), + OrderSvcName: orderSvc, + UserSvcName: userSvc, + } +} diff --git a/services/payment/payment.go b/services/payment/payment.go new file mode 100644 index 0000000..4eda18a --- /dev/null +++ b/services/payment/payment.go @@ -0,0 +1,152 @@ +package main + +import ( + "flag" + "net" + "os" + "strconv" + + "lone-services/pkg/discovery" + "lone-services/pkg/modelbase" + "lone-services/pkg/mysql" + "lone-services/pkg/redis" + "lone-services/pkg/utils" + "lone-services/pkg/validate" + payment "lone-services/rpc/payment/pb" + "lone-services/services/payment/internal/config" + "lone-services/services/payment/internal/server" + "lone-services/services/payment/internal/svc" + + "github.com/zeromicro/go-zero/core/conf" + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/core/service" + "github.com/zeromicro/go-zero/zrpc" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" +) + +var configFile = flag.String("f", "etc/payment.yaml", "the config file") + +func main() { + flag.Parse() + var c config.Config + conf.MustLoad(*configFile, &c) + + nacosParam := utils.NacosConfig{ + Hosts: c.Nacos.Hosts, + NamespaceId: c.Nacos.NamespaceId, + Group: c.Nacos.Group, + ConfigID: c.Nacos.ConfigID, + } + utils.InitConfig(nacosParam) + + logConf := logx.LogConf{ + ServiceName: utils.GetConfigString("log.serviceName"), + Mode: utils.GetConfigString("log.mode"), + Encoding: utils.GetConfigString("log.encoding"), + Level: utils.GetConfigString("log.level"), + Path: utils.GetConfigString("log.path"), + KeepDays: utils.GetConfigInt("log.keepDays"), + MaxSize: utils.GetConfigInt("log.maxSize"), + MaxBackups: utils.GetConfigInt("log.maxBackups"), + Compress: utils.GetConfigBool("log.compress"), + } + logx.SetUp(logConf) + logx.AddWriter(logx.NewWriter(os.Stdout)) + + listenOn := utils.GetConfigString("base.listenOn") + mode := utils.GetConfigString("base.mode") + serviceName := utils.GetConfigString("base.name") + + if err := discovery.Init(discovery.Config{ + Hosts: c.Nacos.Hosts, + NamespaceId: c.Nacos.NamespaceId, + Group: c.Nacos.Group, + }); err != nil { + logx.Errorf("nacos init: %v", err) + os.Exit(1) + } + + port, err := listenPort(listenOn) + if err != nil { + logx.Errorf("parse ListenOn: %v", err) + os.Exit(1) + } + + if err := discovery.Register(discovery.Instance{ + ServiceName: serviceName, + IP: c.Nacos.RegisterIP, + Port: port, + Group: c.Nacos.Group, + }); err != nil { + logx.Errorf("nacos register: %v", err) + os.Exit(1) + } + logx.Infof("服务注册成功: %s:%d", c.Nacos.RegisterIP, port) + + defer func() { + if err := discovery.Deregister(); err != nil { + logx.Errorf("nacos deregister: %v", err) + } + }() + + db, err := mysql.New(mysql.Config{ + Host: utils.GetConfigString("mysql.host"), + Port: utils.GetConfigInt("mysql.port"), + User: utils.GetConfigString("mysql.user"), + Password: utils.GetConfigString("mysql.password"), + Database: utils.GetConfigString("mysql.database"), + Charset: utils.GetConfigString("mysql.charset"), + Prefix: utils.GetConfigString("mysql.prefix"), + ReadHost: utils.GetConfigString("mysql_read.host"), + ReadPort: utils.GetConfigInt("mysql_read.port"), + ReadUser: utils.GetConfigString("mysql_read.user"), + ReadPassword: utils.GetConfigString("mysql_read.password"), + ReadDatabase: utils.GetConfigString("mysql_read.database"), + }) + if err != nil { + logx.Errorf("mysql init: %v", err) + os.Exit(1) + } + + if err := redis.Init(redis.Config{ + Host: utils.GetConfigString("redis.host"), + Port: utils.GetConfigInt("redis.port"), + Password: utils.GetConfigString("redis.password"), + DB: utils.GetConfigInt("redis.db"), + }); err != nil { + logx.Errorf("redis init: %v", err) + os.Exit(1) + } + + debug := utils.GetConfigBool("mysql.debug") + modelbase.Init(db, modelbase.Config{Prefix: utils.GetConfigString("mysql.prefix"), Debug: debug}) + + rpcConf := zrpc.RpcServerConf{ + ListenOn: listenOn, + } + rpcConf.Mode = mode + + ctx := svc.NewServiceContext(c, db) + + s := zrpc.MustNewServer(rpcConf, func(grpcServer *grpc.Server) { + payment.RegisterPaymentServer(grpcServer, server.NewPaymentServer(ctx)) + if mode == service.DevMode || mode == service.TestMode { + reflection.Register(grpcServer) + } + }) + defer s.Stop() + + s.AddUnaryInterceptors(validate.UnaryServerInterceptor(validate.MustNew())) + + logx.Infof("Starting rpc server at %s...", listenOn) + s.Start() +} + +func listenPort(listenOn string) (uint64, error) { + _, portStr, err := net.SplitHostPort(listenOn) + if err != nil { + return 0, err + } + return strconv.ParseUint(portStr, 10, 64) +} diff --git a/services/payment/paymentclient/payment.go b/services/payment/paymentclient/payment.go new file mode 100644 index 0000000..82362ab --- /dev/null +++ b/services/payment/paymentclient/payment.go @@ -0,0 +1,76 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl 1.10.1 +// Source: payment.proto + +package paymentClient + +import ( + "context" + + "lone-services/rpc/payment/pb" + + "github.com/zeromicro/go-zero/zrpc" + "google.golang.org/grpc" +) + +type ( + CloseReq = payment.CloseReq + NotifyReq = payment.NotifyReq + PayReq = payment.PayReq + QueryReq = payment.QueryReq + RefundReq = payment.RefundReq + Response = payment.Response + + Payment interface { + // 发起支付 + Pay(ctx context.Context, in *PayReq, opts ...grpc.CallOption) (*Response, error) + // 查询支付状态 + Query(ctx context.Context, in *QueryReq, opts ...grpc.CallOption) (*Response, error) + // 关闭支付 + Close(ctx context.Context, in *CloseReq, opts ...grpc.CallOption) (*Response, error) + // 退款 + Refund(ctx context.Context, in *RefundReq, opts ...grpc.CallOption) (*Response, error) + // 支付回调(由 BFF/网关转发原始 body + headers) + Notify(ctx context.Context, in *NotifyReq, opts ...grpc.CallOption) (*Response, error) + } + + defaultPayment struct { + cli zrpc.Client + } +) + +func NewPayment(cli zrpc.Client) Payment { + return &defaultPayment{ + cli: cli, + } +} + +// 发起支付 +func (m *defaultPayment) Pay(ctx context.Context, in *PayReq, opts ...grpc.CallOption) (*Response, error) { + client := payment.NewPaymentClient(m.cli.Conn()) + return client.Pay(ctx, in, opts...) +} + +// 查询支付状态 +func (m *defaultPayment) Query(ctx context.Context, in *QueryReq, opts ...grpc.CallOption) (*Response, error) { + client := payment.NewPaymentClient(m.cli.Conn()) + return client.Query(ctx, in, opts...) +} + +// 关闭支付 +func (m *defaultPayment) Close(ctx context.Context, in *CloseReq, opts ...grpc.CallOption) (*Response, error) { + client := payment.NewPaymentClient(m.cli.Conn()) + return client.Close(ctx, in, opts...) +} + +// 退款 +func (m *defaultPayment) Refund(ctx context.Context, in *RefundReq, opts ...grpc.CallOption) (*Response, error) { + client := payment.NewPaymentClient(m.cli.Conn()) + return client.Refund(ctx, in, opts...) +} + +// 支付回调(由 BFF/网关转发原始 body + headers) +func (m *defaultPayment) Notify(ctx context.Context, in *NotifyReq, opts ...grpc.CallOption) (*Response, error) { + client := payment.NewPaymentClient(m.cli.Conn()) + return client.Notify(ctx, in, opts...) +}