feat: 初始化 Wails + React + Go 桌面应用模板
- Wails v2.16.0 + React 19 + TypeScript + Vite 7 - 插件系统(build tag 控制编译,plugin.json 运行时配置) - 支付插件(授权、账号、订单、退款、账单) - 高并发基础设施(任务池、批量处理器、限流器、环形缓冲、管道) - Windows 系统托盘(Win32 API) - CodeGraph 索引 + .memory 记忆包 - 默认图标 assets/icon.png
This commit is contained in:
272
internal/payment/client.go
Normal file
272
internal/payment/client.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 配置与数据结构
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
// Config 支付平台配置,可接入任意支付网关
|
||||
type Config struct {
|
||||
AppID string `json:"app_id"`
|
||||
MchID string `json:"mch_id"` // 商户号(可选)
|
||||
APIKey string `json:"api_key"` // 签名密钥
|
||||
BaseURL string `json:"base_url"` // 网关地址
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
AutoRefreshToken bool `json:"auto_refresh_token"` // 是否自动续期
|
||||
}
|
||||
|
||||
// Token 授权令牌
|
||||
type Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
IsExpired func() bool
|
||||
}
|
||||
|
||||
func (t *Token) update(expiresIn int) {
|
||||
t.ExpiresIn = expiresIn
|
||||
t.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
t.IsExpired = func() bool {
|
||||
return time.Now().After(t.ExpiresAt.Add(-30 * time.Second)) // 提前30秒判定过期
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// HTTP 客户端(带自动重填和签名)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
// Client 支付 HTTP 客户端
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
cfg Config
|
||||
httpClient *http.Client
|
||||
token *Token
|
||||
tokenMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewClient 创建支付客户端
|
||||
func NewClient(cfg Config) *Client {
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 10 * time.Second
|
||||
}
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = "https://api.example.com"
|
||||
}
|
||||
c := &Client{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{Timeout: cfg.Timeout},
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// GetToken 获取访问令牌
|
||||
func (c *Client) GetToken(ctx context.Context) (*Token, error) {
|
||||
c.tokenMu.RLock()
|
||||
if c.token != nil && !c.token.IsExpired() {
|
||||
c.tokenMu.RUnlock()
|
||||
return c.token, nil
|
||||
}
|
||||
c.tokenMu.RUnlock()
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// double-check
|
||||
c.tokenMu.RLock()
|
||||
if c.token != nil && !c.token.IsExpired() {
|
||||
c.tokenMu.RUnlock()
|
||||
return c.token, nil
|
||||
}
|
||||
c.tokenMu.RUnlock()
|
||||
|
||||
reqURL := fmt.Sprintf("%s/oauth/token", c.cfg.BaseURL)
|
||||
body := map[string]string{
|
||||
"app_id": c.cfg.AppID,
|
||||
"timestamp": fmt.Sprintf("%d", time.Now().Unix()),
|
||||
"nonce": generateNonce(),
|
||||
}
|
||||
reqBody, _ := json.Marshal(body)
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, strings.NewReader(string(reqBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
addSignature(req, body, c.cfg.APIKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("get token failed: %s", result.Message)
|
||||
}
|
||||
|
||||
c.token = &Token{}
|
||||
c.token.update(result.Data.ExpiresIn)
|
||||
c.token.AccessToken = result.Data.AccessToken
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
// RefreshToken 主动刷新令牌
|
||||
func (c *Client) RefreshToken(ctx context.Context) (*Token, error) {
|
||||
reqURL := fmt.Sprintf("%s/oauth/refresh", c.cfg.BaseURL)
|
||||
body := map[string]string{
|
||||
"app_id": c.cfg.AppID,
|
||||
"timestamp": fmt.Sprintf("%d", time.Now().Unix()),
|
||||
}
|
||||
reqBody, _ := json.Marshal(body)
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, strings.NewReader(string(reqBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
addSignature(req, body, c.cfg.APIKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("refresh token failed: %s", result.Message)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.token = &Token{}
|
||||
c.token.update(result.Data.ExpiresIn)
|
||||
c.token.AccessToken = result.Data.AccessToken
|
||||
c.mu.Unlock()
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
// Get 发送 GET 请求(自动携带 token)
|
||||
func (c *Client) Get(ctx context.Context, path string, params map[string]string) ([]byte, error) {
|
||||
token, err := c.GetToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.doRequest(ctx, http.MethodGet, path, params, token.AccessToken, nil)
|
||||
}
|
||||
|
||||
// Post 发送 POST 请求(自动携带 token)
|
||||
func (c *Client) Post(ctx context.Context, path string, params map[string]string, body any) ([]byte, error) {
|
||||
token, err := c.GetToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.doRequest(ctx, http.MethodPost, path, params, token.AccessToken, body)
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(ctx context.Context, method, path string, params map[string]string, token string, body any) ([]byte, error) {
|
||||
reqURL := c.cfg.BaseURL + path
|
||||
values := url.Values{}
|
||||
for k, v := range params {
|
||||
values.Set(k, v)
|
||||
}
|
||||
if values.Encode() != "" {
|
||||
reqURL += "?" + values.Encode()
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
var contentType string
|
||||
if body != nil {
|
||||
data, _ := json.Marshal(body)
|
||||
reqBody = strings.NewReader(string(data))
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, method, reqURL, reqBody)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request %s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 签名辅助
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
func addSignature(req *http.Request, body map[string]string, apiKey string) {
|
||||
signStr := buildSignString(body)
|
||||
sig := hmacSHA256(signStr, apiKey)
|
||||
req.Header.Set("X-Signature", sig)
|
||||
}
|
||||
|
||||
func buildSignString(params map[string]string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sortStrings(keys)
|
||||
var sb strings.Builder
|
||||
for _, k := range keys {
|
||||
sb.WriteString(k)
|
||||
sb.WriteString("=")
|
||||
sb.WriteString(params[k])
|
||||
sb.WriteString("&")
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "&")
|
||||
}
|
||||
|
||||
func hmacSHA256(data, key string) string {
|
||||
m := hmac.New(sha256.New, []byte(key))
|
||||
m.Write([]byte(data))
|
||||
return fmt.Sprintf("%x", m.Sum(nil))
|
||||
}
|
||||
|
||||
func sortStrings(s []string) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
||||
s[j], s[j-1] = s[j-1], s[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateNonce() string {
|
||||
// 简单 nonce,生产环境建议使用 crypto/rand
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
149
internal/payment/service.go
Normal file
149
internal/payment/service.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Service 支付业务层,内部封装 Client,对外暴露方法
|
||||
type Service struct {
|
||||
client *Client
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// NewService 创建支付服务
|
||||
func NewService(cfg Config) *Service {
|
||||
return &Service{
|
||||
client: NewClient(cfg),
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// GetClient 获取底层 HTTP 客户端(供高级用法)
|
||||
func (s *Service) GetClient() *Client {
|
||||
return s.client
|
||||
}
|
||||
|
||||
// Login 登录授权,获取 access_token
|
||||
func (s *Service) Login(ctx context.Context) (map[string]any, error) {
|
||||
token, err := s.client.GetToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"access_token": token.AccessToken,
|
||||
"expires_in": token.ExpiresIn,
|
||||
"message": "授权成功",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CheckAuth 查询授权状态
|
||||
func (s *Service) CheckAuth(ctx context.Context) (map[string]any, error) {
|
||||
token, err := s.client.GetToken(ctx)
|
||||
if err != nil {
|
||||
return map[string]any{"success": false, "message": "授权失效,请重新登录"}, err
|
||||
}
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"access_token": token.AccessToken,
|
||||
"expires_in": token.ExpiresIn,
|
||||
"message": "授权有效",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Refresh 主动刷新令牌
|
||||
func (s *Service) Refresh(ctx context.Context) (map[string]any, error) {
|
||||
token, err := s.client.RefreshToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"access_token": token.AccessToken,
|
||||
"expires_in": token.ExpiresIn,
|
||||
"message": "续期成功",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAccountInfo 获取授权账号信息
|
||||
func (s *Service) GetAccountInfo(ctx context.Context) (map[string]any, error) {
|
||||
raw, err := s.client.Get(ctx, "/v1/account/info", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get account info: %w", err)
|
||||
}
|
||||
return parseAPIResponse(raw)
|
||||
}
|
||||
|
||||
// QueryOrder 通过 order_id 查询订单
|
||||
func (s *Service) QueryOrder(ctx context.Context, orderID string) (map[string]any, error) {
|
||||
raw, err := s.client.Get(ctx, "/v1/orders/"+orderID, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query order: %w", err)
|
||||
}
|
||||
return parseAPIResponse(raw)
|
||||
}
|
||||
|
||||
// QueryOrderByOutNo 通过商户订单号查询
|
||||
func (s *Service) QueryOrderByOutNo(ctx context.Context, outTradeNo string) (map[string]any, error) {
|
||||
raw, err := s.client.Get(ctx, "/v1/orders", map[string]string{"out_trade_no": outTradeNo})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query order by out_no: %w", err)
|
||||
}
|
||||
return parseAPIResponse(raw)
|
||||
}
|
||||
|
||||
// RequestRefund 申请退款
|
||||
func (s *Service) RequestRefund(ctx context.Context, req map[string]any) (map[string]any, error) {
|
||||
raw, err := s.client.Post(ctx, "/v1/refunds", nil, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request refund: %w", err)
|
||||
}
|
||||
return parseAPIResponse(raw)
|
||||
}
|
||||
|
||||
// GetBill 分页获取账单
|
||||
func (s *Service) GetBill(ctx context.Context, page, pageSize int) (map[string]any, error) {
|
||||
params := map[string]string{
|
||||
"page": fmt.Sprintf("%d", page),
|
||||
"page_size": fmt.Sprintf("%d", pageSize),
|
||||
}
|
||||
raw, err := s.client.Get(ctx, "/v1/bills", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get bill: %w", err)
|
||||
}
|
||||
return parseAPIResponse(raw)
|
||||
}
|
||||
|
||||
// GetBillByDate 按日期范围查账单
|
||||
func (s *Service) GetBillByDate(ctx context.Context, startDate, endDate string) (map[string]any, error) {
|
||||
params := map[string]string{
|
||||
"start_date": startDate,
|
||||
"end_date": endDate,
|
||||
}
|
||||
raw, err := s.client.Get(ctx, "/v1/bills/range", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get bill by date: %w", err)
|
||||
}
|
||||
return parseAPIResponse(raw)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 内部辅助
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
func parseAPIResponse(raw []byte) (map[string]any, error) {
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("api error code=%d msg=%s", result.Code, result.Message)
|
||||
}
|
||||
return map[string]any{"success": true, "data": result.Data, "message": result.Message}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user