feat: add desktop bug report collection
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
type rateLimitEntry struct {
|
||||
windowStart time.Time
|
||||
count int
|
||||
}
|
||||
|
||||
type inMemoryRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]rateLimitEntry
|
||||
max int
|
||||
window time.Duration
|
||||
lastSweep time.Time
|
||||
}
|
||||
|
||||
func RateLimitByClientIP(max int, window time.Duration, code int, message, detail string) gin.HandlerFunc {
|
||||
limiter := &inMemoryRateLimiter{
|
||||
entries: make(map[string]rateLimitEntry),
|
||||
max: max,
|
||||
window: window,
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if !limiter.allow(c.ClientIP(), time.Now()) {
|
||||
response.Error(c, response.ErrTooManyRequests(code, message, detail))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *inMemoryRateLimiter) allow(key string, now time.Time) bool {
|
||||
if l == nil || l.max <= 0 || l.window <= 0 || key == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.lastSweep.IsZero() || now.Sub(l.lastSweep) >= l.window {
|
||||
l.lastSweep = now
|
||||
for entryKey, entry := range l.entries {
|
||||
if now.Sub(entry.windowStart) >= l.window {
|
||||
delete(l.entries, entryKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entry, ok := l.entries[key]
|
||||
if !ok || now.Sub(entry.windowStart) >= l.window {
|
||||
l.entries[key] = rateLimitEntry{windowStart: now, count: 1}
|
||||
return true
|
||||
}
|
||||
if entry.count >= l.max {
|
||||
return false
|
||||
}
|
||||
entry.count++
|
||||
l.entries[key] = entry
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestRateLimitByClientIPBlocksAfterLimit(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET(
|
||||
"/limited",
|
||||
RateLimitByClientIP(2, time.Minute, 42961, "rate_limited", "too many requests"),
|
||||
func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
},
|
||||
)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/limited", nil)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusNoContent {
|
||||
t.Fatalf("request %d status = %d, want %d", i+1, res.Code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/limited", nil)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("third request status = %d, want %d", res.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,10 @@ var routeDocs = map[string]routeDoc{
|
||||
"GET /api/ops/desktop-client/releases/:id/download-url": {"解析桌面客户端下载地址", "解析指定桌面客户端版本的下载地址。"},
|
||||
"POST /api/ops/desktop-client/releases/:id/enabled": {"切换桌面客户端版本状态", "启用或禁用指定桌面客户端版本。"},
|
||||
"DELETE /api/ops/desktop-client/releases/:id": {"删除桌面客户端版本", "删除指定桌面客户端版本配置。"},
|
||||
"GET /api/ops/desktop-client/bug-reports": {"客户端 Bug 列表", "分页查询桌面客户端崩溃和用户反馈记录。"},
|
||||
"GET /api/ops/desktop-client/bug-reports/:id": {"客户端 Bug 详情", "查看桌面客户端崩溃元数据、运行快照和 dump 对象。"},
|
||||
"PATCH /api/ops/desktop-client/bug-reports/:id": {"更新客户端 Bug 状态", "调整客户端 Bug 状态、级别和处理备注。"},
|
||||
"GET /api/ops/desktop-client/bug-reports/:id/dump-url": {"解析客户端 dump 下载地址", "生成指定客户端 Bug dump 文件的短期下载地址。"},
|
||||
|
||||
// --- Ops:合规 ---
|
||||
"GET /api/ops/compliance/dictionaries": {"合规词库列表", "分页查询内容合规词库。"},
|
||||
@@ -144,11 +148,13 @@ var routeDocs = map[string]routeDoc{
|
||||
"GET /api/desktop/releases/download-url": {"解析客户端下载地址", "用户开始下载时按最新版本配置即时生成下载地址,私有 OSS 会返回签名 URL。"},
|
||||
"GET /api/desktop/releases/updater/:platform/:arch/:channel/:version": {"自动更新 Feed", "桌面客户端开始自动更新时读取 Electron updater feed,私有 OSS 会在 feed 中写入短时签名下载地址。"},
|
||||
"GET /api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed": {"自动更新 Feed 文件", "兼容 Electron updater 对 latest*.yml 的二次请求,返回动态生成的 updater YAML。"},
|
||||
"POST /api/desktop/bug-reports": {"客户端 Bug 上报", "兼容 Electron crashReporter multipart 上报,保存 dump 到 OSS 并记录元数据。"},
|
||||
"POST /api/desktop/clients/register": {"桌面客户端注册", "由租户用户登录后调用,为本机桌面客户端申请客户端凭证。"},
|
||||
"POST /api/desktop/clients/rotate": {"轮换客户端密钥", "客户端使用旧凭证调用,换取新的客户端密钥。"},
|
||||
"POST /api/desktop/clients/heartbeat": {"客户端心跳上报", "桌面客户端定时上报存活与版本,服务端用于在线状态展示。"},
|
||||
"POST /api/desktop/clients/offline": {"客户端主动离线", "桌面客户端退出/休眠时上报,立即将本机标记为离线。"},
|
||||
"POST /api/desktop/clients/revoke": {"吊销客户端", "由桌面端调用,立即注销当前客户端凭证。"},
|
||||
"POST /api/desktop/clients/bug-reports": {"客户端鉴权 Bug 上报", "桌面客户端带 client token 上报崩溃或用户反馈,服务端自动绑定租户、用户和设备。"},
|
||||
"GET /api/desktop/clients/releases/latest": {"查询最新客户端版本(桌面鉴权)", "桌面客户端带 client token 查询最新版本,默认沿用本机注册渠道。"},
|
||||
"GET /api/desktop/clients/releases/download-url": {"解析客户端下载地址(桌面鉴权)", "桌面客户端开始更新下载时即时获取下载地址,私有 OSS 会返回签名 URL。"},
|
||||
// --- Desktop:派单与拉取 ---
|
||||
|
||||
@@ -414,6 +414,8 @@ func isMultipartRoute(route gin.RouteInfo) bool {
|
||||
}
|
||||
path := route.Path
|
||||
return path == "/api/tenant/images" ||
|
||||
path == "/api/desktop/bug-reports" ||
|
||||
path == "/api/desktop/clients/bug-reports" ||
|
||||
path == "/api/ops/site-domain-mappings/import" ||
|
||||
path == "/api/ops/object-storage/objects/upload" ||
|
||||
path == "/api/ops/desktop-client/releases/upload" ||
|
||||
@@ -431,6 +433,23 @@ func multipartRequestBody(path string) map[string]any {
|
||||
}
|
||||
required := []string{"file"}
|
||||
|
||||
if path == "/api/desktop/bug-reports" || path == "/api/desktop/clients/bug-reports" {
|
||||
properties = map[string]any{
|
||||
"upload_file_minidump": map[string]any{
|
||||
"type": "string",
|
||||
"format": "binary",
|
||||
"description": "Electron crashReporter 默认上传的 minidump 字段;手动反馈也可用 dump 或 file 字段。",
|
||||
},
|
||||
"report_type": map[string]any{"type": "string", "enum": []string{"crash", "bug"}},
|
||||
"severity": map[string]any{"type": "string", "enum": []string{"low", "medium", "high", "critical"}},
|
||||
"title": map[string]any{"type": "string"},
|
||||
"description": map[string]any{"type": "string"},
|
||||
"runtime_snapshot": map[string]any{"type": "string"},
|
||||
"app_log_tail": map[string]any{"type": "string"},
|
||||
}
|
||||
required = nil
|
||||
}
|
||||
|
||||
if path == "/api/tenant/images" {
|
||||
properties["folder_id"] = map[string]any{"type": "string"}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user