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:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
backend/
|
||||
airwallex-sdk/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
*.exe
|
||||
data/
|
||||
.git/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -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"]
|
||||
23
airwallex/authorizations.go
Normal file
23
airwallex/authorizations.go
Normal file
@@ -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
|
||||
}
|
||||
28
airwallex/cardholders.go
Normal file
28
airwallex/cardholders.go
Normal file
@@ -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)
|
||||
}
|
||||
43
airwallex/cards.go
Normal file
43
airwallex/cards.go
Normal file
@@ -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)
|
||||
}
|
||||
254
airwallex/client.go
Normal file
254
airwallex/client.go
Normal file
@@ -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
|
||||
}
|
||||
11
airwallex/config.go
Normal file
11
airwallex/config.go
Normal file
@@ -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)
|
||||
}
|
||||
23
airwallex/transactions.go
Normal file
23
airwallex/transactions.go
Normal file
@@ -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
|
||||
}
|
||||
22
airwallex/types.go
Normal file
22
airwallex/types.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
74
config/config.go
Normal file
74
config/config.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
54
go.mod
Normal file
54
go.mod
Normal file
@@ -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
|
||||
)
|
||||
122
go.sum
Normal file
122
go.sum
Normal file
@@ -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=
|
||||
67
handlers/auth.go
Normal file
67
handlers/auth.go
Normal 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
50
handlers/cardholders.go
Normal 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
154
handlers/cards.go
Normal 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
58
handlers/dashboard.go
Normal 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
160
handlers/external_api.go
Normal 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
95
handlers/logs.go
Normal 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
70
handlers/settings.go
Normal 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
143
handlers/tokens.go
Normal 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
44
handlers/transactions.go
Normal 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)
|
||||
}
|
||||
114
main.go
Normal file
114
main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
84
middleware/api_key.go
Normal file
84
middleware/api_key.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
69
middleware/auth.go
Normal file
69
middleware/auth.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
14
models/api_token.go
Normal file
14
models/api_token.go
Normal file
@@ -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"`
|
||||
}
|
||||
14
models/audit_log.go
Normal file
14
models/audit_log.go
Normal file
@@ -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"`
|
||||
}
|
||||
15
models/card_log.go
Normal file
15
models/card_log.go
Normal file
@@ -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"`
|
||||
}
|
||||
46
models/db.go
Normal file
46
models/db.go
Normal file
@@ -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
|
||||
}
|
||||
40
models/init.go
Normal file
40
models/init.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
37
models/system_setting.go
Normal file
37
models/system_setting.go
Normal file
@@ -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)
|
||||
}
|
||||
429
services/airwallex_service.go
Normal file
429
services/airwallex_service.go
Normal file
@@ -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
|
||||
}
|
||||
34
services/audit.go
Normal file
34
services/audit.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user