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,245 @@
package transport
import (
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/ops/app"
"github.com/geo-platform/tenant-api/internal/shared/response"
tenantapp "github.com/geo-platform/tenant-api/internal/tenant/app"
tenantrepo "github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type updateDesktopBugReportRequest struct {
Status string `json:"status" binding:"required"`
Severity string `json:"severity" binding:"required"`
ResolutionNote *string `json:"resolution_note"`
}
func uploadDesktopBugReportHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
input, err := parseDesktopBugReportMultipart(c)
if err != nil {
response.Error(c, err)
return
}
result, err := svc.UploadPublic(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func uploadDesktopBugReportForClientHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
input, err := parseDesktopBugReportMultipart(c)
if err != nil {
response.Error(c, err)
return
}
client, _ := c.Request.Context().Value(desktopBugReportClientContextKey{}).(*tenantrepo.DesktopClient)
result, err := svc.UploadForClient(c.Request.Context(), client, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func desktopBugReportClientMiddleware(repo tenantrepo.DesktopClientRepository) gin.HandlerFunc {
return func(c *gin.Context) {
token, err := desktopBugReportBearerClientToken(c.GetHeader("Authorization"))
if err != nil {
response.Error(c, err)
c.Abort()
return
}
client, err := repo.GetByTokenHash(c.Request.Context(), tenantapp.HashDesktopClientToken(token))
if err != nil {
response.Error(c, response.ErrUnauthorized(40131, "invalid_client_token", "desktop client token is invalid or revoked"))
c.Abort()
return
}
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), desktopBugReportClientContextKey{}, client))
c.Next()
}
}
func listDesktopBugReportsHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
result, err := svc.List(c.Request.Context(), app.DesktopBugReportListInput{
Keyword: c.Query("keyword"),
Status: c.Query("status"),
Severity: c.Query("severity"),
ReportType: c.Query("report_type"),
Platform: c.Query("platform"),
Process: c.Query("process_type"),
TenantID: parseOptionalInt64Query(c.Query("tenant_id")),
ClientID: c.Query("client_id"),
Page: page,
Size: size,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func getDesktopBugReportHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
result, err := svc.Get(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func updateDesktopBugReportHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
var body updateDesktopBugReportRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
result, err := svc.Update(c.Request.Context(), actorFromGin(c), c.Param("id"), app.DesktopBugReportUpdateInput{
Status: body.Status,
Severity: body.Severity,
ResolutionNote: body.ResolutionNote,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func resolveDesktopBugReportDumpURLHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
result, err := svc.ResolveDumpDownloadURL(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func parseDesktopBugReportMultipart(c *gin.Context) (app.DesktopBugReportUploadInput, error) {
if c.Request.ContentLength > app.MaxDesktopBugMultipartBytes() {
return app.DesktopBugReportUploadInput{}, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, app.MaxDesktopBugMultipartBytes())
if err := c.Request.ParseMultipartForm(app.MaxDesktopBugMultipartBytes()); err != nil {
if strings.Contains(err.Error(), "request body too large") {
return app.DesktopBugReportUploadInput{}, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
return app.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 app.DesktopBugReportUploadInput{}, err
}
runtimeSnapshot := []byte(firstNonEmpty(fields["runtime_snapshot"], fields["_runtime_snapshot"]))
if len(runtimeSnapshot) > 0 && !json.Valid(runtimeSnapshot) {
runtimeSnapshot = []byte("{}")
}
tenantID := parseOptionalInt64Query(firstNonEmpty(fields["tenant_id"], fields["_tenant_id"]))
workspaceID := parseOptionalInt64Query(firstNonEmpty(fields["workspace_id"], fields["_workspace_id"]))
userID := parseOptionalInt64Query(firstNonEmpty(fields["user_id"], fields["_user_id"]))
return app.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"]),
TenantID: tenantID,
WorkspaceID: workspaceID,
UserID: userID,
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: app.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, app.MaxDesktopBugDumpBytes()+1))
if err != nil {
return "", nil, response.ErrBadRequest(40066, "desktop_bug_dump_read_failed", "dump 文件读取失败")
}
if int64(len(content)) > app.MaxDesktopBugDumpBytes() {
return "", nil, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
return fileHeader.Filename, content, nil
}
return "", nil, nil
}
func parseOptionalInt64Query(value string) *int64 {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil || parsed <= 0 {
return nil
}
return &parsed
}
type desktopBugReportClientContextKey struct{}
func desktopBugReportBearerClientToken(header string) (string, error) {
parts := strings.SplitN(strings.TrimSpace(header), " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || strings.TrimSpace(parts[1]) == "" {
return "", response.ErrUnauthorized(40130, "missing_client_token", "desktop client token is required")
}
return strings.TrimSpace(parts[1]), nil
}
+14
View File
@@ -1,6 +1,8 @@
package transport
import (
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
@@ -10,6 +12,7 @@ import (
"github.com/geo-platform/tenant-api/internal/shared/middleware"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/shared/swagger"
tenantrepo "github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type Deps struct {
@@ -28,6 +31,7 @@ type Deps struct {
SiteDomains *app.SiteDomainMappingService
Releases *app.DesktopClientReleaseService
ObjectStorage *app.ObjectStorageService
BugReports *app.DesktopBugReportService
Compliance *app.ComplianceService
Scheduler *app.SchedulerService
MediaSupply *app.MediaSupplyService
@@ -75,6 +79,12 @@ func RegisterRoutes(d Deps) {
d.Engine.GET("/api/desktop/releases/download-url", resolveLatestDesktopClientReleaseDownloadURLHandler(d.Releases))
d.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed", desktopClientReleaseUpdaterFeedHandler(d.Releases))
d.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version", desktopClientReleaseUpdaterFeedHandler(d.Releases))
publicBugReportRateLimit := middleware.RateLimitByClientIP(10, time.Minute, 42961, "desktop_bug_report_rate_limited", "客户端 Bug 上报过于频繁,请稍后再试")
d.Engine.POST("/api/desktop/bug-reports", publicBugReportRateLimit, uploadDesktopBugReportHandler(d.BugReports))
desktopAuthed := d.Engine.Group("/api/desktop")
desktopAuthed.Use(desktopBugReportClientMiddleware(tenantrepo.NewDesktopClientRepository(d.DB)))
desktopAuthed.POST("/clients/bug-reports", uploadDesktopBugReportForClientHandler(d.BugReports))
api := d.Engine.Group("/api/ops")
{
@@ -168,6 +178,10 @@ func RegisterRoutes(d Deps) {
authed.GET("/desktop-client/releases/:id/download-url", resolveDesktopClientReleaseDownloadURLHandler(d.Releases))
authed.POST("/desktop-client/releases/:id/enabled", setDesktopClientReleaseEnabledHandler(d.Releases))
authed.DELETE("/desktop-client/releases/:id", deleteDesktopClientReleaseHandler(d.Releases))
authed.GET("/desktop-client/bug-reports", listDesktopBugReportsHandler(d.BugReports))
authed.GET("/desktop-client/bug-reports/:id", getDesktopBugReportHandler(d.BugReports))
authed.PATCH("/desktop-client/bug-reports/:id", updateDesktopBugReportHandler(d.BugReports))
authed.GET("/desktop-client/bug-reports/:id/dump-url", resolveDesktopBugReportDumpURLHandler(d.BugReports))
compliance := authed.Group("/compliance")
compliance.GET("/dictionaries", listComplianceDictionariesHandler(d.Compliance))