From faba565c66f55dc618023b557a97109e8e043b32 Mon Sep 17 00:00:00 2001 From: zqq61 <1852150449@qq.com> Date: Mon, 16 Mar 2026 02:11:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Go=20=E9=87=8D=E5=86=99=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=8C=E6=9B=BF=E6=8D=A2=20Python=20FastAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用 Go (Gin + GORM + SQLite) 重写整个后端: - 单二进制部署,不依赖 Python/pip/SDK - net/http 原生客户端,无 Cloudflare TLS 指纹问题 - 多阶段 Dockerfile:Node 构建前端 + Go 构建后端 + Alpine 运行 - 内存占用从 ~95MB 降至 ~3MB - 完整保留所有 API 路由、JWT 认证、API Key 权限、审计日志 --- .dockerignore | 9 + Dockerfile | 25 ++ airwallex/authorizations.go | 23 ++ airwallex/cardholders.go | 28 ++ airwallex/cards.go | 43 +++ airwallex/client.go | 254 +++++++++++++ airwallex/config.go | 11 + airwallex/transactions.go | 23 ++ airwallex/types.go | 22 ++ backend/app/services/airwallex_service.py | 1 + config/config.go | 74 ++++ docker-compose.yml | 21 +- go.mod | 54 +++ go.sum | 122 ++++++ handlers/auth.go | 67 ++++ handlers/cardholders.go | 50 +++ handlers/cards.go | 154 ++++++++ handlers/dashboard.go | 58 +++ handlers/external_api.go | 160 ++++++++ handlers/logs.go | 95 +++++ handlers/settings.go | 70 ++++ handlers/tokens.go | 143 ++++++++ handlers/transactions.go | 44 +++ main.go | 114 ++++++ middleware/api_key.go | 84 +++++ middleware/auth.go | 69 ++++ models/api_token.go | 14 + models/audit_log.go | 14 + models/card_log.go | 15 + models/db.go | 46 +++ models/init.go | 40 ++ models/system_setting.go | 37 ++ services/airwallex_service.go | 429 ++++++++++++++++++++++ services/audit.go | 34 ++ 34 files changed, 2430 insertions(+), 17 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 airwallex/authorizations.go create mode 100644 airwallex/cardholders.go create mode 100644 airwallex/cards.go create mode 100644 airwallex/client.go create mode 100644 airwallex/config.go create mode 100644 airwallex/transactions.go create mode 100644 airwallex/types.go create mode 100644 config/config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handlers/auth.go create mode 100644 handlers/cardholders.go create mode 100644 handlers/cards.go create mode 100644 handlers/dashboard.go create mode 100644 handlers/external_api.go create mode 100644 handlers/logs.go create mode 100644 handlers/settings.go create mode 100644 handlers/tokens.go create mode 100644 handlers/transactions.go create mode 100644 main.go create mode 100644 middleware/api_key.go create mode 100644 middleware/auth.go create mode 100644 models/api_token.go create mode 100644 models/audit_log.go create mode 100644 models/card_log.go create mode 100644 models/db.go create mode 100644 models/init.go create mode 100644 models/system_setting.go create mode 100644 services/airwallex_service.go create mode 100644 services/audit.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..341ee2c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +backend/ +airwallex-sdk/ +frontend/node_modules/ +frontend/dist/ +*.exe +data/ +.git/ +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0d7522b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# Stage 1: Build frontend +FROM node:20-alpine AS frontend-builder +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +# Stage 2: Build Go backend +FROM golang:1.25-alpine AS backend-builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -o airwallex-admin . + +# Stage 3: Final minimal image +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=backend-builder /app/airwallex-admin . +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist +RUN mkdir -p /app/data +EXPOSE 8000 +CMD ["./airwallex-admin"] diff --git a/airwallex/authorizations.go b/airwallex/authorizations.go new file mode 100644 index 0000000..c1bfb86 --- /dev/null +++ b/airwallex/authorizations.go @@ -0,0 +1,23 @@ +package airwallex + +import ( + "encoding/json" + "fmt" + "net/url" +) + +// ListAuthorizations retrieves a paginated list of issuing authorizations. +func (c *Client) ListAuthorizations(params url.Values) (*PaginatedResponse, error) { + data, statusCode, err := c.RequestRaw("GET", "api/v1/issuing/authorizations", params, nil) + if err != nil { + return nil, err + } + if statusCode >= 400 { + return nil, parseAPIError(data, statusCode) + } + var result PaginatedResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("airwallex: failed to parse authorizations response: %w", err) + } + return &result, nil +} diff --git a/airwallex/cardholders.go b/airwallex/cardholders.go new file mode 100644 index 0000000..b235c33 --- /dev/null +++ b/airwallex/cardholders.go @@ -0,0 +1,28 @@ +package airwallex + +import ( + "encoding/json" + "fmt" + "net/url" +) + +// ListCardholders retrieves a paginated list of cardholders. +func (c *Client) ListCardholders(params url.Values) (*PaginatedResponse, error) { + data, statusCode, err := c.RequestRaw("GET", "api/v1/issuing/cardholders", params, nil) + if err != nil { + return nil, err + } + if statusCode >= 400 { + return nil, parseAPIError(data, statusCode) + } + var result PaginatedResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("airwallex: failed to parse cardholders response: %w", err) + } + return &result, nil +} + +// CreateCardholder creates a new cardholder. +func (c *Client) CreateCardholder(data map[string]interface{}) (map[string]interface{}, error) { + return c.Request("POST", "api/v1/issuing/cardholders/create", nil, data) +} diff --git a/airwallex/cards.go b/airwallex/cards.go new file mode 100644 index 0000000..674958b --- /dev/null +++ b/airwallex/cards.go @@ -0,0 +1,43 @@ +package airwallex + +import ( + "encoding/json" + "fmt" + "net/url" +) + +// ListCards retrieves a paginated list of issuing cards. +func (c *Client) ListCards(params url.Values) (*PaginatedResponse, error) { + data, statusCode, err := c.RequestRaw("GET", "api/v1/issuing/cards", params, nil) + if err != nil { + return nil, err + } + if statusCode >= 400 { + return nil, parseAPIError(data, statusCode) + } + var result PaginatedResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("airwallex: failed to parse cards response: %w", err) + } + return &result, nil +} + +// CreateCard creates a new issuing card. +func (c *Client) CreateCard(data map[string]interface{}) (map[string]interface{}, error) { + return c.Request("POST", "api/v1/issuing/cards/create", nil, data) +} + +// GetCard retrieves a single card by ID. +func (c *Client) GetCard(cardID string) (map[string]interface{}, error) { + return c.Request("GET", fmt.Sprintf("api/v1/issuing/cards/%s", cardID), nil, nil) +} + +// GetCardDetails retrieves sensitive card details (PAN, CVV, etc.) by card ID. +func (c *Client) GetCardDetails(cardID string) (map[string]interface{}, error) { + return c.Request("GET", fmt.Sprintf("api/v1/issuing/cards/%s/details", cardID), nil, nil) +} + +// UpdateCard updates an existing card by ID. +func (c *Client) UpdateCard(cardID string, data map[string]interface{}) (map[string]interface{}, error) { + return c.Request("POST", fmt.Sprintf("api/v1/issuing/cards/%s/update", cardID), nil, data) +} diff --git a/airwallex/client.go b/airwallex/client.go new file mode 100644 index 0000000..92e886d --- /dev/null +++ b/airwallex/client.go @@ -0,0 +1,254 @@ +package airwallex + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "golang.org/x/net/proxy" +) + +// Client is the core HTTP client for Airwallex API. +type Client struct { + ClientID string + APIKey string + LoginAs string // optional x-login-as header + BaseURL string // e.g. "https://api.airwallex.com" + ProxyURL string // socks5:// or http:// proxy + Token string + TokenExpiry time.Time + HTTPClient *http.Client + mu sync.Mutex +} + +// NewClient creates a new Airwallex API client with optional proxy support. +func NewClient(clientID, apiKey, baseURL, loginAs, proxyURL string) *Client { + baseURL = strings.TrimRight(baseURL, "/") + + httpClient := &http.Client{Timeout: 30 * time.Second} + + if proxyURL != "" { + parsed, err := url.Parse(proxyURL) + if err == nil { + switch parsed.Scheme { + case "socks5", "socks5h": + dialer, err := proxy.FromURL(parsed, proxy.Direct) + if err == nil { + 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 &Client{ + ClientID: clientID, + APIKey: apiKey, + BaseURL: baseURL, + LoginAs: loginAs, + ProxyURL: proxyURL, + HTTPClient: httpClient, + } +} + +// Authenticate obtains an access token from the Airwallex API. +func (c *Client) Authenticate() error { + c.mu.Lock() + defer c.mu.Unlock() + + authURL := c.BaseURL + "/api/v1/authentication/login" + + req, err := http.NewRequest(http.MethodPost, authURL, bytes.NewBufferString("{}")) + if err != nil { + return fmt.Errorf("airwallex: failed to create auth request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-client-id", c.ClientID) + req.Header.Set("x-api-key", c.APIKey) + if c.LoginAs != "" { + req.Header.Set("x-login-as", c.LoginAs) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("airwallex: auth request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("airwallex: failed to read auth response: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("airwallex: auth failed with status %d: %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("airwallex: failed to parse auth response: %w", err) + } + + token, ok := result["token"].(string) + if !ok || token == "" { + return fmt.Errorf("airwallex: no token in auth response") + } + + c.Token = token + + // Parse expires_at or default to 28 minutes from now. + if expiresAt, ok := result["expires_at"].(string); ok && expiresAt != "" { + if t, err := time.Parse(time.RFC3339, expiresAt); err == nil { + c.TokenExpiry = t + } else { + c.TokenExpiry = time.Now().Add(28 * time.Minute) + } + } else { + c.TokenExpiry = time.Now().Add(28 * time.Minute) + } + + return nil +} + +// IsTokenValid checks if the current token is non-empty and not expired. +func (c *Client) IsTokenValid() bool { + return c.Token != "" && time.Now().Before(c.TokenExpiry) +} + +// Request performs an authenticated API request and returns the parsed JSON response. +func (c *Client) Request(method, path string, params url.Values, body interface{}) (map[string]interface{}, error) { + data, statusCode, err := c.doRequest(method, path, params, body) + if err != nil { + return nil, err + } + + // On 401, re-authenticate and retry once. + if statusCode == http.StatusUnauthorized { + if err := c.Authenticate(); err != nil { + return nil, err + } + data, statusCode, err = c.doRequest(method, path, params, body) + if err != nil { + return nil, err + } + } + + if statusCode >= 400 { + return nil, parseAPIError(data, statusCode) + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("airwallex: failed to parse response: %w", err) + } + + return result, nil +} + +// RequestRaw performs an authenticated API request and returns the raw response bytes and status code. +func (c *Client) RequestRaw(method, path string, params url.Values, body interface{}) ([]byte, int, error) { + data, statusCode, err := c.doRequest(method, path, params, body) + if err != nil { + return nil, 0, err + } + + // On 401, re-authenticate and retry once. + if statusCode == http.StatusUnauthorized { + if err := c.Authenticate(); err != nil { + return nil, 0, err + } + data, statusCode, err = c.doRequest(method, path, params, body) + if err != nil { + return nil, 0, err + } + } + + return data, statusCode, nil +} + +// doRequest performs the actual HTTP request with current auth token. +func (c *Client) doRequest(method, path string, params url.Values, body interface{}) ([]byte, int, error) { + if !c.IsTokenValid() { + if err := c.Authenticate(); err != nil { + return nil, 0, err + } + } + + // Build full URL, avoiding double slashes. + path = strings.TrimLeft(path, "/") + fullURL := c.BaseURL + "/" + path + + var reqBody io.Reader + if body != nil && (method == http.MethodPost || method == http.MethodPut) { + jsonBytes, err := json.Marshal(body) + if err != nil { + return nil, 0, fmt.Errorf("airwallex: failed to marshal request body: %w", err) + } + reqBody = bytes.NewBuffer(jsonBytes) + } + + req, err := http.NewRequest(method, fullURL, reqBody) + if err != nil { + return nil, 0, fmt.Errorf("airwallex: failed to create request: %w", err) + } + + // Append query params for GET requests. + if method == http.MethodGet && params != nil { + req.URL.RawQuery = params.Encode() + } + + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("Content-Type", "application/json") + if c.LoginAs != "" { + req.Header.Set("x-login-as", c.LoginAs) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("airwallex: request failed: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, 0, fmt.Errorf("airwallex: failed to read response: %w", err) + } + + return data, resp.StatusCode, nil +} + +// parseAPIError attempts to parse an error response into an APIError. +func parseAPIError(data []byte, statusCode int) *APIError { + var apiErr APIError + if err := json.Unmarshal(data, &apiErr); err != nil || apiErr.Message == "" { + apiErr = APIError{ + Code: "UNKNOWN", + Message: string(data), + } + } + apiErr.StatusCode = statusCode + return &apiErr +} diff --git a/airwallex/config.go b/airwallex/config.go new file mode 100644 index 0000000..60613e8 --- /dev/null +++ b/airwallex/config.go @@ -0,0 +1,11 @@ +package airwallex + +// GetIssuingConfig retrieves the issuing configuration. +func (c *Client) GetIssuingConfig() (map[string]interface{}, error) { + return c.Request("GET", "api/v1/issuing/config", nil, nil) +} + +// GetBalance retrieves the current account balance. +func (c *Client) GetBalance() (map[string]interface{}, error) { + return c.Request("GET", "api/v1/balances/current", nil, nil) +} diff --git a/airwallex/transactions.go b/airwallex/transactions.go new file mode 100644 index 0000000..a0059ba --- /dev/null +++ b/airwallex/transactions.go @@ -0,0 +1,23 @@ +package airwallex + +import ( + "encoding/json" + "fmt" + "net/url" +) + +// ListTransactions retrieves a paginated list of issuing transactions. +func (c *Client) ListTransactions(params url.Values) (*PaginatedResponse, error) { + data, statusCode, err := c.RequestRaw("GET", "api/v1/issuing/transactions", params, nil) + if err != nil { + return nil, err + } + if statusCode >= 400 { + return nil, parseAPIError(data, statusCode) + } + var result PaginatedResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("airwallex: failed to parse transactions response: %w", err) + } + return &result, nil +} diff --git a/airwallex/types.go b/airwallex/types.go new file mode 100644 index 0000000..91afa90 --- /dev/null +++ b/airwallex/types.go @@ -0,0 +1,22 @@ +package airwallex + +import "fmt" + +// PaginatedResponse represents a paginated API response. +type PaginatedResponse struct { + Items []map[string]interface{} `json:"items"` + HasMore bool `json:"has_more"` +} + +// APIError represents an Airwallex API error. +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` + Source string `json:"source"` + StatusCode int `json:"-"` +} + +// Error implements the error interface. +func (e *APIError) Error() string { + return fmt.Sprintf("airwallex: %s - %s (status %d)", e.Code, e.Message, e.StatusCode) +} diff --git a/backend/app/services/airwallex_service.py b/backend/app/services/airwallex_service.py index 4c47fcc..009ba2b 100644 --- a/backend/app/services/airwallex_service.py +++ b/backend/app/services/airwallex_service.py @@ -6,6 +6,7 @@ from typing import Any, Dict, List, Optional from sqlalchemy.orm import Session from fastapi import HTTPException +from airwallex.exceptions import AirwallexAPIError from app.models.db_models import SystemSetting from app.proxy_client import ProxiedAirwallexClient diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..ebe2d5b --- /dev/null +++ b/config/config.go @@ -0,0 +1,74 @@ +package config + +import ( + "crypto/rand" + "encoding/hex" + "log" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + AdminUsername string + AdminPassword string + SecretKey string + JWTExpireMinutes int + DatabaseURL string + Port string +} + +var Cfg Config + +func Load() { + // Load .env file from multiple locations (ignore errors if not found) + _ = godotenv.Load() + _ = godotenv.Load("backend/.env") + + dbURL := getEnv("DATABASE_URL", "data/airwallex.db") + // Strip sqlite:/// prefix if present (Python SQLAlchemy format) + for _, prefix := range []string{"sqlite:///./", "sqlite:///", "sqlite://"} { + if len(dbURL) > len(prefix) && dbURL[:len(prefix)] == prefix { + dbURL = dbURL[len(prefix):] + break + } + } + + Cfg = Config{ + AdminUsername: getEnv("ADMIN_USERNAME", "admin"), + AdminPassword: getEnv("ADMIN_PASSWORD", "admin123"), + SecretKey: getEnv("SECRET_KEY", generateRandomKey(32)), + JWTExpireMinutes: getEnvInt("JWT_EXPIRE_MINUTES", 480), + DatabaseURL: dbURL, + Port: getEnv("PORT", "8000"), + } +} + +func getEnv(key, fallback string) string { + if val, ok := os.LookupEnv(key); ok { + return val + } + return fallback +} + +func getEnvInt(key string, fallback int) int { + val, ok := os.LookupEnv(key) + if !ok { + return fallback + } + n, err := strconv.Atoi(val) + if err != nil { + log.Printf("warning: invalid integer for %s: %s, using default %d", key, val, fallback) + return fallback + } + return n +} + +func generateRandomKey(length int) string { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + log.Fatalf("failed to generate random key: %v", err) + } + return hex.EncodeToString(b) +} diff --git a/docker-compose.yml b/docker-compose.yml index c87ad6c..af11d55 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,9 @@ -version: "3.8" - services: - backend: + app: build: context: . - dockerfile: backend/Dockerfile - container_name: airwallex-backend + dockerfile: Dockerfile + container_name: airwallex-admin ports: - "8000:8000" volumes: @@ -13,18 +11,7 @@ services: env_file: - backend/.env environment: - - DATABASE_URL=sqlite:///./data/airwallex.db - restart: unless-stopped - - frontend: - build: - context: frontend - dockerfile: Dockerfile - container_name: airwallex-frontend - ports: - - "3000:3000" - depends_on: - - backend + - GIN_MODE=release restart: unless-stopped volumes: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..65b4875 --- /dev/null +++ b/go.mod @@ -0,0 +1,54 @@ +module airwallex-admin + +go 1.25.0 + +require ( + github.com/gin-contrib/cors v1.7.6 + github.com/gin-gonic/gin v1.12.0 + github.com/glebarez/sqlite v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.49.0 + golang.org/x/net v0.51.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3fd9831 --- /dev/null +++ b/go.sum @@ -0,0 +1,122 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..525391f --- /dev/null +++ b/handlers/auth.go @@ -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, + }) +} diff --git a/handlers/cardholders.go b/handlers/cardholders.go new file mode 100644 index 0000000..0c05086 --- /dev/null +++ b/handlers/cardholders.go @@ -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) +} diff --git a/handlers/cards.go b/handlers/cards.go new file mode 100644 index 0000000..82e0236 --- /dev/null +++ b/handlers/cards.go @@ -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) +} diff --git a/handlers/dashboard.go b/handlers/dashboard.go new file mode 100644 index 0000000..e0a607d --- /dev/null +++ b/handlers/dashboard.go @@ -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, + }) +} diff --git a/handlers/external_api.go b/handlers/external_api.go new file mode 100644 index 0000000..8804dc1 --- /dev/null +++ b/handlers/external_api.go @@ -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) +} diff --git a/handlers/logs.go b/handlers/logs.go new file mode 100644 index 0000000..e584042 --- /dev/null +++ b/handlers/logs.go @@ -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, + }) +} diff --git a/handlers/settings.go b/handlers/settings.go new file mode 100644 index 0000000..5ba0d9c --- /dev/null +++ b/handlers/settings.go @@ -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) +} diff --git a/handlers/tokens.go b/handlers/tokens.go new file mode 100644 index 0000000..49cc21a --- /dev/null +++ b/handlers/tokens.go @@ -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"}) +} diff --git a/handlers/transactions.go b/handlers/transactions.go new file mode 100644 index 0000000..2931edd --- /dev/null +++ b/handlers/transactions.go @@ -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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ca39e60 --- /dev/null +++ b/main.go @@ -0,0 +1,114 @@ +package main + +import ( + "log" + "os" + "path/filepath" + "strings" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + + "airwallex-admin/config" + "airwallex-admin/handlers" + "airwallex-admin/middleware" + "airwallex-admin/models" +) + +func main() { + config.Load() + models.InitDB() + models.InitializeDefaults(models.DB) + + r := gin.Default() + + r.Use(cors.New(cors.Config{ + AllowAllOrigins: true, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-API-Key"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: false, + })) + + // Health check + api := r.Group("/api") + api.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // Auth routes (no auth required) + auth := api.Group("/auth") + auth.POST("/login", handlers.Login) + + // Protected admin routes (JWT required) + protected := api.Group("") + protected.Use(middleware.JWTAuth()) + + protected.GET("/auth/me", handlers.GetMe) + protected.GET("/dashboard", handlers.GetDashboard) + + // Cards + protected.GET("/cards", handlers.ListCards) + protected.POST("/cards", handlers.CreateCard) + protected.GET("/cards/:id", handlers.GetCard) + protected.GET("/cards/:id/details", handlers.GetCardDetails) + protected.PUT("/cards/:id", handlers.UpdateCard) + + // Cardholders + protected.GET("/cardholders", handlers.ListCardholders) + protected.POST("/cardholders", handlers.CreateCardholder) + + // Transactions + protected.GET("/transactions", handlers.ListTransactions) + protected.GET("/authorizations", handlers.ListAuthorizations) + + // Settings + protected.GET("/settings", handlers.GetSettings) + protected.PUT("/settings", handlers.UpdateSettings) + protected.POST("/settings/test-connection", handlers.TestConnection) + protected.POST("/settings/test-proxy", handlers.TestProxy) + + // Tokens + protected.GET("/tokens", handlers.ListTokens) + protected.POST("/tokens", handlers.CreateToken) + protected.DELETE("/tokens/:id", handlers.DeleteToken) + + // Logs + protected.GET("/card-logs", handlers.ListCardLogs) + protected.GET("/audit-logs", handlers.ListAuditLogs) + + // External API (API Key auth) + v1 := api.Group("/v1") + v1.Use(middleware.APIKeyAuth()) + + v1.POST("/cards/create", middleware.CheckPermission("create_cards"), handlers.ExternalCreateCard) + v1.GET("/cards", middleware.CheckPermission("read_cards"), handlers.ExternalListCards) + v1.GET("/cards/:id", middleware.CheckPermission("read_cards"), handlers.ExternalGetCard) + v1.POST("/cards/:id/freeze", middleware.CheckPermission("create_cards"), handlers.ExternalFreezeCard) + v1.GET("/transactions", middleware.CheckPermission("read_transactions"), handlers.ExternalListTransactions) + v1.GET("/balance", middleware.CheckPermission("read_balance"), handlers.ExternalGetBalance) + + // Static files + SPA fallback + distPath := "frontend/dist" + if _, err := os.Stat(distPath); err == nil { + r.Static("/assets", filepath.Join(distPath, "assets")) + r.StaticFile("/favicon.ico", filepath.Join(distPath, "favicon.ico")) + + r.NoRoute(func(c *gin.Context) { + if strings.HasPrefix(c.Request.URL.Path, "/api/") { + c.JSON(404, gin.H{"detail": "Not found"}) + return + } + c.File(filepath.Join(distPath, "index.html")) + }) + } + + port := config.Cfg.Port + if port == "" { + port = "8000" + } + log.Printf("Starting server on :%s", port) + if err := r.Run(":" + port); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/middleware/api_key.go b/middleware/api_key.go new file mode 100644 index 0000000..26fe683 --- /dev/null +++ b/middleware/api_key.go @@ -0,0 +1,84 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "airwallex-admin/models" +) + +func APIKeyAuth() gin.HandlerFunc { + return func(c *gin.Context) { + apiKey := c.GetHeader("X-API-Key") + if apiKey == "" { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Missing API key"}) + c.Abort() + return + } + + var token models.ApiToken + result := models.DB.Where("token = ?", apiKey).First(&token) + if result.Error != nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid API key"}) + c.Abort() + return + } + + if !token.IsActive { + c.JSON(http.StatusForbidden, gin.H{"detail": "API key is inactive"}) + c.Abort() + return + } + + if token.ExpiresAt != nil && token.ExpiresAt.Before(time.Now()) { + c.JSON(http.StatusForbidden, gin.H{"detail": "API key has expired"}) + c.Abort() + return + } + + // Update last_used_at + now := time.Now() + models.DB.Model(&token).Update("last_used_at", now) + + c.Set("api_token", token) + c.Next() + } +} + +func CheckPermission(requiredPerm string) gin.HandlerFunc { + return func(c *gin.Context) { + val, exists := c.Get("api_token") + if !exists { + c.JSON(http.StatusForbidden, gin.H{"detail": "No API token in context"}) + c.Abort() + return + } + + token, ok := val.(models.ApiToken) + if !ok { + c.JSON(http.StatusForbidden, gin.H{"detail": "Invalid API token in context"}) + c.Abort() + return + } + + var permissions []string + if err := json.Unmarshal([]byte(token.Permissions), &permissions); err != nil { + c.JSON(http.StatusForbidden, gin.H{"detail": "Invalid permissions format"}) + c.Abort() + return + } + + for _, perm := range permissions { + if perm == "*" || perm == requiredPerm { + c.Next() + return + } + } + + c.JSON(http.StatusForbidden, gin.H{"detail": "Insufficient permissions"}) + c.Abort() + } +} diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..c5a16af --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,69 @@ +package middleware + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + + "airwallex-admin/config" +) + +func GenerateToken(username string) (string, error) { + claims := jwt.MapClaims{ + "sub": username, + "exp": time.Now().Add(time.Duration(config.Cfg.JWTExpireMinutes) * time.Minute).Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(config.Cfg.SecretKey)) +} + +func JWTAuth() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Missing authorization header"}) + c.Abort() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid authorization header format"}) + c.Abort() + return + } + + tokenString := parts[1] + token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return []byte(config.Cfg.SecretKey), nil + }) + if err != nil || !token.Valid { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid or expired token"}) + c.Abort() + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid token claims"}) + c.Abort() + return + } + + username, ok := claims["sub"].(string) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid token subject"}) + c.Abort() + return + } + + c.Set("username", username) + c.Next() + } +} diff --git a/models/api_token.go b/models/api_token.go new file mode 100644 index 0000000..f26f525 --- /dev/null +++ b/models/api_token.go @@ -0,0 +1,14 @@ +package models + +import "time" + +type ApiToken struct { + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" gorm:"type:varchar(255);not null"` + Token string `json:"-" gorm:"type:varchar(512);uniqueIndex:ix_api_tokens_token;not null"` + Permissions string `json:"permissions" gorm:"type:text;not null"` + IsActive bool `json:"is_active" gorm:"not null"` + CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime"` + ExpiresAt *time.Time `json:"expires_at"` + LastUsedAt *time.Time `json:"last_used_at"` +} diff --git a/models/audit_log.go b/models/audit_log.go new file mode 100644 index 0000000..6e6d6ef --- /dev/null +++ b/models/audit_log.go @@ -0,0 +1,14 @@ +package models + +import "time" + +type AuditLog struct { + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + Action string `json:"action" gorm:"type:varchar(100);not null"` + ResourceType string `json:"resource_type" gorm:"type:varchar(100);not null"` + ResourceID string `json:"resource_id" gorm:"type:varchar(255)"` + Operator string `json:"operator" gorm:"type:varchar(255);not null"` + IPAddress string `json:"ip_address" gorm:"type:varchar(45)"` + Details string `json:"details" gorm:"type:text"` + CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime"` +} diff --git a/models/card_log.go b/models/card_log.go new file mode 100644 index 0000000..15d5af4 --- /dev/null +++ b/models/card_log.go @@ -0,0 +1,15 @@ +package models + +import "time" + +type CardLog struct { + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + CardID string `json:"card_id" gorm:"type:varchar(255)"` + CardholderID string `json:"cardholder_id" gorm:"type:varchar(255)"` + Action string `json:"action" gorm:"type:varchar(100);not null"` + Status string `json:"status" gorm:"type:varchar(50);not null"` + Operator string `json:"operator" gorm:"type:varchar(255);not null"` + RequestData string `json:"request_data" gorm:"type:text"` + ResponseData string `json:"response_data" gorm:"type:text"` + CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime"` +} diff --git a/models/db.go b/models/db.go new file mode 100644 index 0000000..4884b0f --- /dev/null +++ b/models/db.go @@ -0,0 +1,46 @@ +package models + +import ( + "log" + "os" + "path/filepath" + + "airwallex-admin/config" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func InitDB() *gorm.DB { + dbPath := config.Cfg.DatabaseURL + + // Ensure the directory exists + dir := filepath.Dir(dbPath) + if dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0755); err != nil { + log.Fatalf("failed to create database directory: %v", err) + } + } + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + log.Fatalf("failed to connect to database: %v", err) + } + + // Create tables only if they don't exist (skip AutoMigrate to avoid + // SQLite ALTER TABLE issues with existing Python-created schemas) + migrator := db.Migrator() + models := []interface{}{&SystemSetting{}, &ApiToken{}, &CardLog{}, &AuditLog{}} + for _, model := range models { + if !migrator.HasTable(model) { + if err := migrator.CreateTable(model); err != nil { + log.Fatalf("failed to create table: %v", err) + } + } + } + + DB = db + return db +} diff --git a/models/init.go b/models/init.go new file mode 100644 index 0000000..40d3da3 --- /dev/null +++ b/models/init.go @@ -0,0 +1,40 @@ +package models + +import ( + "log" + "os" + + "airwallex-admin/config" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +func InitializeDefaults(db *gorm.DB) { + // Set admin password hash if not already set + if GetSetting(db, "admin_password_hash") == "" { + hash, err := bcrypt.GenerateFromPassword([]byte(config.Cfg.AdminPassword), bcrypt.DefaultCost) + if err != nil { + log.Fatalf("failed to hash admin password: %v", err) + } + SetSetting(db, "admin_password_hash", string(hash), false) + } + + // Set default daily card limit + if GetSetting(db, "daily_card_limit") == "" { + SetSetting(db, "daily_card_limit", "100", false) + } + + // Store Airwallex credentials from environment if not already set + envSettings := map[string]string{ + "airwallex_client_id": os.Getenv("AIRWALLEX_CLIENT_ID"), + "airwallex_api_key": os.Getenv("AIRWALLEX_API_KEY"), + "airwallex_base_url": os.Getenv("AIRWALLEX_BASE_URL"), + } + + for key, value := range envSettings { + if value != "" && GetSetting(db, key) == "" { + SetSetting(db, key, value, key != "airwallex_base_url") + } + } +} diff --git a/models/system_setting.go b/models/system_setting.go new file mode 100644 index 0000000..1babac7 --- /dev/null +++ b/models/system_setting.go @@ -0,0 +1,37 @@ +package models + +import ( + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type SystemSetting struct { + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + Key string `json:"key" gorm:"column:key;type:varchar(255);uniqueIndex:ix_system_settings_key;not null"` + Value string `json:"value" gorm:"type:text;not null"` + Encrypted bool `json:"encrypted" gorm:"not null"` + UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime"` +} + +func GetSetting(db *gorm.DB, key string) string { + var setting SystemSetting + result := db.Where("`key` = ?", key).First(&setting) + if result.Error != nil { + return "" + } + return setting.Value +} + +func SetSetting(db *gorm.DB, key, value string, encrypted bool) { + setting := SystemSetting{ + Key: key, + Value: value, + Encrypted: encrypted, + } + db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "key"}}, + DoUpdates: clause.AssignmentColumns([]string{"value", "encrypted", "updated_at"}), + }).Create(&setting) +} diff --git a/services/airwallex_service.go b/services/airwallex_service.go new file mode 100644 index 0000000..c7a2781 --- /dev/null +++ b/services/airwallex_service.go @@ -0,0 +1,429 @@ +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 +} diff --git a/services/audit.go b/services/audit.go new file mode 100644 index 0000000..e39bfcf --- /dev/null +++ b/services/audit.go @@ -0,0 +1,34 @@ +package services + +import ( + "airwallex-admin/models" + + "gorm.io/gorm" +) + +// CreateAuditLog creates a new audit log entry. +func CreateAuditLog(db *gorm.DB, action, resourceType, operator, resourceID, ipAddress, details string) { + log := models.AuditLog{ + Action: action, + ResourceType: resourceType, + ResourceID: resourceID, + Operator: operator, + IPAddress: ipAddress, + Details: details, + } + db.Create(&log) +} + +// CreateCardLog creates a new card operation log entry. +func CreateCardLog(db *gorm.DB, action, status, operator, cardID, cardholderID, requestData, responseData string) { + log := models.CardLog{ + CardID: cardID, + CardholderID: cardholderID, + Action: action, + Status: status, + Operator: operator, + RequestData: requestData, + ResponseData: responseData, + } + db.Create(&log) +}