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:
selfrelease
2026-06-23 10:48:22 +08:00
parent 91f4fac23c
commit 65dc805eb5
28 changed files with 1414 additions and 174 deletions
+2 -2
View File
@@ -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,
},
+54
View File
@@ -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
)
+80
View File
@@ -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
}
+70 -5
View File
@@ -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()
+6
View File
@@ -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)
+62
View File
@@ -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()
}
+77
View File
@@ -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")
}
}
+5
View File
@@ -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)
+93
View File
@@ -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 "未知错误"
}
}
+80
View File
@@ -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)
}
}