feat: add desktop bug report collection
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user