feat: add desktop bug report collection

This commit is contained in:
2026-05-31 21:55:29 +08:00
parent 0a9dcec70f
commit e490a267ff
27 changed files with 2938 additions and 3 deletions
@@ -0,0 +1,137 @@
package transport
import (
"encoding/json"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
opsapp "github.com/geo-platform/tenant-api/internal/ops/app"
opsrepo "github.com/geo-platform/tenant-api/internal/ops/repository"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type DesktopBugReportHandler struct {
svc *opsapp.DesktopBugReportService
}
func NewDesktopBugReportHandler(a *bootstrap.App) *DesktopBugReportHandler {
return &DesktopBugReportHandler{
svc: opsapp.NewDesktopBugReportService(
opsrepo.NewDesktopBugReportRepository(a.DB),
a.ObjectStorage,
nil,
),
}
}
func (h *DesktopBugReportHandler) Upload(c *gin.Context) {
input, err := parseDesktopBugReportMultipart(c)
if err != nil {
response.Error(c, err)
return
}
data, err := h.svc.UploadForClient(c.Request.Context(), MustDesktopClient(c.Request.Context()), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopBugReportHandler) UploadPublic(c *gin.Context) {
input, err := parseDesktopBugReportMultipart(c)
if err != nil {
response.Error(c, err)
return
}
data, err := h.svc.UploadPublic(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func parseDesktopBugReportMultipart(c *gin.Context) (opsapp.DesktopBugReportUploadInput, error) {
if c.Request.ContentLength > opsapp.MaxDesktopBugMultipartBytes() {
return opsapp.DesktopBugReportUploadInput{}, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, opsapp.MaxDesktopBugMultipartBytes())
if err := c.Request.ParseMultipartForm(opsapp.MaxDesktopBugMultipartBytes()); err != nil {
if strings.Contains(err.Error(), "request body too large") {
return opsapp.DesktopBugReportUploadInput{}, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
return opsapp.DesktopBugReportUploadInput{}, response.ErrBadRequest(40065, "invalid_desktop_bug_report", "客户端 Bug 上报表单无效")
}
fields := map[string]string{}
if c.Request.MultipartForm != nil {
for key, values := range c.Request.MultipartForm.Value {
if len(values) == 0 {
continue
}
fields[key] = values[0]
}
}
dumpName, dumpContent, err := readDesktopBugDump(c)
if err != nil {
return opsapp.DesktopBugReportUploadInput{}, err
}
runtimeSnapshot := []byte(firstNonEmpty(fields["runtime_snapshot"], fields["_runtime_snapshot"]))
if len(runtimeSnapshot) > 0 && !json.Valid(runtimeSnapshot) {
runtimeSnapshot = []byte("{}")
}
return opsapp.DesktopBugReportUploadInput{
ReportType: firstNonEmpty(fields["report_type"], fields["_report_type"]),
Severity: firstNonEmpty(fields["severity"], fields["_severity"]),
Title: firstNonEmpty(fields["title"], fields["_title"]),
Description: firstNonEmpty(fields["description"], fields["_description"]),
DesktopClientID: firstNonEmpty(fields["client_id"], fields["_client_id"]),
CrashGUID: fields["guid"],
CrashReportID: firstNonEmpty(fields["crash_report_id"], fields["id"]),
ProcessType: firstNonEmpty(fields["process_type"], fields["ptype"]),
ElectronVersion: fields["ver"],
AppVersion: firstNonEmpty(fields["app_version"], fields["_version"]),
ProductName: firstNonEmpty(fields["_productName"], fields["prod"]),
Platform: fields["platform"],
OSArch: firstNonEmpty(fields["osarch"], fields["cpu_arch"]),
DeviceName: fields["device_name"],
FormFields: fields,
RuntimeSnapshot: runtimeSnapshot,
AppLogTail: firstNonEmpty(fields["app_log_tail"], fields["_app_log_tail"]),
DumpFileName: dumpName,
DumpContent: dumpContent,
UploaderIP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
OccurredAt: opsapp.ParseDesktopBugOccurredAt(firstNonEmpty(fields["occurred_at"], fields["_occurred_at"])),
}, nil
}
func readDesktopBugDump(c *gin.Context) (string, []byte, error) {
for _, field := range []string{"upload_file_minidump", "dump", "file"} {
fileHeader, err := c.FormFile(field)
if err != nil {
continue
}
file, err := fileHeader.Open()
if err != nil {
return "", nil, response.ErrBadRequest(40066, "desktop_bug_dump_open_failed", "dump 文件读取失败")
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, opsapp.MaxDesktopBugDumpBytes()+1))
if err != nil {
return "", nil, response.ErrBadRequest(40066, "desktop_bug_dump_read_failed", "dump 文件读取失败")
}
if int64(len(content)) > opsapp.MaxDesktopBugDumpBytes() {
return "", nil, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
return fileHeader.Filename, content, nil
}
return "", nil, nil
}
@@ -1,6 +1,8 @@
package transport
import (
"time"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/middleware"
@@ -34,6 +36,7 @@ func RegisterRoutes(app *bootstrap.App) {
desktopClientHandler := NewDesktopClientHandler(app)
desktopReleaseHandler := NewDesktopReleaseHandler(app)
desktopBugReportHandler := NewDesktopBugReportHandler(app)
desktopAccountHandler := NewDesktopAccountHandler(app)
desktopTaskHandler := NewDesktopTaskHandler(app)
desktopDispatchHandler := NewDesktopDispatchHandler(app)
@@ -45,6 +48,8 @@ func RegisterRoutes(app *bootstrap.App) {
app.Engine.GET("/api/desktop/releases/download-url", desktopReleaseHandler.DownloadURLPublic)
app.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed", desktopReleaseHandler.UpdaterFeedPublic)
app.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version", desktopReleaseHandler.UpdaterFeedPublic)
publicBugReportRateLimit := middleware.RateLimitByClientIP(10, time.Minute, 42961, "desktop_bug_report_rate_limited", "客户端 Bug 上报过于频繁,请稍后再试")
app.Engine.POST("/api/desktop/bug-reports", publicBugReportRateLimit, desktopBugReportHandler.UploadPublic)
desktopAuth := app.Engine.Group("/api/desktop")
desktopAuth.Use(DesktopClientMiddleware(repository.NewDesktopClientRepository(app.DB)))
@@ -52,6 +57,7 @@ func RegisterRoutes(app *bootstrap.App) {
desktopAuth.POST("/clients/heartbeat", desktopClientHandler.Heartbeat)
desktopAuth.POST("/clients/offline", desktopClientHandler.Offline)
desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke)
desktopAuth.POST("/clients/bug-reports", desktopBugReportHandler.Upload)
desktopAuth.GET("/clients/releases/latest", desktopReleaseHandler.Latest)
desktopAuth.GET("/clients/releases/download-url", desktopReleaseHandler.DownloadURL)
desktopAuth.GET("/dispatch", desktopDispatchHandler.Dispatch)