diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index 6c6723e..d8a2444 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -33,6 +33,11 @@ import { type DesktopAppSettingKey, } from './app-settings' import { getDesktopAppVersion, getDesktopReleaseChannel } from './app-version' +import { + initDesktopBugReporter, + submitManualDesktopBugReport, + syncDesktopBugReporterSession, +} from './bug-reporter' import { defaultDesktopUpdateChannel, startDesktopClientUpdate, @@ -131,6 +136,7 @@ function silencePackagedConsole(): void { } silencePackagedConsole() +initDesktopBugReporter() if (isDevelopmentRuntime) { installObservedGlobalFetch() } @@ -957,6 +963,17 @@ function registerBridgeHandlers(): void { ipcMain.handle('desktop:runtime-account-snapshot', (_event, accountId: string) => captureRuntimeAccountSnapshot(accountId), ) + safeHandle( + 'desktop:submit-bug-report', + async ( + _event, + input: { + title?: string + description?: string + severity?: string + }, + ) => submitManualDesktopBugReport(input ?? {}), + ) safeHandle('desktop:bind-publish-account', async (_event, platformId: string) => { const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo noteRuntimeAccountBound(account) @@ -1052,6 +1069,7 @@ function registerBridgeHandlers(): void { 'desktop:runtime-session-sync', (_event, session: DesktopRuntimeSessionSyncRequest | null) => { syncRuntimeSession(session) + syncDesktopBugReporterSession(session) return null }, ) diff --git a/apps/desktop-client/src/main/bug-reporter.ts b/apps/desktop-client/src/main/bug-reporter.ts new file mode 100644 index 0000000..28c8872 --- /dev/null +++ b/apps/desktop-client/src/main/bug-reporter.ts @@ -0,0 +1,306 @@ +import { readdirSync, statSync } from 'node:fs' +import { appendFile, readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import type { DesktopRuntimeSessionSyncRequest } from '@geo/shared-types' +import { crashReporter } from 'electron' +import { app } from 'electron/main' + +import { normalizeDesktopApiBaseURL } from '../shared/api-base-url' +import { getDesktopAppVersion, getDesktopReleaseChannel } from './app-version' +import { resolveDesktopDeviceInfo } from './device-id' +import { createRuntimeSnapshot } from './runtime-snapshot' + +const BUG_REPORT_LOG_FILE = 'desktop-bug-reporter.jsonl' +const MAX_LOG_TAIL_BYTES = 96 * 1024 +const MAX_RUNTIME_SNAPSHOT_BYTES = 180 * 1024 +const CRASH_EXTRA_KEYS = [ + 'app_version', + 'channel', + 'device_name', + 'platform', + 'cpu_arch', + 'client_id', + 'tenant_id', + 'workspace_id', + 'user_id', +] + +interface BugReporterSession { + baseURL: string + clientToken: string | null + tenantID: number | null + workspaceID: number | null + userID: number | null + clientID: string | null + deviceName: string | null + platform: string | null + osArch: string | null + appVersion: string | null + channel: string | null +} + +export interface ManualDesktopBugReportInput { + title?: string + description?: string + severity?: string +} + +let currentSession: BugReporterSession = { + baseURL: normalizeDesktopApiBaseURL(process.env.DESKTOP_API_BASE_URL), + clientToken: null, + tenantID: null, + workspaceID: null, + userID: null, + clientID: null, + deviceName: null, + platform: process.platform, + osArch: process.arch, + appVersion: getDesktopAppVersion(), + channel: getDesktopReleaseChannel(), +} +let crashReporterStarted = false + +export function initDesktopBugReporter(): void { + if (crashReporterStarted) { + return + } + crashReporterStarted = true + + const device = resolveDesktopDeviceInfo() + currentSession = { + ...currentSession, + deviceName: device.device_name, + platform: device.os, + osArch: device.cpu_arch, + } + + crashReporter.start({ + submitURL: buildBugReportURL(currentSession.baseURL, false), + productName: 'ShengxinPush', + uploadToServer: true, + rateLimit: true, + compress: true, + globalExtra: buildStaticCrashReporterExtra(), + }) +} + +export function syncDesktopBugReporterSession( + session: DesktopRuntimeSessionSyncRequest | null, +): void { + const client = session?.desktop_client ?? null + currentSession = { + ...currentSession, + baseURL: normalizeDesktopApiBaseURL(session?.api_base_url ?? process.env.DESKTOP_API_BASE_URL), + clientToken: session?.client_token ?? null, + tenantID: normalizePositiveNumber(client?.tenant_id), + workspaceID: normalizePositiveNumber(client?.workspace_id), + userID: normalizePositiveNumber(client?.user_id), + clientID: client?.id ?? null, + deviceName: client?.device_name ?? currentSession.deviceName, + platform: client?.os ?? currentSession.platform, + osArch: client?.cpu_arch ?? currentSession.osArch, + appVersion: client?.client_version ?? getDesktopAppVersion(), + channel: client?.channel ?? getDesktopReleaseChannel(), + } + updateCrashReporterParameters() +} + +export async function submitManualDesktopBugReport( + input: ManualDesktopBugReportInput, +): Promise<{ id: string; dump_object_key?: string | null }> { + const title = String(input.title ?? '').trim() + const description = String(input.description ?? '').trim() + if (!title) { + throw new Error('bug_report_title_required') + } + if (!description) { + throw new Error('bug_report_description_required') + } + + const form = new FormData() + appendBaseFields(form, 'bug') + form.set('title', limitText(title, 200)) + form.set('description', limitText(description, 4000)) + form.set('severity', normalizeSeverity(input.severity)) + form.set('runtime_snapshot', await safeRuntimeSnapshotJSON()) + form.set('app_log_tail', await readLogTail()) + + const url = buildBugReportURL(currentSession.baseURL, Boolean(currentSession.clientToken)) + const headers: Record = {} + if (currentSession.clientToken) { + headers.Authorization = `Bearer ${currentSession.clientToken}` + } + const response = await fetch(url, { + method: 'POST', + headers, + body: form, + }) + const payload = (await response.json().catch(() => null)) as { + code?: number + message?: string + detail?: string + data?: { id?: string; dump_object_key?: string | null } + } | null + + if (!response.ok || payload?.code !== 0) { + throw new Error( + payload?.detail || payload?.message || `bug_report_upload_failed_${response.status}`, + ) + } + + const id = payload.data?.id + if (!id) { + throw new Error('bug_report_upload_invalid_response') + } + await appendBugReporterLog({ action: 'manual_bug_report_uploaded', id }) + return { + id, + dump_object_key: payload.data?.dump_object_key ?? null, + } +} + +function buildBugReportURL(baseURL: string, authed: boolean): string { + const path = authed ? '/api/desktop/clients/bug-reports' : '/api/desktop/bug-reports' + return new URL(path, normalizeDesktopApiBaseURL(baseURL)).toString() +} + +function appendBaseFields(form: FormData, reportType: 'crash' | 'bug'): void { + const fields = buildCrashReporterExtra() + form.set('report_type', reportType) + form.set('occurred_at', new Date().toISOString()) + for (const [key, value] of Object.entries(fields)) { + if (value) { + form.set(key, value) + } + } +} + +function buildCrashReporterExtra(): Record { + return { + ...buildStaticCrashReporterExtra(), + client_id: limitExtra(currentSession.clientID ?? ''), + tenant_id: limitExtra(currentSession.tenantID ? String(currentSession.tenantID) : ''), + workspace_id: limitExtra(currentSession.workspaceID ? String(currentSession.workspaceID) : ''), + user_id: limitExtra(currentSession.userID ? String(currentSession.userID) : ''), + } +} + +function buildStaticCrashReporterExtra(): Record { + return { + app_version: limitExtra(currentSession.appVersion ?? getDesktopAppVersion()), + channel: limitExtra(currentSession.channel ?? getDesktopReleaseChannel()), + device_name: limitExtra(currentSession.deviceName ?? ''), + platform: limitExtra(currentSession.platform ?? process.platform), + cpu_arch: limitExtra(currentSession.osArch ?? process.arch), + } +} + +function updateCrashReporterParameters(): void { + if (!crashReporterStarted) { + return + } + for (const key of CRASH_EXTRA_KEYS) { + crashReporter.removeExtraParameter(key) + } + for (const [key, value] of Object.entries(buildCrashReporterExtra())) { + if (value) { + crashReporter.addExtraParameter(key, value) + } + } +} + +async function safeRuntimeSnapshotJSON(): Promise { + try { + const raw = JSON.stringify(createRuntimeSnapshot()) + return raw.length > MAX_RUNTIME_SNAPSHOT_BYTES ? raw.slice(0, MAX_RUNTIME_SNAPSHOT_BYTES) : raw + } catch { + return '{}' + } +} + +async function readLogTail(): Promise { + const candidates = resolveLogCandidates() + for (const candidate of candidates) { + try { + const stat = statSync(candidate) + if (!stat.isFile()) { + continue + } + const content = await readFile(candidate, 'utf8') + return content.slice(-MAX_LOG_TAIL_BYTES) + } catch { + // Try the next known log file. + } + } + return '' +} + +function resolveLogCandidates(): string[] { + const dirs = [app.getPath('logs'), app.getPath('userData')] + const candidates: string[] = [] + for (const dir of dirs) { + try { + for (const name of readdirSync(dir)) { + const lower = name.toLowerCase() + if (lower.endsWith('.log') || lower.endsWith('.jsonl')) { + candidates.push(join(dir, name)) + } + } + } catch { + // Logs may not exist in dev or first-run installs. + } + } + return candidates.sort((left, right) => { + try { + return statSync(right).mtimeMs - statSync(left).mtimeMs + } catch { + return 0 + } + }) +} + +function normalizeSeverity(value: string | undefined): string { + switch ( + String(value ?? '') + .trim() + .toLowerCase() + ) { + case 'low': + case 'medium': + case 'high': + case 'critical': + return String(value).trim().toLowerCase() + default: + return 'medium' + } +} + +function normalizePositiveNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} + +function limitExtra(value: string): string { + return limitText(value, 120) +} + +function limitText(value: string, max: number): string { + const trimmed = value.trim() + if (!trimmed) { + return '' + } + const runes = [...trimmed] + return runes.length > max ? runes.slice(0, max).join('') : trimmed +} + +async function appendBugReporterLog(event: Record): Promise { + try { + await appendFile( + join(app.getPath('userData'), BUG_REPORT_LOG_FILE), + `${JSON.stringify({ at: new Date().toISOString(), ...event })}\n`, + 'utf8', + ) + } catch { + // Best-effort local breadcrumbs only. + } +} diff --git a/apps/desktop-client/src/preload/bridge.ts b/apps/desktop-client/src/preload/bridge.ts index 7b20d65..8438466 100644 --- a/apps/desktop-client/src/preload/bridge.ts +++ b/apps/desktop-client/src/preload/bridge.ts @@ -1,6 +1,8 @@ import type { CreatePublishJobResponse, DesktopAccountInfo, + DesktopBugReportSubmitRequest, + DesktopBugReportSubmitResponse, DesktopClientReleaseCheckResponse, DesktopClientRotateResponse, DesktopClientUpdateProgressEvent, @@ -145,6 +147,11 @@ const desktopBridge = { ipcRenderer.invoke('desktop:runtime-account-snapshot', accountId) as Promise< DesktopRuntimeSnapshot['accounts'][number] | null >, + submitBugReport: (payload: DesktopBugReportSubmitRequest) => + ipcRenderer.invoke( + 'desktop:submit-bug-report', + payload, + ) as Promise, bindPublishAccount: (platformId: string) => ipcRenderer.invoke('desktop:bind-publish-account', platformId) as Promise, refreshRuntimeAccounts: () => diff --git a/apps/desktop-client/src/renderer/components/DesktopShell.vue b/apps/desktop-client/src/renderer/components/DesktopShell.vue index 7665aef..a936025 100644 --- a/apps/desktop-client/src/renderer/components/DesktopShell.vue +++ b/apps/desktop-client/src/renderer/components/DesktopShell.vue @@ -1,6 +1,7 @@ + + diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 57e52e3..48645cf 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -417,6 +417,17 @@ export interface DesktopRuntimeSessionSyncRequest { desktop_client: DesktopClientInfo | null } +export interface DesktopBugReportSubmitRequest { + title?: string + description?: string + severity?: 'low' | 'medium' | 'high' | 'critical' +} + +export interface DesktopBugReportSubmitResponse { + id: string + dump_object_key?: string | null +} + export interface CreatePublishJobAccountRequest { account_id: string } diff --git a/server/cmd/ops-api/main.go b/server/cmd/ops-api/main.go index 096fd20..693577a 100644 --- a/server/cmd/ops-api/main.go +++ b/server/cmd/ops-api/main.go @@ -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, diff --git a/server/internal/ops/app/desktop_bug_report.go b/server/internal/ops/app/desktop_bug_report.go new file mode 100644 index 0000000..cfed7fa --- /dev/null +++ b/server/internal/ops/app/desktop_bug_report.go @@ -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 +} diff --git a/server/internal/ops/app/desktop_bug_report_test.go b/server/internal/ops/app/desktop_bug_report_test.go new file mode 100644 index 0000000..f160614 --- /dev/null +++ b/server/internal/ops/app/desktop_bug_report_test.go @@ -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) + } +} diff --git a/server/internal/ops/domain/types.go b/server/internal/ops/domain/types.go index 95ec085..068a83e 100644 --- a/server/internal/ops/domain/types.go +++ b/server/internal/ops/domain/types.go @@ -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 +} diff --git a/server/internal/ops/repository/desktop_bug_report.go b/server/internal/ops/repository/desktop_bug_report.go new file mode 100644 index 0000000..3a168cc --- /dev/null +++ b/server/internal/ops/repository/desktop_bug_report.go @@ -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 +} diff --git a/server/internal/ops/transport/desktop_bug_report_handler.go b/server/internal/ops/transport/desktop_bug_report_handler.go new file mode 100644 index 0000000..4e98376 --- /dev/null +++ b/server/internal/ops/transport/desktop_bug_report_handler.go @@ -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 +} diff --git a/server/internal/ops/transport/router.go b/server/internal/ops/transport/router.go index e348d22..8bf55a4 100644 --- a/server/internal/ops/transport/router.go +++ b/server/internal/ops/transport/router.go @@ -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)) diff --git a/server/internal/shared/middleware/rate_limit.go b/server/internal/shared/middleware/rate_limit.go new file mode 100644 index 0000000..8a7a505 --- /dev/null +++ b/server/internal/shared/middleware/rate_limit.go @@ -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 +} diff --git a/server/internal/shared/middleware/rate_limit_test.go b/server/internal/shared/middleware/rate_limit_test.go new file mode 100644 index 0000000..ad530e4 --- /dev/null +++ b/server/internal/shared/middleware/rate_limit_test.go @@ -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) + } +} diff --git a/server/internal/shared/swagger/descriptions.go b/server/internal/shared/swagger/descriptions.go index 6023d07..2301bb5 100644 --- a/server/internal/shared/swagger/descriptions.go +++ b/server/internal/shared/swagger/descriptions.go @@ -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:派单与拉取 --- diff --git a/server/internal/shared/swagger/swagger.go b/server/internal/shared/swagger/swagger.go index c461ec4..0c9584d 100644 --- a/server/internal/shared/swagger/swagger.go +++ b/server/internal/shared/swagger/swagger.go @@ -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"} } diff --git a/server/internal/tenant/transport/desktop_bug_report_handler.go b/server/internal/tenant/transport/desktop_bug_report_handler.go new file mode 100644 index 0000000..7d7eddc --- /dev/null +++ b/server/internal/tenant/transport/desktop_bug_report_handler.go @@ -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 +} diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index 477e542..eade732 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -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) diff --git a/server/migrations_ops/20260525123000_desktop_client_release_updater_compat.down.sql b/server/migrations_ops/20260525123000_desktop_client_release_updater_compat.down.sql new file mode 100644 index 0000000..1f10ca6 --- /dev/null +++ b/server/migrations_ops/20260525123000_desktop_client_release_updater_compat.down.sql @@ -0,0 +1,2 @@ +-- Intentionally no-op. See the matching up migration for context. +SELECT 1; diff --git a/server/migrations_ops/20260525123000_desktop_client_release_updater_compat.up.sql b/server/migrations_ops/20260525123000_desktop_client_release_updater_compat.up.sql new file mode 100644 index 0000000..eacff31 --- /dev/null +++ b/server/migrations_ops/20260525123000_desktop_client_release_updater_compat.up.sql @@ -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; diff --git a/server/migrations_ops/20260531110000_create_desktop_bug_reports.down.sql b/server/migrations_ops/20260531110000_create_desktop_bug_reports.down.sql new file mode 100644 index 0000000..6876936 --- /dev/null +++ b/server/migrations_ops/20260531110000_create_desktop_bug_reports.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS desktop_bug_reports; diff --git a/server/migrations_ops/20260531110000_create_desktop_bug_reports.up.sql b/server/migrations_ops/20260531110000_create_desktop_bug_reports.up.sql new file mode 100644 index 0000000..d04cb7c --- /dev/null +++ b/server/migrations_ops/20260531110000_create_desktop_bug_reports.up.sql @@ -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;