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 }