用 Go (Gin + GORM + SQLite) 重写整个后端: - 单二进制部署,不依赖 Python/pip/SDK - net/http 原生客户端,无 Cloudflare TLS 指纹问题 - 多阶段 Dockerfile:Node 构建前端 + Go 构建后端 + Alpine 运行 - 内存占用从 ~95MB 降至 ~3MB - 完整保留所有 API 路由、JWT 认证、API Key 权限、审计日志
430 lines
11 KiB
Go
430 lines
11 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
|
|
"airwallex-admin/airwallex"
|
|
"airwallex-admin/models"
|
|
|
|
"golang.org/x/net/proxy"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var (
|
|
clientInstance *airwallex.Client
|
|
clientConfigHash string
|
|
clientMu sync.Mutex
|
|
)
|
|
|
|
// buildProxyURL reads proxy settings from the database and constructs a proxy URL.
|
|
func buildProxyURL(db *gorm.DB) string {
|
|
proxyIP := models.GetSetting(db, "proxy_ip")
|
|
proxyPort := models.GetSetting(db, "proxy_port")
|
|
if proxyIP == "" || proxyPort == "" {
|
|
return ""
|
|
}
|
|
|
|
proxyType := models.GetSetting(db, "proxy_type")
|
|
if proxyType == "" {
|
|
proxyType = "http"
|
|
}
|
|
|
|
proxyUser := models.GetSetting(db, "proxy_username")
|
|
proxyPass := models.GetSetting(db, "proxy_password")
|
|
|
|
var userInfo string
|
|
if proxyUser != "" {
|
|
if proxyPass != "" {
|
|
userInfo = proxyUser + ":" + proxyPass + "@"
|
|
} else {
|
|
userInfo = proxyUser + "@"
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("%s://%s%s:%s", proxyType, userInfo, proxyIP, proxyPort)
|
|
}
|
|
|
|
// getConfigHash computes an MD5 hash of the current Airwallex configuration.
|
|
func getConfigHash(db *gorm.DB) string {
|
|
clientID := models.GetSetting(db, "airwallex_client_id")
|
|
apiKey := models.GetSetting(db, "airwallex_api_key")
|
|
baseURL := models.GetSetting(db, "airwallex_base_url")
|
|
loginAs := models.GetSetting(db, "airwallex_login_as")
|
|
proxyURL := buildProxyURL(db)
|
|
|
|
raw := clientID + apiKey + baseURL + loginAs + proxyURL
|
|
hash := md5.Sum([]byte(raw))
|
|
return fmt.Sprintf("%x", hash)
|
|
}
|
|
|
|
// GetClient returns a cached or newly created Airwallex client based on current settings.
|
|
func GetClient(db *gorm.DB) (*airwallex.Client, error) {
|
|
clientMu.Lock()
|
|
defer clientMu.Unlock()
|
|
|
|
clientID := models.GetSetting(db, "airwallex_client_id")
|
|
apiKey := models.GetSetting(db, "airwallex_api_key")
|
|
if clientID == "" || apiKey == "" {
|
|
return nil, fmt.Errorf("Airwallex credentials not configured")
|
|
}
|
|
|
|
baseURL := models.GetSetting(db, "airwallex_base_url")
|
|
if baseURL == "" {
|
|
baseURL = "https://api.airwallex.com"
|
|
}
|
|
|
|
loginAs := models.GetSetting(db, "airwallex_login_as")
|
|
proxyURL := buildProxyURL(db)
|
|
|
|
hash := fmt.Sprintf("%x", md5.Sum([]byte(clientID+apiKey+baseURL+loginAs+proxyURL)))
|
|
|
|
if clientInstance != nil && hash == clientConfigHash {
|
|
return clientInstance, nil
|
|
}
|
|
|
|
clientInstance = airwallex.NewClient(clientID, apiKey, baseURL, loginAs, proxyURL)
|
|
clientConfigHash = hash
|
|
return clientInstance, nil
|
|
}
|
|
|
|
// EnsureAuthenticated returns a client that has a valid authentication token.
|
|
func EnsureAuthenticated(db *gorm.DB) (*airwallex.Client, error) {
|
|
client, err := GetClient(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := client.Authenticate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
// ListCards retrieves a paginated list of issuing cards.
|
|
func ListCards(db *gorm.DB, pageNum, pageSize int, cardholderID, status string) (map[string]interface{}, error) {
|
|
client, err := EnsureAuthenticated(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("page_num", fmt.Sprintf("%d", pageNum))
|
|
params.Set("page_size", fmt.Sprintf("%d", pageSize))
|
|
if cardholderID != "" {
|
|
params.Set("cardholder_id", cardholderID)
|
|
}
|
|
if status != "" {
|
|
params.Set("status", status)
|
|
}
|
|
|
|
result, err := client.ListCards(params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"items": result.Items,
|
|
"has_more": result.HasMore,
|
|
"page_num": pageNum,
|
|
"page_size": pageSize,
|
|
}, nil
|
|
}
|
|
|
|
// CreateCard creates a new issuing card.
|
|
func CreateCard(db *gorm.DB, cardData map[string]interface{}) (map[string]interface{}, error) {
|
|
client, err := EnsureAuthenticated(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client.CreateCard(cardData)
|
|
}
|
|
|
|
// GetCard retrieves a single card by ID.
|
|
func GetCard(db *gorm.DB, cardID string) (map[string]interface{}, error) {
|
|
client, err := EnsureAuthenticated(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client.GetCard(cardID)
|
|
}
|
|
|
|
// GetCardDetails retrieves sensitive card details by card ID.
|
|
func GetCardDetails(db *gorm.DB, cardID string) (map[string]interface{}, error) {
|
|
client, err := EnsureAuthenticated(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client.GetCardDetails(cardID)
|
|
}
|
|
|
|
// UpdateCard updates an existing card by ID.
|
|
func UpdateCard(db *gorm.DB, cardID string, data map[string]interface{}) (map[string]interface{}, error) {
|
|
client, err := EnsureAuthenticated(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client.UpdateCard(cardID, data)
|
|
}
|
|
|
|
// ListCardholders retrieves a paginated list of cardholders.
|
|
func ListCardholders(db *gorm.DB, pageNum, pageSize int) (map[string]interface{}, error) {
|
|
client, err := EnsureAuthenticated(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("page_num", fmt.Sprintf("%d", pageNum))
|
|
params.Set("page_size", fmt.Sprintf("%d", pageSize))
|
|
|
|
result, err := client.ListCardholders(params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"items": result.Items,
|
|
"has_more": result.HasMore,
|
|
"page_num": pageNum,
|
|
"page_size": pageSize,
|
|
}, nil
|
|
}
|
|
|
|
// CreateCardholder creates a new cardholder.
|
|
func CreateCardholder(db *gorm.DB, data map[string]interface{}) (map[string]interface{}, error) {
|
|
client, err := EnsureAuthenticated(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client.CreateCardholder(data)
|
|
}
|
|
|
|
// ListTransactions retrieves a paginated list of issuing transactions.
|
|
func ListTransactions(db *gorm.DB, pageNum, pageSize int, cardID, fromDate, toDate string) (map[string]interface{}, error) {
|
|
client, err := EnsureAuthenticated(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("page_num", fmt.Sprintf("%d", pageNum))
|
|
params.Set("page_size", fmt.Sprintf("%d", pageSize))
|
|
if cardID != "" {
|
|
params.Set("card_id", cardID)
|
|
}
|
|
if fromDate != "" {
|
|
params.Set("from_created_at", fromDate)
|
|
}
|
|
if toDate != "" {
|
|
params.Set("to_created_at", toDate)
|
|
}
|
|
|
|
result, err := client.ListTransactions(params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"items": result.Items,
|
|
"has_more": result.HasMore,
|
|
"page_num": pageNum,
|
|
"page_size": pageSize,
|
|
}, nil
|
|
}
|
|
|
|
// ListAuthorizations retrieves a paginated list of issuing authorizations.
|
|
func ListAuthorizations(db *gorm.DB, pageNum, pageSize int, cardID, status, fromDate, toDate string) (map[string]interface{}, error) {
|
|
client, err := EnsureAuthenticated(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("page_num", fmt.Sprintf("%d", pageNum))
|
|
params.Set("page_size", fmt.Sprintf("%d", pageSize))
|
|
if cardID != "" {
|
|
params.Set("card_id", cardID)
|
|
}
|
|
if status != "" {
|
|
params.Set("status", status)
|
|
}
|
|
if fromDate != "" {
|
|
params.Set("from_created_at", fromDate)
|
|
}
|
|
if toDate != "" {
|
|
params.Set("to_created_at", toDate)
|
|
}
|
|
|
|
result, err := client.ListAuthorizations(params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"items": result.Items,
|
|
"has_more": result.HasMore,
|
|
"page_num": pageNum,
|
|
"page_size": pageSize,
|
|
}, nil
|
|
}
|
|
|
|
// GetBalance retrieves the current account balance.
|
|
func GetBalance(db *gorm.DB) (map[string]interface{}, error) {
|
|
client, err := EnsureAuthenticated(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result, err := client.GetBalance()
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetIssuingConfig retrieves the issuing configuration.
|
|
func GetIssuingConfig(db *gorm.DB) (map[string]interface{}, error) {
|
|
client, err := EnsureAuthenticated(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client.GetIssuingConfig()
|
|
}
|
|
|
|
// TestConnection tests the Airwallex API connection.
|
|
func TestConnection(db *gorm.DB) map[string]interface{} {
|
|
_, err := EnsureAuthenticated(db)
|
|
if err != nil {
|
|
return map[string]interface{}{
|
|
"success": false,
|
|
"message": err.Error(),
|
|
}
|
|
}
|
|
return map[string]interface{}{
|
|
"success": true,
|
|
"message": "Connection successful",
|
|
}
|
|
}
|
|
|
|
// buildHTTPClientWithProxy creates an http.Client configured with the given proxy URL.
|
|
func buildHTTPClientWithProxy(proxyURL string) *http.Client {
|
|
httpClient := &http.Client{Timeout: 15 * time.Second}
|
|
|
|
if proxyURL == "" {
|
|
return httpClient
|
|
}
|
|
|
|
parsed, err := url.Parse(proxyURL)
|
|
if err != nil {
|
|
return httpClient
|
|
}
|
|
|
|
switch parsed.Scheme {
|
|
case "socks5", "socks5h":
|
|
dialer, err := proxy.FromURL(parsed, proxy.Direct)
|
|
if err != nil {
|
|
return httpClient
|
|
}
|
|
contextDialer, ok := dialer.(proxy.ContextDialer)
|
|
if ok {
|
|
httpClient.Transport = &http.Transport{
|
|
DialContext: contextDialer.DialContext,
|
|
}
|
|
} else {
|
|
httpClient.Transport = &http.Transport{
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
return dialer.Dial(network, addr)
|
|
},
|
|
}
|
|
}
|
|
case "http", "https":
|
|
httpClient.Transport = &http.Transport{
|
|
Proxy: http.ProxyURL(parsed),
|
|
}
|
|
}
|
|
|
|
return httpClient
|
|
}
|
|
|
|
// fetchIPInfo fetches IP geolocation info from ip-api.com using the provided HTTP client.
|
|
func fetchIPInfo(httpClient *http.Client) (ip, country, city, isp, status string, err error) {
|
|
resp, err := httpClient.Get("http://ip-api.com/json/?lang=zh-CN")
|
|
if err != nil {
|
|
return "", "", "", "", "fail", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", "", "", "", "fail", err
|
|
}
|
|
|
|
var data map[string]interface{}
|
|
if err := json.Unmarshal(body, &data); err != nil {
|
|
return "", "", "", "", "fail", err
|
|
}
|
|
|
|
getString := func(key string) string {
|
|
if v, ok := data[key].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
return getString("query"), getString("country"), getString("city"), getString("isp"), getString("status"), nil
|
|
}
|
|
|
|
// TestProxy tests both proxy and direct connectivity, returning IP geolocation info.
|
|
func TestProxy(db *gorm.DB) map[string]interface{} {
|
|
result := map[string]interface{}{
|
|
"proxy_configured": false,
|
|
"success": false,
|
|
}
|
|
|
|
proxyURL := buildProxyURL(db)
|
|
result["proxy_url"] = proxyURL
|
|
|
|
if proxyURL != "" {
|
|
result["proxy_configured"] = true
|
|
|
|
proxyClient := buildHTTPClientWithProxy(proxyURL)
|
|
ip, country, city, isp, status, err := fetchIPInfo(proxyClient)
|
|
if err != nil {
|
|
result["proxy_status"] = "fail"
|
|
result["proxy_ip"] = err.Error()
|
|
} else {
|
|
result["proxy_ip"] = ip
|
|
result["proxy_country"] = country
|
|
result["proxy_city"] = city
|
|
result["proxy_isp"] = isp
|
|
result["proxy_status"] = status
|
|
if status == "success" {
|
|
result["success"] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Direct connection test
|
|
directClient := &http.Client{Timeout: 15 * time.Second}
|
|
ip, country, city, isp, status, err := fetchIPInfo(directClient)
|
|
if err != nil {
|
|
result["direct_status"] = "fail"
|
|
result["direct_ip"] = err.Error()
|
|
} else {
|
|
result["direct_ip"] = ip
|
|
result["direct_country"] = country
|
|
result["direct_city"] = city
|
|
result["direct_isp"] = isp
|
|
result["direct_status"] = status
|
|
}
|
|
|
|
return result
|
|
}
|