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:
2026-04-07 12:08:16 +08:00
parent 8655b39b03
commit 5ca3633be4
36 changed files with 4552 additions and 134 deletions

View 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()
}
}