feat: 系统优化 - ESLint、Tailwind、前端健壮性、后端工程化、运维可观测性
- 前端: ESLint+Prettier配置、Tailwind v4配置、ErrorBoundary、全局AuthLoader优化、ReactQuery分层 - 后端: MinIO凭证移除、Docker统一为govai品牌、zerolog日志封装、错误码枚举、文件上传校验、单元测试(13项全通过) - 运维: 健康检查增强(PG/Redis ping)、Prometheus指标(/metrics端点)、多租户tenant包、RateLimit nil防御 - 移动: citation_prompt.txt → internal/assets/
This commit is contained in:
@@ -116,8 +116,8 @@ func Load() *Config {
|
||||
},
|
||||
MinIO: MinIOConfig{
|
||||
Endpoint: getEnv("MINIO_ENDPOINT", "localhost:9000"),
|
||||
AccessKey: getEnv("MINIO_ACCESS_KEY", "minioadmin"),
|
||||
SecretKey: getEnv("MINIO_SECRET_KEY", "minioadmin"),
|
||||
AccessKey: getEnv("MINIO_ACCESS_KEY", ""),
|
||||
SecretKey: getEnv("MINIO_SECRET_KEY", ""),
|
||||
Bucket: getEnv("MINIO_BUCKET", "aily-files"),
|
||||
UseSSL: false,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// ==================== 分页与查询 ====================
|
||||
const (
|
||||
DefaultPage = 1
|
||||
DefaultPageSize = 20
|
||||
MaxPageSize = 100
|
||||
)
|
||||
|
||||
// ==================== Token 与会话 ====================
|
||||
const (
|
||||
DefaultAccessTokenExpiry = 24 * time.Hour
|
||||
DefaultRefreshTokenExpiry = 7 * 24 * time.Hour
|
||||
TokenHeader = "Authorization"
|
||||
TokenPrefix = "Bearer "
|
||||
)
|
||||
|
||||
// ==================== 文件上传 ====================
|
||||
const (
|
||||
MaxFileSize = 10 * 1024 * 1024 // 10MB
|
||||
AllowedFileExtensions = ".pdf,.doc,.docx,.txt,.md,.jpg,.jpeg,.png,.gif,.xlsx,.xls"
|
||||
AllowedMimeTypes = "application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown,image/jpeg,image/png,image/gif,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
|
||||
// ==================== AI 模型 ====================
|
||||
const (
|
||||
DefaultEmbeddingDimensions = 1024
|
||||
DefaultEmbeddingModel = "text-embedding-v3"
|
||||
DefaultLLMTemperature = 0.7
|
||||
DefaultLLMTopP = 0.95
|
||||
DefaultLLMMaxTokens = 8192
|
||||
)
|
||||
|
||||
// ==================== RateLimit ====================
|
||||
const (
|
||||
DefaultRateLimitMax = 100
|
||||
DefaultRateLimitWindow = 1 * time.Minute
|
||||
)
|
||||
|
||||
// ==================== Redis Key 前缀 ====================
|
||||
const (
|
||||
RedisKeyPrefixRateLimit = "rl:"
|
||||
RedisKeyPrefixSession = "session:"
|
||||
RedisKeyPrefixToken = "token:"
|
||||
RedisKeyPrefixCache = "cache:"
|
||||
)
|
||||
|
||||
// ==================== Chat ====================
|
||||
const (
|
||||
MaxConversationHistory = 50
|
||||
MaxMessageLength = 4000
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/enterprise-ai-platform/server/internal/config"
|
||||
)
|
||||
|
||||
// ValidateFile checks file size, extension, and MIME type.
|
||||
// Returns nil if valid, or an error message if invalid.
|
||||
func ValidateFile(header *multipart.FileHeader, allowedExtensions []string, maxSize int64) string {
|
||||
if header.Size > maxSize {
|
||||
return fmt.Sprintf("文件大小超出限制,最大支持 %dMB", maxSize/(1024*1024))
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext != "" {
|
||||
ext = ext[1:] // strip leading "."
|
||||
}
|
||||
|
||||
extAllowed := false
|
||||
for _, e := range allowedExtensions {
|
||||
if strings.EqualFold(e, ext) {
|
||||
extAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !extAllowed {
|
||||
return fmt.Sprintf("不支持的文件类型:.%s,仅支持:%s", ext, strings.Join(allowedExtensions, "、"))
|
||||
}
|
||||
|
||||
// Verify MIME type matches extension
|
||||
mimeType := header.Header.Get("Content-Type")
|
||||
if mimeType != "" {
|
||||
extMime, err := mime.ExtensionsByType(mimeType)
|
||||
if err == nil && len(extMime) > 0 {
|
||||
mimeAllowed := false
|
||||
for _, e := range extMime {
|
||||
if strings.EqualFold(strings.TrimPrefix(e, "."), ext) {
|
||||
mimeAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !mimeAllowed && !strings.HasPrefix(mimeType, "text/") && !strings.HasPrefix(mimeType, "application/") {
|
||||
return "文件类型与实际内容不匹配"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "" // valid
|
||||
}
|
||||
|
||||
// AllowedDocumentExtensions returns the list of allowed document file extensions.
|
||||
func AllowedDocumentExtensions() []string {
|
||||
return []string{"pdf", "docx", "txt", "md", "csv", "xlsx"}
|
||||
}
|
||||
|
||||
// AllowedDocumentMaxSize returns the max file size for document uploads.
|
||||
func AllowedDocumentMaxSize() int64 {
|
||||
return config.MaxFileSize
|
||||
}
|
||||
|
||||
// ReadAllWithLimit reads all content from r up to maxSize bytes.
|
||||
// Returns error if content exceeds maxSize.
|
||||
func ReadAllWithLimit(r io.Reader, maxSize int64) ([]byte, error) {
|
||||
limited := &io.LimitedReader{R: r, N: maxSize + 1}
|
||||
data, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limited.N == 0 {
|
||||
return nil, fmt.Errorf("文件内容超出 %dMB 限制", maxSize/(1024*1024))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -6,15 +6,80 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/enterprise-ai-platform/server/internal/response"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var startTime = time.Now()
|
||||
type HealthHandler struct {
|
||||
pool *pgxpool.Pool
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"service": "aily-portal-api",
|
||||
func NewHealthHandler(pool *pgxpool.Pool, rdb *redis.Client) *HealthHandler {
|
||||
return &HealthHandler{pool: pool, rdb: rdb}
|
||||
}
|
||||
|
||||
func (h *HealthHandler) HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
type dep struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
deps := []dep{}
|
||||
|
||||
// PostgreSQL
|
||||
if h.pool != nil {
|
||||
if err := h.pool.Ping(ctx); err != nil {
|
||||
deps = append(deps, dep{Name: "postgres", Status: "down", Error: err.Error()})
|
||||
} else {
|
||||
deps = append(deps, dep{Name: "postgres", Status: "up"})
|
||||
}
|
||||
} else {
|
||||
deps = append(deps, dep{Name: "postgres", Status: "not_configured"})
|
||||
}
|
||||
|
||||
// Redis
|
||||
if h.rdb != nil {
|
||||
if err := h.rdb.Ping(ctx).Err(); err != nil {
|
||||
deps = append(deps, dep{Name: "redis", Status: "down", Error: err.Error()})
|
||||
} else {
|
||||
deps = append(deps, dep{Name: "redis", Status: "up"})
|
||||
}
|
||||
} else {
|
||||
deps = append(deps, dep{Name: "redis", Status: "not_configured"})
|
||||
}
|
||||
|
||||
// Overall status
|
||||
status := "ok"
|
||||
httpStatus := http.StatusOK
|
||||
for _, d := range deps {
|
||||
if d.Status == "down" {
|
||||
status = "degraded"
|
||||
httpStatus = http.StatusServiceUnavailable
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime stats
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
response.JSON(w, httpStatus, map[string]any{
|
||||
"status": status,
|
||||
"service": "govai-portal-api",
|
||||
"uptime": time.Since(startTime).String(),
|
||||
"go": runtime.Version(),
|
||||
"memory": map[string]any{
|
||||
" Alloc": m.Alloc / 1024 / 1024,
|
||||
"Sys": m.Sys / 1024 / 1024,
|
||||
"NumGC": m.NumGC,
|
||||
"Goroutine": runtime.NumGoroutine(),
|
||||
},
|
||||
"dependencies": deps,
|
||||
})
|
||||
}
|
||||
|
||||
// startTime is shared with the original health.go init block.
|
||||
var startTime = time.Now()
|
||||
@@ -254,6 +254,12 @@ func (h *KnowledgeHandler) UploadDocument(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 文件安全校验
|
||||
if msg := ValidateFile(header, AllowedDocumentExtensions(), AllowedDocumentMaxSize()); msg != "" {
|
||||
response.BadRequest(w, msg)
|
||||
return
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err = h.pool.QueryRow(r.Context(),
|
||||
`SELECT EXISTS(SELECT 1 FROM knowledge_bases WHERE id = $1)`, kbID).Scan(&exists)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
func Init(level string, jsonFormat bool) {
|
||||
var output io.Writer = os.Stdout
|
||||
if !jsonFormat {
|
||||
output = zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: time.RFC3339,
|
||||
}
|
||||
}
|
||||
|
||||
lvl, err := zerolog.ParseLevel(level)
|
||||
if err != nil {
|
||||
lvl = zerolog.InfoLevel
|
||||
}
|
||||
|
||||
log = zerolog.New(output).
|
||||
Level(lvl).
|
||||
With().
|
||||
Timestamp().
|
||||
Caller().
|
||||
Logger()
|
||||
}
|
||||
|
||||
func Get() *zerolog.Logger {
|
||||
return &log
|
||||
}
|
||||
|
||||
// Info/fatal/warn/error 等直接透传
|
||||
func Info() *zerolog.Event {
|
||||
return log.Info()
|
||||
}
|
||||
|
||||
func Warn() *zerolog.Event {
|
||||
return log.Warn()
|
||||
}
|
||||
|
||||
func Error() *zerolog.Event {
|
||||
return log.Error()
|
||||
}
|
||||
|
||||
func Debug() *zerolog.Event {
|
||||
return log.Debug()
|
||||
}
|
||||
|
||||
func Fatal() *zerolog.Event {
|
||||
return log.Fatal()
|
||||
}
|
||||
|
||||
func Ctx(ctx interface{ Value(key interface{}) interface{} }) zerolog.Logger {
|
||||
return log.With().Interface("ctx", ctx).Logger()
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
// HTTP requests
|
||||
HTTPRequestsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "http_requests_total",
|
||||
Help: "Total number of HTTP requests",
|
||||
},
|
||||
[]string{"method", "path", "status"},
|
||||
)
|
||||
|
||||
HTTPRequestDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "http_request_duration_seconds",
|
||||
Help: "HTTP request latency in seconds",
|
||||
Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
|
||||
},
|
||||
[]string{"method", "path"},
|
||||
)
|
||||
|
||||
// AI / LLM tokens
|
||||
LLMTokensTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "ai_tokens_total",
|
||||
Help: "Total number of AI tokens consumed",
|
||||
},
|
||||
[]string{"model", "type"}, // type: prompt | completion
|
||||
)
|
||||
|
||||
LLMRequestsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "ai_requests_total",
|
||||
Help: "Total number of AI/LLM API requests",
|
||||
},
|
||||
[]string{"model", "status"},
|
||||
)
|
||||
|
||||
LLMRequestDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "ai_request_duration_seconds",
|
||||
Help: "AI/LLM API request latency in seconds",
|
||||
Buckets: []float64{0.1, 0.5, 1, 2, 5, 10, 30, 60},
|
||||
},
|
||||
[]string{"model"},
|
||||
)
|
||||
|
||||
// Chat / conversation
|
||||
ConversationsTotal = promauto.NewCounter(
|
||||
prometheus.CounterOpts{
|
||||
Name: "conversations_total",
|
||||
Help: "Total number of chat conversations created",
|
||||
},
|
||||
)
|
||||
|
||||
MessagesTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "messages_total",
|
||||
Help: "Total number of chat messages",
|
||||
},
|
||||
[]string{"role"}, // role: user | assistant
|
||||
)
|
||||
|
||||
// Auth
|
||||
AuthFailuresTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "auth_failures_total",
|
||||
Help: "Total number of authentication failures",
|
||||
},
|
||||
[]string{"reason"},
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestGetUserID(t *testing.T) {
|
||||
userID := uuid.New()
|
||||
ctx := context.WithValue(context.Background(), UserIDKey, userID)
|
||||
if got := GetUserID(ctx); got != userID {
|
||||
t.Errorf("GetUserID() = %v, want %v", got, userID)
|
||||
}
|
||||
|
||||
ctx = context.Background()
|
||||
if got := GetUserID(ctx); got != uuid.Nil {
|
||||
t.Errorf("GetUserID() from empty ctx = %v, want uuid.Nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRole(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), RoleKey, "admin")
|
||||
if got := GetRole(ctx); got != "admin" {
|
||||
t.Errorf("GetRole() = %v, want admin", got)
|
||||
}
|
||||
|
||||
ctx = context.Background()
|
||||
if got := GetRole(ctx); got != "" {
|
||||
t.Errorf("GetRole() from empty ctx = %v, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRole_UserRole(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrapped := RequireRole("admin")(handler)
|
||||
mux.Handle("/admin", wrapped)
|
||||
|
||||
// user 角色 → 403
|
||||
userCtx := context.WithValue(context.Background(), RoleKey, "user")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin", nil).WithContext(userCtx)
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Errorf("user role: got %d, want %d", rr.Code, http.StatusForbidden)
|
||||
}
|
||||
|
||||
// admin 角色 → 200
|
||||
adminCtx := context.WithValue(context.Background(), RoleKey, "admin")
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin", nil).WithContext(adminCtx)
|
||||
rr = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("admin role: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
// 无角色 → 403
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
rr = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Errorf("no role: got %d, want %d", rr.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSuperAdmin(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrapped := RequireSuperAdmin(handler)
|
||||
mux.Handle("/platform", wrapped)
|
||||
|
||||
// super_admin → 200
|
||||
superAdminCtx := context.WithValue(context.Background(), RoleKey, "super_admin")
|
||||
req := httptest.NewRequest(http.MethodGet, "/platform", nil).WithContext(superAdminCtx)
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("super_admin: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
// admin → 403
|
||||
adminCtx := context.WithValue(context.Background(), RoleKey, "admin")
|
||||
req = httptest.NewRequest(http.MethodGet, "/platform", nil).WithContext(adminCtx)
|
||||
rr = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Errorf("admin: got %d, want %d", rr.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_NilPool(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
fn := AuditLog(nil)
|
||||
var called bool
|
||||
fn(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
})).ServeHTTP(rr, req)
|
||||
|
||||
if !called {
|
||||
t.Error("AuditLog middleware did not call next handler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimit_NilRedis(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrapped := RateLimit(nil, 5, 0)(handler)
|
||||
mux.Handle("/test", wrapped)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code == http.StatusTooManyRequests {
|
||||
t.Error("RateLimit should bypass when Redis is unavailable")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
func RateLimit(rdb *redis.Client, maxRequests int, window time.Duration) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if rdb == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := GetUserID(r.Context())
|
||||
key := fmt.Sprintf("rl:%s:%s", userID.String(), r.URL.Path)
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package response
|
||||
|
||||
import "net/http"
|
||||
|
||||
// 错误码枚举,统一业务错误定义
|
||||
type ErrCode int
|
||||
|
||||
const (
|
||||
// 通用错误 (0xxxx)
|
||||
ErrCodeSuccess ErrCode = 0
|
||||
ErrCodeBadRequest ErrCode = 40001
|
||||
ErrCodeUnauthorized ErrCode = 40101
|
||||
ErrCodeForbidden ErrCode = 40301
|
||||
ErrCodeNotFound ErrCode = 40401
|
||||
ErrCodeRequestTimeout ErrCode = 40801
|
||||
ErrCodeTooManyRequests ErrCode = 42901
|
||||
ErrCodeInternalError ErrCode = 50001
|
||||
ErrCodeServiceUnavailable ErrCode = 50301
|
||||
ErrCodeGatewayTimeout ErrCode = 50401
|
||||
|
||||
// 业务错误 (6xxxx)
|
||||
ErrCodeInvalidToken ErrCode = 40102
|
||||
ErrCodeTokenExpired ErrCode = 40103
|
||||
ErrCodeUserDisabled ErrCode = 40104
|
||||
ErrCodeOrgNotFound ErrCode = 40402
|
||||
ErrCodeAppNotFound ErrCode = 40403
|
||||
ErrCodeKnowledgeNotFound ErrCode = 40404
|
||||
ErrCodeInsufficientQuota ErrCode = 42902
|
||||
ErrCodeFileTooLarge ErrCode = 40002
|
||||
ErrCodeInvalidFileType ErrCode = 40003
|
||||
)
|
||||
|
||||
var httpStatusMap = map[ErrCode]int{
|
||||
ErrCodeSuccess: http.StatusOK,
|
||||
ErrCodeBadRequest: http.StatusBadRequest,
|
||||
ErrCodeUnauthorized: http.StatusUnauthorized,
|
||||
ErrCodeForbidden: http.StatusForbidden,
|
||||
ErrCodeNotFound: http.StatusNotFound,
|
||||
ErrCodeRequestTimeout: http.StatusRequestTimeout,
|
||||
ErrCodeTooManyRequests: http.StatusTooManyRequests,
|
||||
ErrCodeInternalError: http.StatusInternalServerError,
|
||||
ErrCodeServiceUnavailable: http.StatusServiceUnavailable,
|
||||
ErrCodeGatewayTimeout: http.StatusGatewayTimeout,
|
||||
ErrCodeInvalidToken: http.StatusUnauthorized,
|
||||
ErrCodeTokenExpired: http.StatusUnauthorized,
|
||||
ErrCodeUserDisabled: http.StatusUnauthorized,
|
||||
ErrCodeOrgNotFound: http.StatusNotFound,
|
||||
ErrCodeAppNotFound: http.StatusNotFound,
|
||||
ErrCodeKnowledgeNotFound: http.StatusNotFound,
|
||||
ErrCodeInsufficientQuota: http.StatusTooManyRequests,
|
||||
ErrCodeFileTooLarge: http.StatusBadRequest,
|
||||
ErrCodeInvalidFileType: http.StatusBadRequest,
|
||||
}
|
||||
|
||||
func (e ErrCode) Status() int {
|
||||
if s, ok := httpStatusMap[e]; ok {
|
||||
return s
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
func (e ErrCode) Code() int {
|
||||
return int(e)
|
||||
}
|
||||
|
||||
func (e ErrCode) Error() string {
|
||||
return e.String()
|
||||
}
|
||||
|
||||
func (e ErrCode) String() string {
|
||||
switch e {
|
||||
case ErrCodeSuccess: return "成功"
|
||||
case ErrCodeBadRequest: return "请求参数有误"
|
||||
case ErrCodeUnauthorized: return "未授权"
|
||||
case ErrCodeForbidden: return "无权限"
|
||||
case ErrCodeNotFound: return "资源不存在"
|
||||
case ErrCodeRequestTimeout: return "请求超时"
|
||||
case ErrCodeTooManyRequests: return "请求过于频繁"
|
||||
case ErrCodeInternalError: return "服务器内部错误"
|
||||
case ErrCodeServiceUnavailable: return "服务不可用"
|
||||
case ErrCodeGatewayTimeout: return "网关超时"
|
||||
case ErrCodeInvalidToken: return "无效的认证令牌"
|
||||
case ErrCodeTokenExpired: return "认证令牌已过期"
|
||||
case ErrCodeUserDisabled: return "用户已被禁用"
|
||||
case ErrCodeOrgNotFound: return "机构不存在"
|
||||
case ErrCodeAppNotFound: return "应用不存在"
|
||||
case ErrCodeKnowledgeNotFound: return "知识库不存在"
|
||||
case ErrCodeInsufficientQuota: return "配额不足"
|
||||
case ErrCodeFileTooLarge: return "文件超出大小限制"
|
||||
case ErrCodeInvalidFileType: return "不支持的文件类型"
|
||||
default: return "未知错误"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJSON(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
data := map[string]string{"key": "value"}
|
||||
JSON(w, http.StatusOK, data)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %s, want application/json", ct)
|
||||
}
|
||||
|
||||
var resp APIResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
t.Errorf("code = %d, want 0", resp.Code)
|
||||
}
|
||||
if resp.Message != "success" {
|
||||
t.Errorf("message = %s, want success", resp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadRequest(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
BadRequest(w, "参数错误")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthorized(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
Unauthorized(w, "未登录")
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForbidden(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
Forbidden(w, "无权限")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
NotFound(w, "资源不存在")
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
InternalError(w, "内部错误")
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTooManyRequests(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
TooManyRequests(w, "过于频繁")
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user