feat: add desktop bug report collection
This commit is contained in:
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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: '内容安全',
|
||||||
|
|||||||
@@ -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 ?? '—'
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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:派单与拉取 ---
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user