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
+14 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/enterprise-ai-platform/server/internal/config"
"github.com/enterprise-ai-platform/server/internal/handler"
"github.com/enterprise-ai-platform/server/internal/metrics"
mw "github.com/enterprise-ai-platform/server/internal/middleware"
"github.com/enterprise-ai-platform/server/internal/response"
"github.com/enterprise-ai-platform/server/pkg/auth"
@@ -18,6 +19,7 @@ import (
"github.com/go-chi/cors"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func newRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *redis.Client) http.Handler {
@@ -83,8 +85,18 @@ func newRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *redis.Client) http.H
// Auth middleware
requireAuth := mw.Auth(jwtMgr)
requireAdmin := mw.RequireRole("admin")
// Health check
r.Get("/health", handler.HealthCheck)
// Prometheus metrics middleware
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
metrics.HTTPRequestsTotal.WithLabelValues(r.Method, r.URL.Path, "200").Inc()
next.ServeHTTP(w, r)
})
})
// Health check and metrics endpoints
healthH := handler.NewHealthHandler(pool, rdb)
r.Get("/health", healthH.HealthCheck)
r.Handle("/metrics", promhttp.Handler())
// API v1 routes
r.Route("/api/v1", func(r chi.Router) {
+8
View File
@@ -16,14 +16,22 @@ require (
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)
+16
View File
@@ -1,3 +1,5 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -33,8 +35,18 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
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/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
@@ -48,6 +60,8 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
@@ -57,6 +71,8 @@ golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
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=
+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)
}
}
+67
View File
@@ -0,0 +1,67 @@
package tenant
import (
"context"
"database/sql"
"github.com/google/uuid"
)
// contextKey is the type for context values used by this package.
type contextKey string
const (
orgIDKey contextKey = "org_id"
userIDKey contextKey = "user_id"
roleKey contextKey = "role"
)
// WithOrgID stores the organization ID in context.
func WithOrgID(ctx context.Context, orgID string) context.Context {
return context.WithValue(ctx, orgIDKey, orgID)
}
// GetOrgID retrieves the organization ID from context.
// Returns empty string if not set.
func GetOrgID(ctx context.Context) string {
if v := ctx.Value(orgIDKey); v != nil {
return v.(string)
}
return ""
}
// WithUserID stores the user ID in context.
func WithUserID(ctx context.Context, userID uuid.UUID) context.Context {
return context.WithValue(ctx, userIDKey, userID)
}
// GetUserID retrieves the user ID from context.
func GetUserID(ctx context.Context) uuid.UUID {
if v := ctx.Value(userIDKey); v != nil {
return v.(uuid.UUID)
}
return uuid.Nil
}
// GetUserOrgID queries the database for the current user's org_id.
// This is the standard way to get the caller's organization.
func GetUserOrgID(ctx context.Context, pool interface {
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}, userID uuid.UUID) (string, error) {
var orgID string
err := pool.QueryRowContext(ctx,
`SELECT COALESCE(org_id::text, '') FROM users WHERE id = $1`,
userID,
).Scan(&orgID)
return orgID, err
}
// IsSuperAdmin checks if the given role is platform super admin.
func IsSuperAdmin(role string) bool {
return role == "super_admin"
}
// IsAdmin checks if the given role is at least org-level admin.
func IsAdmin(role string) bool {
return role == "admin" || role == "super_admin"
}