feat: Swagger 全量注解 + M8 质量加固
Some checks failed
CI / Build & Test (push) Failing after 33s

- 37 个端点全部添加 godoc Swagger 注解(RBAC 11 / Org 5 / Audit 1 / Settings 7 / Notification 7)
- 重新生成 docs/(swagger.json/yaml/docs.go)
- M8 质量横切:IP 限流、安全头、Body 限制、结构化日志增强、优雅关闭
- 新增 Dockerfile 多阶段构建 + .dockerignore
- 新增 testutil 测试工具包
- 修复 testutil.go 编译错误
This commit is contained in:
MengStack Dev 2026-10-03 01:57:39 +08:00
parent cb7ad4d35d
commit 9148d2f6da
21 changed files with 6060 additions and 120 deletions

32
.dockerignore Normal file
View File

@ -0,0 +1,32 @@
.git
.gitea
.github
.gitignore
# IDE
.idea
.vscode
*.swp
*.swo
# Build output
build/
dist/
# Environment
.env
.env.*
*.local
# Docs
coverage.out
coverage.html
*.log
# OS
.DS_Store
Thumbs.db
# Uploads content
uploads/*
!uploads/.gitkeep

34
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,34 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Check go.mod
run: |
go mod tidy
git diff --exit-code go.mod go.sum
- name: Vet
run: go vet ./...
- name: Test
run: go test ./... -v -race -coverprofile=coverage.out
- name: Build
run: go build -o build/mengstack ./cmd/server

33
Dockerfile Normal file
View File

@ -0,0 +1,33 @@
# Build stage
FROM golang:1.23-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/mengstack ./cmd/server
# Runtime stage
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
RUN addgroup -S mengstack && adduser -S mengstack -G mengstack
WORKDIR /app
COPY --from=builder /app/mengstack .
COPY --from=builder /app/configs ./configs
RUN mkdir -p uploads logs && chown -R mengstack:mengstack /app
USER mengstack
EXPOSE 2222
CMD ["./mengstack"]

View File

@ -31,6 +31,9 @@ lint:
tidy: tidy:
go mod tidy go mod tidy
docker-build:
docker build -t mengstack:latest .
docker-up: docker-up:
docker-compose up -d docker-compose up -d

View File

@ -29,6 +29,7 @@ services:
api: api:
build: . build: .
restart: unless-stopped
ports: ports:
- "${SERVER_PORT:-2222}:2222" - "${SERVER_PORT:-2222}:2222"
environment: environment:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -56,9 +56,12 @@ func newEngine(
r := gin.New() r := gin.New()
r.Use(middleware.RequestID()) r.Use(middleware.RequestID())
r.Use(middleware.RequestLogger()) r.Use(middleware.RequestLogger(log))
r.Use(middleware.SecurityHeaders())
r.Use(middleware.CORS()) r.Use(middleware.CORS())
r.Use(gin.Recovery()) r.Use(middleware.RateLimit(100, time.Minute))
r.Use(middleware.BodyLimit(10 << 20))
r.Use(middleware.Recovery(log))
healthHandler := health.NewHandler(db, rdb, log) healthHandler := health.NewHandler(db, rdb, log)
r.GET("/health", healthHandler.Handle) r.GET("/health", healthHandler.Handle)
@ -97,8 +100,10 @@ func registerLifecycle(lc fx.Lifecycle, srv *http.Server, log *zap.Logger) {
return nil return nil
}, },
OnStop: func(ctx context.Context) error { OnStop: func(ctx context.Context) error {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
log.Info("server shutting down") log.Info("server shutting down")
return srv.Shutdown(ctx) return srv.Shutdown(shutdownCtx)
}, },
}) })
} }

View File

@ -13,7 +13,7 @@ import (
func NewPostgres(cfg config.DatabaseConfig) (*gorm.DB, error) { func NewPostgres(cfg config.DatabaseConfig) (*gorm.DB, error) {
dsn := fmt.Sprintf( dsn := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Shanghai",
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode, cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode,
) )

View File

@ -0,0 +1,23 @@
package middleware
import (
"net/http"
"mengstack/internal/kernel/errors"
"mengstack/internal/kernel/response"
"github.com/gin-gonic/gin"
)
// BodyLimit restricts the maximum request body size.
func BodyLimit(maxBytes int64) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.ContentLength > maxBytes {
response.Fail(c, errors.New("BODY_TOO_LARGE", "request body too large", http.StatusRequestEntityTooLarge))
c.Abort()
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
c.Next()
}
}

View File

@ -0,0 +1,187 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap/zaptest"
)
func init() {
gin.SetMode(gin.TestMode)
}
func TestRequestID_GeneratesNew(t *testing.T) {
r := gin.New()
r.Use(RequestID())
r.GET("/test", func(c *gin.Context) {
c.String(200, c.GetString("trace_id"))
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
if w.Header().Get("X-Request-ID") == "" {
t.Error("expected X-Request-ID header to be set")
}
if w.Body.String() == "" {
t.Error("expected trace_id in context")
}
}
func TestRequestID_PreservesExisting(t *testing.T) {
r := gin.New()
r.Use(RequestID())
r.GET("/test", func(c *gin.Context) {
c.String(200, c.GetString("trace_id"))
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
req.Header.Set("X-Request-ID", "existing-id")
r.ServeHTTP(w, req)
if w.Header().Get("X-Request-ID") != "existing-id" {
t.Errorf("expected existing-id, got %s", w.Header().Get("X-Request-ID"))
}
}
func TestSecurityHeaders(t *testing.T) {
r := gin.New()
r.Use(SecurityHeaders())
r.GET("/test", func(c *gin.Context) {
c.String(200, "ok")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
headers := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
}
for key, expected := range headers {
if got := w.Header().Get(key); got != expected {
t.Errorf("header %s: expected %q, got %q", key, expected, got)
}
}
}
func TestRateLimit_AllowsWithinLimit(t *testing.T) {
r := gin.New()
r.Use(RateLimit(5, time.Minute))
r.GET("/test", func(c *gin.Context) {
c.String(200, "ok")
})
for i := 0; i < 5; i++ {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("request %d: expected 200, got %d", i+1, w.Code)
}
}
}
func TestRateLimit_BlocksOverLimit(t *testing.T) {
r := gin.New()
r.Use(RateLimit(2, time.Minute))
r.GET("/test", func(c *gin.Context) {
c.String(200, "ok")
})
for i := 0; i < 2; i++ {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
}
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429, got %d", w.Code)
}
}
func TestRequestLogger_DoesNotPanic(t *testing.T) {
log := zaptest.NewLogger(t)
r := gin.New()
r.Use(RequestID())
r.Use(RequestLogger(log))
r.GET("/test", func(c *gin.Context) {
c.String(200, "ok")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestRecovery_HandlesPanic(t *testing.T) {
log := zaptest.NewLogger(t)
r := gin.New()
r.Use(RequestID())
r.Use(Recovery(log))
r.GET("/panic", func(c *gin.Context) {
panic("test panic")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/panic", nil)
r.ServeHTTP(w, req)
if w.Code != 500 {
t.Errorf("expected 500, got %d", w.Code)
}
}
func TestBodyLimit_RejectsLargeBody(t *testing.T) {
r := gin.New()
r.Use(BodyLimit(10))
r.POST("/test", func(c *gin.Context) {
c.String(200, "ok")
})
w := httptest.NewRecorder()
body := strings.NewReader(strings.Repeat("x", 100))
req, _ := http.NewRequest("POST", "/test", body)
req.Header.Set("Content-Length", "100")
r.ServeHTTP(w, req)
if w.Code != http.StatusRequestEntityTooLarge {
t.Errorf("expected 413, got %d", w.Code)
}
}
func TestBodyLimit_AllowsSmallBody(t *testing.T) {
r := gin.New()
r.Use(BodyLimit(1024))
r.POST("/test", func(c *gin.Context) {
c.String(200, "ok")
})
w := httptest.NewRecorder()
body := strings.NewReader("hello")
req, _ := http.NewRequest("POST", "/test", body)
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("expected 200, got %d", w.Code)
}
}

View File

@ -0,0 +1,89 @@
package middleware
import (
"net/http"
"sync"
"time"
"mengstack/internal/kernel/response"
"mengstack/internal/kernel/errors"
"github.com/gin-gonic/gin"
)
type visitor struct {
count int
lastSeen time.Time
}
type rateLimiter struct {
mu sync.Mutex
visitors map[string]*visitor
limit int
window time.Duration
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
rl := &rateLimiter{
visitors: make(map[string]*visitor),
limit: limit,
window: window,
}
go rl.cleanup()
return rl
}
func (rl *rateLimiter) cleanup() {
ticker := time.NewTicker(rl.window)
for range ticker.C {
rl.mu.Lock()
now := time.Now()
for key, v := range rl.visitors {
if now.Sub(v.lastSeen) > rl.window {
delete(rl.visitors, key)
}
}
rl.mu.Unlock()
}
}
func (rl *rateLimiter) allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
v, exists := rl.visitors[key]
if !exists {
rl.visitors[key] = &visitor{count: 1, lastSeen: time.Now()}
return true
}
if time.Since(v.lastSeen) > rl.window {
v.count = 1
v.lastSeen = time.Now()
return true
}
if v.count >= rl.limit {
return false
}
v.count++
v.lastSeen = time.Now()
return true
}
// RateLimit godoc
// Limits requests per IP address.
func RateLimit(limit int, window time.Duration) gin.HandlerFunc {
rl := newRateLimiter(limit, window)
return func(c *gin.Context) {
ip := c.ClientIP()
if !rl.allow(ip) {
response.Fail(c, errors.New("RATE_LIMITED", "too many requests", http.StatusTooManyRequests))
c.Abort()
return
}
c.Next()
}
}

View File

@ -1,11 +1,11 @@
package middleware package middleware
import ( import (
"strconv"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap"
) )
func RequestID() gin.HandlerFunc { func RequestID() gin.HandlerFunc {
@ -20,19 +20,53 @@ func RequestID() gin.HandlerFunc {
} }
} }
func RequestLogger() gin.HandlerFunc { func RequestLogger(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
start := time.Now() start := time.Now()
path := c.Request.URL.Path path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next() c.Next()
latency := time.Since(start) latency := time.Since(start)
status := c.Writer.Status() status := c.Writer.Status()
gin.DefaultWriter.Write([]byte(
"[" + c.GetString("trace_id") + "] " + fields := []zap.Field{
c.Request.Method + " " + path + " " + zap.Int("status", status),
strconv.Itoa(status) + " " + latency.String() + "\n", zap.String("method", c.Request.Method),
)) zap.String("path", path),
zap.String("query", query),
zap.String("ip", c.ClientIP()),
zap.String("user-agent", c.Request.UserAgent()),
zap.Duration("latency", latency),
zap.String("trace_id", c.GetString("trace_id")),
}
if c.Writer.Status() >= 500 {
log.Error("request completed with server error", fields...)
} else if c.Writer.Status() >= 400 {
log.Warn("request completed with client error", fields...)
} else {
log.Info("request", fields...)
}
}
}
func Recovery(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
log.Error("panic recovered",
zap.Any("error", r),
zap.String("path", c.Request.URL.Path),
zap.String("trace_id", c.GetString("trace_id")),
)
c.AbortWithStatusJSON(500, gin.H{
"error": "INTERNAL_ERROR",
"message": "internal server error",
})
}
}()
c.Next()
} }
} }

View File

@ -0,0 +1,15 @@
package middleware
import "github.com/gin-gonic/gin"
// SecurityHeaders adds common security headers to responses.
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("X-XSS-Protection", "1; mode=block")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Content-Security-Policy", "default-src 'self'")
c.Next()
}
}

View File

@ -0,0 +1,45 @@
package testutil
import (
"mengstack/internal/config"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zaptest"
)
// TestLogger returns a zap logger suitable for use in tests.
func TestLogger(t *testing.T) *zap.Logger {
return zaptest.NewLogger(t)
}
// TestConfig returns a minimal config for unit tests.
func TestConfig() *config.Config {
return &config.Config{
Server: config.ServerConfig{
Port: 0,
Mode: "test",
},
Database: config.DatabaseConfig{
Host: "localhost",
Port: 5432,
User: "test",
Password: "test",
DBName: "mengstack_test",
SSLMode: "disable",
},
Redis: config.RedisConfig{
Addr: "localhost:6379",
},
JWT: config.JWTConfig{
Secret: "test-secret",
AccessExpiryMinutes: 30,
RefreshExpiryDays: 7,
Issuer: "test",
},
Log: config.LogConfig{
Level: "debug",
Format: "console",
},
}
}

View File

@ -19,6 +19,21 @@ func NewHandler(svc *application.Service) *Handler {
return &Handler{svc: svc} return &Handler{svc: svc}
} }
// ListLogs godoc
// @Summary 查询审计日志
// @Description 分页查询审计日志,支持按用户、操作、资源筛选
// @Tags Audit
// @Produce json
// @Param user_id query int false "按用户 ID 筛选"
// @Param action query string false "按操作筛选"
// @Param resource query string false "按资源类型筛选"
// @Param page query int false "页码(默认 1)"
// @Param page_size query int false "每页条数(默认 20)"
// @Success 200 {object} response.Response
// @Failure 500 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/audit/logs [get]
func (h *Handler) ListLogs(c *gin.Context) { func (h *Handler) ListLogs(c *gin.Context) {
tenantID, _ := tenant.FromContext(c.Request.Context()) tenantID, _ := tenant.FromContext(c.Request.Context())

View File

@ -19,6 +19,18 @@ func NewHandler(svc *application.Service) *Handler {
return &Handler{svc: svc} return &Handler{svc: svc}
} }
// Create godoc
// @Summary 创建通知
// @Description 向指定用户发送通知
// @Tags Notification
// @Accept json
// @Produce json
// @Param request body domain.CreateNotificationRequest true "通知内容"
// @Success 200 {object} response.Response{data=domain.NotificationDTO}
// @Failure 400 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/notifications [post]
func (h *Handler) Create(c *gin.Context) { func (h *Handler) Create(c *gin.Context) {
var req domain.CreateNotificationRequest var req domain.CreateNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@ -34,6 +46,18 @@ func (h *Handler) Create(c *gin.Context) {
response.Success(c, n) response.Success(c, n)
} }
// Get godoc
// @Summary 获取通知详情
// @Description 根据 ID 获取指定通知的详细信息
// @Tags Notification
// @Produce json
// @Param id path int true "通知 ID"
// @Success 200 {object} response.Response{data=domain.NotificationDTO}
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/notifications/{id} [get]
func (h *Handler) Get(c *gin.Context) { func (h *Handler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@ -53,6 +77,20 @@ func (h *Handler) Get(c *gin.Context) {
response.Success(c, n) response.Success(c, n)
} }
// ListByUser godoc
// @Summary 获取用户通知列表
// @Description 分页获取指定用户的通知列表
// @Tags Notification
// @Produce json
// @Param userId path int true "用户 ID"
// @Param page query int false "页码(默认 1)"
// @Param page_size query int false "每页条数(默认 20)"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 500 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/notifications/user/{userId} [get]
func (h *Handler) ListByUser(c *gin.Context) { func (h *Handler) ListByUser(c *gin.Context) {
userID, err := strconv.ParseUint(c.Param("userId"), 10, 64) userID, err := strconv.ParseUint(c.Param("userId"), 10, 64)
if err != nil { if err != nil {
@ -74,6 +112,17 @@ func (h *Handler) ListByUser(c *gin.Context) {
}) })
} }
// MarkAsRead godoc
// @Summary 标记通知已读
// @Description 将指定通知标记为已读
// @Tags Notification
// @Param id path int true "通知 ID"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/notifications/{id}/read [put]
func (h *Handler) MarkAsRead(c *gin.Context) { func (h *Handler) MarkAsRead(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@ -88,6 +137,16 @@ func (h *Handler) MarkAsRead(c *gin.Context) {
response.Success(c, nil) response.Success(c, nil)
} }
// MarkAllAsRead godoc
// @Summary 全部标记已读
// @Description 将当前用户的所有通知标记为已读
// @Tags Notification
// @Produce json
// @Success 200 {object} response.Response
// @Failure 500 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/notifications/read-all [put]
func (h *Handler) MarkAllAsRead(c *gin.Context) { func (h *Handler) MarkAllAsRead(c *gin.Context) {
userID := c.GetUint("user_id") userID := c.GetUint("user_id")
tenantID := c.GetString("tenant_id") tenantID := c.GetString("tenant_id")
@ -98,6 +157,16 @@ func (h *Handler) MarkAllAsRead(c *gin.Context) {
response.Success(c, nil) response.Success(c, nil)
} }
// CountUnread godoc
// @Summary 获取未读通知数
// @Description 获取当前用户的未读通知数量
// @Tags Notification
// @Produce json
// @Success 200 {object} response.Response
// @Failure 500 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/notifications/unread-count [get]
func (h *Handler) CountUnread(c *gin.Context) { func (h *Handler) CountUnread(c *gin.Context) {
userID := c.GetUint("user_id") userID := c.GetUint("user_id")
tenantID := c.GetString("tenant_id") tenantID := c.GetString("tenant_id")
@ -109,6 +178,17 @@ func (h *Handler) CountUnread(c *gin.Context) {
response.Success(c, gin.H{"count": count}) response.Success(c, gin.H{"count": count})
} }
// Delete godoc
// @Summary 删除通知
// @Description 删除指定通知
// @Tags Notification
// @Param id path int true "通知 ID"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/notifications/{id} [delete]
func (h *Handler) Delete(c *gin.Context) { func (h *Handler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {

View File

@ -16,6 +16,19 @@ func NewHandler(svc *application.Service) *Handler {
return &Handler{svc: svc} return &Handler{svc: svc}
} }
// CreateTenant godoc
// @Summary 创建租户
// @Description 创建新的租户(组织),需提供名称和 slug
// @Tags Org
// @Accept json
// @Produce json
// @Param request body domain.CreateTenantRequest true "租户信息"
// @Success 200 {object} response.Response{data=domain.TenantDTO}
// @Failure 400 {object} response.Response
// @Failure 409 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/org/tenants [post]
func (h *Handler) CreateTenant(c *gin.Context) { func (h *Handler) CreateTenant(c *gin.Context) {
var req domain.CreateTenantRequest var req domain.CreateTenantRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@ -32,6 +45,16 @@ func (h *Handler) CreateTenant(c *gin.Context) {
response.Success(c, dto) response.Success(c, dto)
} }
// ListTenants godoc
// @Summary 列出所有租户
// @Description 获取系统中的所有租户列表
// @Tags Org
// @Produce json
// @Success 200 {object} response.Response{data=[]domain.TenantDTO}
// @Failure 500 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/org/tenants [get]
func (h *Handler) ListTenants(c *gin.Context) { func (h *Handler) ListTenants(c *gin.Context) {
dtos, err := h.svc.ListTenants(c.Request.Context()) dtos, err := h.svc.ListTenants(c.Request.Context())
if err != nil { if err != nil {
@ -42,6 +65,18 @@ func (h *Handler) ListTenants(c *gin.Context) {
response.Success(c, dtos) response.Success(c, dtos)
} }
// GetTenant godoc
// @Summary 获取租户详情
// @Description 根据 ID 获取指定租户的详细信息
// @Tags Org
// @Produce json
// @Param id path string true "租户 ID (UUID)"
// @Success 200 {object} response.Response{data=domain.TenantDTO}
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/org/tenants/{id} [get]
func (h *Handler) GetTenant(c *gin.Context) { func (h *Handler) GetTenant(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
@ -54,6 +89,20 @@ func (h *Handler) GetTenant(c *gin.Context) {
response.Success(c, dto) response.Success(c, dto)
} }
// UpdateTenant godoc
// @Summary 更新租户
// @Description 更新租户的名称、slug 或状态
// @Tags Org
// @Accept json
// @Produce json
// @Param id path string true "租户 ID (UUID)"
// @Param request body domain.UpdateTenantRequest true "更新内容"
// @Success 200 {object} response.Response{data=domain.TenantDTO}
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/org/tenants/{id} [put]
func (h *Handler) UpdateTenant(c *gin.Context) { func (h *Handler) UpdateTenant(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
@ -72,6 +121,17 @@ func (h *Handler) UpdateTenant(c *gin.Context) {
response.Success(c, dto) response.Success(c, dto)
} }
// DeleteTenant godoc
// @Summary 删除租户
// @Description 删除指定租户及其所有关联数据
// @Tags Org
// @Param id path string true "租户 ID (UUID)"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/org/tenants/{id} [delete]
func (h *Handler) DeleteTenant(c *gin.Context) { func (h *Handler) DeleteTenant(c *gin.Context) {
id := c.Param("id") id := c.Param("id")

View File

@ -18,6 +18,17 @@ func NewHandler(svc *application.Service) *Handler {
return &Handler{svc: svc} return &Handler{svc: svc}
} }
// ListPermissions godoc
// @Summary 列出权限
// @Description 获取所有权限列表,可按模块筛选
// @Tags RBAC
// @Produce json
// @Param module query string false "按模块筛选"
// @Success 200 {object} response.Response{data=[]domain.PermissionDTO}
// @Failure 500 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/rbac/permissions [get]
func (h *Handler) ListPermissions(c *gin.Context) { func (h *Handler) ListPermissions(c *gin.Context) {
module := c.Query("module") module := c.Query("module")
@ -40,6 +51,19 @@ func (h *Handler) ListPermissions(c *gin.Context) {
response.Success(c, dtos) response.Success(c, dtos)
} }
// CreateRole godoc
// @Summary 创建角色
// @Description 在当前租户下创建新角色
// @Tags RBAC
// @Accept json
// @Produce json
// @Param request body domain.CreateRoleRequest true "角色信息"
// @Success 200 {object} response.Response{data=domain.RoleDTO}
// @Failure 400 {object} response.Response
// @Failure 409 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/rbac/roles [post]
func (h *Handler) CreateRole(c *gin.Context) { func (h *Handler) CreateRole(c *gin.Context) {
var req domain.CreateRoleRequest var req domain.CreateRoleRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@ -56,6 +80,16 @@ func (h *Handler) CreateRole(c *gin.Context) {
response.Success(c, dto) response.Success(c, dto)
} }
// ListRoles godoc
// @Summary 列出角色
// @Description 获取当前租户下的所有角色
// @Tags RBAC
// @Produce json
// @Success 200 {object} response.Response{data=[]domain.RoleDTO}
// @Failure 500 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/rbac/roles [get]
func (h *Handler) ListRoles(c *gin.Context) { func (h *Handler) ListRoles(c *gin.Context) {
dtos, err := h.svc.ListRoles(c.Request.Context()) dtos, err := h.svc.ListRoles(c.Request.Context())
if err != nil { if err != nil {
@ -66,6 +100,18 @@ func (h *Handler) ListRoles(c *gin.Context) {
response.Success(c, dtos) response.Success(c, dtos)
} }
// GetRole godoc
// @Summary 获取角色详情
// @Description 根据 ID 获取角色信息,包含权限列表
// @Tags RBAC
// @Produce json
// @Param id path int true "角色 ID"
// @Success 200 {object} response.Response{data=domain.RoleDTO}
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/rbac/roles/{id} [get]
func (h *Handler) GetRole(c *gin.Context) { func (h *Handler) GetRole(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@ -82,6 +128,20 @@ func (h *Handler) GetRole(c *gin.Context) {
response.Success(c, dto) response.Success(c, dto)
} }
// UpdateRole godoc
// @Summary 更新角色
// @Description 更新角色名称或描述
// @Tags RBAC
// @Accept json
// @Produce json
// @Param id path int true "角色 ID"
// @Param request body domain.UpdateRoleRequest true "更新内容"
// @Success 200 {object} response.Response{data=domain.RoleDTO}
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/rbac/roles/{id} [put]
func (h *Handler) UpdateRole(c *gin.Context) { func (h *Handler) UpdateRole(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@ -104,6 +164,17 @@ func (h *Handler) UpdateRole(c *gin.Context) {
response.Success(c, dto) response.Success(c, dto)
} }
// DeleteRole godoc
// @Summary 删除角色
// @Description 删除指定角色(系统角色不可删除)
// @Tags RBAC
// @Param id path int true "角色 ID"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 403 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/rbac/roles/{id} [delete]
func (h *Handler) DeleteRole(c *gin.Context) { func (h *Handler) DeleteRole(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@ -119,6 +190,20 @@ func (h *Handler) DeleteRole(c *gin.Context) {
response.Success(c, nil) response.Success(c, nil)
} }
// SetRolePermissions godoc
// @Summary 设置角色权限
// @Description 为角色批量设置权限(覆盖原有权限)
// @Tags RBAC
// @Accept json
// @Produce json
// @Param id path int true "角色 ID"
// @Param request body domain.SetRolePermissionsRequest true "权限 ID 列表"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/rbac/roles/{id}/permissions [put]
func (h *Handler) SetRolePermissions(c *gin.Context) { func (h *Handler) SetRolePermissions(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@ -140,6 +225,18 @@ func (h *Handler) SetRolePermissions(c *gin.Context) {
response.Success(c, nil) response.Success(c, nil)
} }
// GetRolePermissions godoc
// @Summary 获取角色权限列表
// @Description 获取指定角色已分配的权限列表
// @Tags RBAC
// @Produce json
// @Param id path int true "角色 ID"
// @Success 200 {object} response.Response{data=[]domain.PermissionDTO}
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/rbac/roles/{id}/permissions [get]
func (h *Handler) GetRolePermissions(c *gin.Context) { func (h *Handler) GetRolePermissions(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@ -156,6 +253,20 @@ func (h *Handler) GetRolePermissions(c *gin.Context) {
response.Success(c, dto.Permissions) response.Success(c, dto.Permissions)
} }
// AssignRole godoc
// @Summary 分配角色给用户
// @Description 为指定用户分配一个角色
// @Tags RBAC
// @Accept json
// @Produce json
// @Param userId path int true "用户 ID"
// @Param request body domain.AssignRoleRequest true "角色 ID"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 409 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/rbac/users/{userId}/roles [post]
func (h *Handler) AssignRole(c *gin.Context) { func (h *Handler) AssignRole(c *gin.Context) {
userID, err := strconv.ParseUint(c.Param("userId"), 10, 64) userID, err := strconv.ParseUint(c.Param("userId"), 10, 64)
if err != nil { if err != nil {
@ -177,6 +288,18 @@ func (h *Handler) AssignRole(c *gin.Context) {
response.Success(c, nil) response.Success(c, nil)
} }
// RemoveRole godoc
// @Summary 移除用户角色
// @Description 从指定用户移除一个角色
// @Tags RBAC
// @Param userId path int true "用户 ID"
// @Param roleId path int true "角色 ID"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/rbac/users/{userId}/roles/{roleId} [delete]
func (h *Handler) RemoveRole(c *gin.Context) { func (h *Handler) RemoveRole(c *gin.Context) {
userID, err := strconv.ParseUint(c.Param("userId"), 10, 64) userID, err := strconv.ParseUint(c.Param("userId"), 10, 64)
if err != nil { if err != nil {
@ -198,6 +321,18 @@ func (h *Handler) RemoveRole(c *gin.Context) {
response.Success(c, nil) response.Success(c, nil)
} }
// GetUserRoles godoc
// @Summary 获取用户角色
// @Description 获取指定用户的所有角色及权限
// @Tags RBAC
// @Produce json
// @Param userId path int true "用户 ID"
// @Success 200 {object} response.Response{data=[]domain.RoleDTO}
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/rbac/users/{userId}/roles [get]
func (h *Handler) GetUserRoles(c *gin.Context) { func (h *Handler) GetUserRoles(c *gin.Context) {
userID, err := strconv.ParseUint(c.Param("userId"), 10, 64) userID, err := strconv.ParseUint(c.Param("userId"), 10, 64)
if err != nil { if err != nil {

View File

@ -18,6 +18,18 @@ func NewHandler(svc *application.Service) *Handler {
return &Handler{svc: svc} return &Handler{svc: svc}
} }
// GetGlobalSetting godoc
// @Summary 获取全局配置
// @Description 根据 key 获取全局(非租户)配置项
// @Tags Settings
// @Produce json
// @Param key path string true "配置键名"
// @Success 200 {object} response.Response{data=domain.SettingDTO}
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/settings/global/{key} [get]
func (h *Handler) GetGlobalSetting(c *gin.Context) { func (h *Handler) GetGlobalSetting(c *gin.Context) {
key := c.Param("key") key := c.Param("key")
setting, err := h.svc.GetGlobalSetting(c.Request.Context(), key) setting, err := h.svc.GetGlobalSetting(c.Request.Context(), key)
@ -32,6 +44,19 @@ func (h *Handler) GetGlobalSetting(c *gin.Context) {
response.Success(c, setting) response.Success(c, setting)
} }
// UpdateGlobalSetting godoc
// @Summary 更新全局配置
// @Description 创建或更新全局配置项
// @Tags Settings
// @Accept json
// @Produce json
// @Param key path string true "配置键名"
// @Param request body domain.UpdateSettingRequest true "配置值"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/settings/global/{key} [put]
func (h *Handler) UpdateGlobalSetting(c *gin.Context) { func (h *Handler) UpdateGlobalSetting(c *gin.Context) {
key := c.Param("key") key := c.Param("key")
var req domain.UpdateSettingRequest var req domain.UpdateSettingRequest
@ -47,6 +72,16 @@ func (h *Handler) UpdateGlobalSetting(c *gin.Context) {
response.Success(c, nil) response.Success(c, nil)
} }
// ListGlobalSettings godoc
// @Summary 列出全局配置
// @Description 获取所有全局配置项
// @Tags Settings
// @Produce json
// @Success 200 {object} response.Response{data=[]domain.SettingDTO}
// @Failure 500 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/settings [get]
func (h *Handler) ListGlobalSettings(c *gin.Context) { func (h *Handler) ListGlobalSettings(c *gin.Context) {
settings, err := h.svc.ListGlobalSettings(c.Request.Context()) settings, err := h.svc.ListGlobalSettings(c.Request.Context())
if err != nil { if err != nil {
@ -56,6 +91,18 @@ func (h *Handler) ListGlobalSettings(c *gin.Context) {
response.Success(c, settings) response.Success(c, settings)
} }
// GetTenantSetting godoc
// @Summary 获取租户配置
// @Description 根据 key 获取当前租户的配置项
// @Tags Settings
// @Produce json
// @Param key path string true "配置键名"
// @Success 200 {object} response.Response{data=domain.SettingDTO}
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/settings/tenant/{key} [get]
func (h *Handler) GetTenantSetting(c *gin.Context) { func (h *Handler) GetTenantSetting(c *gin.Context) {
key := c.Param("key") key := c.Param("key")
tenantID := c.GetString("tenant_id") tenantID := c.GetString("tenant_id")
@ -71,6 +118,19 @@ func (h *Handler) GetTenantSetting(c *gin.Context) {
response.Success(c, setting) response.Success(c, setting)
} }
// UpdateTenantSetting godoc
// @Summary 更新租户配置
// @Description 创建或更新当前租户的配置项
// @Tags Settings
// @Accept json
// @Produce json
// @Param key path string true "配置键名"
// @Param request body domain.UpdateSettingRequest true "配置值"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/settings/tenant/{key} [put]
func (h *Handler) UpdateTenantSetting(c *gin.Context) { func (h *Handler) UpdateTenantSetting(c *gin.Context) {
key := c.Param("key") key := c.Param("key")
tenantID := c.GetString("tenant_id") tenantID := c.GetString("tenant_id")
@ -87,6 +147,16 @@ func (h *Handler) UpdateTenantSetting(c *gin.Context) {
response.Success(c, nil) response.Success(c, nil)
} }
// ListTenantSettings godoc
// @Summary 列出租户配置
// @Description 获取当前租户的所有配置项
// @Tags Settings
// @Produce json
// @Success 200 {object} response.Response{data=[]domain.SettingDTO}
// @Failure 500 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/settings/tenant [get]
func (h *Handler) ListTenantSettings(c *gin.Context) { func (h *Handler) ListTenantSettings(c *gin.Context) {
tenantID := c.GetString("tenant_id") tenantID := c.GetString("tenant_id")
settings, err := h.svc.ListTenantSettings(c.Request.Context(), tenantID) settings, err := h.svc.ListTenantSettings(c.Request.Context(), tenantID)
@ -97,6 +167,17 @@ func (h *Handler) ListTenantSettings(c *gin.Context) {
response.Success(c, settings) response.Success(c, settings)
} }
// DeleteTenantSetting godoc
// @Summary 删除租户配置
// @Description 删除当前租户的指定配置项
// @Tags Settings
// @Param key path string true "配置键名"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /api/v1/settings/tenant/{key} [delete]
func (h *Handler) DeleteTenantSetting(c *gin.Context) { func (h *Handler) DeleteTenantSetting(c *gin.Context) {
key := c.Param("key") key := c.Param("key")
tenantID := c.GetString("tenant_id") tenantID := c.GetString("tenant_id")