Unverified Commit c6b3de11 authored by NepetaLemon's avatar NepetaLemon Committed by GitHub
Browse files

ci(backend): 添加 github actions (#10)

## 变更内容

### CI/CD
- 添加 GitHub Actions 工作流(test + golangci-lint)
- 添加 golangci-lint 配置,启用 errcheck/govet/staticcheck/unused/depguard
- 通过 depguard 强制 service 层不能直接导入 repository

### 错误处理修复
- 修复 CSV 写入、SSE 流式输出、随机数生成等未处理的错误
- GenerateRedeemCode() 现在返回 error

### 资源泄露修复
- 统一使用 defer func() { _ = xxx.Close() }() 模式

### 代码清理
- 移除未使用的常量
- 简化 nil map 检查
- 统一代码格式
parent f1325e9a
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
cache: true
- name: Run tests
run: go test ./...
golangci-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
cache: true
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
args: --timeout=5m
version: "2"
linters:
default: none
enable:
- depguard
- errcheck
- govet
- ineffassign
- staticcheck
- unused
settings:
depguard:
rules:
# Enforce: service must not depend on repository.
service-no-repository:
list-mode: original
files:
- internal/service/**
deny:
- pkg: sub2api/internal/repository
desc: "service must not import repository"
formatters:
enable:
- gofmt
...@@ -573,7 +573,7 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) { ...@@ -573,7 +573,7 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
// For API Key accounts: return models based on model_mapping // For API Key accounts: return models based on model_mapping
mapping := account.GetModelMapping() mapping := account.GetModelMapping()
if mapping == nil || len(mapping) == 0 { if len(mapping) == 0 {
// No mapping configured, return default models // No mapping configured, return default models
response.Success(c, claude.DefaultModels) response.Success(c, claude.DefaultModels)
return return
......
...@@ -236,7 +236,6 @@ func (h *ProxyHandler) GetProxyAccounts(c *gin.Context) { ...@@ -236,7 +236,6 @@ func (h *ProxyHandler) GetProxyAccounts(c *gin.Context) {
response.Paginated(c, accounts, total, page, pageSize) response.Paginated(c, accounts, total, page, pageSize)
} }
// BatchCreateProxyItem represents a single proxy in batch create request // BatchCreateProxyItem represents a single proxy in batch create request
type BatchCreateProxyItem struct { type BatchCreateProxyItem struct {
Protocol string `json:"protocol" binding:"required,oneof=http https socks5"` Protocol string `json:"protocol" binding:"required,oneof=http https socks5"`
......
...@@ -187,7 +187,10 @@ func (h *RedeemHandler) Export(c *gin.Context) { ...@@ -187,7 +187,10 @@ func (h *RedeemHandler) Export(c *gin.Context) {
writer := csv.NewWriter(&buf) writer := csv.NewWriter(&buf)
// Write header // Write header
writer.Write([]string{"id", "code", "type", "value", "status", "used_by", "used_at", "created_at"}) if err := writer.Write([]string{"id", "code", "type", "value", "status", "used_by", "used_at", "created_at"}); err != nil {
response.InternalError(c, "Failed to export redeem codes: "+err.Error())
return
}
// Write data rows // Write data rows
for _, code := range codes { for _, code := range codes {
...@@ -199,7 +202,7 @@ func (h *RedeemHandler) Export(c *gin.Context) { ...@@ -199,7 +202,7 @@ func (h *RedeemHandler) Export(c *gin.Context) {
if code.UsedAt != nil { if code.UsedAt != nil {
usedAt = code.UsedAt.Format("2006-01-02 15:04:05") usedAt = code.UsedAt.Format("2006-01-02 15:04:05")
} }
writer.Write([]string{ if err := writer.Write([]string{
fmt.Sprintf("%d", code.ID), fmt.Sprintf("%d", code.ID),
code.Code, code.Code,
code.Type, code.Type,
...@@ -208,10 +211,17 @@ func (h *RedeemHandler) Export(c *gin.Context) { ...@@ -208,10 +211,17 @@ func (h *RedeemHandler) Export(c *gin.Context) {
usedBy, usedBy,
usedAt, usedAt,
code.CreatedAt.Format("2006-01-02 15:04:05"), code.CreatedAt.Format("2006-01-02 15:04:05"),
}) }); err != nil {
response.InternalError(c, "Failed to export redeem codes: "+err.Error())
return
}
} }
writer.Flush() writer.Flush()
if err := writer.Error(); err != nil {
response.InternalError(c, "Failed to export redeem codes: "+err.Error())
return
}
c.Header("Content-Type", "text/csv") c.Header("Content-Type", "text/csv")
c.Header("Content-Disposition", "attachment; filename=redeem_codes.csv") c.Header("Content-Disposition", "attachment; filename=redeem_codes.csv")
......
...@@ -268,7 +268,9 @@ func (h *GatewayHandler) waitForSlotWithPing(c *gin.Context, slotType string, id ...@@ -268,7 +268,9 @@ func (h *GatewayHandler) waitForSlotWithPing(c *gin.Context, slotType string, id
c.Header("X-Accel-Buffering", "no") c.Header("X-Accel-Buffering", "no")
*streamStarted = true *streamStarted = true
} }
fmt.Fprintf(c.Writer, "data: {\"type\": \"ping\"}\n\n") if _, err := fmt.Fprintf(c.Writer, "data: {\"type\": \"ping\"}\n\n"); err != nil {
return nil, err
}
flusher.Flush() flusher.Flush()
} }
...@@ -414,7 +416,9 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e ...@@ -414,7 +416,9 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
if ok { if ok {
// Send error event in SSE format // Send error event in SSE format
errorEvent := fmt.Sprintf(`data: {"type": "error", "error": {"type": "%s", "message": "%s"}}`+"\n\n", errType, message) errorEvent := fmt.Sprintf(`data: {"type": "error", "error": {"type": "%s", "message": "%s"}}`+"\n\n", errType, message)
fmt.Fprint(c.Writer, errorEvent) if _, err := fmt.Fprint(c.Writer, errorEvent); err != nil {
_ = c.Error(err)
}
flusher.Flush() flusher.Flush()
} }
return return
......
...@@ -163,7 +163,7 @@ func (a *Account) GetModelMapping() map[string]string { ...@@ -163,7 +163,7 @@ func (a *Account) GetModelMapping() map[string]string {
// 如果没有设置模型映射,则支持所有模型 // 如果没有设置模型映射,则支持所有模型
func (a *Account) IsModelSupported(requestedModel string) bool { func (a *Account) IsModelSupported(requestedModel string) bool {
mapping := a.GetModelMapping() mapping := a.GetModelMapping()
if mapping == nil || len(mapping) == 0 { if len(mapping) == 0 {
return true // 没有映射配置,支持所有模型 return true // 没有映射配置,支持所有模型
} }
_, exists := mapping[requestedModel] _, exists := mapping[requestedModel]
...@@ -174,7 +174,7 @@ func (a *Account) IsModelSupported(requestedModel string) bool { ...@@ -174,7 +174,7 @@ func (a *Account) IsModelSupported(requestedModel string) bool {
// 如果没有映射,返回原始模型名 // 如果没有映射,返回原始模型名
func (a *Account) GetMappedModel(requestedModel string) string { func (a *Account) GetMappedModel(requestedModel string) string {
mapping := a.GetModelMapping() mapping := a.GetModelMapping()
if mapping == nil || len(mapping) == 0 { if len(mapping) == 0 {
return requestedModel return requestedModel
} }
if mappedModel, exists := mapping[requestedModel]; exists { if mappedModel, exists := mapping[requestedModel]; exists {
......
...@@ -40,8 +40,10 @@ func (r *RedeemCode) CanUse() bool { ...@@ -40,8 +40,10 @@ func (r *RedeemCode) CanUse() bool {
} }
// GenerateRedeemCode 生成唯一的兑换码 // GenerateRedeemCode 生成唯一的兑换码
func GenerateRedeemCode() string { func GenerateRedeemCode() (string, error) {
b := make([]byte, 16) b := make([]byte, 16)
rand.Read(b) if _, err := rand.Read(b); err != nil {
return hex.EncodeToString(b) return "", err
}
return hex.EncodeToString(b), nil
} }
...@@ -37,11 +37,15 @@ func TestInitInvalidTimezone(t *testing.T) { ...@@ -37,11 +37,15 @@ func TestInitInvalidTimezone(t *testing.T) {
func TestTimeNowAffected(t *testing.T) { func TestTimeNowAffected(t *testing.T) {
// Reset to UTC first // Reset to UTC first
Init("UTC") if err := Init("UTC"); err != nil {
t.Fatalf("Init failed with UTC: %v", err)
}
utcNow := time.Now() utcNow := time.Now()
// Switch to Shanghai (UTC+8) // Switch to Shanghai (UTC+8)
Init("Asia/Shanghai") if err := Init("Asia/Shanghai"); err != nil {
t.Fatalf("Init failed with Asia/Shanghai: %v", err)
}
shanghaiNow := time.Now() shanghaiNow := time.Now()
// The times should be the same instant, but different timezone representation // The times should be the same instant, but different timezone representation
...@@ -58,7 +62,9 @@ func TestTimeNowAffected(t *testing.T) { ...@@ -58,7 +62,9 @@ func TestTimeNowAffected(t *testing.T) {
} }
func TestToday(t *testing.T) { func TestToday(t *testing.T) {
Init("Asia/Shanghai") if err := Init("Asia/Shanghai"); err != nil {
t.Fatalf("Init failed with Asia/Shanghai: %v", err)
}
today := Today() today := Today()
now := Now() now := Now()
...@@ -75,7 +81,9 @@ func TestToday(t *testing.T) { ...@@ -75,7 +81,9 @@ func TestToday(t *testing.T) {
} }
func TestStartOfDay(t *testing.T) { func TestStartOfDay(t *testing.T) {
Init("Asia/Shanghai") if err := Init("Asia/Shanghai"); err != nil {
t.Fatalf("Init failed with Asia/Shanghai: %v", err)
}
// Create a time at 15:30:45 // Create a time at 15:30:45
testTime := time.Date(2024, 6, 15, 15, 30, 45, 123456789, Location()) testTime := time.Date(2024, 6, 15, 15, 30, 45, 123456789, Location())
...@@ -91,7 +99,9 @@ func TestTruncateVsStartOfDay(t *testing.T) { ...@@ -91,7 +99,9 @@ func TestTruncateVsStartOfDay(t *testing.T) {
// This test demonstrates why Truncate(24*time.Hour) can be problematic // This test demonstrates why Truncate(24*time.Hour) can be problematic
// and why StartOfDay is more reliable for timezone-aware code // and why StartOfDay is more reliable for timezone-aware code
Init("Asia/Shanghai") if err := Init("Asia/Shanghai"); err != nil {
t.Fatalf("Init failed with Asia/Shanghai: %v", err)
}
now := Now() now := Now()
......
...@@ -43,7 +43,7 @@ func (s *claudeUsageService) FetchUsage(ctx context.Context, accessToken, proxyU ...@@ -43,7 +43,7 @@ func (s *claudeUsageService) FetchUsage(ctx context.Context, accessToken, proxyU
if err != nil { if err != nil {
return nil, fmt.Errorf("request failed: %w", err) return nil, fmt.Errorf("request failed: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
......
...@@ -38,7 +38,7 @@ func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo strin ...@@ -38,7 +38,7 @@ func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo strin
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode) return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
...@@ -63,7 +63,7 @@ func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string ...@@ -63,7 +63,7 @@ func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned %d", resp.StatusCode) return fmt.Errorf("download returned %d", resp.StatusCode)
...@@ -78,7 +78,7 @@ func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string ...@@ -78,7 +78,7 @@ func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string
if err != nil { if err != nil {
return err return err
} }
defer out.Close() defer func() { _ = out.Close() }()
// SECURITY: Use LimitReader to enforce max download size even if Content-Length is missing/wrong // SECURITY: Use LimitReader to enforce max download size even if Content-Length is missing/wrong
limited := io.LimitReader(resp.Body, maxSize+1) limited := io.LimitReader(resp.Body, maxSize+1)
...@@ -89,7 +89,7 @@ func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string ...@@ -89,7 +89,7 @@ func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string
// Check if we hit the limit (downloaded more than maxSize) // Check if we hit the limit (downloaded more than maxSize)
if written > maxSize { if written > maxSize {
os.Remove(dest) // Clean up partial file _ = os.Remove(dest) // Clean up partial file (best-effort)
return fmt.Errorf("download exceeded maximum size of %d bytes", maxSize) return fmt.Errorf("download exceeded maximum size of %d bytes", maxSize)
} }
...@@ -106,7 +106,7 @@ func (c *githubReleaseClient) FetchChecksumFile(ctx context.Context, url string) ...@@ -106,7 +106,7 @@ func (c *githubReleaseClient) FetchChecksumFile(ctx context.Context, url string)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode) return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
......
...@@ -33,7 +33,7 @@ func (c *pricingRemoteClient) FetchPricingJSON(ctx context.Context, url string) ...@@ -33,7 +33,7 @@ func (c *pricingRemoteClient) FetchPricingJSON(ctx context.Context, url string)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode) return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
...@@ -52,7 +52,7 @@ func (c *pricingRemoteClient) FetchHashText(ctx context.Context, url string) (st ...@@ -52,7 +52,7 @@ func (c *pricingRemoteClient) FetchHashText(ctx context.Context, url string) (st
if err != nil { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d", resp.StatusCode) return "", fmt.Errorf("HTTP %d", resp.StatusCode)
......
...@@ -43,7 +43,7 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s ...@@ -43,7 +43,7 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("proxy connection failed: %w", err) return nil, 0, fmt.Errorf("proxy connection failed: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
latencyMs := time.Since(startTime).Milliseconds() latencyMs := time.Since(startTime).Milliseconds()
......
...@@ -44,7 +44,7 @@ func (v *turnstileVerifier) VerifyToken(ctx context.Context, secretKey, token, r ...@@ -44,7 +44,7 @@ func (v *turnstileVerifier) VerifyToken(ctx context.Context, secretKey, token, r
if err != nil { if err != nil {
return nil, fmt.Errorf("send request: %w", err) return nil, fmt.Errorf("send request: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var result service.TurnstileVerifyResponse var result service.TurnstileVerifyResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
......
...@@ -51,16 +51,23 @@ func NewAccountTestService(accountRepo ports.AccountRepository, oauthService *OA ...@@ -51,16 +51,23 @@ func NewAccountTestService(accountRepo ports.AccountRepository, oauthService *OA
} }
// generateSessionString generates a Claude Code style session string // generateSessionString generates a Claude Code style session string
func generateSessionString() string { func generateSessionString() (string, error) {
bytes := make([]byte, 32) bytes := make([]byte, 32)
rand.Read(bytes) if _, err := rand.Read(bytes); err != nil {
return "", err
}
hex64 := hex.EncodeToString(bytes) hex64 := hex.EncodeToString(bytes)
sessionUUID := uuid.New().String() sessionUUID := uuid.New().String()
return fmt.Sprintf("user_%s_account__session_%s", hex64, sessionUUID) return fmt.Sprintf("user_%s_account__session_%s", hex64, sessionUUID), nil
} }
// createTestPayload creates a Claude Code style test request payload // createTestPayload creates a Claude Code style test request payload
func createTestPayload(modelID string) map[string]interface{} { func createTestPayload(modelID string) (map[string]interface{}, error) {
sessionID, err := generateSessionString()
if err != nil {
return nil, err
}
return map[string]interface{}{ return map[string]interface{}{
"model": modelID, "model": modelID,
"messages": []map[string]interface{}{ "messages": []map[string]interface{}{
...@@ -87,12 +94,12 @@ func createTestPayload(modelID string) map[string]interface{} { ...@@ -87,12 +94,12 @@ func createTestPayload(modelID string) map[string]interface{} {
}, },
}, },
"metadata": map[string]string{ "metadata": map[string]string{
"user_id": generateSessionString(), "user_id": sessionID,
}, },
"max_tokens": 1024, "max_tokens": 1024,
"temperature": 1, "temperature": 1,
"stream": true, "stream": true,
} }, nil
} }
// TestAccountConnection tests an account's connection by sending a test request // TestAccountConnection tests an account's connection by sending a test request
...@@ -116,7 +123,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int ...@@ -116,7 +123,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
// For API Key accounts with model mapping, map the model // For API Key accounts with model mapping, map the model
if account.Type == "apikey" { if account.Type == "apikey" {
mapping := account.GetModelMapping() mapping := account.GetModelMapping()
if mapping != nil && len(mapping) > 0 { if len(mapping) > 0 {
if mappedModel, exists := mapping[testModelID]; exists { if mappedModel, exists := mapping[testModelID]; exists {
testModelID = mappedModel testModelID = mappedModel
} }
...@@ -178,7 +185,10 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int ...@@ -178,7 +185,10 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
c.Writer.Flush() c.Writer.Flush()
// Create Claude Code style payload (same for all account types) // Create Claude Code style payload (same for all account types)
payload := createTestPayload(testModelID) payload, err := createTestPayload(testModelID)
if err != nil {
return s.sendErrorAndEnd(c, "Failed to create test payload")
}
payloadBytes, _ := json.Marshal(payload) payloadBytes, _ := json.Marshal(payload)
// Send test_start event // Send test_start event
...@@ -216,7 +226,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int ...@@ -216,7 +226,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
if err != nil { if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
...@@ -284,7 +294,10 @@ func (s *AccountTestService) processStream(c *gin.Context, body io.Reader) error ...@@ -284,7 +294,10 @@ func (s *AccountTestService) processStream(c *gin.Context, body io.Reader) error
// sendEvent sends a SSE event to the client // sendEvent sends a SSE event to the client
func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) { func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) {
eventJSON, _ := json.Marshal(event) eventJSON, _ := json.Marshal(event)
fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON) if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil {
log.Printf("failed to write SSE event: %v", err)
return
}
c.Writer.Flush() c.Writer.Flush()
} }
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment