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
+3
View File
@@ -86,6 +86,7 @@ func main() {
auditsRepo := repository.NewAuditRepository(pool)
siteDomainMappingsRepo := repository.NewSiteDomainMappingRepository(monitoringPool)
desktopClientReleasesRepo := repository.NewDesktopClientReleaseRepository(pool)
desktopBugReportsRepo := repository.NewDesktopBugReportRepository(pool)
schedulerRepo := repository.NewSchedulerRepository(pool)
ipRegionResolver, err := ipregion.NewResolver(cfg.IPRegion.V4XDBPath, cfg.IPRegion.V6XDBPath, logger)
@@ -123,6 +124,7 @@ func main() {
siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc)
objectStorageClient := objectstorage.New(cfg.ObjectStorage, logger)
desktopClientReleaseSvc := app.NewDesktopClientReleaseService(desktopClientReleasesRepo, objectStorageClient, auditSvc)
desktopBugReportSvc := app.NewDesktopBugReportService(desktopBugReportsRepo, objectStorageClient, auditSvc)
objectStorageSvc := app.NewObjectStorageService(objectStorageClient, auditSvc).WithLogger(logger)
complianceSvc := app.NewComplianceService(pool, auditSvc, logger)
schedulerSvc := app.NewSchedulerService(schedulerRepo, auditSvc)
@@ -180,6 +182,7 @@ func main() {
SiteDomains: siteDomainMappingSvc,
Releases: desktopClientReleaseSvc,
ObjectStorage: objectStorageSvc,
BugReports: desktopBugReportSvc,
Compliance: complianceSvc,
Scheduler: schedulerSvc,
MediaSupply: mediaSupplySvc,
@@ -0,0 +1,685 @@
package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/geo-platform/tenant-api/internal/ops/domain"
"github.com/geo-platform/tenant-api/internal/ops/repository"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/geo-platform/tenant-api/internal/shared/response"
tenantrepo "github.com/geo-platform/tenant-api/internal/tenant/repository"
)
const (
ActionDesktopBugReportCreate = "desktop_bug_report.create"
ActionDesktopBugReportUpdate = "desktop_bug_report.update"
)
const (
DesktopBugReportTypeCrash = "crash"
DesktopBugReportTypeBug = "bug"
)
const (
DesktopBugReportStatusNew = "new"
DesktopBugReportStatusTriaged = "triaged"
DesktopBugReportStatusInProgress = "in_progress"
DesktopBugReportStatusResolved = "resolved"
DesktopBugReportStatusIgnored = "ignored"
)
const (
DesktopBugReportSeverityLow = "low"
DesktopBugReportSeverityMedium = "medium"
DesktopBugReportSeverityHigh = "high"
DesktopBugReportSeverityCritical = "critical"
)
const maxDesktopBugDumpBytes = 64 * 1024 * 1024
const maxDesktopBugMultipartBytes = maxDesktopBugDumpBytes + 4*1024*1024
type DesktopBugReportService struct {
reports *repository.DesktopBugReportRepository
storage objectstorage.Client
audits *AuditService
}
func NewDesktopBugReportService(
reports *repository.DesktopBugReportRepository,
storage objectstorage.Client,
audits *AuditService,
) *DesktopBugReportService {
return &DesktopBugReportService{reports: reports, storage: storage, audits: audits}
}
type DesktopBugReportUploadInput struct {
ReportType string
Severity string
Title string
Description string
TenantID *int64
WorkspaceID *int64
UserID *int64
DesktopClientID string
CrashGUID string
CrashReportID string
ProcessType string
ElectronVersion string
AppVersion string
ProductName string
Platform string
OSArch string
DeviceName string
FormFields map[string]string
RuntimeSnapshot []byte
AppLogTail string
DumpFileName string
DumpContent []byte
UploaderIP string
UserAgent string
OccurredAt time.Time
}
type DesktopBugReportListInput struct {
Keyword string
Status string
Severity string
ReportType string
Platform string
Process string
TenantID *int64
ClientID string
Page int
Size int
}
type DesktopBugReportUpdateInput struct {
Status string
Severity string
ResolutionNote *string
}
type DesktopBugReportListResult struct {
Items []DesktopBugReportView `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
type DesktopBugReportUploadResult struct {
ID string `json:"id"`
DumpObjectKey *string `json:"dump_object_key,omitempty"`
}
type DesktopBugReportDownloadURLResult struct {
DownloadURL string `json:"download_url"`
}
type DesktopBugReportView struct {
ID string `json:"id"`
ReportType string `json:"report_type"`
Status string `json:"status"`
Severity string `json:"severity"`
TenantID *int64 `json:"tenant_id,omitempty"`
TenantName *string `json:"tenant_name,omitempty"`
WorkspaceID *int64 `json:"workspace_id,omitempty"`
WorkspaceName *string `json:"workspace_name,omitempty"`
UserID *int64 `json:"user_id,omitempty"`
UserName *string `json:"user_name,omitempty"`
UserPhone *string `json:"user_phone,omitempty"`
UserEmail *string `json:"user_email,omitempty"`
DesktopClientID *string `json:"desktop_client_id,omitempty"`
CrashGUID *string `json:"crash_guid,omitempty"`
CrashReportID *string `json:"crash_report_id,omitempty"`
ProcessType *string `json:"process_type,omitempty"`
ElectronVersion *string `json:"electron_version,omitempty"`
AppVersion *string `json:"app_version,omitempty"`
ProductName *string `json:"product_name,omitempty"`
Platform *string `json:"platform,omitempty"`
OSArch *string `json:"os_arch,omitempty"`
DeviceName *string `json:"device_name,omitempty"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
DumpObjectKey *string `json:"dump_object_key,omitempty"`
DumpFileName *string `json:"dump_file_name,omitempty"`
DumpSizeBytes *int64 `json:"dump_size_bytes,omitempty"`
DumpSHA256 *string `json:"dump_sha256,omitempty"`
FormFields json.RawMessage `json:"form_fields"`
RuntimeSnapshot json.RawMessage `json:"runtime_snapshot"`
AppLogTail *string `json:"app_log_tail,omitempty"`
ResolutionNote *string `json:"resolution_note,omitempty"`
UploaderIP *string `json:"uploader_ip,omitempty"`
UserAgent *string `json:"user_agent,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *DesktopBugReportService) UploadPublic(ctx context.Context, in DesktopBugReportUploadInput) (*DesktopBugReportUploadResult, error) {
in.TenantID = nil
in.WorkspaceID = nil
in.UserID = nil
in.DesktopClientID = ""
in.FormFields = dropPublicDesktopBugIdentityFields(in.FormFields)
return s.Upload(ctx, in)
}
func (s *DesktopBugReportService) Upload(ctx context.Context, in DesktopBugReportUploadInput) (*DesktopBugReportUploadResult, error) {
if s == nil || s.reports == nil {
return nil, response.ErrServiceUnavailable(50361, "desktop_bug_report_unavailable", "desktop bug report service is unavailable")
}
normalized, err := normalizeDesktopBugReportUploadInput(in)
if err != nil {
return nil, err
}
var dumpObjectKey *string
var dumpSHA256 *string
var dumpSize *int64
if len(normalized.DumpContent) > 0 {
if err := s.ensureStorageReady(); err != nil {
return nil, err
}
sum := sha256.Sum256(normalized.DumpContent)
sha256Hex := hex.EncodeToString(sum[:])
size := int64(len(normalized.DumpContent))
objectKey := buildDesktopBugDumpObjectKey(normalized, sha256Hex)
contentType := detectDesktopBugDumpContentType(normalized.DumpFileName, normalized.DumpContent)
if err := s.storage.PutBytes(ctx, objectKey, normalized.DumpContent, contentType); err != nil {
appErr := response.ErrInternal(50061, "desktop_bug_dump_upload_failed", "failed to upload dump to object storage")
appErr.Cause = err
return nil, appErr
}
dumpObjectKey = &objectKey
dumpSHA256 = &sha256Hex
dumpSize = &size
}
formFields, _ := json.Marshal(normalized.FormFields)
report, err := s.reports.Create(ctx, repository.CreateDesktopBugReportInput{
ID: uuid.NewString(),
ReportType: normalized.ReportType,
Status: DesktopBugReportStatusNew,
Severity: normalized.Severity,
TenantID: normalized.TenantID,
WorkspaceID: normalized.WorkspaceID,
UserID: normalized.UserID,
DesktopClientID: optionalUUIDString(normalized.DesktopClientID),
CrashGUID: optionalText(normalized.CrashGUID),
CrashReportID: optionalText(normalized.CrashReportID),
ProcessType: optionalText(normalized.ProcessType),
ElectronVersion: optionalText(normalized.ElectronVersion),
AppVersion: optionalText(normalized.AppVersion),
ProductName: optionalText(normalized.ProductName),
Platform: optionalText(normalized.Platform),
OSArch: optionalText(normalized.OSArch),
DeviceName: optionalText(normalized.DeviceName),
Title: normalized.Title,
Description: optionalText(normalized.Description),
DumpObjectKey: dumpObjectKey,
DumpFileName: optionalText(normalized.DumpFileName),
DumpSizeBytes: dumpSize,
DumpSHA256: dumpSHA256,
FormFields: formFields,
RuntimeSnapshot: normalized.RuntimeSnapshot,
AppLogTail: optionalText(normalized.AppLogTail),
UploaderIP: optionalText(normalized.UploaderIP),
UserAgent: optionalText(normalized.UserAgent),
OccurredAt: normalized.OccurredAt,
})
if err != nil {
return nil, err
}
return &DesktopBugReportUploadResult{ID: report.ID, DumpObjectKey: report.DumpObjectKey}, nil
}
func (s *DesktopBugReportService) UploadForClient(
ctx context.Context,
client *tenantrepo.DesktopClient,
in DesktopBugReportUploadInput,
) (*DesktopBugReportUploadResult, error) {
if client == nil {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
tenantID := client.TenantID
workspaceID := client.WorkspaceID
userID := client.UserID
in.TenantID = &tenantID
in.WorkspaceID = &workspaceID
in.UserID = &userID
in.DesktopClientID = client.ID.String()
if strings.TrimSpace(in.DeviceName) == "" && client.DeviceName != nil {
in.DeviceName = *client.DeviceName
}
if strings.TrimSpace(in.Platform) == "" && client.OS != nil {
in.Platform = *client.OS
}
if strings.TrimSpace(in.OSArch) == "" && client.CPUArch != nil {
in.OSArch = *client.CPUArch
}
if strings.TrimSpace(in.AppVersion) == "" && client.ClientVersion != nil {
in.AppVersion = *client.ClientVersion
}
return s.Upload(ctx, in)
}
func (s *DesktopBugReportService) List(ctx context.Context, in DesktopBugReportListInput) (*DesktopBugReportListResult, error) {
page := in.Page
if page <= 0 {
page = 1
}
size := in.Size
if size <= 0 || size > 200 {
size = 20
}
items, total, err := s.reports.List(ctx, repository.DesktopBugReportFilter{
Keyword: strings.TrimSpace(in.Keyword),
Status: normalizeDesktopBugStatus(in.Status),
Severity: normalizeDesktopBugSeverity(in.Severity),
ReportType: normalizeDesktopBugReportType(in.ReportType),
Platform: normalizeDesktopBugToken(in.Platform),
Process: normalizeDesktopBugToken(in.Process),
TenantID: in.TenantID,
ClientID: normalizeUUIDString(in.ClientID),
Limit: size,
Offset: (page - 1) * size,
})
if err != nil {
return nil, err
}
views := make([]DesktopBugReportView, 0, len(items))
for i := range items {
views = append(views, desktopBugReportView(&items[i]))
}
return &DesktopBugReportListResult{Items: views, Total: total, Page: page, Size: size}, nil
}
func (s *DesktopBugReportService) Get(ctx context.Context, id string) (*DesktopBugReportView, error) {
report, err := s.reports.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrDesktopBugReportNotFound) {
return nil, response.ErrNotFound(40461, "desktop_bug_report_not_found", "客户端 Bug 记录不存在")
}
return nil, err
}
view := desktopBugReportView(report)
return &view, nil
}
func (s *DesktopBugReportService) Update(ctx context.Context, actor *Actor, id string, in DesktopBugReportUpdateInput) (*DesktopBugReportView, error) {
status := normalizeDesktopBugStatus(in.Status)
if !isAllowedDesktopBugStatus(status) {
return nil, response.ErrBadRequest(40061, "invalid_desktop_bug_status", "状态必须是 new、triaged、in_progress、resolved 或 ignored")
}
severity := normalizeDesktopBugSeverity(in.Severity)
if !isAllowedDesktopBugSeverity(severity) {
return nil, response.ErrBadRequest(40062, "invalid_desktop_bug_severity", "严重级别必须是 low、medium、high 或 critical")
}
report, err := s.reports.UpdateState(ctx, id, repository.UpdateDesktopBugReportStateInput{
Status: status,
Severity: severity,
ResolutionNote: trimOptionalDesktopBugString(in.ResolutionNote, 4000),
})
if err != nil {
if errors.Is(err, repository.ErrDesktopBugReportNotFound) {
return nil, response.ErrNotFound(40461, "desktop_bug_report_not_found", "客户端 Bug 记录不存在")
}
return nil, err
}
s.audit(ctx, actor, ActionDesktopBugReportUpdate, report)
view := desktopBugReportView(report)
return &view, nil
}
func (s *DesktopBugReportService) ResolveDumpDownloadURL(ctx context.Context, id string) (*DesktopBugReportDownloadURLResult, error) {
report, err := s.reports.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrDesktopBugReportNotFound) {
return nil, response.ErrNotFound(40461, "desktop_bug_report_not_found", "客户端 Bug 记录不存在")
}
return nil, err
}
if report.DumpObjectKey == nil || strings.TrimSpace(*report.DumpObjectKey) == "" {
return nil, response.ErrNotFound(40462, "desktop_bug_dump_not_found", "该记录没有 dump 文件")
}
if err := s.ensureStorageReady(); err != nil {
return nil, err
}
downloadURL, err := s.storage.DownloadURL(*report.DumpObjectKey)
if err != nil {
appErr := response.ErrInternal(50062, "desktop_bug_dump_url_failed", "failed to generate dump download URL")
appErr.Cause = err
return nil, appErr
}
return &DesktopBugReportDownloadURLResult{DownloadURL: downloadURL}, nil
}
func (s *DesktopBugReportService) ensureStorageReady() error {
if s == nil || s.storage == nil {
return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
}
if err := s.storage.Validate(); err != nil {
return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
}
return nil
}
func normalizeDesktopBugReportUploadInput(in DesktopBugReportUploadInput) (DesktopBugReportUploadInput, error) {
in.ReportType = normalizeDesktopBugReportType(in.ReportType)
if in.ReportType == "" {
in.ReportType = DesktopBugReportTypeCrash
}
if !isAllowedDesktopBugReportType(in.ReportType) {
return DesktopBugReportUploadInput{}, response.ErrBadRequest(40063, "invalid_desktop_bug_report_type", "上报类型必须是 crash 或 bug")
}
in.Severity = normalizeDesktopBugSeverity(in.Severity)
if in.Severity == "" {
if in.ReportType == DesktopBugReportTypeCrash {
in.Severity = DesktopBugReportSeverityHigh
} else {
in.Severity = DesktopBugReportSeverityMedium
}
}
if !isAllowedDesktopBugSeverity(in.Severity) {
return DesktopBugReportUploadInput{}, response.ErrBadRequest(40062, "invalid_desktop_bug_severity", "严重级别必须是 low、medium、high 或 critical")
}
in.Title = limitDesktopBugText(firstNonEmptyString(
in.Title,
in.FormFields["_title"],
in.FormFields["title"],
defaultDesktopBugTitle(in),
), 200)
in.Description = limitDesktopBugText(firstNonEmptyString(in.Description, in.FormFields["_description"], in.FormFields["description"]), 4000)
in.CrashGUID = limitDesktopBugText(firstNonEmptyString(in.CrashGUID, in.FormFields["guid"]), 200)
in.CrashReportID = limitDesktopBugText(firstNonEmptyString(in.CrashReportID, in.FormFields["crash_report_id"], in.FormFields["id"]), 200)
in.ProcessType = limitDesktopBugText(firstNonEmptyString(in.ProcessType, in.FormFields["process_type"], in.FormFields["ptype"]), 80)
in.ElectronVersion = limitDesktopBugText(firstNonEmptyString(in.ElectronVersion, in.FormFields["ver"]), 80)
in.AppVersion = limitDesktopBugText(firstNonEmptyString(in.AppVersion, in.FormFields["app_version"], in.FormFields["_version"]), 80)
in.ProductName = limitDesktopBugText(firstNonEmptyString(in.ProductName, in.FormFields["_productName"], in.FormFields["prod"]), 120)
in.Platform = limitDesktopBugText(firstNonEmptyString(in.Platform, in.FormFields["platform"]), 80)
in.OSArch = limitDesktopBugText(firstNonEmptyString(in.OSArch, in.FormFields["osarch"], in.FormFields["cpu_arch"]), 80)
in.DeviceName = limitDesktopBugText(firstNonEmptyString(in.DeviceName, in.FormFields["device_name"]), 200)
in.DesktopClientID = normalizeUUIDString(firstNonEmptyString(in.DesktopClientID, in.FormFields["client_id"]))
if len(in.DumpContent) > maxDesktopBugDumpBytes {
return DesktopBugReportUploadInput{}, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
in.DumpFileName = sanitizeDesktopBugFileName(in.DumpFileName)
if len(in.DumpContent) > 0 && in.DumpFileName == "" {
in.DumpFileName = "upload_file_minidump.dmp"
}
in.AppLogTail = limitDesktopBugText(in.AppLogTail, 20000)
if len(in.RuntimeSnapshot) == 0 || !json.Valid(in.RuntimeSnapshot) {
in.RuntimeSnapshot = []byte("{}")
}
in.FormFields = sanitizeDesktopBugFormFields(in.FormFields)
if in.OccurredAt.IsZero() {
in.OccurredAt = time.Now().UTC()
}
return in, nil
}
func defaultDesktopBugTitle(in DesktopBugReportUploadInput) string {
if in.ReportType == DesktopBugReportTypeBug {
return "用户反馈"
}
process := firstNonEmptyString(in.ProcessType, in.FormFields["process_type"], in.FormFields["ptype"], "unknown")
platform := firstNonEmptyString(in.Platform, in.FormFields["platform"], "unknown")
return fmt.Sprintf("客户端崩溃:%s / %s", process, platform)
}
func sanitizeDesktopBugFormFields(fields map[string]string) map[string]string {
out := make(map[string]string)
for key, value := range fields {
normalizedKey := strings.TrimSpace(key)
if normalizedKey == "" || len(normalizedKey) > 120 {
continue
}
out[normalizedKey] = limitDesktopBugText(value, 2000)
}
return out
}
func dropPublicDesktopBugIdentityFields(fields map[string]string) map[string]string {
if len(fields) == 0 {
return fields
}
out := make(map[string]string, len(fields))
for key, value := range fields {
switch strings.ToLower(strings.TrimSpace(key)) {
case "tenant_id", "_tenant_id", "workspace_id", "_workspace_id", "user_id", "_user_id",
"client_id", "_client_id", "desktop_client_id", "_desktop_client_id":
continue
default:
out[key] = value
}
}
return out
}
func buildDesktopBugDumpObjectKey(in DesktopBugReportUploadInput, sha256Hex string) string {
datePrefix := time.Now().UTC().Format("2006/01/02")
reportKind := in.ReportType
if reportKind == "" {
reportKind = DesktopBugReportTypeCrash
}
clientID := normalizeUUIDString(in.DesktopClientID)
if clientID == "" {
clientID = "anonymous"
}
ext := strings.ToLower(filepath.Ext(in.DumpFileName))
if ext == "" || len(ext) > 10 {
ext = ".dmp"
}
return fmt.Sprintf("desktop-bug-reports/%s/%s/%s/%s%s", reportKind, datePrefix, clientID, sha256Hex, ext)
}
func detectDesktopBugDumpContentType(fileName string, content []byte) string {
lower := strings.ToLower(fileName)
switch {
case strings.HasSuffix(lower, ".dmp"):
return "application/vnd.microsoft.portable-executable"
case strings.HasSuffix(lower, ".gz"):
return "application/gzip"
case strings.HasSuffix(lower, ".zip"):
return "application/zip"
default:
if len(content) > 0 {
return http.DetectContentType(content)
}
return "application/octet-stream"
}
}
func desktopBugReportView(item *domain.DesktopBugReport) DesktopBugReportView {
return DesktopBugReportView{
ID: item.ID,
ReportType: item.ReportType,
Status: item.Status,
Severity: item.Severity,
TenantID: item.TenantID,
TenantName: item.TenantName,
WorkspaceID: item.WorkspaceID,
WorkspaceName: item.WorkspaceName,
UserID: item.UserID,
UserName: item.UserName,
UserPhone: item.UserPhone,
UserEmail: item.UserEmail,
DesktopClientID: item.DesktopClientID,
CrashGUID: item.CrashGUID,
CrashReportID: item.CrashReportID,
ProcessType: item.ProcessType,
ElectronVersion: item.ElectronVersion,
AppVersion: item.AppVersion,
ProductName: item.ProductName,
Platform: item.Platform,
OSArch: item.OSArch,
DeviceName: item.DeviceName,
Title: item.Title,
Description: item.Description,
DumpObjectKey: item.DumpObjectKey,
DumpFileName: item.DumpFileName,
DumpSizeBytes: item.DumpSizeBytes,
DumpSHA256: item.DumpSHA256,
FormFields: item.FormFields,
RuntimeSnapshot: item.RuntimeSnapshot,
AppLogTail: item.AppLogTail,
ResolutionNote: item.ResolutionNote,
UploaderIP: item.UploaderIP,
UserAgent: item.UserAgent,
OccurredAt: item.OccurredAt,
ResolvedAt: item.ResolvedAt,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
func (s *DesktopBugReportService) audit(ctx context.Context, actor *Actor, action string, item *domain.DesktopBugReport) {
if actor == nil || s.audits == nil || item == nil {
return
}
event := actor.audit(action, "desktop_bug_report", 0, map[string]any{
"id": item.ID,
"report_type": item.ReportType,
"status": item.Status,
"severity": item.Severity,
"dump_object_key": item.DumpObjectKey,
})
event.TargetID = item.ID
_ = s.audits.Append(ctx, event)
}
func normalizeDesktopBugReportType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeDesktopBugStatus(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeDesktopBugSeverity(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeDesktopBugToken(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func isAllowedDesktopBugReportType(value string) bool {
return value == DesktopBugReportTypeCrash || value == DesktopBugReportTypeBug
}
func isAllowedDesktopBugStatus(value string) bool {
switch value {
case DesktopBugReportStatusNew, DesktopBugReportStatusTriaged, DesktopBugReportStatusInProgress, DesktopBugReportStatusResolved, DesktopBugReportStatusIgnored:
return true
default:
return false
}
}
func isAllowedDesktopBugSeverity(value string) bool {
switch value {
case DesktopBugReportSeverityLow, DesktopBugReportSeverityMedium, DesktopBugReportSeverityHigh, DesktopBugReportSeverityCritical:
return true
default:
return false
}
}
func optionalText(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
func optionalUUIDString(value string) *string {
value = normalizeUUIDString(value)
if value == "" {
return nil
}
return &value
}
func normalizeUUIDString(value string) string {
value = strings.TrimSpace(value)
if _, err := uuid.Parse(value); err != nil {
return ""
}
return value
}
func limitDesktopBugText(value string, limit int) string {
value = strings.TrimSpace(value)
if value == "" || limit <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit])
}
func trimOptionalDesktopBugString(value *string, limit int) *string {
if value == nil {
return nil
}
trimmed := limitDesktopBugText(*value, limit)
if trimmed == "" {
return nil
}
return &trimmed
}
func sanitizeDesktopBugFileName(value string) string {
value = strings.TrimSpace(filepath.Base(value))
if value == "" || value == "." || value == "/" || value == "\\" {
return ""
}
value = strings.Map(func(r rune) rune {
if r == '\\' || r == '/' || r == '\r' || r == '\n' {
return -1
}
return r
}, value)
return limitDesktopBugText(value, 200)
}
var desktopBugISODatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T`)
func ParseDesktopBugOccurredAt(value string) time.Time {
value = strings.TrimSpace(value)
if value == "" || !desktopBugISODatePattern.MatchString(value) {
return time.Time{}
}
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return time.Time{}
}
return parsed.UTC()
}
func MaxDesktopBugMultipartBytes() int64 {
return maxDesktopBugMultipartBytes
}
func MaxDesktopBugDumpBytes() int64 {
return maxDesktopBugDumpBytes
}
@@ -0,0 +1,42 @@
package app
import "testing"
func TestDropPublicDesktopBugIdentityFields(t *testing.T) {
fields := map[string]string{
"tenant_id": "1",
"_tenant_id": "2",
"workspace_id": "3",
"_workspace_id": "4",
"user_id": "5",
"_user_id": "6",
"client_id": "spoofed-client",
"_client_id": "spoofed-electron-client",
"desktop_client_id": "spoofed-desktop-client",
"_desktop_client_id": "spoofed-electron-desktop-client",
"title": "renderer crash",
"guid": "crash-guid",
}
sanitized := dropPublicDesktopBugIdentityFields(fields)
for _, key := range []string{
"tenant_id",
"_tenant_id",
"workspace_id",
"_workspace_id",
"user_id",
"_user_id",
"client_id",
"_client_id",
"desktop_client_id",
"_desktop_client_id",
} {
if _, ok := sanitized[key]; ok {
t.Fatalf("sanitized fields retained spoofable identity key %q", key)
}
}
if sanitized["title"] != "renderer crash" || sanitized["guid"] != "crash-guid" {
t.Fatalf("sanitized fields dropped non-identity metadata: %#v", sanitized)
}
}
+45 -1
View File
@@ -1,6 +1,9 @@
package domain
import "time"
import (
"encoding/json"
"time"
)
const (
StatusActive = "active"
@@ -102,3 +105,44 @@ type DesktopClientRelease struct {
CreatedAt time.Time
UpdatedAt time.Time
}
type DesktopBugReport struct {
ID string
ReportType string
Status string
Severity string
TenantID *int64
TenantName *string
WorkspaceID *int64
WorkspaceName *string
UserID *int64
UserName *string
UserPhone *string
UserEmail *string
DesktopClientID *string
CrashGUID *string
CrashReportID *string
ProcessType *string
ElectronVersion *string
AppVersion *string
ProductName *string
Platform *string
OSArch *string
DeviceName *string
Title string
Description *string
DumpObjectKey *string
DumpFileName *string
DumpSizeBytes *int64
DumpSHA256 *string
FormFields json.RawMessage
RuntimeSnapshot json.RawMessage
AppLogTail *string
ResolutionNote *string
UploaderIP *string
UserAgent *string
OccurredAt time.Time
ResolvedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -0,0 +1,367 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/ops/domain"
)
var ErrDesktopBugReportNotFound = errors.New("desktop bug report not found")
type DesktopBugReportRepository struct {
pool *pgxpool.Pool
}
func NewDesktopBugReportRepository(pool *pgxpool.Pool) *DesktopBugReportRepository {
return &DesktopBugReportRepository{pool: pool}
}
type CreateDesktopBugReportInput struct {
ID string
ReportType string
Status string
Severity string
TenantID *int64
WorkspaceID *int64
UserID *int64
DesktopClientID *string
CrashGUID *string
CrashReportID *string
ProcessType *string
ElectronVersion *string
AppVersion *string
ProductName *string
Platform *string
OSArch *string
DeviceName *string
Title string
Description *string
DumpObjectKey *string
DumpFileName *string
DumpSizeBytes *int64
DumpSHA256 *string
FormFields []byte
RuntimeSnapshot []byte
AppLogTail *string
UploaderIP *string
UserAgent *string
OccurredAt time.Time
}
type DesktopBugReportFilter struct {
Keyword string
Status string
Severity string
ReportType string
Platform string
Process string
TenantID *int64
ClientID string
Limit int
Offset int
}
type UpdateDesktopBugReportStateInput struct {
Status string
Severity string
ResolutionNote *string
}
const desktopBugReportSelectColumns = `
br.id::text,
br.report_type,
br.status,
br.severity,
br.tenant_id,
t.name,
br.workspace_id,
w.name,
br.user_id,
u.name,
u.phone,
u.email,
br.desktop_client_id::text,
br.crash_guid,
br.crash_report_id,
br.process_type,
br.electron_version,
br.app_version,
br.product_name,
br.platform,
br.os_arch,
br.device_name,
br.title,
br.description,
br.dump_object_key,
br.dump_file_name,
br.dump_size_bytes,
br.dump_sha256,
br.form_fields,
br.runtime_snapshot,
br.app_log_tail,
br.resolution_note,
br.uploader_ip,
br.user_agent,
br.occurred_at,
br.resolved_at,
br.created_at,
br.updated_at`
const desktopBugReportFromClause = `
FROM desktop_bug_reports br
LEFT JOIN tenants t ON t.id = br.tenant_id
LEFT JOIN workspaces w ON w.id = br.workspace_id
LEFT JOIN users u ON u.id = br.user_id`
func (r *DesktopBugReportRepository) Create(ctx context.Context, in CreateDesktopBugReportInput) (*domain.DesktopBugReport, error) {
var id string
if err := r.pool.QueryRow(ctx, `
INSERT INTO desktop_bug_reports (
id, report_type, status, severity, tenant_id, workspace_id, user_id,
desktop_client_id, crash_guid, crash_report_id, process_type, electron_version,
app_version, product_name, platform, os_arch, device_name, title, description,
dump_object_key, dump_file_name, dump_size_bytes, dump_sha256, form_fields,
runtime_snapshot, app_log_tail, uploader_ip, user_agent, occurred_at
)
VALUES (
$1::uuid, $2, $3, $4, $5, $6, $7,
$8::uuid, $9, $10, $11, $12,
$13, $14, $15, $16, $17, $18, $19,
$20, $21, $22, $23, $24::jsonb,
$25::jsonb, $26, $27, $28, $29
)
RETURNING id::text`,
in.ID, in.ReportType, in.Status, in.Severity, in.TenantID, in.WorkspaceID, in.UserID,
in.DesktopClientID, in.CrashGUID, in.CrashReportID, in.ProcessType, in.ElectronVersion,
in.AppVersion, in.ProductName, in.Platform, in.OSArch, in.DeviceName, in.Title, in.Description,
in.DumpObjectKey, in.DumpFileName, in.DumpSizeBytes, in.DumpSHA256, string(defaultJSON(in.FormFields)),
string(defaultJSON(in.RuntimeSnapshot)), in.AppLogTail, in.UploaderIP, in.UserAgent, in.OccurredAt).Scan(&id); err != nil {
return nil, err
}
return r.GetByID(ctx, id)
}
func (r *DesktopBugReportRepository) List(ctx context.Context, f DesktopBugReportFilter) ([]domain.DesktopBugReport, int64, error) {
args := []any{}
where := "1=1"
if f.Keyword != "" {
args = append(args, "%"+f.Keyword+"%")
where += fmt.Sprintf(` AND (
br.id::text ILIKE $%d OR
COALESCE(br.title, '') ILIKE $%d OR
COALESCE(br.description, '') ILIKE $%d OR
COALESCE(br.crash_guid, '') ILIKE $%d OR
COALESCE(br.crash_report_id, '') ILIKE $%d OR
COALESCE(br.dump_object_key, '') ILIKE $%d OR
COALESCE(br.device_name, '') ILIKE $%d OR
COALESCE(t.name, '') ILIKE $%d OR
COALESCE(w.name, '') ILIKE $%d OR
COALESCE(u.name, '') ILIKE $%d OR
COALESCE(u.phone, '') ILIKE $%d OR
COALESCE(u.email, '') ILIKE $%d
)`, len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args))
}
if f.Status != "" {
args = append(args, f.Status)
where += fmt.Sprintf(" AND br.status = $%d", len(args))
}
if f.Severity != "" {
args = append(args, f.Severity)
where += fmt.Sprintf(" AND br.severity = $%d", len(args))
}
if f.ReportType != "" {
args = append(args, f.ReportType)
where += fmt.Sprintf(" AND br.report_type = $%d", len(args))
}
if f.Platform != "" {
args = append(args, f.Platform)
where += fmt.Sprintf(" AND br.platform = $%d", len(args))
}
if f.Process != "" {
args = append(args, f.Process)
where += fmt.Sprintf(" AND br.process_type = $%d", len(args))
}
if f.TenantID != nil {
args = append(args, *f.TenantID)
where += fmt.Sprintf(" AND br.tenant_id = $%d", len(args))
}
if f.ClientID != "" {
args = append(args, f.ClientID)
where += fmt.Sprintf(" AND br.desktop_client_id = $%d::uuid", len(args))
}
var total int64
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) "+desktopBugReportFromClause+" WHERE "+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
limit := f.Limit
if limit <= 0 || limit > 200 {
limit = 20
}
offset := f.Offset
if offset < 0 {
offset = 0
}
args = append(args, limit, offset)
limitArg := len(args) - 1
offsetArg := len(args)
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
SELECT %s
%s
WHERE %s
ORDER BY br.created_at DESC, br.id DESC
LIMIT $%d OFFSET $%d`, desktopBugReportSelectColumns, desktopBugReportFromClause, where, limitArg, offsetArg), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]domain.DesktopBugReport, 0, limit)
for rows.Next() {
item, scanErr := scanDesktopBugReport(rows)
if scanErr != nil {
return nil, 0, scanErr
}
items = append(items, *item)
}
return items, total, rows.Err()
}
func (r *DesktopBugReportRepository) GetByID(ctx context.Context, id string) (*domain.DesktopBugReport, error) {
return scanDesktopBugReport(r.pool.QueryRow(ctx, `
SELECT `+desktopBugReportSelectColumns+`
`+desktopBugReportFromClause+`
WHERE br.id = $1::uuid`, id))
}
func (r *DesktopBugReportRepository) UpdateState(ctx context.Context, id string, in UpdateDesktopBugReportStateInput) (*domain.DesktopBugReport, error) {
var updatedID string
if err := r.pool.QueryRow(ctx, `
UPDATE desktop_bug_reports br
SET status = $2,
severity = $3,
resolution_note = $4,
resolved_at = CASE
WHEN $2 IN ('resolved', 'ignored') AND resolved_at IS NULL THEN NOW()
WHEN $2 NOT IN ('resolved', 'ignored') THEN NULL
ELSE resolved_at
END,
updated_at = NOW()
WHERE br.id = $1::uuid
RETURNING br.id::text`,
id, in.Status, in.Severity, in.ResolutionNote).Scan(&updatedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrDesktopBugReportNotFound
}
return nil, err
}
return r.GetByID(ctx, updatedID)
}
func scanDesktopBugReport(row pgx.Row) (*domain.DesktopBugReport, error) {
var item domain.DesktopBugReport
var tenantID, workspaceID, userID, dumpSize pgtype.Int8
var tenantName, workspaceName, userName, userPhone, userEmail pgtype.Text
var desktopClientID, crashGUID, crashReportID, processType, electronVersion, appVersion pgtype.Text
var productName, platform, osArch, deviceName, description, dumpObjectKey, dumpFileName pgtype.Text
var dumpSHA256, appLogTail, resolutionNote, uploaderIP, userAgent pgtype.Text
var formFields, runtimeSnapshot []byte
var resolvedAt pgtype.Timestamptz
err := row.Scan(
&item.ID,
&item.ReportType,
&item.Status,
&item.Severity,
&tenantID,
&tenantName,
&workspaceID,
&workspaceName,
&userID,
&userName,
&userPhone,
&userEmail,
&desktopClientID,
&crashGUID,
&crashReportID,
&processType,
&electronVersion,
&appVersion,
&productName,
&platform,
&osArch,
&deviceName,
&item.Title,
&description,
&dumpObjectKey,
&dumpFileName,
&dumpSize,
&dumpSHA256,
&formFields,
&runtimeSnapshot,
&appLogTail,
&resolutionNote,
&uploaderIP,
&userAgent,
&item.OccurredAt,
&resolvedAt,
&item.CreatedAt,
&item.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrDesktopBugReportNotFound
}
return nil, err
}
item.TenantID = int64PtrFromPg(tenantID)
item.TenantName = stringPtrFromPg(tenantName)
item.WorkspaceID = int64PtrFromPg(workspaceID)
item.WorkspaceName = stringPtrFromPg(workspaceName)
item.UserID = int64PtrFromPg(userID)
item.UserName = stringPtrFromPg(userName)
item.UserPhone = stringPtrFromPg(userPhone)
item.UserEmail = stringPtrFromPg(userEmail)
item.DesktopClientID = stringPtrFromPg(desktopClientID)
item.CrashGUID = stringPtrFromPg(crashGUID)
item.CrashReportID = stringPtrFromPg(crashReportID)
item.ProcessType = stringPtrFromPg(processType)
item.ElectronVersion = stringPtrFromPg(electronVersion)
item.AppVersion = stringPtrFromPg(appVersion)
item.ProductName = stringPtrFromPg(productName)
item.Platform = stringPtrFromPg(platform)
item.OSArch = stringPtrFromPg(osArch)
item.DeviceName = stringPtrFromPg(deviceName)
item.Description = stringPtrFromPg(description)
item.DumpObjectKey = stringPtrFromPg(dumpObjectKey)
item.DumpFileName = stringPtrFromPg(dumpFileName)
item.DumpSizeBytes = int64PtrFromPg(dumpSize)
item.DumpSHA256 = stringPtrFromPg(dumpSHA256)
item.FormFields = json.RawMessage(defaultJSON(formFields))
item.RuntimeSnapshot = json.RawMessage(defaultJSON(runtimeSnapshot))
item.AppLogTail = stringPtrFromPg(appLogTail)
item.ResolutionNote = stringPtrFromPg(resolutionNote)
item.UploaderIP = stringPtrFromPg(uploaderIP)
item.UserAgent = stringPtrFromPg(userAgent)
item.ResolvedAt = timePtrFromPg(resolvedAt)
return &item, nil
}
func defaultJSON(value []byte) []byte {
if len(value) == 0 || !json.Valid(value) {
return []byte("{}")
}
return value
}
@@ -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))
@@ -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:派单与拉取 ---
+19
View File
@@ -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"}
}
@@ -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)
@@ -0,0 +1,2 @@
-- Intentionally no-op. See the matching up migration for context.
SELECT 1;
@@ -0,0 +1,3 @@
-- Compatibility marker for databases that applied the desktop client release
-- updater migration under this version before it was committed as 20260525113000.
SELECT 1;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS desktop_bug_reports;
@@ -0,0 +1,61 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS desktop_bug_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
report_type TEXT NOT NULL DEFAULT 'crash'
CHECK (report_type IN ('crash', 'bug')),
status TEXT NOT NULL DEFAULT 'new'
CHECK (status IN ('new', 'triaged', 'in_progress', 'resolved', 'ignored')),
severity TEXT NOT NULL DEFAULT 'medium'
CHECK (severity IN ('low', 'medium', 'high', 'critical')),
tenant_id BIGINT REFERENCES tenants(id) ON DELETE SET NULL,
workspace_id BIGINT REFERENCES workspaces(id) ON DELETE SET NULL,
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
desktop_client_id UUID REFERENCES desktop_clients(id) ON DELETE SET NULL,
crash_guid TEXT,
crash_report_id TEXT,
process_type TEXT,
electron_version TEXT,
app_version TEXT,
product_name TEXT,
platform TEXT,
os_arch TEXT,
device_name TEXT,
title TEXT NOT NULL,
description TEXT,
dump_object_key TEXT,
dump_file_name TEXT,
dump_size_bytes BIGINT,
dump_sha256 TEXT,
form_fields JSONB NOT NULL DEFAULT '{}'::jsonb,
runtime_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
app_log_tail TEXT,
resolution_note TEXT,
uploader_ip TEXT,
user_agent TEXT,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_desktop_bug_reports_created_at
ON desktop_bug_reports (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_desktop_bug_reports_status_created
ON desktop_bug_reports (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_desktop_bug_reports_severity_created
ON desktop_bug_reports (severity, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_desktop_bug_reports_client
ON desktop_bug_reports (desktop_client_id, created_at DESC)
WHERE desktop_client_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_desktop_bug_reports_workspace
ON desktop_bug_reports (tenant_id, workspace_id, created_at DESC)
WHERE tenant_id IS NOT NULL AND workspace_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_desktop_bug_reports_crash_guid
ON desktop_bug_reports (crash_guid)
WHERE crash_guid IS NOT NULL;