feat: 系统全面优化 - 设备管理/登录日志导出/性能监控/设置页面
后端: - 新增全局设备管理 API(DeviceHandler.GetAllDevices) - 新增登录日志导出功能(LogHandler.ExportLoginLogs, CSV/XLSX) - 新增设置服务(SettingsService)和设置页面 API - 设备管理支持多条件筛选(状态/信任状态/关键词) - 登录日志支持流式导出防 OOM - 操作日志支持按方法/时间范围搜索 - 主题配置服务(ThemeService) - 增强监控健康检查(Prometheus metrics + SLO) - 移除旧 ratelimit.go(已迁移至 robustness) - 修复 SocialAccount NULL 扫描问题 - 新增 API 契约测试、Handler 测试、Settings 测试 前端: - 新增管理员设备管理页面(DevicesPage) - 新增管理员登录日志导出功能 - 新增系统设置页面(SettingsPage) - 设备管理支持筛选和分页 - 增强 HTTP 响应类型 测试: - 业务逻辑测试 68 个(含并发 CONC_001~003) - 规模测试 16 个(P99 百分位统计) - E2E 测试、集成测试、契约测试 - 性能基准测试、鲁棒性测试 全面测试通过(38 个测试包)
This commit is contained in:
@@ -107,6 +107,22 @@ func (m *IPFilterMiddleware) isTrustedProxy(ip string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// InternalOnly 限制只有内网 IP 可以访问(用于 /metrics 等运维端点)
|
||||
// Prometheus scraper 通常部署在同一内网,不需要 JWT 鉴权,但必须限制来源
|
||||
func InternalOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
if !isPrivateIP(ip) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"code": 403,
|
||||
"message": "此端点仅限内网访问",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// isPrivateIP 判断是否为内网 IP
|
||||
func isPrivateIP(ipStr string) bool {
|
||||
ip := net.ParseIP(ipStr)
|
||||
|
||||
@@ -31,8 +31,9 @@ func Logger() gin.HandlerFunc {
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.Request.UserAgent()
|
||||
userID, _ := c.Get("user_id")
|
||||
traceID := GetTraceID(c)
|
||||
|
||||
log.Printf("[API] %s %s %s | status: %d | latency: %v | ip: %s | user_id: %v | ua: %s",
|
||||
log.Printf("[API] %s %s %s | status: %d | latency: %v | ip: %s | user_id: %v | trace_id: %s | ua: %s",
|
||||
time.Now().Format("2006-01-02 15:04:05"),
|
||||
method,
|
||||
path,
|
||||
@@ -40,12 +41,13 @@ func Logger() gin.HandlerFunc {
|
||||
latency,
|
||||
ip,
|
||||
userID,
|
||||
traceID,
|
||||
userAgent,
|
||||
)
|
||||
|
||||
if len(c.Errors) > 0 {
|
||||
for _, err := range c.Errors {
|
||||
log.Printf("[Error] %v", err)
|
||||
log.Printf("[Error] trace_id: %s | %v", traceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
135
internal/api/middleware/response_wrapper.go
Normal file
135
internal/api/middleware/response_wrapper.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// responseWrapper 捕获 handler 输出的中间件
|
||||
// 将所有裸 JSON 响应自动包装为 {code: 0, message: "success", data: ...} 格式
|
||||
type responseWrapper struct {
|
||||
gin.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (w *responseWrapper) Write(b []byte) (int, error) {
|
||||
w.body.Write(b)
|
||||
// 不再同时写到原始 writer,让 body 完全缓冲
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (w *responseWrapper) WriteString(s string) (int, error) {
|
||||
w.body.WriteString(s)
|
||||
return len(s), nil
|
||||
}
|
||||
|
||||
func (w *responseWrapper) WriteHeader(code int) {
|
||||
w.statusCode = code
|
||||
// 不实际写入,让 gin 的最终写入处理
|
||||
}
|
||||
|
||||
// ResponseWrapper 返回包装响应格式的中间件
|
||||
func ResponseWrapper() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 跳过非 JSON 响应(如文件下载、流式响应)
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
if strings.Contains(contentType, "text/event-stream") ||
|
||||
contentType == "application/octet-stream" ||
|
||||
strings.HasPrefix(c.Request.URL.Path, "/swagger/") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 包装 response writer 以捕获输出
|
||||
wrapper := &responseWrapper{
|
||||
ResponseWriter: c.Writer,
|
||||
body: bytes.NewBuffer(nil),
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
c.Writer = wrapper
|
||||
|
||||
c.Next()
|
||||
|
||||
// 检查是否已标记为已包装
|
||||
if _, exists := c.Get("response_wrapped"); exists {
|
||||
// 直接把捕获的内容写回到底层 writer
|
||||
wrapper.ResponseWriter.WriteHeader(wrapper.statusCode)
|
||||
wrapper.ResponseWriter.Write(wrapper.body.Bytes())
|
||||
return
|
||||
}
|
||||
|
||||
// 只处理成功响应(2xx)
|
||||
if wrapper.statusCode < 200 || wrapper.statusCode >= 300 {
|
||||
// 非成功状态,直接把捕获的内容写回
|
||||
wrapper.ResponseWriter.WriteHeader(wrapper.statusCode)
|
||||
wrapper.ResponseWriter.Write(wrapper.body.Bytes())
|
||||
return
|
||||
}
|
||||
|
||||
// 解析捕获的 body
|
||||
if wrapper.body.Len() == 0 {
|
||||
wrapper.ResponseWriter.WriteHeader(wrapper.statusCode)
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes := wrapper.body.Bytes()
|
||||
|
||||
// 尝试解析为 JSON 对象
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(bodyBytes, &raw); err != nil {
|
||||
// 不是有效 JSON,不包装
|
||||
wrapper.ResponseWriter.WriteHeader(wrapper.statusCode)
|
||||
wrapper.ResponseWriter.Write(bodyBytes)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已经是标准格式(有 code 字段)
|
||||
var checkMap map[string]interface{}
|
||||
if err := json.Unmarshal(bodyBytes, &checkMap); err == nil {
|
||||
if _, hasCode := checkMap["code"]; hasCode {
|
||||
// 已经是标准格式,不重复包装
|
||||
wrapper.ResponseWriter.WriteHeader(wrapper.statusCode)
|
||||
wrapper.ResponseWriter.Write(bodyBytes)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 包装为标准格式
|
||||
wrapped := map[string]interface{}{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": raw,
|
||||
}
|
||||
|
||||
wrappedBytes, err := json.Marshal(wrapped)
|
||||
if err != nil {
|
||||
wrapper.ResponseWriter.WriteHeader(wrapper.statusCode)
|
||||
wrapper.ResponseWriter.Write(bodyBytes)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置响应头并写入包装后的内容
|
||||
wrapper.ResponseWriter.Header().Set("Content-Type", "application/json")
|
||||
wrapper.ResponseWriter.WriteHeader(wrapper.statusCode)
|
||||
wrapper.ResponseWriter.Write(wrappedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// WrapResponse 标记响应为已包装,防止重复包装
|
||||
// handler 中使用 response.Success() 等方法后调用此函数
|
||||
func WrapResponse(c *gin.Context) {
|
||||
c.Set("response_wrapped", true)
|
||||
}
|
||||
|
||||
// NoWrapper 跳过包装的中间件处理器
|
||||
func NoWrapper() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
WrapResponse(c)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
56
internal/api/middleware/trace_id.go
Normal file
56
internal/api/middleware/trace_id.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
// TraceIDHeader 追踪 ID 的 HTTP 响应头名称
|
||||
TraceIDHeader = "X-Trace-ID"
|
||||
// TraceIDKey gin.Context 中的 key
|
||||
TraceIDKey = "trace_id"
|
||||
)
|
||||
|
||||
// TraceID 中间件:为每个请求生成唯一追踪 ID
|
||||
// 追踪 ID 写入 gin.Context 和响应头,供日志和下游服务关联
|
||||
func TraceID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 优先复用上游传入的 Trace ID(如 API 网关、前端)
|
||||
traceID := c.GetHeader(TraceIDHeader)
|
||||
if traceID == "" {
|
||||
traceID = generateTraceID()
|
||||
}
|
||||
|
||||
c.Set(TraceIDKey, traceID)
|
||||
c.Header(TraceIDHeader, traceID)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// generateTraceID 生成 16 字节随机 hex 字符串,格式:时间前缀+随机后缀
|
||||
// 例:20260405-a1b2c3d4e5f60718
|
||||
func generateTraceID() string {
|
||||
b := make([]byte, 8)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
// 降级:使用时间戳
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", time.Now().Format("20060102"), hex.EncodeToString(b))
|
||||
}
|
||||
|
||||
// GetTraceID 从 gin.Context 获取 trace ID(供 handler 使用)
|
||||
func GetTraceID(c *gin.Context) string {
|
||||
if v, exists := c.Get(TraceIDKey); exists {
|
||||
if id, ok := v.(string); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user