feat: add desktop bug report collection

This commit is contained in:
2026-05-31 21:55:29 +08:00
parent 0a9dcec70f
commit e490a267ff
27 changed files with 2938 additions and 3 deletions
+18
View File
@@ -33,6 +33,11 @@ import {
type DesktopAppSettingKey, type DesktopAppSettingKey,
} from './app-settings' } from './app-settings'
import { getDesktopAppVersion, getDesktopReleaseChannel } from './app-version' import { getDesktopAppVersion, getDesktopReleaseChannel } from './app-version'
import {
initDesktopBugReporter,
submitManualDesktopBugReport,
syncDesktopBugReporterSession,
} from './bug-reporter'
import { import {
defaultDesktopUpdateChannel, defaultDesktopUpdateChannel,
startDesktopClientUpdate, startDesktopClientUpdate,
@@ -131,6 +136,7 @@ function silencePackagedConsole(): void {
} }
silencePackagedConsole() silencePackagedConsole()
initDesktopBugReporter()
if (isDevelopmentRuntime) { if (isDevelopmentRuntime) {
installObservedGlobalFetch() installObservedGlobalFetch()
} }
@@ -957,6 +963,17 @@ function registerBridgeHandlers(): void {
ipcMain.handle('desktop:runtime-account-snapshot', (_event, accountId: string) => ipcMain.handle('desktop:runtime-account-snapshot', (_event, accountId: string) =>
captureRuntimeAccountSnapshot(accountId), 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) => { safeHandle('desktop:bind-publish-account', async (_event, platformId: string) => {
const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo
noteRuntimeAccountBound(account) noteRuntimeAccountBound(account)
@@ -1052,6 +1069,7 @@ function registerBridgeHandlers(): void {
'desktop:runtime-session-sync', 'desktop:runtime-session-sync',
(_event, session: DesktopRuntimeSessionSyncRequest | null) => { (_event, session: DesktopRuntimeSessionSyncRequest | null) => {
syncRuntimeSession(session) syncRuntimeSession(session)
syncDesktopBugReporterSession(session)
return null return null
}, },
) )
@@ -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<string, string> = {}
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<string, string> {
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<string, string> {
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<string> {
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<string> {
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<string, unknown>): Promise<void> {
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.
}
}
@@ -1,6 +1,8 @@
import type { import type {
CreatePublishJobResponse, CreatePublishJobResponse,
DesktopAccountInfo, DesktopAccountInfo,
DesktopBugReportSubmitRequest,
DesktopBugReportSubmitResponse,
DesktopClientReleaseCheckResponse, DesktopClientReleaseCheckResponse,
DesktopClientRotateResponse, DesktopClientRotateResponse,
DesktopClientUpdateProgressEvent, DesktopClientUpdateProgressEvent,
@@ -145,6 +147,11 @@ const desktopBridge = {
ipcRenderer.invoke('desktop:runtime-account-snapshot', accountId) as Promise< ipcRenderer.invoke('desktop:runtime-account-snapshot', accountId) as Promise<
DesktopRuntimeSnapshot['accounts'][number] | null DesktopRuntimeSnapshot['accounts'][number] | null
>, >,
submitBugReport: (payload: DesktopBugReportSubmitRequest) =>
ipcRenderer.invoke(
'desktop:submit-bug-report',
payload,
) as Promise<DesktopBugReportSubmitResponse>,
bindPublishAccount: (platformId: string) => bindPublishAccount: (platformId: string) =>
ipcRenderer.invoke('desktop:bind-publish-account', platformId) as Promise<DesktopAccountInfo>, ipcRenderer.invoke('desktop:bind-publish-account', platformId) as Promise<DesktopAccountInfo>,
refreshRuntimeAccounts: () => refreshRuntimeAccounts: () =>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
AppstoreOutlined, AppstoreOutlined,
BugOutlined,
DownloadOutlined, DownloadOutlined,
LinkOutlined, LinkOutlined,
LogoutOutlined, LogoutOutlined,
@@ -8,6 +9,7 @@ import {
SendOutlined, SendOutlined,
SettingOutlined, SettingOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router' import { RouterLink, RouterView, useRoute } from 'vue-router'
@@ -35,6 +37,14 @@ const updateModalOpen = ref(false)
const updateModalVersion = ref('') const updateModalVersion = ref('')
const updateModalForce = ref(false) const updateModalForce = ref(false)
const updateModalStarted = ref(false) const updateModalStarted = ref(false)
const bugReportOpen = ref(false)
const bugReportSubmitting = ref(false)
type BugReportSeverity = 'low' | 'medium' | 'high' | 'critical'
const bugReportForm = ref({
title: '',
description: '',
severity: 'medium' as BugReportSeverity,
})
const accountAwareRoutes = new Set(['/media-platforms', '/ai-platforms']) const accountAwareRoutes = new Set(['/media-platforms', '/ai-platforms'])
@@ -136,7 +146,10 @@ const updateProgressBarPercent = computed(() => {
if (updateProgress.value?.stage === 'checking') { if (updateProgress.value?.stage === 'checking') {
return 8 return 8
} }
if (updateProgress.value?.stage === 'downloaded' || updateProgress.value?.stage === 'installing') { if (
updateProgress.value?.stage === 'downloaded' ||
updateProgress.value?.stage === 'installing'
) {
return 100 return 100
} }
return updateProgressPercent.value ?? 8 return updateProgressPercent.value ?? 8
@@ -173,6 +186,37 @@ function openSettingsWindow() {
void window.desktopBridge.app.openSettingsWindow() void window.desktopBridge.app.openSettingsWindow()
} }
function openBugReportDialog() {
bugReportForm.value = {
title: '',
description: '',
severity: 'medium' as BugReportSeverity,
}
bugReportOpen.value = true
}
async function submitBugReport() {
const title = bugReportForm.value.title.trim()
const description = bugReportForm.value.description.trim()
if (!title || !description) {
return
}
bugReportSubmitting.value = true
try {
const result = await window.desktopBridge.app.submitBugReport({
title,
description,
severity: bugReportForm.value.severity,
})
bugReportOpen.value = false
message.success(`反馈已提交:${result.id}`)
} catch (error) {
message.error(error instanceof Error ? error.message : '反馈提交失败')
} finally {
bugReportSubmitting.value = false
}
}
async function startClientReleaseUpdate() { async function startClientReleaseUpdate() {
updateModalStarted.value = true updateModalStarted.value = true
const updated = await startClientUpdate() const updated = await startClientUpdate()
@@ -275,6 +319,11 @@ watch(
</div> </div>
</a-tooltip> </a-tooltip>
<div class="action-cluster"> <div class="action-cluster">
<a-tooltip title="反馈问题" placement="top">
<button type="button" class="footer-icon-btn" @click="openBugReportDialog">
<BugOutlined />
</button>
</a-tooltip>
<a-tooltip title="设置" placement="top"> <a-tooltip title="设置" placement="top">
<button type="button" class="footer-icon-btn" @click="openSettingsWindow"> <button type="button" class="footer-icon-btn" @click="openSettingsWindow">
<SettingOutlined /> <SettingOutlined />
@@ -388,6 +437,47 @@ watch(
</div> </div>
</section> </section>
</a-modal> </a-modal>
<a-modal
v-model:open="bugReportOpen"
title="反馈客户端问题"
:confirm-loading="bugReportSubmitting"
ok-text="提交"
cancel-text="取消"
:ok-button-props="{
disabled: !bugReportForm.title.trim() || !bugReportForm.description.trim(),
}"
@ok="submitBugReport"
>
<a-form layout="vertical" :model="bugReportForm">
<a-form-item label="标题" required>
<a-input
v-model:value="bugReportForm.title"
placeholder="例如:发布时客户端卡住"
:maxlength="120"
/>
</a-form-item>
<a-form-item label="严重级别">
<a-segmented
v-model:value="bugReportForm.severity"
:options="[
{ label: '低', value: 'low' },
{ label: '中', value: 'medium' },
{ label: '高', value: 'high' },
{ label: '致命', value: 'critical' },
]"
/>
</a-form-item>
<a-form-item label="现象描述" required>
<a-textarea
v-model:value="bugReportForm.description"
placeholder="请描述刚才做了什么、看到了什么异常"
:maxlength="2000"
:rows="5"
/>
</a-form-item>
</a-form>
</a-modal>
</div> </div>
</template> </template>
+6 -1
View File
@@ -1,10 +1,12 @@
import type { import type {
CreatePublishJobResponse, CreatePublishJobResponse,
DesktopAccountInfo, DesktopAccountInfo,
DesktopBugReportSubmitRequest,
DesktopBugReportSubmitResponse,
DesktopClientReleaseCheckResponse, DesktopClientReleaseCheckResponse,
DesktopClientRotateResponse,
DesktopClientUpdateProgressEvent, DesktopClientUpdateProgressEvent,
DesktopClientUpdateResult, DesktopClientUpdateResult,
DesktopClientRotateResponse,
DesktopPublishTaskListResponse, DesktopPublishTaskListResponse,
DesktopRuntimeSessionSyncRequest, DesktopRuntimeSessionSyncRequest,
ListDesktopPublishTasksParams, ListDesktopPublishTasksParams,
@@ -61,6 +63,9 @@ declare global {
runtimeAccountSnapshot( runtimeAccountSnapshot(
accountId: string, accountId: string,
): Promise<DesktopRuntimeSnapshot['accounts'][number] | null> ): Promise<DesktopRuntimeSnapshot['accounts'][number] | null>
submitBugReport(
payload: DesktopBugReportSubmitRequest,
): Promise<DesktopBugReportSubmitResponse>
bindPublishAccount(platformId: string): Promise<DesktopAccountInfo> bindPublishAccount(platformId: string): Promise<DesktopAccountInfo>
refreshRuntimeAccounts(): Promise<null> refreshRuntimeAccounts(): Promise<null>
probeRuntimeAccount( probeRuntimeAccount(
+7
View File
@@ -49,6 +49,7 @@
import { import {
AuditOutlined, AuditOutlined,
BookOutlined, BookOutlined,
BugOutlined,
ClockCircleOutlined, ClockCircleOutlined,
CloudDownloadOutlined, CloudDownloadOutlined,
ControlOutlined, ControlOutlined,
@@ -88,6 +89,7 @@ const menuLeaves: MenuLeaf[] = [
{ key: '/site-domain-mappings', label: '站点映射', path: '/site-domain-mappings' }, { key: '/site-domain-mappings', label: '站点映射', path: '/site-domain-mappings' },
{ key: '/object-storage', label: '对象存储', path: '/object-storage' }, { key: '/object-storage', label: '对象存储', path: '/object-storage' },
{ key: '/desktop-client/releases', label: '客户端版本', path: '/desktop-client/releases' }, { key: '/desktop-client/releases', label: '客户端版本', path: '/desktop-client/releases' },
{ key: '/desktop-client/bug-reports', label: '客户端 Bug', path: '/desktop-client/bug-reports' },
{ key: '/compliance/policy', label: '全局策略', path: '/compliance/policy' }, { key: '/compliance/policy', label: '全局策略', path: '/compliance/policy' },
{ key: '/compliance/dictionaries', label: '合规词库', path: '/compliance/dictionaries' }, { key: '/compliance/dictionaries', label: '合规词库', path: '/compliance/dictionaries' },
{ key: '/compliance/manual-reviews', label: '人工审阅', path: '/compliance/manual-reviews' }, { key: '/compliance/manual-reviews', label: '人工审阅', path: '/compliance/manual-reviews' },
@@ -126,6 +128,11 @@ const menuItems = computed<ItemType[]>(() => [
label: '客户端版本', label: '客户端版本',
icon: () => h(CloudDownloadOutlined), icon: () => h(CloudDownloadOutlined),
}, },
{
key: '/desktop-client/bug-reports',
label: '客户端 Bug',
icon: () => h(BugOutlined),
},
{ {
key: 'compliance', key: 'compliance',
label: '内容安全', label: '内容安全',
+124
View File
@@ -0,0 +1,124 @@
import { http } from '@/lib/http'
export type DesktopBugReportType = 'crash' | 'bug'
export type DesktopBugReportStatus = 'new' | 'triaged' | 'in_progress' | 'resolved' | 'ignored'
export type DesktopBugReportSeverity = 'low' | 'medium' | 'high' | 'critical'
export interface DesktopBugReport {
id: string
report_type: DesktopBugReportType
status: DesktopBugReportStatus
severity: DesktopBugReportSeverity
tenant_id?: number | null
tenant_name?: string | null
workspace_id?: number | null
workspace_name?: string | null
user_id?: number | null
user_name?: string | null
user_phone?: string | null
user_email?: string | null
desktop_client_id?: string | null
crash_guid?: string | null
crash_report_id?: string | null
process_type?: string | null
electron_version?: string | null
app_version?: string | null
product_name?: string | null
platform?: string | null
os_arch?: string | null
device_name?: string | null
title: string
description?: string | null
dump_object_key?: string | null
dump_file_name?: string | null
dump_size_bytes?: number | null
dump_sha256?: string | null
form_fields: Record<string, unknown>
runtime_snapshot: Record<string, unknown>
app_log_tail?: string | null
resolution_note?: string | null
uploader_ip?: string | null
user_agent?: string | null
occurred_at: string
resolved_at?: string | null
created_at: string
updated_at: string
}
export interface DesktopBugReportList {
items: DesktopBugReport[]
total: number
page: number
size: number
}
export interface DesktopBugReportUpdatePayload {
status: DesktopBugReportStatus
severity: DesktopBugReportSeverity
resolution_note?: string | null
}
export interface DesktopBugReportDumpURL {
download_url: string
}
export const desktopBugReportStatusOptions = [
{ label: '新建', value: 'new' },
{ label: '已分诊', value: 'triaged' },
{ label: '处理中', value: 'in_progress' },
{ label: '已解决', value: 'resolved' },
{ label: '忽略', value: 'ignored' },
]
export const desktopBugReportSeverityOptions = [
{ label: '低', value: 'low' },
{ label: '中', value: 'medium' },
{ label: '高', value: 'high' },
{ label: '致命', value: 'critical' },
]
export const desktopBugReportTypeOptions = [
{ label: '崩溃', value: 'crash' },
{ label: '反馈', value: 'bug' },
]
export const desktopBugReportApi = {
list(params: {
keyword?: string
status?: string
severity?: string
report_type?: string
platform?: string
process_type?: string
tenant_id?: number
client_id?: string
page?: number
size?: number
}) {
return http.get<DesktopBugReportList>('/desktop-client/bug-reports', params)
},
get(id: string) {
return http.get<DesktopBugReport>(`/desktop-client/bug-reports/${id}`)
},
update(id: string, payload: DesktopBugReportUpdatePayload) {
return http.patch<DesktopBugReport, DesktopBugReportUpdatePayload>(
`/desktop-client/bug-reports/${id}`,
payload,
)
},
resolveDumpURL(id: string) {
return http.get<DesktopBugReportDumpURL>(`/desktop-client/bug-reports/${id}/dump-url`)
},
}
export function desktopBugReportStatusLabel(value?: string | null): string {
return desktopBugReportStatusOptions.find((item) => item.value === value)?.label ?? value ?? '—'
}
export function desktopBugReportSeverityLabel(value?: string | null): string {
return desktopBugReportSeverityOptions.find((item) => item.value === value)?.label ?? value ?? '—'
}
export function desktopBugReportTypeLabel(value?: string | null): string {
return desktopBugReportTypeOptions.find((item) => item.value === value)?.label ?? value ?? '—'
}
+6
View File
@@ -79,6 +79,12 @@ export const router = createRouter({
component: () => import('@/views/DesktopClientReleasesView.vue'), component: () => import('@/views/DesktopClientReleasesView.vue'),
meta: { title: '客户端版本' }, meta: { title: '客户端版本' },
}, },
{
path: 'desktop-client/bug-reports',
name: 'desktop-client-bug-reports',
component: () => import('@/views/DesktopBugReportsView.vue'),
meta: { title: '客户端 Bug' },
},
{ {
path: 'compliance/policy', path: 'compliance/policy',
name: 'compliance-policy', name: 'compliance-policy',
@@ -0,0 +1,619 @@
<template>
<div class="bug-page">
<div class="bug-header">
<div>
<h2 class="ops-page-title bug-title">客户端 Bug</h2>
<div class="bug-summary">
<span>全部 {{ total }}</span>
<span>新建 {{ pageNewCount }}</span>
<span>致命 {{ pageCriticalCount }}</span>
</div>
</div>
<a-button @click="reload">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
<div class="ops-card bug-card">
<div class="ops-toolbar bug-toolbar">
<a-input
v-model:value="filter.keyword"
class="bug-search"
placeholder="搜索标题 / 用户 / 设备 / crash guid / Object Key"
allow-clear
@press-enter="resetAndReload"
@change="onKeywordChange"
/>
<a-select
v-model:value="filter.report_type"
class="bug-select"
:options="desktopBugReportTypeOptions"
placeholder="类型"
allow-clear
@change="resetAndReload"
/>
<a-select
v-model:value="filter.status"
class="bug-select"
:options="desktopBugReportStatusOptions"
placeholder="状态"
allow-clear
@change="resetAndReload"
/>
<a-select
v-model:value="filter.severity"
class="bug-select"
:options="desktopBugReportSeverityOptions"
placeholder="级别"
allow-clear
@change="resetAndReload"
/>
<a-select
v-model:value="filter.platform"
class="bug-select"
:options="platformOptions"
placeholder="平台"
allow-clear
@change="resetAndReload"
/>
</div>
<a-table
:columns="columns"
:data-source="rows"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 1280 }"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="bug-title-cell">
<strong>{{ record.title }}</strong>
<span>{{ record.id }}</span>
</div>
</template>
<template v-else-if="column.key === 'scope'">
<div class="scope-cell">
<strong>{{ record.tenant_name || `租户 #${record.tenant_id || '—'}` }}</strong>
<span>{{ userLabel(record) }}</span>
</div>
</template>
<template v-else-if="column.key === 'runtime'">
<div class="runtime-cell">
<strong>{{ record.device_name || '未知设备' }}</strong>
<span>
{{ platformLabel(record.platform) }} · {{ record.process_type || 'unknown' }} · v{{
record.app_version || '—'
}}
</span>
</div>
</template>
<template v-else-if="column.key === 'labels'">
<a-space size="small">
<a-tag :color="record.report_type === 'crash' ? 'red' : 'blue'">
{{ desktopBugReportTypeLabel(record.report_type) }}
</a-tag>
<a-tag :color="severityColor(record.severity)">
{{ desktopBugReportSeverityLabel(record.severity) }}
</a-tag>
<a-tag :color="statusColor(record.status)">
{{ desktopBugReportStatusLabel(record.status) }}
</a-tag>
</a-space>
</template>
<template v-else-if="column.key === 'dump'">
<div v-if="record.dump_object_key" class="dump-cell">
<strong>{{ record.dump_file_name || 'minidump.dmp' }}</strong>
<span>{{ formatBytes(record.dump_size_bytes) }}</span>
</div>
<span v-else class="muted"> dump</span>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDate(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<div class="table-actions-row" style="justify-content: flex-end">
<a-tooltip title="详情">
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-edit"
@click="openDetail(record)"
>
<EyeOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="复制 dump 下载地址">
<a-button
type="text"
shape="circle"
size="small"
class="action-btn"
:disabled="!record.dump_object_key"
@click="copyDumpURL(record)"
>
<DownloadOutlined />
</a-button>
</a-tooltip>
</div>
</template>
</template>
</a-table>
</div>
<a-drawer
v-model:open="detailOpen"
title="客户端 Bug 详情"
width="760"
:destroy-on-close="true"
>
<template v-if="selected">
<div class="detail-head">
<div>
<h3>{{ selected.title }}</h3>
<p>{{ selected.id }}</p>
</div>
<a-space>
<a-tag :color="severityColor(selected.severity)">
{{ desktopBugReportSeverityLabel(selected.severity) }}
</a-tag>
<a-tag :color="statusColor(selected.status)">
{{ desktopBugReportStatusLabel(selected.status) }}
</a-tag>
</a-space>
</div>
<a-form layout="vertical" class="state-form">
<div class="form-grid">
<a-form-item label="状态">
<a-select v-model:value="stateForm.status" :options="desktopBugReportStatusOptions" />
</a-form-item>
<a-form-item label="级别">
<a-select
v-model:value="stateForm.severity"
:options="desktopBugReportSeverityOptions"
/>
</a-form-item>
</div>
<a-form-item label="处理备注">
<a-textarea v-model:value="stateForm.resolution_note" :rows="3" />
</a-form-item>
<a-button type="primary" :loading="saving" @click="saveState">保存处理状态</a-button>
</a-form>
<a-descriptions bordered size="small" :column="2" class="detail-descriptions">
<a-descriptions-item label="类型">
{{ desktopBugReportTypeLabel(selected.report_type) }}
</a-descriptions-item>
<a-descriptions-item label="进程">{{ selected.process_type || '—' }}</a-descriptions-item>
<a-descriptions-item label="租户">
{{ selected.tenant_name || selected.tenant_id || '—' }}
</a-descriptions-item>
<a-descriptions-item label="用户">{{ userLabel(selected) }}</a-descriptions-item>
<a-descriptions-item label="设备">{{ selected.device_name || '—' }}</a-descriptions-item>
<a-descriptions-item label="客户端 ID">
{{ selected.desktop_client_id || '—' }}
</a-descriptions-item>
<a-descriptions-item label="平台">
{{ platformLabel(selected.platform) }} / {{ selected.os_arch || '—' }}
</a-descriptions-item>
<a-descriptions-item label="版本">
App {{ selected.app_version || '—' }} · Electron {{ selected.electron_version || '—' }}
</a-descriptions-item>
<a-descriptions-item label="Crash GUID" :span="2">
{{ selected.crash_guid || '—' }}
</a-descriptions-item>
<a-descriptions-item label="OSS Key" :span="2">
<span class="mono">{{ selected.dump_object_key || '—' }}</span>
</a-descriptions-item>
<a-descriptions-item label="发生时间">
{{ formatDate(selected.occurred_at) }}
</a-descriptions-item>
<a-descriptions-item label="上报时间">
{{ formatDate(selected.created_at) }}
</a-descriptions-item>
</a-descriptions>
<div class="detail-section">
<h4>问题描述</h4>
<p class="detail-text">{{ selected.description || '—' }}</p>
</div>
<div class="detail-section">
<h4>运行快照</h4>
<pre>{{ prettyJSON(selected.runtime_snapshot) }}</pre>
</div>
<div class="detail-section">
<h4>上报字段</h4>
<pre>{{ prettyJSON(selected.form_fields) }}</pre>
</div>
<div class="detail-section">
<h4>日志尾巴</h4>
<pre>{{ selected.app_log_tail || '—' }}</pre>
</div>
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { DownloadOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import type { TablePaginationConfig } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import { computed, onMounted, reactive, ref } from 'vue'
import { copyTextToClipboard } from '@/lib/clipboard'
import {
desktopBugReportApi,
desktopBugReportSeverityLabel,
desktopBugReportSeverityOptions,
desktopBugReportStatusLabel,
desktopBugReportStatusOptions,
desktopBugReportTypeLabel,
desktopBugReportTypeOptions,
type DesktopBugReport,
type DesktopBugReportSeverity,
type DesktopBugReportStatus,
} from '@/lib/desktop-bug-reports'
import { showOpsError } from '@/lib/errors'
const rows = ref<DesktopBugReport[]>([])
const total = ref(0)
const loading = ref(false)
const saving = ref(false)
const detailOpen = ref(false)
const selected = ref<DesktopBugReport | null>(null)
const filter = reactive({
keyword: '',
report_type: undefined as string | undefined,
status: undefined as string | undefined,
severity: undefined as string | undefined,
platform: undefined as string | undefined,
page: 1,
size: 20,
})
const stateForm = reactive({
status: 'new' as DesktopBugReportStatus,
severity: 'medium' as DesktopBugReportSeverity,
resolution_note: '',
})
let keywordTimer: ReturnType<typeof setTimeout> | null = null
const columns = [
{ title: '问题', key: 'title', width: 280 },
{ title: '归属', key: 'scope', width: 220 },
{ title: '运行环境', key: 'runtime', width: 260 },
{ title: '标签', key: 'labels', width: 220 },
{ title: 'Dump', key: 'dump', width: 220 },
{ title: '上报时间', key: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 120, fixed: 'right' },
]
const platformOptions = [
{ label: 'macOS', value: 'darwin' },
{ label: 'Windows', value: 'win32' },
{ label: 'Linux', value: 'linux' },
]
const pageNewCount = computed(() => rows.value.filter((item) => item.status === 'new').length)
const pageCriticalCount = computed(
() => rows.value.filter((item) => item.severity === 'critical').length,
)
const pagination = computed<TablePaginationConfig>(() => ({
current: filter.page,
pageSize: filter.size,
total: total.value,
showSizeChanger: true,
showTotal: (value) => `${value}`,
}))
async function reload() {
loading.value = true
try {
const result = await desktopBugReportApi.list({
keyword: filter.keyword || undefined,
report_type: filter.report_type,
status: filter.status,
severity: filter.severity,
platform: filter.platform,
page: filter.page,
size: filter.size,
})
rows.value = result.items
total.value = result.total
} catch (error) {
showOpsError(error, '客户端 Bug 加载失败')
} finally {
loading.value = false
}
}
function resetAndReload() {
filter.page = 1
void reload()
}
function onKeywordChange() {
if (keywordTimer) {
clearTimeout(keywordTimer)
}
keywordTimer = setTimeout(resetAndReload, 300)
}
function onTableChange(p: TablePaginationConfig) {
filter.page = p.current ?? 1
filter.size = p.pageSize ?? 20
void reload()
}
async function openDetail(row: DesktopBugReport) {
detailOpen.value = true
selected.value = row
assignStateForm(row)
try {
const detail = await desktopBugReportApi.get(row.id)
selected.value = detail
assignStateForm(detail)
} catch (error) {
showOpsError(error, '客户端 Bug 详情加载失败')
}
}
function assignStateForm(row: DesktopBugReport) {
stateForm.status = row.status
stateForm.severity = row.severity
stateForm.resolution_note = row.resolution_note ?? ''
}
async function saveState() {
if (!selected.value) {
return
}
saving.value = true
try {
const updated = await desktopBugReportApi.update(selected.value.id, {
status: stateForm.status,
severity: stateForm.severity,
resolution_note: stateForm.resolution_note || null,
})
selected.value = updated
rows.value = rows.value.map((item) => (item.id === updated.id ? updated : item))
message.success('处理状态已保存')
} catch (error) {
showOpsError(error, '处理状态保存失败')
} finally {
saving.value = false
}
}
async function copyDumpURL(row: DesktopBugReport) {
try {
const result = await desktopBugReportApi.resolveDumpURL(row.id)
await copyTextToClipboard(result.download_url)
message.success('dump 下载地址已复制')
} catch (error) {
showOpsError(error, 'dump 下载地址生成失败')
}
}
function userLabel(row: DesktopBugReport): string {
return (
row.user_name ||
row.user_phone ||
row.user_email ||
(row.user_id ? `用户 #${row.user_id}` : '—')
)
}
function platformLabel(value?: string | null): string {
return platformOptions.find((item) => item.value === value)?.label ?? value ?? '—'
}
function severityColor(value: string): string {
const map: Record<string, string> = {
low: 'default',
medium: 'gold',
high: 'orange',
critical: 'red',
}
return map[value] ?? 'default'
}
function statusColor(value: string): string {
const map: Record<string, string> = {
new: 'red',
triaged: 'blue',
in_progress: 'purple',
resolved: 'green',
ignored: 'default',
}
return map[value] ?? 'default'
}
function formatBytes(value?: number | null): string {
if (!value || value <= 0) {
return '—'
}
if (value < 1024) {
return `${value} B`
}
if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KB`
}
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
function formatDate(value?: string | null): string {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
}
function prettyJSON(value: unknown): string {
try {
return JSON.stringify(value ?? {}, null, 2)
} catch {
return '{}'
}
}
onMounted(reload)
</script>
<style scoped>
.bug-page {
min-width: 0;
}
.bug-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.bug-title.ops-page-title {
margin-bottom: 8px;
}
.bug-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: #64748b;
font-size: 13px;
font-weight: 600;
}
.bug-summary span {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 10px;
border-radius: 999px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.bug-toolbar {
align-items: stretch;
}
.bug-search {
width: min(420px, 100%);
}
.bug-select {
width: 132px;
}
.bug-title-cell,
.scope-cell,
.runtime-cell,
.dump-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.bug-title-cell strong,
.scope-cell strong,
.runtime-cell strong,
.dump-cell strong {
color: #0f172a;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bug-title-cell span,
.scope-cell span,
.runtime-cell span,
.dump-cell span,
.muted {
color: #64748b;
font-size: 12px;
}
.detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.detail-head h3 {
margin: 0;
color: #0f172a;
font-size: 20px;
}
.detail-head p {
margin: 6px 0 0;
color: #64748b;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.state-form {
padding: 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
margin-bottom: 20px;
background: #f8fafc;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.detail-descriptions {
margin-bottom: 20px;
}
.detail-section {
margin-top: 20px;
}
.detail-section h4 {
margin: 0 0 8px;
color: #0f172a;
}
.detail-text {
margin: 0;
color: #334155;
white-space: pre-wrap;
line-height: 1.7;
}
pre {
max-height: 280px;
overflow: auto;
padding: 12px;
margin: 0;
border-radius: 8px;
background: #0f172a;
color: #e2e8f0;
font-size: 12px;
line-height: 1.6;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
}
</style>
+11
View File
@@ -417,6 +417,17 @@ export interface DesktopRuntimeSessionSyncRequest {
desktop_client: DesktopClientInfo | null 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 { export interface CreatePublishJobAccountRequest {
account_id: string account_id: string
} }
+3
View File
@@ -86,6 +86,7 @@ func main() {
auditsRepo := repository.NewAuditRepository(pool) auditsRepo := repository.NewAuditRepository(pool)
siteDomainMappingsRepo := repository.NewSiteDomainMappingRepository(monitoringPool) siteDomainMappingsRepo := repository.NewSiteDomainMappingRepository(monitoringPool)
desktopClientReleasesRepo := repository.NewDesktopClientReleaseRepository(pool) desktopClientReleasesRepo := repository.NewDesktopClientReleaseRepository(pool)
desktopBugReportsRepo := repository.NewDesktopBugReportRepository(pool)
schedulerRepo := repository.NewSchedulerRepository(pool) schedulerRepo := repository.NewSchedulerRepository(pool)
ipRegionResolver, err := ipregion.NewResolver(cfg.IPRegion.V4XDBPath, cfg.IPRegion.V6XDBPath, logger) ipRegionResolver, err := ipregion.NewResolver(cfg.IPRegion.V4XDBPath, cfg.IPRegion.V6XDBPath, logger)
@@ -123,6 +124,7 @@ func main() {
siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc) siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc)
objectStorageClient := objectstorage.New(cfg.ObjectStorage, logger) objectStorageClient := objectstorage.New(cfg.ObjectStorage, logger)
desktopClientReleaseSvc := app.NewDesktopClientReleaseService(desktopClientReleasesRepo, objectStorageClient, auditSvc) desktopClientReleaseSvc := app.NewDesktopClientReleaseService(desktopClientReleasesRepo, objectStorageClient, auditSvc)
desktopBugReportSvc := app.NewDesktopBugReportService(desktopBugReportsRepo, objectStorageClient, auditSvc)
objectStorageSvc := app.NewObjectStorageService(objectStorageClient, auditSvc).WithLogger(logger) objectStorageSvc := app.NewObjectStorageService(objectStorageClient, auditSvc).WithLogger(logger)
complianceSvc := app.NewComplianceService(pool, auditSvc, logger) complianceSvc := app.NewComplianceService(pool, auditSvc, logger)
schedulerSvc := app.NewSchedulerService(schedulerRepo, auditSvc) schedulerSvc := app.NewSchedulerService(schedulerRepo, auditSvc)
@@ -180,6 +182,7 @@ func main() {
SiteDomains: siteDomainMappingSvc, SiteDomains: siteDomainMappingSvc,
Releases: desktopClientReleaseSvc, Releases: desktopClientReleaseSvc,
ObjectStorage: objectStorageSvc, ObjectStorage: objectStorageSvc,
BugReports: desktopBugReportSvc,
Compliance: complianceSvc, Compliance: complianceSvc,
Scheduler: schedulerSvc, Scheduler: schedulerSvc,
MediaSupply: mediaSupplySvc, MediaSupply: mediaSupplySvc,
@@ -0,0 +1,685 @@
package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/geo-platform/tenant-api/internal/ops/domain"
"github.com/geo-platform/tenant-api/internal/ops/repository"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/geo-platform/tenant-api/internal/shared/response"
tenantrepo "github.com/geo-platform/tenant-api/internal/tenant/repository"
)
const (
ActionDesktopBugReportCreate = "desktop_bug_report.create"
ActionDesktopBugReportUpdate = "desktop_bug_report.update"
)
const (
DesktopBugReportTypeCrash = "crash"
DesktopBugReportTypeBug = "bug"
)
const (
DesktopBugReportStatusNew = "new"
DesktopBugReportStatusTriaged = "triaged"
DesktopBugReportStatusInProgress = "in_progress"
DesktopBugReportStatusResolved = "resolved"
DesktopBugReportStatusIgnored = "ignored"
)
const (
DesktopBugReportSeverityLow = "low"
DesktopBugReportSeverityMedium = "medium"
DesktopBugReportSeverityHigh = "high"
DesktopBugReportSeverityCritical = "critical"
)
const maxDesktopBugDumpBytes = 64 * 1024 * 1024
const maxDesktopBugMultipartBytes = maxDesktopBugDumpBytes + 4*1024*1024
type DesktopBugReportService struct {
reports *repository.DesktopBugReportRepository
storage objectstorage.Client
audits *AuditService
}
func NewDesktopBugReportService(
reports *repository.DesktopBugReportRepository,
storage objectstorage.Client,
audits *AuditService,
) *DesktopBugReportService {
return &DesktopBugReportService{reports: reports, storage: storage, audits: audits}
}
type DesktopBugReportUploadInput struct {
ReportType string
Severity string
Title string
Description string
TenantID *int64
WorkspaceID *int64
UserID *int64
DesktopClientID string
CrashGUID string
CrashReportID string
ProcessType string
ElectronVersion string
AppVersion string
ProductName string
Platform string
OSArch string
DeviceName string
FormFields map[string]string
RuntimeSnapshot []byte
AppLogTail string
DumpFileName string
DumpContent []byte
UploaderIP string
UserAgent string
OccurredAt time.Time
}
type DesktopBugReportListInput struct {
Keyword string
Status string
Severity string
ReportType string
Platform string
Process string
TenantID *int64
ClientID string
Page int
Size int
}
type DesktopBugReportUpdateInput struct {
Status string
Severity string
ResolutionNote *string
}
type DesktopBugReportListResult struct {
Items []DesktopBugReportView `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
type DesktopBugReportUploadResult struct {
ID string `json:"id"`
DumpObjectKey *string `json:"dump_object_key,omitempty"`
}
type DesktopBugReportDownloadURLResult struct {
DownloadURL string `json:"download_url"`
}
type DesktopBugReportView struct {
ID string `json:"id"`
ReportType string `json:"report_type"`
Status string `json:"status"`
Severity string `json:"severity"`
TenantID *int64 `json:"tenant_id,omitempty"`
TenantName *string `json:"tenant_name,omitempty"`
WorkspaceID *int64 `json:"workspace_id,omitempty"`
WorkspaceName *string `json:"workspace_name,omitempty"`
UserID *int64 `json:"user_id,omitempty"`
UserName *string `json:"user_name,omitempty"`
UserPhone *string `json:"user_phone,omitempty"`
UserEmail *string `json:"user_email,omitempty"`
DesktopClientID *string `json:"desktop_client_id,omitempty"`
CrashGUID *string `json:"crash_guid,omitempty"`
CrashReportID *string `json:"crash_report_id,omitempty"`
ProcessType *string `json:"process_type,omitempty"`
ElectronVersion *string `json:"electron_version,omitempty"`
AppVersion *string `json:"app_version,omitempty"`
ProductName *string `json:"product_name,omitempty"`
Platform *string `json:"platform,omitempty"`
OSArch *string `json:"os_arch,omitempty"`
DeviceName *string `json:"device_name,omitempty"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
DumpObjectKey *string `json:"dump_object_key,omitempty"`
DumpFileName *string `json:"dump_file_name,omitempty"`
DumpSizeBytes *int64 `json:"dump_size_bytes,omitempty"`
DumpSHA256 *string `json:"dump_sha256,omitempty"`
FormFields json.RawMessage `json:"form_fields"`
RuntimeSnapshot json.RawMessage `json:"runtime_snapshot"`
AppLogTail *string `json:"app_log_tail,omitempty"`
ResolutionNote *string `json:"resolution_note,omitempty"`
UploaderIP *string `json:"uploader_ip,omitempty"`
UserAgent *string `json:"user_agent,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *DesktopBugReportService) UploadPublic(ctx context.Context, in DesktopBugReportUploadInput) (*DesktopBugReportUploadResult, error) {
in.TenantID = nil
in.WorkspaceID = nil
in.UserID = nil
in.DesktopClientID = ""
in.FormFields = dropPublicDesktopBugIdentityFields(in.FormFields)
return s.Upload(ctx, in)
}
func (s *DesktopBugReportService) Upload(ctx context.Context, in DesktopBugReportUploadInput) (*DesktopBugReportUploadResult, error) {
if s == nil || s.reports == nil {
return nil, response.ErrServiceUnavailable(50361, "desktop_bug_report_unavailable", "desktop bug report service is unavailable")
}
normalized, err := normalizeDesktopBugReportUploadInput(in)
if err != nil {
return nil, err
}
var dumpObjectKey *string
var dumpSHA256 *string
var dumpSize *int64
if len(normalized.DumpContent) > 0 {
if err := s.ensureStorageReady(); err != nil {
return nil, err
}
sum := sha256.Sum256(normalized.DumpContent)
sha256Hex := hex.EncodeToString(sum[:])
size := int64(len(normalized.DumpContent))
objectKey := buildDesktopBugDumpObjectKey(normalized, sha256Hex)
contentType := detectDesktopBugDumpContentType(normalized.DumpFileName, normalized.DumpContent)
if err := s.storage.PutBytes(ctx, objectKey, normalized.DumpContent, contentType); err != nil {
appErr := response.ErrInternal(50061, "desktop_bug_dump_upload_failed", "failed to upload dump to object storage")
appErr.Cause = err
return nil, appErr
}
dumpObjectKey = &objectKey
dumpSHA256 = &sha256Hex
dumpSize = &size
}
formFields, _ := json.Marshal(normalized.FormFields)
report, err := s.reports.Create(ctx, repository.CreateDesktopBugReportInput{
ID: uuid.NewString(),
ReportType: normalized.ReportType,
Status: DesktopBugReportStatusNew,
Severity: normalized.Severity,
TenantID: normalized.TenantID,
WorkspaceID: normalized.WorkspaceID,
UserID: normalized.UserID,
DesktopClientID: optionalUUIDString(normalized.DesktopClientID),
CrashGUID: optionalText(normalized.CrashGUID),
CrashReportID: optionalText(normalized.CrashReportID),
ProcessType: optionalText(normalized.ProcessType),
ElectronVersion: optionalText(normalized.ElectronVersion),
AppVersion: optionalText(normalized.AppVersion),
ProductName: optionalText(normalized.ProductName),
Platform: optionalText(normalized.Platform),
OSArch: optionalText(normalized.OSArch),
DeviceName: optionalText(normalized.DeviceName),
Title: normalized.Title,
Description: optionalText(normalized.Description),
DumpObjectKey: dumpObjectKey,
DumpFileName: optionalText(normalized.DumpFileName),
DumpSizeBytes: dumpSize,
DumpSHA256: dumpSHA256,
FormFields: formFields,
RuntimeSnapshot: normalized.RuntimeSnapshot,
AppLogTail: optionalText(normalized.AppLogTail),
UploaderIP: optionalText(normalized.UploaderIP),
UserAgent: optionalText(normalized.UserAgent),
OccurredAt: normalized.OccurredAt,
})
if err != nil {
return nil, err
}
return &DesktopBugReportUploadResult{ID: report.ID, DumpObjectKey: report.DumpObjectKey}, nil
}
func (s *DesktopBugReportService) UploadForClient(
ctx context.Context,
client *tenantrepo.DesktopClient,
in DesktopBugReportUploadInput,
) (*DesktopBugReportUploadResult, error) {
if client == nil {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
tenantID := client.TenantID
workspaceID := client.WorkspaceID
userID := client.UserID
in.TenantID = &tenantID
in.WorkspaceID = &workspaceID
in.UserID = &userID
in.DesktopClientID = client.ID.String()
if strings.TrimSpace(in.DeviceName) == "" && client.DeviceName != nil {
in.DeviceName = *client.DeviceName
}
if strings.TrimSpace(in.Platform) == "" && client.OS != nil {
in.Platform = *client.OS
}
if strings.TrimSpace(in.OSArch) == "" && client.CPUArch != nil {
in.OSArch = *client.CPUArch
}
if strings.TrimSpace(in.AppVersion) == "" && client.ClientVersion != nil {
in.AppVersion = *client.ClientVersion
}
return s.Upload(ctx, in)
}
func (s *DesktopBugReportService) List(ctx context.Context, in DesktopBugReportListInput) (*DesktopBugReportListResult, error) {
page := in.Page
if page <= 0 {
page = 1
}
size := in.Size
if size <= 0 || size > 200 {
size = 20
}
items, total, err := s.reports.List(ctx, repository.DesktopBugReportFilter{
Keyword: strings.TrimSpace(in.Keyword),
Status: normalizeDesktopBugStatus(in.Status),
Severity: normalizeDesktopBugSeverity(in.Severity),
ReportType: normalizeDesktopBugReportType(in.ReportType),
Platform: normalizeDesktopBugToken(in.Platform),
Process: normalizeDesktopBugToken(in.Process),
TenantID: in.TenantID,
ClientID: normalizeUUIDString(in.ClientID),
Limit: size,
Offset: (page - 1) * size,
})
if err != nil {
return nil, err
}
views := make([]DesktopBugReportView, 0, len(items))
for i := range items {
views = append(views, desktopBugReportView(&items[i]))
}
return &DesktopBugReportListResult{Items: views, Total: total, Page: page, Size: size}, nil
}
func (s *DesktopBugReportService) Get(ctx context.Context, id string) (*DesktopBugReportView, error) {
report, err := s.reports.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrDesktopBugReportNotFound) {
return nil, response.ErrNotFound(40461, "desktop_bug_report_not_found", "客户端 Bug 记录不存在")
}
return nil, err
}
view := desktopBugReportView(report)
return &view, nil
}
func (s *DesktopBugReportService) Update(ctx context.Context, actor *Actor, id string, in DesktopBugReportUpdateInput) (*DesktopBugReportView, error) {
status := normalizeDesktopBugStatus(in.Status)
if !isAllowedDesktopBugStatus(status) {
return nil, response.ErrBadRequest(40061, "invalid_desktop_bug_status", "状态必须是 new、triaged、in_progress、resolved 或 ignored")
}
severity := normalizeDesktopBugSeverity(in.Severity)
if !isAllowedDesktopBugSeverity(severity) {
return nil, response.ErrBadRequest(40062, "invalid_desktop_bug_severity", "严重级别必须是 low、medium、high 或 critical")
}
report, err := s.reports.UpdateState(ctx, id, repository.UpdateDesktopBugReportStateInput{
Status: status,
Severity: severity,
ResolutionNote: trimOptionalDesktopBugString(in.ResolutionNote, 4000),
})
if err != nil {
if errors.Is(err, repository.ErrDesktopBugReportNotFound) {
return nil, response.ErrNotFound(40461, "desktop_bug_report_not_found", "客户端 Bug 记录不存在")
}
return nil, err
}
s.audit(ctx, actor, ActionDesktopBugReportUpdate, report)
view := desktopBugReportView(report)
return &view, nil
}
func (s *DesktopBugReportService) ResolveDumpDownloadURL(ctx context.Context, id string) (*DesktopBugReportDownloadURLResult, error) {
report, err := s.reports.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrDesktopBugReportNotFound) {
return nil, response.ErrNotFound(40461, "desktop_bug_report_not_found", "客户端 Bug 记录不存在")
}
return nil, err
}
if report.DumpObjectKey == nil || strings.TrimSpace(*report.DumpObjectKey) == "" {
return nil, response.ErrNotFound(40462, "desktop_bug_dump_not_found", "该记录没有 dump 文件")
}
if err := s.ensureStorageReady(); err != nil {
return nil, err
}
downloadURL, err := s.storage.DownloadURL(*report.DumpObjectKey)
if err != nil {
appErr := response.ErrInternal(50062, "desktop_bug_dump_url_failed", "failed to generate dump download URL")
appErr.Cause = err
return nil, appErr
}
return &DesktopBugReportDownloadURLResult{DownloadURL: downloadURL}, nil
}
func (s *DesktopBugReportService) ensureStorageReady() error {
if s == nil || s.storage == nil {
return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
}
if err := s.storage.Validate(); err != nil {
return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
}
return nil
}
func normalizeDesktopBugReportUploadInput(in DesktopBugReportUploadInput) (DesktopBugReportUploadInput, error) {
in.ReportType = normalizeDesktopBugReportType(in.ReportType)
if in.ReportType == "" {
in.ReportType = DesktopBugReportTypeCrash
}
if !isAllowedDesktopBugReportType(in.ReportType) {
return DesktopBugReportUploadInput{}, response.ErrBadRequest(40063, "invalid_desktop_bug_report_type", "上报类型必须是 crash 或 bug")
}
in.Severity = normalizeDesktopBugSeverity(in.Severity)
if in.Severity == "" {
if in.ReportType == DesktopBugReportTypeCrash {
in.Severity = DesktopBugReportSeverityHigh
} else {
in.Severity = DesktopBugReportSeverityMedium
}
}
if !isAllowedDesktopBugSeverity(in.Severity) {
return DesktopBugReportUploadInput{}, response.ErrBadRequest(40062, "invalid_desktop_bug_severity", "严重级别必须是 low、medium、high 或 critical")
}
in.Title = limitDesktopBugText(firstNonEmptyString(
in.Title,
in.FormFields["_title"],
in.FormFields["title"],
defaultDesktopBugTitle(in),
), 200)
in.Description = limitDesktopBugText(firstNonEmptyString(in.Description, in.FormFields["_description"], in.FormFields["description"]), 4000)
in.CrashGUID = limitDesktopBugText(firstNonEmptyString(in.CrashGUID, in.FormFields["guid"]), 200)
in.CrashReportID = limitDesktopBugText(firstNonEmptyString(in.CrashReportID, in.FormFields["crash_report_id"], in.FormFields["id"]), 200)
in.ProcessType = limitDesktopBugText(firstNonEmptyString(in.ProcessType, in.FormFields["process_type"], in.FormFields["ptype"]), 80)
in.ElectronVersion = limitDesktopBugText(firstNonEmptyString(in.ElectronVersion, in.FormFields["ver"]), 80)
in.AppVersion = limitDesktopBugText(firstNonEmptyString(in.AppVersion, in.FormFields["app_version"], in.FormFields["_version"]), 80)
in.ProductName = limitDesktopBugText(firstNonEmptyString(in.ProductName, in.FormFields["_productName"], in.FormFields["prod"]), 120)
in.Platform = limitDesktopBugText(firstNonEmptyString(in.Platform, in.FormFields["platform"]), 80)
in.OSArch = limitDesktopBugText(firstNonEmptyString(in.OSArch, in.FormFields["osarch"], in.FormFields["cpu_arch"]), 80)
in.DeviceName = limitDesktopBugText(firstNonEmptyString(in.DeviceName, in.FormFields["device_name"]), 200)
in.DesktopClientID = normalizeUUIDString(firstNonEmptyString(in.DesktopClientID, in.FormFields["client_id"]))
if len(in.DumpContent) > maxDesktopBugDumpBytes {
return DesktopBugReportUploadInput{}, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
in.DumpFileName = sanitizeDesktopBugFileName(in.DumpFileName)
if len(in.DumpContent) > 0 && in.DumpFileName == "" {
in.DumpFileName = "upload_file_minidump.dmp"
}
in.AppLogTail = limitDesktopBugText(in.AppLogTail, 20000)
if len(in.RuntimeSnapshot) == 0 || !json.Valid(in.RuntimeSnapshot) {
in.RuntimeSnapshot = []byte("{}")
}
in.FormFields = sanitizeDesktopBugFormFields(in.FormFields)
if in.OccurredAt.IsZero() {
in.OccurredAt = time.Now().UTC()
}
return in, nil
}
func defaultDesktopBugTitle(in DesktopBugReportUploadInput) string {
if in.ReportType == DesktopBugReportTypeBug {
return "用户反馈"
}
process := firstNonEmptyString(in.ProcessType, in.FormFields["process_type"], in.FormFields["ptype"], "unknown")
platform := firstNonEmptyString(in.Platform, in.FormFields["platform"], "unknown")
return fmt.Sprintf("客户端崩溃:%s / %s", process, platform)
}
func sanitizeDesktopBugFormFields(fields map[string]string) map[string]string {
out := make(map[string]string)
for key, value := range fields {
normalizedKey := strings.TrimSpace(key)
if normalizedKey == "" || len(normalizedKey) > 120 {
continue
}
out[normalizedKey] = limitDesktopBugText(value, 2000)
}
return out
}
func dropPublicDesktopBugIdentityFields(fields map[string]string) map[string]string {
if len(fields) == 0 {
return fields
}
out := make(map[string]string, len(fields))
for key, value := range fields {
switch strings.ToLower(strings.TrimSpace(key)) {
case "tenant_id", "_tenant_id", "workspace_id", "_workspace_id", "user_id", "_user_id",
"client_id", "_client_id", "desktop_client_id", "_desktop_client_id":
continue
default:
out[key] = value
}
}
return out
}
func buildDesktopBugDumpObjectKey(in DesktopBugReportUploadInput, sha256Hex string) string {
datePrefix := time.Now().UTC().Format("2006/01/02")
reportKind := in.ReportType
if reportKind == "" {
reportKind = DesktopBugReportTypeCrash
}
clientID := normalizeUUIDString(in.DesktopClientID)
if clientID == "" {
clientID = "anonymous"
}
ext := strings.ToLower(filepath.Ext(in.DumpFileName))
if ext == "" || len(ext) > 10 {
ext = ".dmp"
}
return fmt.Sprintf("desktop-bug-reports/%s/%s/%s/%s%s", reportKind, datePrefix, clientID, sha256Hex, ext)
}
func detectDesktopBugDumpContentType(fileName string, content []byte) string {
lower := strings.ToLower(fileName)
switch {
case strings.HasSuffix(lower, ".dmp"):
return "application/vnd.microsoft.portable-executable"
case strings.HasSuffix(lower, ".gz"):
return "application/gzip"
case strings.HasSuffix(lower, ".zip"):
return "application/zip"
default:
if len(content) > 0 {
return http.DetectContentType(content)
}
return "application/octet-stream"
}
}
func desktopBugReportView(item *domain.DesktopBugReport) DesktopBugReportView {
return DesktopBugReportView{
ID: item.ID,
ReportType: item.ReportType,
Status: item.Status,
Severity: item.Severity,
TenantID: item.TenantID,
TenantName: item.TenantName,
WorkspaceID: item.WorkspaceID,
WorkspaceName: item.WorkspaceName,
UserID: item.UserID,
UserName: item.UserName,
UserPhone: item.UserPhone,
UserEmail: item.UserEmail,
DesktopClientID: item.DesktopClientID,
CrashGUID: item.CrashGUID,
CrashReportID: item.CrashReportID,
ProcessType: item.ProcessType,
ElectronVersion: item.ElectronVersion,
AppVersion: item.AppVersion,
ProductName: item.ProductName,
Platform: item.Platform,
OSArch: item.OSArch,
DeviceName: item.DeviceName,
Title: item.Title,
Description: item.Description,
DumpObjectKey: item.DumpObjectKey,
DumpFileName: item.DumpFileName,
DumpSizeBytes: item.DumpSizeBytes,
DumpSHA256: item.DumpSHA256,
FormFields: item.FormFields,
RuntimeSnapshot: item.RuntimeSnapshot,
AppLogTail: item.AppLogTail,
ResolutionNote: item.ResolutionNote,
UploaderIP: item.UploaderIP,
UserAgent: item.UserAgent,
OccurredAt: item.OccurredAt,
ResolvedAt: item.ResolvedAt,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
func (s *DesktopBugReportService) audit(ctx context.Context, actor *Actor, action string, item *domain.DesktopBugReport) {
if actor == nil || s.audits == nil || item == nil {
return
}
event := actor.audit(action, "desktop_bug_report", 0, map[string]any{
"id": item.ID,
"report_type": item.ReportType,
"status": item.Status,
"severity": item.Severity,
"dump_object_key": item.DumpObjectKey,
})
event.TargetID = item.ID
_ = s.audits.Append(ctx, event)
}
func normalizeDesktopBugReportType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeDesktopBugStatus(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeDesktopBugSeverity(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeDesktopBugToken(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func isAllowedDesktopBugReportType(value string) bool {
return value == DesktopBugReportTypeCrash || value == DesktopBugReportTypeBug
}
func isAllowedDesktopBugStatus(value string) bool {
switch value {
case DesktopBugReportStatusNew, DesktopBugReportStatusTriaged, DesktopBugReportStatusInProgress, DesktopBugReportStatusResolved, DesktopBugReportStatusIgnored:
return true
default:
return false
}
}
func isAllowedDesktopBugSeverity(value string) bool {
switch value {
case DesktopBugReportSeverityLow, DesktopBugReportSeverityMedium, DesktopBugReportSeverityHigh, DesktopBugReportSeverityCritical:
return true
default:
return false
}
}
func optionalText(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
func optionalUUIDString(value string) *string {
value = normalizeUUIDString(value)
if value == "" {
return nil
}
return &value
}
func normalizeUUIDString(value string) string {
value = strings.TrimSpace(value)
if _, err := uuid.Parse(value); err != nil {
return ""
}
return value
}
func limitDesktopBugText(value string, limit int) string {
value = strings.TrimSpace(value)
if value == "" || limit <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit])
}
func trimOptionalDesktopBugString(value *string, limit int) *string {
if value == nil {
return nil
}
trimmed := limitDesktopBugText(*value, limit)
if trimmed == "" {
return nil
}
return &trimmed
}
func sanitizeDesktopBugFileName(value string) string {
value = strings.TrimSpace(filepath.Base(value))
if value == "" || value == "." || value == "/" || value == "\\" {
return ""
}
value = strings.Map(func(r rune) rune {
if r == '\\' || r == '/' || r == '\r' || r == '\n' {
return -1
}
return r
}, value)
return limitDesktopBugText(value, 200)
}
var desktopBugISODatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T`)
func ParseDesktopBugOccurredAt(value string) time.Time {
value = strings.TrimSpace(value)
if value == "" || !desktopBugISODatePattern.MatchString(value) {
return time.Time{}
}
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return time.Time{}
}
return parsed.UTC()
}
func MaxDesktopBugMultipartBytes() int64 {
return maxDesktopBugMultipartBytes
}
func MaxDesktopBugDumpBytes() int64 {
return maxDesktopBugDumpBytes
}
@@ -0,0 +1,42 @@
package app
import "testing"
func TestDropPublicDesktopBugIdentityFields(t *testing.T) {
fields := map[string]string{
"tenant_id": "1",
"_tenant_id": "2",
"workspace_id": "3",
"_workspace_id": "4",
"user_id": "5",
"_user_id": "6",
"client_id": "spoofed-client",
"_client_id": "spoofed-electron-client",
"desktop_client_id": "spoofed-desktop-client",
"_desktop_client_id": "spoofed-electron-desktop-client",
"title": "renderer crash",
"guid": "crash-guid",
}
sanitized := dropPublicDesktopBugIdentityFields(fields)
for _, key := range []string{
"tenant_id",
"_tenant_id",
"workspace_id",
"_workspace_id",
"user_id",
"_user_id",
"client_id",
"_client_id",
"desktop_client_id",
"_desktop_client_id",
} {
if _, ok := sanitized[key]; ok {
t.Fatalf("sanitized fields retained spoofable identity key %q", key)
}
}
if sanitized["title"] != "renderer crash" || sanitized["guid"] != "crash-guid" {
t.Fatalf("sanitized fields dropped non-identity metadata: %#v", sanitized)
}
}
+45 -1
View File
@@ -1,6 +1,9 @@
package domain package domain
import "time" import (
"encoding/json"
"time"
)
const ( const (
StatusActive = "active" StatusActive = "active"
@@ -102,3 +105,44 @@ type DesktopClientRelease struct {
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
type DesktopBugReport struct {
ID string
ReportType string
Status string
Severity string
TenantID *int64
TenantName *string
WorkspaceID *int64
WorkspaceName *string
UserID *int64
UserName *string
UserPhone *string
UserEmail *string
DesktopClientID *string
CrashGUID *string
CrashReportID *string
ProcessType *string
ElectronVersion *string
AppVersion *string
ProductName *string
Platform *string
OSArch *string
DeviceName *string
Title string
Description *string
DumpObjectKey *string
DumpFileName *string
DumpSizeBytes *int64
DumpSHA256 *string
FormFields json.RawMessage
RuntimeSnapshot json.RawMessage
AppLogTail *string
ResolutionNote *string
UploaderIP *string
UserAgent *string
OccurredAt time.Time
ResolvedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -0,0 +1,367 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/ops/domain"
)
var ErrDesktopBugReportNotFound = errors.New("desktop bug report not found")
type DesktopBugReportRepository struct {
pool *pgxpool.Pool
}
func NewDesktopBugReportRepository(pool *pgxpool.Pool) *DesktopBugReportRepository {
return &DesktopBugReportRepository{pool: pool}
}
type CreateDesktopBugReportInput struct {
ID string
ReportType string
Status string
Severity string
TenantID *int64
WorkspaceID *int64
UserID *int64
DesktopClientID *string
CrashGUID *string
CrashReportID *string
ProcessType *string
ElectronVersion *string
AppVersion *string
ProductName *string
Platform *string
OSArch *string
DeviceName *string
Title string
Description *string
DumpObjectKey *string
DumpFileName *string
DumpSizeBytes *int64
DumpSHA256 *string
FormFields []byte
RuntimeSnapshot []byte
AppLogTail *string
UploaderIP *string
UserAgent *string
OccurredAt time.Time
}
type DesktopBugReportFilter struct {
Keyword string
Status string
Severity string
ReportType string
Platform string
Process string
TenantID *int64
ClientID string
Limit int
Offset int
}
type UpdateDesktopBugReportStateInput struct {
Status string
Severity string
ResolutionNote *string
}
const desktopBugReportSelectColumns = `
br.id::text,
br.report_type,
br.status,
br.severity,
br.tenant_id,
t.name,
br.workspace_id,
w.name,
br.user_id,
u.name,
u.phone,
u.email,
br.desktop_client_id::text,
br.crash_guid,
br.crash_report_id,
br.process_type,
br.electron_version,
br.app_version,
br.product_name,
br.platform,
br.os_arch,
br.device_name,
br.title,
br.description,
br.dump_object_key,
br.dump_file_name,
br.dump_size_bytes,
br.dump_sha256,
br.form_fields,
br.runtime_snapshot,
br.app_log_tail,
br.resolution_note,
br.uploader_ip,
br.user_agent,
br.occurred_at,
br.resolved_at,
br.created_at,
br.updated_at`
const desktopBugReportFromClause = `
FROM desktop_bug_reports br
LEFT JOIN tenants t ON t.id = br.tenant_id
LEFT JOIN workspaces w ON w.id = br.workspace_id
LEFT JOIN users u ON u.id = br.user_id`
func (r *DesktopBugReportRepository) Create(ctx context.Context, in CreateDesktopBugReportInput) (*domain.DesktopBugReport, error) {
var id string
if err := r.pool.QueryRow(ctx, `
INSERT INTO desktop_bug_reports (
id, report_type, status, severity, tenant_id, workspace_id, user_id,
desktop_client_id, crash_guid, crash_report_id, process_type, electron_version,
app_version, product_name, platform, os_arch, device_name, title, description,
dump_object_key, dump_file_name, dump_size_bytes, dump_sha256, form_fields,
runtime_snapshot, app_log_tail, uploader_ip, user_agent, occurred_at
)
VALUES (
$1::uuid, $2, $3, $4, $5, $6, $7,
$8::uuid, $9, $10, $11, $12,
$13, $14, $15, $16, $17, $18, $19,
$20, $21, $22, $23, $24::jsonb,
$25::jsonb, $26, $27, $28, $29
)
RETURNING id::text`,
in.ID, in.ReportType, in.Status, in.Severity, in.TenantID, in.WorkspaceID, in.UserID,
in.DesktopClientID, in.CrashGUID, in.CrashReportID, in.ProcessType, in.ElectronVersion,
in.AppVersion, in.ProductName, in.Platform, in.OSArch, in.DeviceName, in.Title, in.Description,
in.DumpObjectKey, in.DumpFileName, in.DumpSizeBytes, in.DumpSHA256, string(defaultJSON(in.FormFields)),
string(defaultJSON(in.RuntimeSnapshot)), in.AppLogTail, in.UploaderIP, in.UserAgent, in.OccurredAt).Scan(&id); err != nil {
return nil, err
}
return r.GetByID(ctx, id)
}
func (r *DesktopBugReportRepository) List(ctx context.Context, f DesktopBugReportFilter) ([]domain.DesktopBugReport, int64, error) {
args := []any{}
where := "1=1"
if f.Keyword != "" {
args = append(args, "%"+f.Keyword+"%")
where += fmt.Sprintf(` AND (
br.id::text ILIKE $%d OR
COALESCE(br.title, '') ILIKE $%d OR
COALESCE(br.description, '') ILIKE $%d OR
COALESCE(br.crash_guid, '') ILIKE $%d OR
COALESCE(br.crash_report_id, '') ILIKE $%d OR
COALESCE(br.dump_object_key, '') ILIKE $%d OR
COALESCE(br.device_name, '') ILIKE $%d OR
COALESCE(t.name, '') ILIKE $%d OR
COALESCE(w.name, '') ILIKE $%d OR
COALESCE(u.name, '') ILIKE $%d OR
COALESCE(u.phone, '') ILIKE $%d OR
COALESCE(u.email, '') ILIKE $%d
)`, len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args))
}
if f.Status != "" {
args = append(args, f.Status)
where += fmt.Sprintf(" AND br.status = $%d", len(args))
}
if f.Severity != "" {
args = append(args, f.Severity)
where += fmt.Sprintf(" AND br.severity = $%d", len(args))
}
if f.ReportType != "" {
args = append(args, f.ReportType)
where += fmt.Sprintf(" AND br.report_type = $%d", len(args))
}
if f.Platform != "" {
args = append(args, f.Platform)
where += fmt.Sprintf(" AND br.platform = $%d", len(args))
}
if f.Process != "" {
args = append(args, f.Process)
where += fmt.Sprintf(" AND br.process_type = $%d", len(args))
}
if f.TenantID != nil {
args = append(args, *f.TenantID)
where += fmt.Sprintf(" AND br.tenant_id = $%d", len(args))
}
if f.ClientID != "" {
args = append(args, f.ClientID)
where += fmt.Sprintf(" AND br.desktop_client_id = $%d::uuid", len(args))
}
var total int64
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) "+desktopBugReportFromClause+" WHERE "+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
limit := f.Limit
if limit <= 0 || limit > 200 {
limit = 20
}
offset := f.Offset
if offset < 0 {
offset = 0
}
args = append(args, limit, offset)
limitArg := len(args) - 1
offsetArg := len(args)
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
SELECT %s
%s
WHERE %s
ORDER BY br.created_at DESC, br.id DESC
LIMIT $%d OFFSET $%d`, desktopBugReportSelectColumns, desktopBugReportFromClause, where, limitArg, offsetArg), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]domain.DesktopBugReport, 0, limit)
for rows.Next() {
item, scanErr := scanDesktopBugReport(rows)
if scanErr != nil {
return nil, 0, scanErr
}
items = append(items, *item)
}
return items, total, rows.Err()
}
func (r *DesktopBugReportRepository) GetByID(ctx context.Context, id string) (*domain.DesktopBugReport, error) {
return scanDesktopBugReport(r.pool.QueryRow(ctx, `
SELECT `+desktopBugReportSelectColumns+`
`+desktopBugReportFromClause+`
WHERE br.id = $1::uuid`, id))
}
func (r *DesktopBugReportRepository) UpdateState(ctx context.Context, id string, in UpdateDesktopBugReportStateInput) (*domain.DesktopBugReport, error) {
var updatedID string
if err := r.pool.QueryRow(ctx, `
UPDATE desktop_bug_reports br
SET status = $2,
severity = $3,
resolution_note = $4,
resolved_at = CASE
WHEN $2 IN ('resolved', 'ignored') AND resolved_at IS NULL THEN NOW()
WHEN $2 NOT IN ('resolved', 'ignored') THEN NULL
ELSE resolved_at
END,
updated_at = NOW()
WHERE br.id = $1::uuid
RETURNING br.id::text`,
id, in.Status, in.Severity, in.ResolutionNote).Scan(&updatedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrDesktopBugReportNotFound
}
return nil, err
}
return r.GetByID(ctx, updatedID)
}
func scanDesktopBugReport(row pgx.Row) (*domain.DesktopBugReport, error) {
var item domain.DesktopBugReport
var tenantID, workspaceID, userID, dumpSize pgtype.Int8
var tenantName, workspaceName, userName, userPhone, userEmail pgtype.Text
var desktopClientID, crashGUID, crashReportID, processType, electronVersion, appVersion pgtype.Text
var productName, platform, osArch, deviceName, description, dumpObjectKey, dumpFileName pgtype.Text
var dumpSHA256, appLogTail, resolutionNote, uploaderIP, userAgent pgtype.Text
var formFields, runtimeSnapshot []byte
var resolvedAt pgtype.Timestamptz
err := row.Scan(
&item.ID,
&item.ReportType,
&item.Status,
&item.Severity,
&tenantID,
&tenantName,
&workspaceID,
&workspaceName,
&userID,
&userName,
&userPhone,
&userEmail,
&desktopClientID,
&crashGUID,
&crashReportID,
&processType,
&electronVersion,
&appVersion,
&productName,
&platform,
&osArch,
&deviceName,
&item.Title,
&description,
&dumpObjectKey,
&dumpFileName,
&dumpSize,
&dumpSHA256,
&formFields,
&runtimeSnapshot,
&appLogTail,
&resolutionNote,
&uploaderIP,
&userAgent,
&item.OccurredAt,
&resolvedAt,
&item.CreatedAt,
&item.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrDesktopBugReportNotFound
}
return nil, err
}
item.TenantID = int64PtrFromPg(tenantID)
item.TenantName = stringPtrFromPg(tenantName)
item.WorkspaceID = int64PtrFromPg(workspaceID)
item.WorkspaceName = stringPtrFromPg(workspaceName)
item.UserID = int64PtrFromPg(userID)
item.UserName = stringPtrFromPg(userName)
item.UserPhone = stringPtrFromPg(userPhone)
item.UserEmail = stringPtrFromPg(userEmail)
item.DesktopClientID = stringPtrFromPg(desktopClientID)
item.CrashGUID = stringPtrFromPg(crashGUID)
item.CrashReportID = stringPtrFromPg(crashReportID)
item.ProcessType = stringPtrFromPg(processType)
item.ElectronVersion = stringPtrFromPg(electronVersion)
item.AppVersion = stringPtrFromPg(appVersion)
item.ProductName = stringPtrFromPg(productName)
item.Platform = stringPtrFromPg(platform)
item.OSArch = stringPtrFromPg(osArch)
item.DeviceName = stringPtrFromPg(deviceName)
item.Description = stringPtrFromPg(description)
item.DumpObjectKey = stringPtrFromPg(dumpObjectKey)
item.DumpFileName = stringPtrFromPg(dumpFileName)
item.DumpSizeBytes = int64PtrFromPg(dumpSize)
item.DumpSHA256 = stringPtrFromPg(dumpSHA256)
item.FormFields = json.RawMessage(defaultJSON(formFields))
item.RuntimeSnapshot = json.RawMessage(defaultJSON(runtimeSnapshot))
item.AppLogTail = stringPtrFromPg(appLogTail)
item.ResolutionNote = stringPtrFromPg(resolutionNote)
item.UploaderIP = stringPtrFromPg(uploaderIP)
item.UserAgent = stringPtrFromPg(userAgent)
item.ResolvedAt = timePtrFromPg(resolvedAt)
return &item, nil
}
func defaultJSON(value []byte) []byte {
if len(value) == 0 || !json.Valid(value) {
return []byte("{}")
}
return value
}
@@ -0,0 +1,245 @@
package transport
import (
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/ops/app"
"github.com/geo-platform/tenant-api/internal/shared/response"
tenantapp "github.com/geo-platform/tenant-api/internal/tenant/app"
tenantrepo "github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type updateDesktopBugReportRequest struct {
Status string `json:"status" binding:"required"`
Severity string `json:"severity" binding:"required"`
ResolutionNote *string `json:"resolution_note"`
}
func uploadDesktopBugReportHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
input, err := parseDesktopBugReportMultipart(c)
if err != nil {
response.Error(c, err)
return
}
result, err := svc.UploadPublic(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func uploadDesktopBugReportForClientHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
input, err := parseDesktopBugReportMultipart(c)
if err != nil {
response.Error(c, err)
return
}
client, _ := c.Request.Context().Value(desktopBugReportClientContextKey{}).(*tenantrepo.DesktopClient)
result, err := svc.UploadForClient(c.Request.Context(), client, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func desktopBugReportClientMiddleware(repo tenantrepo.DesktopClientRepository) gin.HandlerFunc {
return func(c *gin.Context) {
token, err := desktopBugReportBearerClientToken(c.GetHeader("Authorization"))
if err != nil {
response.Error(c, err)
c.Abort()
return
}
client, err := repo.GetByTokenHash(c.Request.Context(), tenantapp.HashDesktopClientToken(token))
if err != nil {
response.Error(c, response.ErrUnauthorized(40131, "invalid_client_token", "desktop client token is invalid or revoked"))
c.Abort()
return
}
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), desktopBugReportClientContextKey{}, client))
c.Next()
}
}
func listDesktopBugReportsHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
result, err := svc.List(c.Request.Context(), app.DesktopBugReportListInput{
Keyword: c.Query("keyword"),
Status: c.Query("status"),
Severity: c.Query("severity"),
ReportType: c.Query("report_type"),
Platform: c.Query("platform"),
Process: c.Query("process_type"),
TenantID: parseOptionalInt64Query(c.Query("tenant_id")),
ClientID: c.Query("client_id"),
Page: page,
Size: size,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func getDesktopBugReportHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
result, err := svc.Get(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func updateDesktopBugReportHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
var body updateDesktopBugReportRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
result, err := svc.Update(c.Request.Context(), actorFromGin(c), c.Param("id"), app.DesktopBugReportUpdateInput{
Status: body.Status,
Severity: body.Severity,
ResolutionNote: body.ResolutionNote,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func resolveDesktopBugReportDumpURLHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
result, err := svc.ResolveDumpDownloadURL(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func parseDesktopBugReportMultipart(c *gin.Context) (app.DesktopBugReportUploadInput, error) {
if c.Request.ContentLength > app.MaxDesktopBugMultipartBytes() {
return app.DesktopBugReportUploadInput{}, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, app.MaxDesktopBugMultipartBytes())
if err := c.Request.ParseMultipartForm(app.MaxDesktopBugMultipartBytes()); err != nil {
if strings.Contains(err.Error(), "request body too large") {
return app.DesktopBugReportUploadInput{}, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
return app.DesktopBugReportUploadInput{}, response.ErrBadRequest(40065, "invalid_desktop_bug_report", "客户端 Bug 上报表单无效")
}
fields := map[string]string{}
if c.Request.MultipartForm != nil {
for key, values := range c.Request.MultipartForm.Value {
if len(values) == 0 {
continue
}
fields[key] = values[0]
}
}
dumpName, dumpContent, err := readDesktopBugDump(c)
if err != nil {
return app.DesktopBugReportUploadInput{}, err
}
runtimeSnapshot := []byte(firstNonEmpty(fields["runtime_snapshot"], fields["_runtime_snapshot"]))
if len(runtimeSnapshot) > 0 && !json.Valid(runtimeSnapshot) {
runtimeSnapshot = []byte("{}")
}
tenantID := parseOptionalInt64Query(firstNonEmpty(fields["tenant_id"], fields["_tenant_id"]))
workspaceID := parseOptionalInt64Query(firstNonEmpty(fields["workspace_id"], fields["_workspace_id"]))
userID := parseOptionalInt64Query(firstNonEmpty(fields["user_id"], fields["_user_id"]))
return app.DesktopBugReportUploadInput{
ReportType: firstNonEmpty(fields["report_type"], fields["_report_type"]),
Severity: firstNonEmpty(fields["severity"], fields["_severity"]),
Title: firstNonEmpty(fields["title"], fields["_title"]),
Description: firstNonEmpty(fields["description"], fields["_description"]),
TenantID: tenantID,
WorkspaceID: workspaceID,
UserID: userID,
DesktopClientID: firstNonEmpty(fields["client_id"], fields["_client_id"]),
CrashGUID: fields["guid"],
CrashReportID: firstNonEmpty(fields["crash_report_id"], fields["id"]),
ProcessType: firstNonEmpty(fields["process_type"], fields["ptype"]),
ElectronVersion: fields["ver"],
AppVersion: firstNonEmpty(fields["app_version"], fields["_version"]),
ProductName: firstNonEmpty(fields["_productName"], fields["prod"]),
Platform: fields["platform"],
OSArch: firstNonEmpty(fields["osarch"], fields["cpu_arch"]),
DeviceName: fields["device_name"],
FormFields: fields,
RuntimeSnapshot: runtimeSnapshot,
AppLogTail: firstNonEmpty(fields["app_log_tail"], fields["_app_log_tail"]),
DumpFileName: dumpName,
DumpContent: dumpContent,
UploaderIP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
OccurredAt: app.ParseDesktopBugOccurredAt(firstNonEmpty(fields["occurred_at"], fields["_occurred_at"])),
}, nil
}
func readDesktopBugDump(c *gin.Context) (string, []byte, error) {
for _, field := range []string{"upload_file_minidump", "dump", "file"} {
fileHeader, err := c.FormFile(field)
if err != nil {
continue
}
file, err := fileHeader.Open()
if err != nil {
return "", nil, response.ErrBadRequest(40066, "desktop_bug_dump_open_failed", "dump 文件读取失败")
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, app.MaxDesktopBugDumpBytes()+1))
if err != nil {
return "", nil, response.ErrBadRequest(40066, "desktop_bug_dump_read_failed", "dump 文件读取失败")
}
if int64(len(content)) > app.MaxDesktopBugDumpBytes() {
return "", nil, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
return fileHeader.Filename, content, nil
}
return "", nil, nil
}
func parseOptionalInt64Query(value string) *int64 {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil || parsed <= 0 {
return nil
}
return &parsed
}
type desktopBugReportClientContextKey struct{}
func desktopBugReportBearerClientToken(header string) (string, error) {
parts := strings.SplitN(strings.TrimSpace(header), " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || strings.TrimSpace(parts[1]) == "" {
return "", response.ErrUnauthorized(40130, "missing_client_token", "desktop client token is required")
}
return strings.TrimSpace(parts[1]), nil
}
+14
View File
@@ -1,6 +1,8 @@
package transport package transport
import ( import (
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap" "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/middleware"
"github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/shared/swagger" "github.com/geo-platform/tenant-api/internal/shared/swagger"
tenantrepo "github.com/geo-platform/tenant-api/internal/tenant/repository"
) )
type Deps struct { type Deps struct {
@@ -28,6 +31,7 @@ type Deps struct {
SiteDomains *app.SiteDomainMappingService SiteDomains *app.SiteDomainMappingService
Releases *app.DesktopClientReleaseService Releases *app.DesktopClientReleaseService
ObjectStorage *app.ObjectStorageService ObjectStorage *app.ObjectStorageService
BugReports *app.DesktopBugReportService
Compliance *app.ComplianceService Compliance *app.ComplianceService
Scheduler *app.SchedulerService Scheduler *app.SchedulerService
MediaSupply *app.MediaSupplyService 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/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/:feed", desktopClientReleaseUpdaterFeedHandler(d.Releases))
d.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version", 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") 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.GET("/desktop-client/releases/:id/download-url", resolveDesktopClientReleaseDownloadURLHandler(d.Releases))
authed.POST("/desktop-client/releases/:id/enabled", setDesktopClientReleaseEnabledHandler(d.Releases)) authed.POST("/desktop-client/releases/:id/enabled", setDesktopClientReleaseEnabledHandler(d.Releases))
authed.DELETE("/desktop-client/releases/:id", deleteDesktopClientReleaseHandler(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 := authed.Group("/compliance")
compliance.GET("/dictionaries", listComplianceDictionariesHandler(d.Compliance)) compliance.GET("/dictionaries", listComplianceDictionariesHandler(d.Compliance))
@@ -0,0 +1,69 @@
package middleware
import (
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type rateLimitEntry struct {
windowStart time.Time
count int
}
type inMemoryRateLimiter struct {
mu sync.Mutex
entries map[string]rateLimitEntry
max int
window time.Duration
lastSweep time.Time
}
func RateLimitByClientIP(max int, window time.Duration, code int, message, detail string) gin.HandlerFunc {
limiter := &inMemoryRateLimiter{
entries: make(map[string]rateLimitEntry),
max: max,
window: window,
}
return func(c *gin.Context) {
if !limiter.allow(c.ClientIP(), time.Now()) {
response.Error(c, response.ErrTooManyRequests(code, message, detail))
c.Abort()
return
}
c.Next()
}
}
func (l *inMemoryRateLimiter) allow(key string, now time.Time) bool {
if l == nil || l.max <= 0 || l.window <= 0 || key == "" {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
if l.lastSweep.IsZero() || now.Sub(l.lastSweep) >= l.window {
l.lastSweep = now
for entryKey, entry := range l.entries {
if now.Sub(entry.windowStart) >= l.window {
delete(l.entries, entryKey)
}
}
}
entry, ok := l.entries[key]
if !ok || now.Sub(entry.windowStart) >= l.window {
l.entries[key] = rateLimitEntry{windowStart: now, count: 1}
return true
}
if entry.count >= l.max {
return false
}
entry.count++
l.entries[key] = entry
return true
}
@@ -0,0 +1,38 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestRateLimitByClientIPBlocksAfterLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET(
"/limited",
RateLimitByClientIP(2, time.Minute, 42961, "rate_limited", "too many requests"),
func(c *gin.Context) {
c.Status(http.StatusNoContent)
},
)
for i := 0; i < 2; i++ {
req := httptest.NewRequest(http.MethodGet, "/limited", nil)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if res.Code != http.StatusNoContent {
t.Fatalf("request %d status = %d, want %d", i+1, res.Code, http.StatusNoContent)
}
}
req := httptest.NewRequest(http.MethodGet, "/limited", nil)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if res.Code != http.StatusTooManyRequests {
t.Fatalf("third request status = %d, want %d", res.Code, http.StatusTooManyRequests)
}
}
@@ -106,6 +106,10 @@ var routeDocs = map[string]routeDoc{
"GET /api/ops/desktop-client/releases/:id/download-url": {"解析桌面客户端下载地址", "解析指定桌面客户端版本的下载地址。"}, "GET /api/ops/desktop-client/releases/:id/download-url": {"解析桌面客户端下载地址", "解析指定桌面客户端版本的下载地址。"},
"POST /api/ops/desktop-client/releases/:id/enabled": {"切换桌面客户端版本状态", "启用或禁用指定桌面客户端版本。"}, "POST /api/ops/desktop-client/releases/:id/enabled": {"切换桌面客户端版本状态", "启用或禁用指定桌面客户端版本。"},
"DELETE /api/ops/desktop-client/releases/:id": {"删除桌面客户端版本", "删除指定桌面客户端版本配置。"}, "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:合规 --- // --- Ops:合规 ---
"GET /api/ops/compliance/dictionaries": {"合规词库列表", "分页查询内容合规词库。"}, "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/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", "桌面客户端开始自动更新时读取 Electron updater feed,私有 OSS 会在 feed 中写入短时签名下载地址。"},
"GET /api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed": {"自动更新 Feed 文件", "兼容 Electron updater 对 latest*.yml 的二次请求,返回动态生成的 updater YAML。"}, "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/register": {"桌面客户端注册", "由租户用户登录后调用,为本机桌面客户端申请客户端凭证。"},
"POST /api/desktop/clients/rotate": {"轮换客户端密钥", "客户端使用旧凭证调用,换取新的客户端密钥。"}, "POST /api/desktop/clients/rotate": {"轮换客户端密钥", "客户端使用旧凭证调用,换取新的客户端密钥。"},
"POST /api/desktop/clients/heartbeat": {"客户端心跳上报", "桌面客户端定时上报存活与版本,服务端用于在线状态展示。"}, "POST /api/desktop/clients/heartbeat": {"客户端心跳上报", "桌面客户端定时上报存活与版本,服务端用于在线状态展示。"},
"POST /api/desktop/clients/offline": {"客户端主动离线", "桌面客户端退出/休眠时上报,立即将本机标记为离线。"}, "POST /api/desktop/clients/offline": {"客户端主动离线", "桌面客户端退出/休眠时上报,立即将本机标记为离线。"},
"POST /api/desktop/clients/revoke": {"吊销客户端", "由桌面端调用,立即注销当前客户端凭证。"}, "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/latest": {"查询最新客户端版本(桌面鉴权)", "桌面客户端带 client token 查询最新版本,默认沿用本机注册渠道。"},
"GET /api/desktop/clients/releases/download-url": {"解析客户端下载地址(桌面鉴权)", "桌面客户端开始更新下载时即时获取下载地址,私有 OSS 会返回签名 URL。"}, "GET /api/desktop/clients/releases/download-url": {"解析客户端下载地址(桌面鉴权)", "桌面客户端开始更新下载时即时获取下载地址,私有 OSS 会返回签名 URL。"},
// --- Desktop:派单与拉取 --- // --- Desktop:派单与拉取 ---
+19
View File
@@ -414,6 +414,8 @@ func isMultipartRoute(route gin.RouteInfo) bool {
} }
path := route.Path path := route.Path
return path == "/api/tenant/images" || 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/site-domain-mappings/import" ||
path == "/api/ops/object-storage/objects/upload" || path == "/api/ops/object-storage/objects/upload" ||
path == "/api/ops/desktop-client/releases/upload" || path == "/api/ops/desktop-client/releases/upload" ||
@@ -431,6 +433,23 @@ func multipartRequestBody(path string) map[string]any {
} }
required := []string{"file"} 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" { if path == "/api/tenant/images" {
properties["folder_id"] = map[string]any{"type": "string"} properties["folder_id"] = map[string]any{"type": "string"}
} }
@@ -0,0 +1,137 @@
package transport
import (
"encoding/json"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
opsapp "github.com/geo-platform/tenant-api/internal/ops/app"
opsrepo "github.com/geo-platform/tenant-api/internal/ops/repository"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type DesktopBugReportHandler struct {
svc *opsapp.DesktopBugReportService
}
func NewDesktopBugReportHandler(a *bootstrap.App) *DesktopBugReportHandler {
return &DesktopBugReportHandler{
svc: opsapp.NewDesktopBugReportService(
opsrepo.NewDesktopBugReportRepository(a.DB),
a.ObjectStorage,
nil,
),
}
}
func (h *DesktopBugReportHandler) Upload(c *gin.Context) {
input, err := parseDesktopBugReportMultipart(c)
if err != nil {
response.Error(c, err)
return
}
data, err := h.svc.UploadForClient(c.Request.Context(), MustDesktopClient(c.Request.Context()), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopBugReportHandler) UploadPublic(c *gin.Context) {
input, err := parseDesktopBugReportMultipart(c)
if err != nil {
response.Error(c, err)
return
}
data, err := h.svc.UploadPublic(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func parseDesktopBugReportMultipart(c *gin.Context) (opsapp.DesktopBugReportUploadInput, error) {
if c.Request.ContentLength > opsapp.MaxDesktopBugMultipartBytes() {
return opsapp.DesktopBugReportUploadInput{}, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, opsapp.MaxDesktopBugMultipartBytes())
if err := c.Request.ParseMultipartForm(opsapp.MaxDesktopBugMultipartBytes()); err != nil {
if strings.Contains(err.Error(), "request body too large") {
return opsapp.DesktopBugReportUploadInput{}, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
return opsapp.DesktopBugReportUploadInput{}, response.ErrBadRequest(40065, "invalid_desktop_bug_report", "客户端 Bug 上报表单无效")
}
fields := map[string]string{}
if c.Request.MultipartForm != nil {
for key, values := range c.Request.MultipartForm.Value {
if len(values) == 0 {
continue
}
fields[key] = values[0]
}
}
dumpName, dumpContent, err := readDesktopBugDump(c)
if err != nil {
return opsapp.DesktopBugReportUploadInput{}, err
}
runtimeSnapshot := []byte(firstNonEmpty(fields["runtime_snapshot"], fields["_runtime_snapshot"]))
if len(runtimeSnapshot) > 0 && !json.Valid(runtimeSnapshot) {
runtimeSnapshot = []byte("{}")
}
return opsapp.DesktopBugReportUploadInput{
ReportType: firstNonEmpty(fields["report_type"], fields["_report_type"]),
Severity: firstNonEmpty(fields["severity"], fields["_severity"]),
Title: firstNonEmpty(fields["title"], fields["_title"]),
Description: firstNonEmpty(fields["description"], fields["_description"]),
DesktopClientID: firstNonEmpty(fields["client_id"], fields["_client_id"]),
CrashGUID: fields["guid"],
CrashReportID: firstNonEmpty(fields["crash_report_id"], fields["id"]),
ProcessType: firstNonEmpty(fields["process_type"], fields["ptype"]),
ElectronVersion: fields["ver"],
AppVersion: firstNonEmpty(fields["app_version"], fields["_version"]),
ProductName: firstNonEmpty(fields["_productName"], fields["prod"]),
Platform: fields["platform"],
OSArch: firstNonEmpty(fields["osarch"], fields["cpu_arch"]),
DeviceName: fields["device_name"],
FormFields: fields,
RuntimeSnapshot: runtimeSnapshot,
AppLogTail: firstNonEmpty(fields["app_log_tail"], fields["_app_log_tail"]),
DumpFileName: dumpName,
DumpContent: dumpContent,
UploaderIP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
OccurredAt: opsapp.ParseDesktopBugOccurredAt(firstNonEmpty(fields["occurred_at"], fields["_occurred_at"])),
}, nil
}
func readDesktopBugDump(c *gin.Context) (string, []byte, error) {
for _, field := range []string{"upload_file_minidump", "dump", "file"} {
fileHeader, err := c.FormFile(field)
if err != nil {
continue
}
file, err := fileHeader.Open()
if err != nil {
return "", nil, response.ErrBadRequest(40066, "desktop_bug_dump_open_failed", "dump 文件读取失败")
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, opsapp.MaxDesktopBugDumpBytes()+1))
if err != nil {
return "", nil, response.ErrBadRequest(40066, "desktop_bug_dump_read_failed", "dump 文件读取失败")
}
if int64(len(content)) > opsapp.MaxDesktopBugDumpBytes() {
return "", nil, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
return fileHeader.Filename, content, nil
}
return "", nil, nil
}
@@ -1,6 +1,8 @@
package transport package transport
import ( import (
"time"
"github.com/geo-platform/tenant-api/internal/bootstrap" "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/auth"
"github.com/geo-platform/tenant-api/internal/shared/middleware" "github.com/geo-platform/tenant-api/internal/shared/middleware"
@@ -34,6 +36,7 @@ func RegisterRoutes(app *bootstrap.App) {
desktopClientHandler := NewDesktopClientHandler(app) desktopClientHandler := NewDesktopClientHandler(app)
desktopReleaseHandler := NewDesktopReleaseHandler(app) desktopReleaseHandler := NewDesktopReleaseHandler(app)
desktopBugReportHandler := NewDesktopBugReportHandler(app)
desktopAccountHandler := NewDesktopAccountHandler(app) desktopAccountHandler := NewDesktopAccountHandler(app)
desktopTaskHandler := NewDesktopTaskHandler(app) desktopTaskHandler := NewDesktopTaskHandler(app)
desktopDispatchHandler := NewDesktopDispatchHandler(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/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/:feed", desktopReleaseHandler.UpdaterFeedPublic)
app.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version", 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 := app.Engine.Group("/api/desktop")
desktopAuth.Use(DesktopClientMiddleware(repository.NewDesktopClientRepository(app.DB))) 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/heartbeat", desktopClientHandler.Heartbeat)
desktopAuth.POST("/clients/offline", desktopClientHandler.Offline) desktopAuth.POST("/clients/offline", desktopClientHandler.Offline)
desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke) desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke)
desktopAuth.POST("/clients/bug-reports", desktopBugReportHandler.Upload)
desktopAuth.GET("/clients/releases/latest", desktopReleaseHandler.Latest) desktopAuth.GET("/clients/releases/latest", desktopReleaseHandler.Latest)
desktopAuth.GET("/clients/releases/download-url", desktopReleaseHandler.DownloadURL) desktopAuth.GET("/clients/releases/download-url", desktopReleaseHandler.DownloadURL)
desktopAuth.GET("/dispatch", desktopDispatchHandler.Dispatch) desktopAuth.GET("/dispatch", desktopDispatchHandler.Dispatch)
@@ -0,0 +1,2 @@
-- Intentionally no-op. See the matching up migration for context.
SELECT 1;
@@ -0,0 +1,3 @@
-- Compatibility marker for databases that applied the desktop client release
-- updater migration under this version before it was committed as 20260525113000.
SELECT 1;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS desktop_bug_reports;
@@ -0,0 +1,61 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS desktop_bug_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
report_type TEXT NOT NULL DEFAULT 'crash'
CHECK (report_type IN ('crash', 'bug')),
status TEXT NOT NULL DEFAULT 'new'
CHECK (status IN ('new', 'triaged', 'in_progress', 'resolved', 'ignored')),
severity TEXT NOT NULL DEFAULT 'medium'
CHECK (severity IN ('low', 'medium', 'high', 'critical')),
tenant_id BIGINT REFERENCES tenants(id) ON DELETE SET NULL,
workspace_id BIGINT REFERENCES workspaces(id) ON DELETE SET NULL,
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
desktop_client_id UUID REFERENCES desktop_clients(id) ON DELETE SET NULL,
crash_guid TEXT,
crash_report_id TEXT,
process_type TEXT,
electron_version TEXT,
app_version TEXT,
product_name TEXT,
platform TEXT,
os_arch TEXT,
device_name TEXT,
title TEXT NOT NULL,
description TEXT,
dump_object_key TEXT,
dump_file_name TEXT,
dump_size_bytes BIGINT,
dump_sha256 TEXT,
form_fields JSONB NOT NULL DEFAULT '{}'::jsonb,
runtime_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
app_log_tail TEXT,
resolution_note TEXT,
uploader_ip TEXT,
user_agent TEXT,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_desktop_bug_reports_created_at
ON desktop_bug_reports (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_desktop_bug_reports_status_created
ON desktop_bug_reports (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_desktop_bug_reports_severity_created
ON desktop_bug_reports (severity, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_desktop_bug_reports_client
ON desktop_bug_reports (desktop_client_id, created_at DESC)
WHERE desktop_client_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_desktop_bug_reports_workspace
ON desktop_bug_reports (tenant_id, workspace_id, created_at DESC)
WHERE tenant_id IS NOT NULL AND workspace_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_desktop_bug_reports_crash_guid
ON desktop_bug_reports (crash_guid)
WHERE crash_guid IS NOT NULL;