feat: Go 重写后端,替换 Python FastAPI

用 Go (Gin + GORM + SQLite) 重写整个后端:
- 单二进制部署,不依赖 Python/pip/SDK
- net/http 原生客户端,无 Cloudflare TLS 指纹问题
- 多阶段 Dockerfile:Node 构建前端 + Go 构建后端 + Alpine 运行
- 内存占用从 ~95MB 降至 ~3MB
- 完整保留所有 API 路由、JWT 认证、API Key 权限、审计日志
This commit is contained in:
zqq61
2026-03-16 02:11:48 +08:00
parent e897c99f59
commit faba565c66
34 changed files with 2430 additions and 17 deletions

67
handlers/auth.go Normal file
View File

@@ -0,0 +1,67 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"airwallex-admin/config"
"airwallex-admin/middleware"
"airwallex-admin/models"
)
type loginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
func Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid request body"})
return
}
if req.Username != config.Cfg.AdminUsername {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
return
}
passwordHash := models.GetSetting(models.DB, "admin_password_hash")
if passwordHash == "" {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
return
}
token, err := middleware.GenerateToken(req.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to generate token"})
return
}
// Create audit log
models.DB.Create(&models.AuditLog{
Action: "login",
ResourceType: "auth",
Operator: req.Username,
IPAddress: c.ClientIP(),
})
c.JSON(http.StatusOK, gin.H{
"access_token": token,
"token_type": "bearer",
})
}
func GetMe(c *gin.Context) {
username, _ := c.Get("username")
c.JSON(http.StatusOK, gin.H{
"username": username,
})
}

50
handlers/cardholders.go Normal file
View File

