feat: add desktop bug report collection

This commit is contained in:
2026-05-31 21:55:29 +08:00
parent 0a9dcec70f
commit e490a267ff
27 changed files with 2938 additions and 3 deletions
@@ -0,0 +1,685 @@
package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/geo-platform/tenant-api/internal/ops/domain"
"github.com/geo-platform/tenant-api/internal/ops/repository"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/geo-platform/tenant-api/internal/shared/response"
tenantrepo "github.com/geo-platform/tenant-api/internal/tenant/repository"
)
const (
ActionDesktopBugReportCreate = "desktop_bug_report.create"
ActionDesktopBugReportUpdate = "desktop_bug_report.update"
)
const (
DesktopBugReportTypeCrash = "crash"
DesktopBugReportTypeBug = "bug"
)
const (
DesktopBugReportStatusNew = "new"
DesktopBugReportStatusTriaged = "triaged"
DesktopBugReportStatusInProgress = "in_progress"
DesktopBugReportStatusResolved = "resolved"
DesktopBugReportStatusIgnored = "ignored"
)
const (
DesktopBugReportSeverityLow = "low"
DesktopBugReportSeverityMedium = "medium"
DesktopBugReportSeverityHigh = "high"
DesktopBugReportSeverityCritical = "critical"
)
const maxDesktopBugDumpBytes = 64 * 1024 * 1024
const maxDesktopBugMultipartBytes = maxDesktopBugDumpBytes + 4*1024*1024
type DesktopBugReportService struct {
reports *repository.DesktopBugReportRepository
storage objectstorage.Client
audits *AuditService
}
func NewDesktopBugReportService(
reports *repository.DesktopBugReportRepository,
storage objectstorage.Client,
audits *AuditService,
) *DesktopBugReportService {
return &DesktopBugReportService{reports: reports, storage: storage, audits: audits}
}
type DesktopBugReportUploadInput struct {
ReportType string
Severity string
Title string
Description string
TenantID *int64
WorkspaceID *int64
UserID *int64
DesktopClientID string
CrashGUID string
CrashReportID string
ProcessType string
ElectronVersion string
AppVersion string
ProductName string
Platform string
OSArch string
DeviceName string
FormFields map[string]string
RuntimeSnapshot []byte
AppLogTail string
DumpFileName string
DumpContent []byte
UploaderIP string
UserAgent string
OccurredAt time.Time
}
type DesktopBugReportListInput struct {
Keyword string
Status string
Severity string
ReportType string
Platform string
Process string
TenantID *int64
ClientID string
Page int
Size int
}
type DesktopBugReportUpdateInput struct {
Status string
Severity string
ResolutionNote *string
}
type DesktopBugReportListResult struct {
Items []DesktopBugReportView `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
type DesktopBugReportUploadResult struct {
ID string `json:"id"`
DumpObjectKey *string `json:"dump_object_key,omitempty"`
}
type DesktopBugReportDownloadURLResult struct {
DownloadURL string `json:"download_url"`
}
type DesktopBugReportView struct {
ID string `json:"id"`
ReportType string `json:"report_type"`
Status string `json:"status"`
Severity string `json:"severity"`
TenantID *int64 `json:"tenant_id,omitempty"`
TenantName *string `json:"tenant_name,omitempty"`
WorkspaceID *int64 `json:"workspace_id,omitempty"`
WorkspaceName *string `json:"workspace_name,omitempty"`
UserID *int64 `json:"user_id,omitempty"`
UserName *string `json:"user_name,omitempty"`
UserPhone *string `json:"user_phone,omitempty"`
UserEmail *string `json:"user_email,omitempty"`
DesktopClientID *string `json:"desktop_client_id,omitempty"`
CrashGUID *string `json:"crash_guid,omitempty"`
CrashReportID *string `json:"crash_report_id,omitempty"`
ProcessType *string `json:"process_type,omitempty"`
ElectronVersion *string `json:"electron_version,omitempty"`
AppVersion *string `json:"app_version,omitempty"`
ProductName *string `json:"product_name,omitempty"`
Platform *string `json:"platform,omitempty"`
OSArch *string `json:"os_arch,omitempty"`
DeviceName *string `json:"device_name,omitempty"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
DumpObjectKey *string `json:"dump_object_key,omitempty"`
DumpFileName *string `json:"dump_file_name,omitempty"`
DumpSizeBytes *int64 `json:"dump_size_bytes,omitempty"`
DumpSHA256 *string `json:"dump_sha256,omitempty"`
FormFields json.RawMessage `json:"form_fields"`
RuntimeSnapshot json.RawMessage `json:"runtime_snapshot"`
AppLogTail *string `json:"app_log_tail,omitempty"`
ResolutionNote *string `json:"resolution_note,omitempty"`
UploaderIP *string `json:"uploader_ip,omitempty"`
UserAgent *string `json:"user_agent,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *DesktopBugReportService) UploadPublic(ctx context.Context, in DesktopBugReportUploadInput) (*DesktopBugReportUploadResult, error) {
in.TenantID = nil
in.WorkspaceID = nil
in.UserID = nil
in.DesktopClientID = ""
in.FormFields = dropPublicDesktopBugIdentityFields(in.FormFields)
return s.Upload(ctx, in)
}
func (s *DesktopBugReportService) Upload(ctx context.Context, in DesktopBugReportUploadInput) (*DesktopBugReportUploadResult, error) {
if s == nil || s.reports == nil {
return nil, response.ErrServiceUnavailable(50361, "desktop_bug_report_unavailable", "desktop bug report service is unavailable")
}
normalized, err := normalizeDesktopBugReportUploadInput(in)
if err != nil {
return nil, err
}
var dumpObjectKey *string
var dumpSHA256 *string
var dumpSize *int64
if len(normalized.DumpContent) > 0 {
if err := s.ensureStorageReady(); err != nil {
return nil, err
}
sum := sha256.Sum256(normalized.DumpContent)
sha256Hex := hex.EncodeToString(sum[:])
size := int64(len(normalized.DumpContent))
objectKey := buildDesktopBugDumpObjectKey(normalized, sha256Hex)
contentType := detectDesktopBugDumpContentType(normalized.DumpFileName, normalized.DumpContent)
if err := s.storage.PutBytes(ctx, objectKey, normalized.DumpContent, contentType); err != nil {
appErr := response.ErrInternal(50061, "desktop_bug_dump_upload_failed", "failed to upload dump to object storage")
appErr.Cause = err
return nil, appErr
}
dumpObjectKey = &objectKey
dumpSHA256 = &sha256Hex
dumpSize = &size
}
formFields, _ := json.Marshal(normalized.FormFields)
report, err := s.reports.Create(ctx, repository.CreateDesktopBugReportInput{
ID: uuid.NewString(),
ReportType: normalized.ReportType,
Status: DesktopBugReportStatusNew,
Severity: normalized.Severity,
TenantID: normalized.TenantID,
WorkspaceID: normalized.WorkspaceID,
UserID: normalized.UserID,
DesktopClientID: optionalUUIDString(normalized.DesktopClientID),
CrashGUID: optionalText(normalized.CrashGUID),
CrashReportID: optionalText(normalized.CrashReportID),
ProcessType: optionalText(normalized.ProcessType),
ElectronVersion: optionalText(normalized.ElectronVersion),
AppVersion: optionalText(normalized.AppVersion),
ProductName: optionalText(normalized.ProductName),
Platform: optionalText(normalized.Platform),
OSArch: optionalText(normalized.OSArch),
DeviceName: optionalText(normalized.DeviceName),
Title: normalized.Title,
Description: optionalText(normalized.Description),
DumpObjectKey: dumpObjectKey,
DumpFileName: optionalText(normalized.DumpFileName),
DumpSizeBytes: dumpSize,
DumpSHA256: dumpSHA256,
FormFields: formFields,
RuntimeSnapshot: normalized.RuntimeSnapshot,
AppLogTail: optionalText(normalized.AppLogTail),
UploaderIP: optionalText(normalized.UploaderIP),
UserAgent: optionalText(normalized.UserAgent),
OccurredAt: normalized.OccurredAt,
})
if err != nil {
return nil, err
}
return &DesktopBugReportUploadResult{ID: report.ID, DumpObjectKey: report.DumpObjectKey}, nil
}
func (s *DesktopBugReportService) UploadForClient(
ctx context.Context,
client *tenantrepo.DesktopClient,
in DesktopBugReportUploadInput,
) (*DesktopBugReportUploadResult, error) {
if client == nil {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
tenantID := client.TenantID
workspaceID := client.WorkspaceID
userID := client.UserID
in.TenantID = &tenantID
in.WorkspaceID = &workspaceID
in.UserID = &userID
in.DesktopClientID = client.ID.String()
if strings.TrimSpace(in.DeviceName) == "" && client.DeviceName != nil {
in.DeviceName = *client.DeviceName
}
if strings.TrimSpace(in.Platform) == "" && client.OS != nil {
in.Platform = *client.OS
}
if strings.TrimSpace(in.OSArch) == "" && client.CPUArch != nil {
in.OSArch = *client.CPUArch
}
if strings.TrimSpace(in.AppVersion) == "" && client.ClientVersion != nil {
in.AppVersion = *client.ClientVersion
}
return s.Upload(ctx, in)
}
func (s *DesktopBugReportService) List(ctx context.Context, in DesktopBugReportListInput) (*DesktopBugReportListResult, error) {
page := in.Page
if page <= 0 {
page = 1
}
size := in.Size
if size <= 0 || size > 200 {
size = 20
}
items, total, err := s.reports.List(ctx, repository.DesktopBugReportFilter{
Keyword: strings.TrimSpace(in.Keyword),
Status: normalizeDesktopBugStatus(in.Status),
Severity: normalizeDesktopBugSeverity(in.Severity),
ReportType: normalizeDesktopBugReportType(in.ReportType),
Platform: normalizeDesktopBugToken(in.Platform),
Process: normalizeDesktopBugToken(in.Process),
TenantID: in.TenantID,
ClientID: normalizeUUIDString(in.ClientID),
Limit: size,
Offset: (page - 1) * size,
})
if err != nil {
return nil, err
}
views := make([]DesktopBugReportView, 0, len(items))
for i := range items {
views = append(views, desktopBugReportView(&items[i]))
}
return &DesktopBugReportListResult{Items: views, Total: total, Page: page, Size: size}, nil
}
func (s *DesktopBugReportService) Get(ctx context.Context, id string) (*DesktopBugReportView, error) {
report, err := s.reports.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrDesktopBugReportNotFound) {
return nil, response.ErrNotFound(40461, "desktop_bug_report_not_found", "客户端 Bug 记录不存在")
}
return nil, err
}
view := desktopBugReportView(report)
return &view, nil
}
func (s *DesktopBugReportService) Update(ctx context.Context, actor *Actor, id string, in DesktopBugReportUpdateInput) (*DesktopBugReportView, error) {
status := normalizeDesktopBugStatus(in.Status)
if !isAllowedDesktopBugStatus(status) {
return nil, response.ErrBadRequest(40061, "invalid_desktop_bug_status", "状态必须是 new、triaged、in_progress、resolved 或 ignored")
}
severity := normalizeDesktopBugSeverity(in.Severity)
if !isAllowedDesktopBugSeverity(severity) {
return nil, response.ErrBadRequest(40062, "invalid_desktop_bug_severity", "严重级别必须是 low、medium、high 或 critical")
}
report, err := s.reports.UpdateState(ctx, id, repository.UpdateDesktopBugReportStateInput{
Status: status,
Severity: severity,
ResolutionNote: trimOptionalDesktopBugString(in.ResolutionNote, 4000),
})
if err != nil {
if errors.Is(err, repository.ErrDesktopBugReportNotFound) {
return nil, response.ErrNotFound(40461, "desktop_bug_report_not_found", "客户端 Bug 记录不存在")
}
return nil, err
}
s.audit(ctx, actor, ActionDesktopBugReportUpdate, report)
view := desktopBugReportView(report)
return &view, nil
}
func (s *DesktopBugReportService) ResolveDumpDownloadURL(ctx context.Context, id string) (*DesktopBugReportDownloadURLResult, error) {
report, err := s.reports.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrDesktopBugReportNotFound) {
return nil, response.ErrNotFound(40461, "desktop_bug_report_not_found", "客户端 Bug 记录不存在")
}
return nil, err
}
if report.DumpObjectKey == nil || strings.TrimSpace(*report.DumpObjectKey) == "" {
return nil, response.ErrNotFound(40462, "desktop_bug_dump_not_found", "该记录没有 dump 文件")
}
if err := s.ensureStorageReady(); err != nil {
return nil, err
}
downloadURL, err := s.storage.DownloadURL(*report.DumpObjectKey)
if err != nil {
appErr := response.ErrInternal(50062, "desktop_bug_dump_url_failed", "failed to generate dump download URL")
appErr.Cause = err
return nil, appErr
}
return &DesktopBugReportDownloadURLResult{DownloadURL: downloadURL}, nil
}
func (s *DesktopBugReportService) ensureStorageReady() error {
if s == nil || s.storage == nil {
return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
}
if err := s.storage.Validate(); err != nil {
return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
}
return nil
}
func normalizeDesktopBugReportUploadInput(in DesktopBugReportUploadInput) (DesktopBugReportUploadInput, error) {
in.ReportType = normalizeDesktopBugReportType(in.ReportType)
if in.ReportType == "" {
in.ReportType = DesktopBugReportTypeCrash
}
if !isAllowedDesktopBugReportType(in.ReportType) {
return DesktopBugReportUploadInput{}, response.ErrBadRequest(40063, "invalid_desktop_bug_report_type", "上报类型必须是 crash 或 bug")
}
in.Severity = normalizeDesktopBugSeverity(in.Severity)
if in.Severity == "" {
if in.ReportType == DesktopBugReportTypeCrash {
in.Severity = DesktopBugReportSeverityHigh
} else {
in.Severity = DesktopBugReportSeverityMedium
}
}
if !isAllowedDesktopBugSeverity(in.Severity) {
return DesktopBugReportUploadInput{}, response.ErrBadRequest(40062, "invalid_desktop_bug_severity", "严重级别必须是 low、medium、high 或 critical")
}
in.Title = limitDesktopBugText(firstNonEmptyString(
in.Title,
in.FormFields["_title"],
in.FormFields["title"],
defaultDesktopBugTitle(in),
), 200)
in.Description = limitDesktopBugText(firstNonEmptyString(in.Description, in.FormFields["_description"], in.FormFields["description"]), 4000)
in.CrashGUID = limitDesktopBugText(firstNonEmptyString(in.CrashGUID, in.FormFields["guid"]), 200)
in.CrashReportID = limitDesktopBugText(firstNonEmptyString(in.CrashReportID, in.FormFields["crash_report_id"], in.FormFields["id"]), 200)
in.ProcessType = limitDesktopBugText(firstNonEmptyString(in.ProcessType, in.FormFields["process_type"], in.FormFields["ptype"]), 80)
in.ElectronVersion = limitDesktopBugText(firstNonEmptyString(in.ElectronVersion, in.FormFields["ver"]), 80)
in.AppVersion = limitDesktopBugText(firstNonEmptyString(in.AppVersion, in.FormFields["app_version"], in.FormFields["_version"]), 80)
in.ProductName = limitDesktopBugText(firstNonEmptyString(in.ProductName, in.FormFields["_productName"], in.FormFields["prod"]), 120)
in.Platform = limitDesktopBugText(firstNonEmptyString(in.Platform, in.FormFields["platform"]), 80)
in.OSArch = limitDesktopBugText(firstNonEmptyString(in.OSArch, in.FormFields["osarch"], in.FormFields["cpu_arch"]), 80)
in.DeviceName = limitDesktopBugText(firstNonEmptyString(in.DeviceName, in.FormFields["device_name"]), 200)
in.DesktopClientID = normalizeUUIDString(firstNonEmptyString(in.DesktopClientID, in.FormFields["client_id"]))
if len(in.DumpContent) > maxDesktopBugDumpBytes {
return DesktopBugReportUploadInput{}, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
in.DumpFileName = sanitizeDesktopBugFileName(in.DumpFileName)
if len(in.DumpContent) > 0 && in.DumpFileName == "" {
in.DumpFileName = "upload_file_minidump.dmp"
}
in.AppLogTail = limitDesktopBugText(in.AppLogTail, 20000)
if len(in.RuntimeSnapshot) == 0 || !json.Valid(in.RuntimeSnapshot) {
in.RuntimeSnapshot = []byte("{}")
}
in.FormFields = sanitizeDesktopBugFormFields(in.FormFields)
if in.OccurredAt.IsZero() {
in.OccurredAt = time.Now().UTC()
}
return in, nil
}
func defaultDesktopBugTitle(in DesktopBugReportUploadInput) string {
if in.ReportType == DesktopBugReportTypeBug {
return "用户反馈"
}
process := firstNonEmptyString(in.ProcessType, in.FormFields["process_type"], in.FormFields["ptype"], "unknown")
platform := firstNonEmptyString(in.Platform, in.FormFields["platform"], "unknown")
return fmt.Sprintf("客户端崩溃:%s / %s", process, platform)
}
func sanitizeDesktopBugFormFields(fields map[string]string) map[string]string {
out := make(map[string]string)
for key, value := range fields {
normalizedKey := strings.TrimSpace(key)
if normalizedKey == "" || len(normalizedKey) > 120 {
continue
}
out[normalizedKey] = limitDesktopBugText(value, 2000)
}
return out
}
func dropPublicDesktopBugIdentityFields(fields map[string]string) map[string]string {
if len(fields) == 0 {
return fields
}
out := make(map[string]string, len(fields))
for key, value := range fields {
switch strings.ToLower(strings.TrimSpace(key)) {
case "tenant_id", "_tenant_id", "workspace_id", "_workspace_id", "user_id", "_user_id",
"client_id", "_client_id", "desktop_client_id", "_desktop_client_id":
continue
default:
out[key] = value
}
}
return out
}
func buildDesktopBugDumpObjectKey(in DesktopBugReportUploadInput, sha256Hex string) string {
datePrefix := time.Now().UTC().Format("2006/01/02")
reportKind := in.ReportType
if reportKind == "" {
reportKind = DesktopBugReportTypeCrash
}
clientID := normalizeUUIDString(in.DesktopClientID)
if clientID == "" {
clientID = "anonymous"
}
ext := strings.ToLower(filepath.Ext(in.DumpFileName))
if ext == "" || len(ext) > 10 {
ext = ".dmp"
}
return fmt.Sprintf("desktop-bug-reports/%s/%s/%s/%s%s", reportKind, datePrefix, clientID, sha256Hex, ext)
}
func detectDesktopBugDumpContentType(fileName string, content []byte) string {
lower := strings.ToLower(fileName)
switch {
case strings.HasSuffix(lower, ".dmp"):
return "application/vnd.microsoft.portable-executable"
case strings.HasSuffix(lower, ".gz"):
return "application/gzip"
case strings.HasSuffix(lower, ".zip"):
return "application/zip"
default:
if len(content) > 0 {
return http.DetectContentType(content)
}
return "application/octet-stream"
}
}
func desktopBugReportView(item *domain.DesktopBugReport) DesktopBugReportView {
return DesktopBugReportView{
ID: item.ID,
ReportType: item.ReportType,
Status: item.Status,
Severity: item.Severity,
TenantID: item.TenantID,
TenantName: item.TenantName,
WorkspaceID: item.WorkspaceID,
WorkspaceName: item.WorkspaceName,
UserID: item.UserID,
UserName: item.UserName,
UserPhone: item.UserPhone,
UserEmail: item.UserEmail,
DesktopClientID: item.DesktopClientID,
CrashGUID: item.CrashGUID,
CrashReportID: item.CrashReportID,
ProcessType: item.ProcessType,
ElectronVersion: item.ElectronVersion,
AppVersion: item.AppVersion,
ProductName: item.ProductName,
Platform: item.Platform,
OSArch: item.OSArch,
DeviceName: item.DeviceName,
Title: item.Title,
Description: item.Description,
DumpObjectKey: item.DumpObjectKey,
DumpFileName: item.DumpFileName,
DumpSizeBytes: item.DumpSizeBytes,
DumpSHA256: item.DumpSHA256,
FormFields: item.FormFields,
RuntimeSnapshot: item.RuntimeSnapshot,
AppLogTail: item.AppLogTail,
ResolutionNote: item.ResolutionNote,
UploaderIP: item.UploaderIP,
UserAgent: item.UserAgent,
OccurredAt: item.OccurredAt,
ResolvedAt: item.ResolvedAt,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
func (s *DesktopBugReportService) audit(ctx context.Context, actor *Actor, action string, item *domain.DesktopBugReport) {
if actor == nil || s.audits == nil || item == nil {
return
}
event := actor.audit(action, "desktop_bug_report", 0, map[string]any{
"id": item.ID,
"report_type": item.ReportType,
"status": item.Status,
"severity": item.Severity,
"dump_object_key": item.DumpObjectKey,
})
event.TargetID = item.ID
_ = s.audits.Append(ctx, event)
}
func normalizeDesktopBugReportType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeDesktopBugStatus(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeDesktopBugSeverity(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeDesktopBugToken(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func isAllowedDesktopBugReportType(value string) bool {
return value == DesktopBugReportTypeCrash || value == DesktopBugReportTypeBug
}
func isAllowedDesktopBugStatus(value string) bool {
switch value {
case DesktopBugReportStatusNew, DesktopBugReportStatusTriaged, DesktopBugReportStatusInProgress, DesktopBugReportStatusResolved, DesktopBugReportStatusIgnored:
return true
default:
return false
}
}
func isAllowedDesktopBugSeverity(value string) bool {
switch value {
case DesktopBugReportSeverityLow, DesktopBugReportSeverityMedium, DesktopBugReportSeverityHigh, DesktopBugReportSeverityCritical:
return true
default:
return false
}
}
func optionalText(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
func optionalUUIDString(value string) *string {
value = normalizeUUIDString(value)
if value == "" {
return nil
}
return &value
}
func normalizeUUIDString(value string) string {
value = strings.TrimSpace(value)
if _, err := uuid.Parse(value); err != nil {
return ""
}
return value
}
func limitDesktopBugText(value string, limit int) string {
value = strings.TrimSpace(value)
if value == "" || limit <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit])
}
func trimOptionalDesktopBugString(value *string, limit int) *string {
if value == nil {
return nil
}
trimmed := limitDesktopBugText(*value, limit)
if trimmed == "" {
return nil
}
return &trimmed
}
func sanitizeDesktopBugFileName(value string) string {
value = strings.TrimSpace(filepath.Base(value))
if value == "" || value == "." || value == "/" || value == "\\" {
return ""
}
value = strings.Map(func(r rune) rune {
if r == '\\' || r == '/' || r == '\r' || r == '\n' {
return -1
}
return r
}, value)
return limitDesktopBugText(value, 200)
}
var desktopBugISODatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T`)
func ParseDesktopBugOccurredAt(value string) time.Time {
value = strings.TrimSpace(value)
if value == "" || !desktopBugISODatePattern.MatchString(value) {
return time.Time{}
}
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return time.Time{}
}
return parsed.UTC()
}
func MaxDesktopBugMultipartBytes() int64 {
return maxDesktopBugMultipartBytes
}
func MaxDesktopBugDumpBytes() int64 {
return maxDesktopBugDumpBytes
}
@@ -0,0 +1,42 @@
package app
import "testing"
func TestDropPublicDesktopBugIdentityFields(t *testing.T) {
fields := map[string]string{
"tenant_id": "1",
"_tenant_id": "2",
"workspace_id": "3",
"_workspace_id": "4",
"user_id": "5",
"_user_id": "6",
"client_id": "spoofed-client",
"_client_id": "spoofed-electron-client",
"desktop_client_id": "spoofed-desktop-client",
"_desktop_client_id": "spoofed-electron-desktop-client",
"title": "renderer crash",
"guid": "crash-guid",
}
sanitized := dropPublicDesktopBugIdentityFields(fields)
for _, key := range []string{
"tenant_id",
"_tenant_id",
"workspace_id",
"_workspace_id",
"user_id",
"_user_id",
"client_id",
"_client_id",
"desktop_client_id",
"_desktop_client_id",
} {
if _, ok := sanitized[key]; ok {
t.Fatalf("sanitized fields retained spoofable identity key %q", key)
}
}
if sanitized["title"] != "renderer crash" || sanitized["guid"] != "crash-guid" {
t.Fatalf("sanitized fields dropped non-identity metadata: %#v", sanitized)
}
}
+45 -1
View File
@@ -1,6 +1,9 @@
package domain
import "time"
import (
"encoding/json"
"time"
)
const (
StatusActive = "active"
@@ -102,3 +105,44 @@ type DesktopClientRelease struct {
CreatedAt time.Time
UpdatedAt time.Time
}
type DesktopBugReport struct {
ID string
ReportType string
Status string
Severity string
TenantID *int64
TenantName *string
WorkspaceID *int64
WorkspaceName *string
UserID *int64
UserName *string
UserPhone *string
UserEmail *string
DesktopClientID *string
CrashGUID *string
CrashReportID *string
ProcessType *string
ElectronVersion *string
AppVersion *string
ProductName *string
Platform *string
OSArch *string
DeviceName *string
Title string
Description *string
DumpObjectKey *string
DumpFileName *string
DumpSizeBytes *int64
DumpSHA256 *string
FormFields json.RawMessage
RuntimeSnapshot json.RawMessage
AppLogTail *string
ResolutionNote *string
UploaderIP *string
UserAgent *string
OccurredAt time.Time
ResolvedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -0,0 +1,367 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/ops/domain"
)
var ErrDesktopBugReportNotFound = errors.New("desktop bug report not found")
type DesktopBugReportRepository struct {
pool *pgxpool.Pool
}
func NewDesktopBugReportRepository(pool *pgxpool.Pool) *DesktopBugReportRepository {
return &DesktopBugReportRepository{pool: pool}
}
type CreateDesktopBugReportInput struct {
ID string
ReportType string
Status string
Severity string
TenantID *int64
WorkspaceID *int64
UserID *int64
DesktopClientID *string
CrashGUID *string
CrashReportID *string
ProcessType *string
ElectronVersion *string
AppVersion *string
ProductName *string
Platform *string
OSArch *string
DeviceName *string
Title string
Description *string
DumpObjectKey *string
DumpFileName *string
DumpSizeBytes *int64
DumpSHA256 *string
FormFields []byte
RuntimeSnapshot []byte
AppLogTail *string
UploaderIP *string
UserAgent *string
OccurredAt time.Time
}
type DesktopBugReportFilter struct {
Keyword string
Status string
Severity string
ReportType string
Platform string
Process string
TenantID *int64
ClientID string
Limit int
Offset int
}
type UpdateDesktopBugReportStateInput struct {
Status string
Severity string
ResolutionNote *string
}
const desktopBugReportSelectColumns = `
br.id::text,
br.report_type,
br.status,
br.severity,
br.tenant_id,
t.name,
br.workspace_id,
w.name,
br.user_id,
u.name,
u.phone,
u.email,
br.desktop_client_id::text,
br.crash_guid,
br.crash_report_id,
br.process_type,
br.electron_version,
br.app_version,
br.product_name,
br.platform,
br.os_arch,
br.device_name,
br.title,
br.description,
br.dump_object_key,
br.dump_file_name,
br.dump_size_bytes,
br.dump_sha256,
br.form_fields,
br.runtime_snapshot,
br.app_log_tail,
br.resolution_note,
br.uploader_ip,
br.user_agent,
br.occurred_at,
br.resolved_at,
br.created_at,
br.updated_at`
const desktopBugReportFromClause = `
FROM desktop_bug_reports br
LEFT JOIN tenants t ON t.id = br.tenant_id
LEFT JOIN workspaces w ON w.id = br.workspace_id
LEFT JOIN users u ON u.id = br.user_id`
func (r *DesktopBugReportRepository) Create(ctx context.Context, in CreateDesktopBugReportInput) (*domain.DesktopBugReport, error) {
var id string
if err := r.pool.QueryRow(ctx, `
INSERT INTO desktop_bug_reports (
id, report_type, status, severity, tenant_id, workspace_id, user_id,
desktop_client_id, crash_guid, crash_report_id, process_type, electron_version,
app_version, product_name, platform, os_arch, device_name, title, description,
dump_object_key, dump_file_name, dump_size_bytes, dump_sha256, form_fields,
runtime_snapshot, app_log_tail, uploader_ip, user_agent, occurred_at
)
VALUES (
$1::uuid, $2, $3, $4, $5, $6, $7,
$8::uuid, $9, $10, $11, $12,
$13, $14, $15, $16, $17, $18, $19,
$20, $21, $22, $23, $24::jsonb,
$25::jsonb, $26, $27, $28, $29
)
RETURNING id::text`,
in.ID, in.ReportType, in.Status, in.Severity, in.TenantID, in.WorkspaceID, in.UserID,
in.DesktopClientID, in.CrashGUID, in.CrashReportID, in.ProcessType, in.ElectronVersion,
in.AppVersion, in.ProductName, in.Platform, in.OSArch, in.DeviceName, in.Title, in.Description,
in.DumpObjectKey, in.DumpFileName, in.DumpSizeBytes, in.DumpSHA256, string(defaultJSON(in.FormFields)),
string(defaultJSON(in.RuntimeSnapshot)), in.AppLogTail, in.UploaderIP, in.UserAgent, in.OccurredAt).Scan(&id); err != nil {
return nil, err
}
return r.GetByID(ctx, id)
}
func (r *DesktopBugReportRepository) List(ctx context.Context, f DesktopBugReportFilter) ([]domain.DesktopBugReport, int64, error) {
args := []any{}
where := "1=1"
if f.Keyword != "" {
args = append(args, "%"+f.Keyword+"%")
where += fmt.Sprintf(` AND (
br.id::text ILIKE $%d OR
COALESCE(br.title, '') ILIKE $%d OR
COALESCE(br.description, '') ILIKE $%d OR
COALESCE(br.crash_guid, '') ILIKE $%d OR
COALESCE(br.crash_report_id, '') ILIKE $%d OR
COALESCE(br.dump_object_key, '') ILIKE $%d OR
COALESCE(br.device_name, '') ILIKE $%d OR
COALESCE(t.name, '') ILIKE $%d OR
COALESCE(w.name, '') ILIKE $%d OR
COALESCE(u.name, '') ILIKE $%d OR
COALESCE(u.phone, '') ILIKE $%d OR
COALESCE(u.email, '') ILIKE $%d
)`, len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args))
}
if f.Status != "" {
args = append(args, f.Status)
where += fmt.Sprintf(" AND br.status = $%d", len(args))
}
if f.Severity != "" {
args = append(args, f.Severity)
where += fmt.Sprintf(" AND br.severity = $%d", len(args))
}
if f.ReportType != "" {
args = append(args, f.ReportType)
where += fmt.Sprintf(" AND br.report_type = $%d", len(args))
}
if f.Platform != "" {
args = append(args, f.Platform)
where += fmt.Sprintf(" AND br.platform = $%d", len(args))
}
if f.Process != "" {
args = append(args, f.Process)
where += fmt.Sprintf(" AND br.process_type = $%d", len(args))
}
if f.TenantID != nil {
args = append(args, *f.TenantID)
where += fmt.Sprintf(" AND br.tenant_id = $%d", len(args))
}
if f.ClientID != "" {
args = append(args, f.ClientID)
where += fmt.Sprintf(" AND br.desktop_client_id = $%d::uuid", len(args))
}
var total int64
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) "+desktopBugReportFromClause+" WHERE "+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
limit := f.Limit
if limit <= 0 || limit > 200 {
limit = 20
}
offset := f.Offset
if offset < 0 {
offset = 0
}
args = append(args, limit, offset)
limitArg := len(args) - 1
offsetArg := len(args)
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
SELECT %s
%s
WHERE %s
ORDER BY br.created_at DESC, br.id DESC
LIMIT $%d OFFSET $%d`, desktopBugReportSelectColumns, desktopBugReportFromClause, where, limitArg, offsetArg), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]domain.DesktopBugReport, 0, limit)
for rows.Next() {
item, scanErr := scanDesktopBugReport(rows)
if scanErr != nil {
return nil, 0, scanErr
}
items = append(items, *item)
}
return items, total, rows.Err()
}
func (r *DesktopBugReportRepository) GetByID(ctx context.Context, id string) (*domain.DesktopBugReport, error) {
return scanDesktopBugReport(r.pool.QueryRow(ctx, `
SELECT `+desktopBugReportSelectColumns+`
`+desktopBugReportFromClause+`
WHERE br.id = $1::uuid`, id))
}
func (r *DesktopBugReportRepository) UpdateState(ctx context.Context, id string, in UpdateDesktopBugReportStateInput) (*domain.DesktopBugReport, error) {
var updatedID string
if err := r.pool.QueryRow(ctx, `
UPDATE desktop_bug_reports br
SET status = $2,
severity = $3,
resolution_note = $4,
resolved_at = CASE
WHEN $2 IN ('resolved', 'ignored') AND resolved_at IS NULL THEN NOW()
WHEN $2 NOT IN ('resolved', 'ignored') THEN NULL
ELSE resolved_at
END,
updated_at = NOW()
WHERE br.id = $1::uuid
RETURNING br.id::text`,
id, in.Status, in.Severity, in.ResolutionNote).Scan(&updatedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrDesktopBugReportNotFound
}
return nil, err
}
return r.GetByID(ctx, updatedID)
}
func scanDesktopBugReport(row pgx.Row) (*domain.DesktopBugReport, error) {
var item domain.DesktopBugReport
var tenantID, workspaceID, userID, dumpSize pgtype.Int8
var tenantName, workspaceName, userName, userPhone, userEmail pgtype.Text
var desktopClientID, crashGUID, crashReportID, processType, electronVersion, appVersion pgtype.Text
var productName, platform, osArch, deviceName, description, dumpObjectKey, dumpFileName pgtype.Text
var dumpSHA256, appLogTail, resolutionNote, uploaderIP, userAgent pgtype.Text
var formFields, runtimeSnapshot []byte
var resolvedAt pgtype.Timestamptz
err := row.Scan(
&item.ID,
&item.ReportType,
&item.Status,
&item.Severity,
&tenantID,
&tenantName,
&workspaceID,
&workspaceName,
&userID,
&userName,
&userPhone,
&userEmail,
&desktopClientID,
&crashGUID,
&crashReportID,
&processType,
&electronVersion,
&appVersion,
&productName,
&platform,
&osArch,
&deviceName,
&item.Title,
&description,
&dumpObjectKey,
&dumpFileName,
&dumpSize,
&dumpSHA256,
&formFields,
&runtimeSnapshot,
&appLogTail,
&resolutionNote,
&uploaderIP,
&userAgent,
&item.OccurredAt,
&resolvedAt,
&item.CreatedAt,
&item.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrDesktopBugReportNotFound
}
return nil, err
}
item.TenantID = int64PtrFromPg(tenantID)
item.TenantName = stringPtrFromPg(tenantName)
item.WorkspaceID = int64PtrFromPg(workspaceID)
item.WorkspaceName = stringPtrFromPg(workspaceName)
item.UserID = int64PtrFromPg(userID)
item.UserName = stringPtrFromPg(userName)
item.UserPhone = stringPtrFromPg(userPhone)
item.UserEmail = stringPtrFromPg(userEmail)
item.DesktopClientID = stringPtrFromPg(desktopClientID)
item.CrashGUID = stringPtrFromPg(crashGUID)
item.CrashReportID = stringPtrFromPg(crashReportID)
item.ProcessType = stringPtrFromPg(processType)
item.ElectronVersion = stringPtrFromPg(electronVersion)
item.AppVersion = stringPtrFromPg(appVersion)
item.ProductName = stringPtrFromPg(productName)
item.Platform = stringPtrFromPg(platform)
item.OSArch = stringPtrFromPg(osArch)
item.DeviceName = stringPtrFromPg(deviceName)
item.Description = stringPtrFromPg(description)
item.DumpObjectKey = stringPtrFromPg(dumpObjectKey)
item.DumpFileName = stringPtrFromPg(dumpFileName)
item.DumpSizeBytes = int64PtrFromPg(dumpSize)
item.DumpSHA256 = stringPtrFromPg(dumpSHA256)
item.FormFields = json.RawMessage(defaultJSON(formFields))
item.RuntimeSnapshot = json.RawMessage(defaultJSON(runtimeSnapshot))
item.AppLogTail = stringPtrFromPg(appLogTail)
item.ResolutionNote = stringPtrFromPg(resolutionNote)
item.UploaderIP = stringPtrFromPg(uploaderIP)
item.UserAgent = stringPtrFromPg(userAgent)
item.ResolvedAt = timePtrFromPg(resolvedAt)
return &item, nil
}
func defaultJSON(value []byte) []byte {
if len(value) == 0 || !json.Valid(value) {
return []byte("{}")
}
return value
}
@@ -0,0 +1,245 @@
package transport
import (
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/ops/app"
"github.com/geo-platform/tenant-api/internal/shared/response"
tenantapp "github.com/geo-platform/tenant-api/internal/tenant/app"
tenantrepo "github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type updateDesktopBugReportRequest struct {
Status string `json:"status" binding:"required"`
Severity string `json:"severity" binding:"required"`
ResolutionNote *string `json:"resolution_note"`
}
func uploadDesktopBugReportHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
input, err := parseDesktopBugReportMultipart(c)
if err != nil {
response.Error(c, err)
return
}
result, err := svc.UploadPublic(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func uploadDesktopBugReportForClientHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
input, err := parseDesktopBugReportMultipart(c)
if err != nil {
response.Error(c, err)
return
}
client, _ := c.Request.Context().Value(desktopBugReportClientContextKey{}).(*tenantrepo.DesktopClient)
result, err := svc.UploadForClient(c.Request.Context(), client, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func desktopBugReportClientMiddleware(repo tenantrepo.DesktopClientRepository) gin.HandlerFunc {
return func(c *gin.Context) {
token, err := desktopBugReportBearerClientToken(c.GetHeader("Authorization"))
if err != nil {
response.Error(c, err)
c.Abort()
return
}
client, err := repo.GetByTokenHash(c.Request.Context(), tenantapp.HashDesktopClientToken(token))
if err != nil {
response.Error(c, response.ErrUnauthorized(40131, "invalid_client_token", "desktop client token is invalid or revoked"))
c.Abort()
return
}
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), desktopBugReportClientContextKey{}, client))
c.Next()
}
}
func listDesktopBugReportsHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
result, err := svc.List(c.Request.Context(), app.DesktopBugReportListInput{
Keyword: c.Query("keyword"),
Status: c.Query("status"),
Severity: c.Query("severity"),
ReportType: c.Query("report_type"),
Platform: c.Query("platform"),
Process: c.Query("process_type"),
TenantID: parseOptionalInt64Query(c.Query("tenant_id")),
ClientID: c.Query("client_id"),
Page: page,
Size: size,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func getDesktopBugReportHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
result, err := svc.Get(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func updateDesktopBugReportHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
var body updateDesktopBugReportRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
result, err := svc.Update(c.Request.Context(), actorFromGin(c), c.Param("id"), app.DesktopBugReportUpdateInput{
Status: body.Status,
Severity: body.Severity,
ResolutionNote: body.ResolutionNote,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func resolveDesktopBugReportDumpURLHandler(svc *app.DesktopBugReportService) gin.HandlerFunc {
return func(c *gin.Context) {
result, err := svc.ResolveDumpDownloadURL(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func parseDesktopBugReportMultipart(c *gin.Context) (app.DesktopBugReportUploadInput, error) {
if c.Request.ContentLength > app.MaxDesktopBugMultipartBytes() {
return app.DesktopBugReportUploadInput{}, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, app.MaxDesktopBugMultipartBytes())
if err := c.Request.ParseMultipartForm(app.MaxDesktopBugMultipartBytes()); err != nil {
if strings.Contains(err.Error(), "request body too large") {
return app.DesktopBugReportUploadInput{}, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
return app.DesktopBugReportUploadInput{}, response.ErrBadRequest(40065, "invalid_desktop_bug_report", "客户端 Bug 上报表单无效")
}
fields := map[string]string{}
if c.Request.MultipartForm != nil {
for key, values := range c.Request.MultipartForm.Value {
if len(values) == 0 {
continue
}
fields[key] = values[0]
}
}
dumpName, dumpContent, err := readDesktopBugDump(c)
if err != nil {
return app.DesktopBugReportUploadInput{}, err
}
runtimeSnapshot := []byte(firstNonEmpty(fields["runtime_snapshot"], fields["_runtime_snapshot"]))
if len(runtimeSnapshot) > 0 && !json.Valid(runtimeSnapshot) {
runtimeSnapshot = []byte("{}")
}
tenantID := parseOptionalInt64Query(firstNonEmpty(fields["tenant_id"], fields["_tenant_id"]))
workspaceID := parseOptionalInt64Query(firstNonEmpty(fields["workspace_id"], fields["_workspace_id"]))
userID := parseOptionalInt64Query(firstNonEmpty(fields["user_id"], fields["_user_id"]))
return app.DesktopBugReportUploadInput{
ReportType: firstNonEmpty(fields["report_type"], fields["_report_type"]),
Severity: firstNonEmpty(fields["severity"], fields["_severity"]),
Title: firstNonEmpty(fields["title"], fields["_title"]),
Description: firstNonEmpty(fields["description"], fields["_description"]),
TenantID: tenantID,
WorkspaceID: workspaceID,
UserID: userID,
DesktopClientID: firstNonEmpty(fields["client_id"], fields["_client_id"]),
CrashGUID: fields["guid"],
CrashReportID: firstNonEmpty(fields["crash_report_id"], fields["id"]),
ProcessType: firstNonEmpty(fields["process_type"], fields["ptype"]),
ElectronVersion: fields["ver"],
AppVersion: firstNonEmpty(fields["app_version"], fields["_version"]),
ProductName: firstNonEmpty(fields["_productName"], fields["prod"]),
Platform: fields["platform"],
OSArch: firstNonEmpty(fields["osarch"], fields["cpu_arch"]),
DeviceName: fields["device_name"],
FormFields: fields,
RuntimeSnapshot: runtimeSnapshot,
AppLogTail: firstNonEmpty(fields["app_log_tail"], fields["_app_log_tail"]),
DumpFileName: dumpName,
DumpContent: dumpContent,
UploaderIP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
OccurredAt: app.ParseDesktopBugOccurredAt(firstNonEmpty(fields["occurred_at"], fields["_occurred_at"])),
}, nil
}
func readDesktopBugDump(c *gin.Context) (string, []byte, error) {
for _, field := range []string{"upload_file_minidump", "dump", "file"} {
fileHeader, err := c.FormFile(field)
if err != nil {
continue
}
file, err := fileHeader.Open()
if err != nil {
return "", nil, response.ErrBadRequest(40066, "desktop_bug_dump_open_failed", "dump 文件读取失败")
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, app.MaxDesktopBugDumpBytes()+1))
if err != nil {
return "", nil, response.ErrBadRequest(40066, "desktop_bug_dump_read_failed", "dump 文件读取失败")
}
if int64(len(content)) > app.MaxDesktopBugDumpBytes() {
return "", nil, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
}
return fileHeader.Filename, content, nil
}
return "", nil, nil
}
func parseOptionalInt64Query(value string) *int64 {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil || parsed <= 0 {
return nil
}
return &parsed
}
type desktopBugReportClientContextKey struct{}
func desktopBugReportBearerClientToken(header string) (string, error) {
parts := strings.SplitN(strings.TrimSpace(header), " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || strings.TrimSpace(parts[1]) == "" {
return "", response.ErrUnauthorized(40130, "missing_client_token", "desktop client token is required")
}
return strings.TrimSpace(parts[1]), nil
}
+14
View File
@@ -1,6 +1,8 @@
package transport
import (
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
@@ -10,6 +12,7 @@ import (
"github.com/geo-platform/tenant-api/internal/shared/middleware"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/shared/swagger"
tenantrepo "github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type Deps struct {
@@ -28,6 +31,7 @@ type Deps struct {
SiteDomains *app.SiteDomainMappingService
Releases *app.DesktopClientReleaseService
ObjectStorage *app.ObjectStorageService
BugReports *app.DesktopBugReportService
Compliance *app.ComplianceService
Scheduler *app.SchedulerService
MediaSupply *app.MediaSupplyService
@@ -75,6 +79,12 @@ func RegisterRoutes(d Deps) {
d.Engine.GET("/api/desktop/releases/download-url", resolveLatestDesktopClientReleaseDownloadURLHandler(d.Releases))
d.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed", desktopClientReleaseUpdaterFeedHandler(d.Releases))
d.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version", desktopClientReleaseUpdaterFeedHandler(d.Releases))
publicBugReportRateLimit := middleware.RateLimitByClientIP(10, time.Minute, 42961, "desktop_bug_report_rate_limited", "客户端 Bug 上报过于频繁,请稍后再试")
d.Engine.POST("/api/desktop/bug-reports", publicBugReportRateLimit, uploadDesktopBugReportHandler(d.BugReports))
desktopAuthed := d.Engine.Group("/api/desktop")
desktopAuthed.Use(desktopBugReportClientMiddleware(tenantrepo.NewDesktopClientRepository(d.DB)))
desktopAuthed.POST("/clients/bug-reports", uploadDesktopBugReportForClientHandler(d.BugReports))
api := d.Engine.Group("/api/ops")
{
@@ -168,6 +178,10 @@ func RegisterRoutes(d Deps) {
authed.GET("/desktop-client/releases/:id/download-url", resolveDesktopClientReleaseDownloadURLHandler(d.Releases))
authed.POST("/desktop-client/releases/:id/enabled", setDesktopClientReleaseEnabledHandler(d.Releases))
authed.DELETE("/desktop-client/releases/:id", deleteDesktopClientReleaseHandler(d.Releases))
authed.GET("/desktop-client/bug-reports", listDesktopBugReportsHandler(d.BugReports))
authed.GET("/desktop-client/bug-reports/:id", getDesktopBugReportHandler(d.BugReports))
authed.PATCH("/desktop-client/bug-reports/:id", updateDesktopBugReportHandler(d.BugReports))
authed.GET("/desktop-client/bug-reports/:id/dump-url", resolveDesktopBugReportDumpURLHandler(d.BugReports))
compliance := authed.Group("/compliance")
compliance.GET("/dictionaries", listComplianceDictionariesHandler(d.Compliance))