@@ -0,0 +1,50 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"airwallex-admin/models"
"airwallex-admin/services"
)
func ListCardholders(c *gin.Context) {
pageNum, _ := strconv.Atoi(c.DefaultQuery("page_num", "0"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
result, err := services.ListCardholders(models.DB, pageNum, pageSize)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func CreateCardholder(c *gin.Context) {
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Invalid request body"})
return
}
result, err := services.CreateCardholder(models.DB, body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
username := c.GetString("username")
cardholderID, _ := result["cardholder_id"].(string)
models.DB.Create(&models.AuditLog{
Action: "create_cardholder",
ResourceType: "cardholder",
ResourceID: cardholderID,
Operator: username,
IPAddress: c.ClientIP(),
})
c.JSON(http.StatusOK, result)
}

154
handlers/cards.go Normal file
View File

@@ -0,0 +1,154 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"airwallex-admin/models"
"airwallex-admin/services"
)
func ListCards(c *gin.Context) {
pageNum, _ := strconv.Atoi(c.DefaultQuery("page_num", "0"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
cardholderID := c.Query("cardholder_id")
status := c.Query("status")
result, err := services.ListCards(models.DB, pageNum, pageSize, cardholderID, status)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func CreateCard(c *gin.Context) {
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Invalid request body"})
return
}
// Check daily card limit
dailyLimit := 100
if v := models.GetSetting(models.DB, "daily_card_limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
dailyLimit = n
}
}
todayStart := time.Now().UTC().Truncate(24 * time.Hour)
var todayCount int64
models.DB.Model(&models.CardLog{}).
Where("action = ? AND status = ? AND created_at >= ?", "create_card", "success", todayStart).
Count(&todayCount)
if int(todayCount) >= dailyLimit {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Daily card creation limit reached"})
return
}
username := c.GetString("username")
reqData, _ := json.Marshal(body)
result, err := services.CreateCard(models.DB, body)
if err != nil {
respData, _ := json.Marshal(map[string]string{"error": err.Error()})
models.DB.Create(&models.CardLog{
Action: "create_card",
Status: "failed",
Operator: username,
RequestData: string(reqData),
ResponseData: string(respData),
})
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
cardID, _ := result["card_id"].(string)
cardholderID, _ := result["cardholder_id"].(string)
respData, _ := json.Marshal(result)
models.DB.Create(&models.CardLog{
CardID: cardID,
CardholderID: cardholderID,
Action: "create_card",
Status: "success",
Operator: username,
RequestData: string(reqData),
ResponseData: string(respData),
})
models.DB.Create(&models.AuditLog{
Action: "create_card",
ResourceType: "card",
ResourceID: cardID,
Operator: username,
IPAddress: c.ClientIP(),
Details: fmt.Sprintf("Created card %s", cardID),
})
c.JSON(http.StatusOK, result)
}
func GetCard(c *gin.Context) {
cardID := c.Param("id")
result, err := services.GetCard(models.DB, cardID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func GetCardDetails(c *gin.Context) {
cardID := c.Param("id")
result, err := services.GetCardDetails(models.DB, cardID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
username := c.GetString("username")
models.DB.Create(&models.AuditLog{
Action: "view_card_details",
ResourceType: "card",
ResourceID: cardID,
Operator: username,
IPAddress: c.ClientIP(),
})
c.JSON(http.StatusOK, result)
}
func UpdateCard(c *gin.Context) {
cardID := c.Param("id")
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Invalid request body"})
return
}
result, err := services.UpdateCard(models.DB, cardID, body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
username := c.GetString("username")
models.DB.Create(&models.AuditLog{
Action: "update_card",
ResourceType: "card",
ResourceID: cardID,
Operator: username,
IPAddress: c.ClientIP(),
})
c.JSON(http.StatusOK, result)
}

58
handlers/dashboard.go Normal file
View File

@@ -0,0 +1,58 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"airwallex-admin/models"
"airwallex-admin/services"
)
func GetDashboard(c *gin.Context) {
// Get daily card limit from settings
dailyCardLimit := 100
if v := models.GetSetting(models.DB, "daily_card_limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
dailyCardLimit = n
}
}
// Count today's successful card creations
todayStart := time.Now().UTC().Truncate(24 * time.Hour)
var todayCardCount int64
models.DB.Model(&models.CardLog{}).
Where("action = ? AND status = ? AND created_at >= ?", "create_card", "success", todayStart).
Count(&todayCardCount)
// Get account balance from API
var accountBalance interface{}
balance, err := services.GetBalance(models.DB)
if err == nil {
accountBalance = balance
}
// Get card counts from API (fetch a large page to count)
var totalCards, activeCards int
resp, err := services.ListCards(models.DB, 0, 200, "", "")
if err == nil {
if items, ok := resp["items"].([]map[string]interface{}); ok {
totalCards = len(items)
for _, item := range items {
if status, ok := item["card_status"].(string); ok && status == "ACTIVE" {
activeCards++
}
}
}
}
c.JSON(http.StatusOK, gin.H{
"total_cards": totalCards,
"active_cards": activeCards,
"today_card_count": todayCardCount,
"daily_card_limit": dailyCardLimit,
"account_balance": accountBalance,
})
}

160
handlers/external_api.go Normal file
View File

@@ -0,0 +1,160 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"airwallex-admin/models"
"airwallex-admin/services"
)
func getOperator(c *gin.Context) string {
token := c.MustGet("api_token").(models.ApiToken)
return fmt.Sprintf("api:%s", token.Name)
}
func ExternalCreateCard(c *gin.Context) {
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Invalid request body"})
return
}
// Check daily card limit
dailyLimit := 100
if v := models.GetSetting(models.DB, "daily_card_limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
dailyLimit = n
}
}
todayStart := time.Now().UTC().Truncate(24 * time.Hour)
var todayCount int64
models.DB.Model(&models.CardLog{}).
Where("action = ? AND status = ? AND created_at >= ?", "create_card", "success", todayStart).
Count(&todayCount)
if int(todayCount) >= dailyLimit {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Daily card creation limit reached"})
return
}
operator := getOperator(c)
reqData, _ := json.Marshal(body)
result, err := services.CreateCard(models.DB, body)
if err != nil {
respData, _ := json.Marshal(map[string]string{"error": err.Error()})
models.DB.Create(&models.CardLog{
Action: "create_card",
Status: "failed",
Operator: operator,
RequestData: string(reqData),
ResponseData: string(respData),
})
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
cardID, _ := result["card_id"].(string)
cardholderID, _ := result["cardholder_id"].(string)
respData, _ := json.Marshal(result)
models.DB.Create(&models.CardLog{
CardID: cardID,
CardholderID: cardholderID,
Action: "create_card",
Status: "success",
Operator: operator,
RequestData: string(reqData),
ResponseData: string(respData),
})
models.DB.Create(&models.AuditLog{
Action: "create_card",
ResourceType: "card",
ResourceID: cardID,
Operator: operator,
IPAddress: c.ClientIP(),
Details: fmt.Sprintf("Created card %s", cardID),
})
c.JSON(http.StatusOK, result)
}
func ExternalListCards(c *gin.Context) {
pageNum, _ := strconv.Atoi(c.DefaultQuery("page_num", "0"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
cardholderID := c.Query("cardholder_id")
status := c.Query("status")
result, err := services.ListCards(models.DB, pageNum, pageSize, cardholderID, status)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func ExternalGetCard(c *gin.Context) {
cardID := c.Param("id")
result, err := services.GetCard(models.DB, cardID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func ExternalFreezeCard(c *gin.Context) {
cardID := c.Param("id")
operator := getOperator(c)
result, err := services.UpdateCard(models.DB, cardID, map[string]interface{}{
"status": "SUSPENDED",
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
models.DB.Create(&models.AuditLog{
Action: "freeze_card",
ResourceType: "card",
ResourceID: cardID,
Operator: operator,
IPAddress: c.ClientIP(),
})
c.JSON(http.StatusOK, result)
}
func ExternalListTransactions(c *gin.Context) {
pageNum, _ := strconv.Atoi(c.DefaultQuery("page_num", "0"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
cardID := c.Query("card_id")
fromCreatedAt := c.Query("from_created_at")
toCreatedAt := c.Query("to_created_at")
result, err := services.ListTransactions(models.DB, pageNum, pageSize, cardID, fromCreatedAt, toCreatedAt)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func ExternalGetBalance(c *gin.Context) {
result, err := services.GetBalance(models.DB)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}

95
handlers/logs.go Normal file
View File

@@ -0,0 +1,95 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"airwallex-admin/models"
)
func ListCardLogs(c *gin.Context) {
pageNum, _ := strconv.Atoi(c.DefaultQuery("page_num", "0"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query := models.DB.Model(&models.CardLog{})
if v := c.Query("card_id"); v != "" {
query = query.Where("card_id = ?", v)
}
if v := c.Query("action"); v != "" {
query = query.Where("action = ?", v)
}
var total int64
query.Count(&total)
var logs []models.CardLog
query.Order("created_at DESC").
Offset(pageNum * pageSize).
Limit(pageSize).
Find(&logs)
if logs == nil {
logs = []models.CardLog{}
}
hasMore := int64((pageNum+1)*pageSize) < total
c.JSON(http.StatusOK, gin.H{
"items": logs,
"page_num": pageNum,
"page_size": pageSize,
"total": total,
"has_more": hasMore,
})
}
func ListAuditLogs(c *gin.Context) {
pageNum, _ := strconv.Atoi(c.DefaultQuery("page_num", "0"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query := models.DB.Model(&models.AuditLog{})
if v := c.Query("action"); v != "" {
query = query.Where("action = ?", v)
}
if v := c.Query("resource_type"); v != "" {
query = query.Where("resource_type = ?", v)
}
if v := c.Query("from_date"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
query = query.Where("created_at >= ?", t)
}
}
if v := c.Query("to_date"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
query = query.Where("created_at < ?", t.Add(24*time.Hour))
}
}
var total int64
query.Count(&total)
var logs []models.AuditLog
query.Order("created_at DESC").
Offset(pageNum * pageSize).
Limit(pageSize).
Find(&logs)
if logs == nil {
logs = []models.AuditLog{}
}
hasMore := int64((pageNum+1)*pageSize) < total
c.JSON(http.StatusOK, gin.H{
"items": logs,
"page_num": pageNum,
"page_size": pageSize,
"total": total,
"has_more": hasMore,
})
}

70
handlers/settings.go Normal file
View File

@@ -0,0 +1,70 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"airwallex-admin/models"
"airwallex-admin/services"
)
var sensitiveKeys = map[string]bool{
"airwallex_api_key": true,
"proxy_password": true,
"admin_password_hash": true,
}
func GetSettings(c *gin.Context) {
var settings []models.SystemSetting
models.DB.Find(&settings)
for i, s := range settings {
if sensitiveKeys[s.Key] {
settings[i].Value = "********"
}
}
c.JSON(http.StatusOK, settings)
}
type settingItem struct {
Key string `json:"key"`
Value string `json:"value"`
}
func UpdateSettings(c *gin.Context) {
var items []settingItem
if err := c.ShouldBindJSON(&items); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Invalid request body"})
return
}
for _, item := range items {
if item.Value == "********" {
continue
}
encrypted := sensitiveKeys[item.Key]
models.SetSetting(models.DB, item.Key, item.Value, encrypted)
}
username := c.GetString("username")
models.DB.Create(&models.AuditLog{
Action: "update_settings",
ResourceType: "settings",
Operator: username,
IPAddress: c.ClientIP(),
})
c.JSON(http.StatusOK, gin.H{"detail": "Settings updated"})
}
func TestConnection(c *gin.Context) {
result := services.TestConnection(models.DB)
c.JSON(http.StatusOK, result)
}
func TestProxy(c *gin.Context) {
result := services.TestProxy(models.DB)
c.JSON(http.StatusOK, result)
}

143
handlers/tokens.go Normal file
View File

@@ -0,0 +1,143 @@
package handlers
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"airwallex-admin/models"
)
func ListTokens(c *gin.Context) {
var tokens []models.ApiToken
models.DB.Where("is_active = ?", true).Find(&tokens)
type tokenResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Token string `json:"token"`
Permissions string `json:"permissions"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at"`
LastUsedAt *time.Time `json:"last_used_at"`
}
var result []tokenResponse
for _, t := range tokens {
masked := t.Token
if len(masked) > 12 {
masked = masked[:8] + "..." + masked[len(masked)-4:]
}
result = append(result, tokenResponse{
ID: t.ID,
Name: t.Name,
Token: masked,
Permissions: t.Permissions,
IsActive: t.IsActive,
CreatedAt: t.CreatedAt,
ExpiresAt: t.ExpiresAt,
LastUsedAt: t.LastUsedAt,
})
}
if result == nil {
result = []tokenResponse{}
}
c.JSON(http.StatusOK, result)
}
type createTokenRequest struct {
Name string `json:"name" binding:"required"`
Permissions []string `json:"permissions"`
ExpiresInDays *int `json:"expires_in_days"`
}
func CreateToken(c *gin.Context) {
var req createTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Invalid request body"})
return
}
// Generate random token
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to generate token"})
return
}
rawToken := base64.RawURLEncoding.EncodeToString(tokenBytes)
// Permissions to JSON string
if req.Permissions == nil {
req.Permissions = []string{}
}
permJSON, _ := json.Marshal(req.Permissions)
token := models.ApiToken{
Name: req.Name,
Token: rawToken,
Permissions: string(permJSON),
IsActive: true,
}
if req.ExpiresInDays != nil && *req.ExpiresInDays > 0 {
expiresAt := time.Now().Add(time.Duration(*req.ExpiresInDays) * 24 * time.Hour)
token.ExpiresAt = &expiresAt
}
if result := models.DB.Create(&token); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to create token"})
return
}
username := c.GetString("username")
models.DB.Create(&models.AuditLog{
Action: "create_token",
ResourceType: "api_token",
ResourceID: fmt.Sprintf("%d", token.ID),
Operator: username,
IPAddress: c.ClientIP(),
})
c.JSON(http.StatusOK, gin.H{
"id": token.ID,
"name": token.Name,
"token": rawToken,
"permissions": req.Permissions,
"is_active": token.IsActive,
"created_at": token.CreatedAt,
"expires_at": token.ExpiresAt,
})
}
func DeleteToken(c *gin.Context) {
tokenID := c.Param("id")
result := models.DB.Model(&models.ApiToken{}).Where("id = ?", tokenID).Update("is_active", false)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to revoke token"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"detail": "Token not found"})
return
}
username := c.GetString("username")
models.DB.Create(&models.AuditLog{
Action: "delete_token",
ResourceType: "api_token",
ResourceID: tokenID,
Operator: username,
IPAddress: c.ClientIP(),
})
c.JSON(http.StatusOK, gin.H{"detail": "Token revoked"})
}

44
handlers/transactions.go Normal file
View File

@@ -0,0 +1,44 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"airwallex-admin/models"
"airwallex-admin/services"
)
func ListTransactions(c *gin.Context) {
pageNum, _ := strconv.Atoi(c.DefaultQuery("page_num", "0"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
cardID := c.Query("card_id")
fromCreatedAt := c.Query("from_created_at")
toCreatedAt := c.Query("to_created_at")
result, err := services.ListTransactions(models.DB, pageNum, pageSize, cardID, fromCreatedAt, toCreatedAt)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func ListAuthorizations(c *gin.Context) {
pageNum, _ := strconv.Atoi(c.DefaultQuery("page_num", "0"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
cardID := c.Query("card_id")
status := c.Query("status")
fromCreatedAt := c.Query("from_created_at")
toCreatedAt := c.Query("to_created_at")
result, err := services.ListAuthorizations(models.DB, pageNum, pageSize, cardID, status, fromCreatedAt, toCreatedAt)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}