From 198dc7f9603fa43198364db61cab533138012509 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Wed, 17 Jun 2026 15:30:05 +0800 Subject: [PATCH] feat(bugcollect): implement BugCollect API v1.1.0 full stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LogController: 新增 /issues/{id}/events, /issues/{id}/trend, 管理接口 (resolve/ignore/assign/bulk),queryIssues 改用 level/status/q 参数 - LogService: 全面重写,eventId 幂等、breadcrumbs 存储、affectedUsers 计数、 Issue status 管理、getIssueEvents/getIssueTrend 独立接口 - Entity: LogIssueEventEntity 增加 eventId/exceptionType/exceptionValue/breadcrumbs; LogIssueEntity 增加 status/affectedUsers/assignee;LogEventEntity 增加 eventId - Repository: LogIssueEventRepository/LogIssueRepository/LogEventRepository 新增 idempotency/filter/trend/bulk 查询方法 - DTO: IssueActionRequest/IssueTrendResponse 新增;IssueResponse/IssueEventResponse 扩展 - V3 migration: log_issue_events/log_issues/log_events 结构升级 Co-Authored-By: Claude Sonnet 4.6 --- .../docs/BugCollect-API-v1.md | 1437 +++++++++++++++++ .../bugcollect/controller/LogController.java | 78 +- .../bugcollect/dto/EventBatchRequest.java | 15 +- .../bugcollect/dto/IssueActionRequest.java | 10 + .../bugcollect/dto/IssueBatchRequest.java | 61 +- .../bugcollect/dto/IssueEventResponse.java | 12 +- .../xuqm/bugcollect/dto/IssueResponse.java | 7 +- .../bugcollect/dto/IssueTrendResponse.java | 10 + .../bugcollect/entity/LogEventEntity.java | 38 +- .../bugcollect/entity/LogIssueEntity.java | 41 +- .../entity/LogIssueEventEntity.java | 68 +- .../repository/LogEventRepository.java | 2 + .../repository/LogIssueEventRepository.java | 18 + .../repository/LogIssueRepository.java | 41 +- .../xuqm/bugcollect/service/LogService.java | 311 ++-- .../migration/V3__bugcollect_v11_upgrade.sql | 23 + 16 files changed, 2000 insertions(+), 172 deletions(-) create mode 100644 xuqm-bugcollect-service/docs/BugCollect-API-v1.md create mode 100644 xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueActionRequest.java create mode 100644 xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueTrendResponse.java create mode 100644 xuqm-bugcollect-service/src/main/resources/db/migration/V3__bugcollect_v11_upgrade.sql diff --git a/xuqm-bugcollect-service/docs/BugCollect-API-v1.md b/xuqm-bugcollect-service/docs/BugCollect-API-v1.md new file mode 100644 index 0000000..a735b8d --- /dev/null +++ b/xuqm-bugcollect-service/docs/BugCollect-API-v1.md @@ -0,0 +1,1437 @@ +# BugCollect API v1 规范 + +> **版本**:1.1.0 +> **日期**:2026-06-17 +> **状态**:Draft +> **设计参考**:[Sentry Event Payloads](https://develop.sentry.dev/sdk/event-payloads/) +> **服务端模块**:`xuqm-bugcollect-service` +> **基础路径**:`/bugcollect/v1` + +--- + +## 目录 + +1. [概述](#1-概述) +2. [术语表](#2-术语表) +3. [认证与安全](#3-认证与安全) +4. [数据上报端点](#4-数据上报端点) + - 4.1 [Issue 批量上报](#41-issue-批量上报) + - 4.2 [Event 批量上报](#42-event-批量上报) + - 4.3 [SourceMap / 符号文件上传](#43-sourcemap--符号文件上传) + - 4.4 [Session 上报](#44-session-上报) +5. [数据模型](#5-数据模型) + - 5.1 [批量信封 Envelope](#51-批量信封-envelope) + - 5.2 [IssueEvent](#52-issueevent) + - 5.3 [LogEvent](#53-logevent) + - 5.4 [Breadcrumb](#54-breadcrumb) + - 5.5 [枚举定义](#55-枚举定义) +6. [查询端点](#6-查询端点) + - 6.1 [Issue 列表](#61-issue-列表) + - 6.2 [Issue 详情](#62-issue-详情) + - 6.3 [Issue 事件列表](#63-issue-事件列表) + - 6.4 [Issue 趋势](#64-issue-趋势) + - 6.5 [Event 列表](#65-event-列表) + - 6.6 [概览统计](#66-概览统计) + - 6.7 [频率排行](#67-频率排行) + - 6.8 [风险排行](#68-风险排行) + - 6.9 [漏斗分析](#69-漏斗分析) +7. [Issue 管理端点](#7-issue-管理端点) + - 7.1 [解决 Issue](#71-解决-issue) + - 7.2 [忽略 Issue](#72-忽略-issue) + - 7.3 [分配责任人](#73-分配责任人) + - 7.4 [批量操作](#74-批量操作) +8. [Webhook](#8-webhook) +9. [错误码](#9-错误码) +10. [Fingerprint 去重机制](#10-fingerprint-去重机制) +11. [SDK 集成指南](#11-sdk-集成指南) +12. [数据库 Schema](#12-数据库-schema) +13. [变更日志](#13-变更日志) + +--- + +## 1. 概述 + +BugCollect 是一个轻量级错误收集与分析服务,设计目标对齐 [Sentry](https://sentry.io) 核心能力,为 XuqmGroup 全平台 SDK(Android、iOS、HarmonyOS、React Native、Web)提供统一的错误上报、聚合、查询和告警能力。 + +### 核心能力 + +| 能力 | 说明 | +|------|------| +| **Issue 聚合** | 按 fingerprint 自动去重,将同类错误聚合为 Issue | +| **Event 追踪** | 每次错误发生记录独立 Event,保留完整上下文和面包屑 | +| **Analytics 事件** | 自定义业务事件上报,支持漏斗分析 | +| **Session 追踪** | 会话生命周期上报,支持 crash-free session rate 计算 | +| **符号化** | 上传 SourceMap / ProGuard mapping / dSYM,支持多平台堆栈还原 | +| **Webhook 告警** | 新 Issue、fatal 级别、阈值超限等事件触发 Webhook 通知 | +| **Issue 管理** | 解决、忽略、分配、批量操作 | +| **多平台** | 统一 API,平台差异由 `platform` 字段区分 | + +### 设计原则 + +1. **Sentry 对齐**:字段命名和结构参考 Sentry,降低学习成本 +2. **批量优先**:上报端点均为批量接口,减少网络请求 +3. **信封封装**:请求体使用信封(Envelope)结构,支持携带 SDK 元信息 +4. **幂等安全**:每条 Event 携带 `eventId`,服务端按此去重 +5. **向前兼容**:未知字段忽略不报错,新字段可选添加 + +--- + +## 2. 术语表 + +| 术语 | 说明 | +|------|------| +| **Issue** | 一类错误的聚合,由 fingerprint 唯一标识。一个 Issue 对应多次 Event | +| **Event** | 一次具体的错误发生记录,包含完整的堆栈、面包屑、设备、用户等上下文 | +| **Analytics Event** | 自定义业务事件(如页面访问、按钮点击),用于漏斗分析 | +| **Session** | 一次应用使用过程,从前台打开到退出/崩溃,用于计算 crash-free rate | +| **Breadcrumb** | 崩溃前的操作轨迹,如导航、网络请求、用户操作,自动附带在 Event 中 | +| **Fingerprint** | 错误指纹,SHA-256 哈希值,用于 Issue 去重分组 | +| **Level** | 错误严重级别:`fatal` > `error` > `warning` > `info` > `debug` | +| **Status** | Issue 状态:`open`(未处理)/ `resolved`(已解决)/ `ignored`(已忽略) | +| **Release** | 应用版本号,对应 `appVersion` | +| **Environment** | 运行环境:`production` / `staging` / `development` / `integration` | +| **Envelope** | 批量请求的信封结构,包含 `sentAt`、`sdk`、`events` | +| **AppKey** | 应用唯一标识,由平台分配 | + +--- + +## 3. 认证与安全 + +### 上报端点(SDK → 服务端) + +SDK 通过 `config.xuqm` 完成初始化认证,`appKey` 由 SDK 自动从配置文件读取并附带在请求体中,**开发者无需手动处理认证逻辑**。 + +``` +appKey 来源:assets/xuqm/config.xuqm(加密文件,由租户平台下发) +``` + +认证链路: + +1. 开发者从租户平台下载 `config.xuqm`,内含 `appKey` 和可选的 `serverUrl` +2. `config.xuqm` 已加密存储,且绑定包名(SDK 启动时校验包名与文件中记录的一致性) +3. SDK 初始化时从平台拉取配置,验证 BugCollect 服务已开通(`t_feature_service.enabled = true`);未开通时 `captureError` 为空操作,不发送任何请求 +4. 服务端收到上报后,验证 `appKey` 有效且对应 BugCollect 服务已激活,否则拒绝写入 + +> **服务激活即权限边界**:BugCollect 服务开通状态由租户在平台管理,未开通的 `appKey` 的上报请求会被服务端拒绝,无需额外 Token。 + +建议 SDK 端通过 **HTTPS** 传输,防止 appKey 被中间人截获。 + +### 查询端点 / 管理端点(Web → 服务端) + +- 通过 API 网关统一鉴权(租户 Token) +- 查询参数中的 `appKey` 必须与鉴权租户匹配 + +### 速率限制 + +| 端点类型 | 限制 | 超限行为 | +|---------|------|---------| +| Issue / Event 上报 | 1000 次/分钟/appKey | HTTP 429,SDK 将事件重新入队,下次重试 | +| Session 上报 | 5000 次/分钟/appKey | HTTP 429,SDK 丢弃(会话数据可接受损失) | +| SourceMap 上传 | 20 次/小时/appKey | HTTP 429 | +| 查询端点 | 100 次/分钟/租户 | HTTP 429 | + +--- + +## 4. 数据上报端点 + +### 4.1 Issue 批量上报 + +捕获的错误、未捕获的崩溃统一通过此端点上报。 + +``` +POST /bugcollect/v1/issues/batch +Content-Type: application/json +``` + +#### 请求体 + +```json +{ + "sentAt": "2026-06-17T10:30:00Z", + "sdk": { + "name": "bugcollect.android", + "version": "1.0.0" + }, + "events": [ + { + "eventId": "550e8400-e29b-41d4-a716-446655440000", + "appKey": "2000111111110002", + "level": "error", + "platform": "android", + "release": "7.2.13", + "environment": "production", + "timestamp": 1718620200000, + "fingerprint": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456", + "exception": { + "type": "IllegalStateException", + "value": "更新服务未开通 (code=40404)", + "stacktrace": "java.lang.IllegalStateException: 更新服务未开通\n\tat com.szyx.app.MainActivity.onCreate(MainActivity.kt:178)\n\tat android.app.Activity.performCreate(Activity.java:8000)" + }, + "breadcrumbs": [ + { + "timestamp": 1718620195000, + "category": "navigation", + "message": "进入首页", + "level": "info" + }, + { + "timestamp": 1718620199000, + "category": "network", + "message": "GET /api/update → 404", + "level": "warning", + "data": { "url": "https://update.dev.xuqinmin.com/", "statusCode": 404 } + } + ], + "user": { + "id": "user-123" + }, + "device": { + "name": "Pixel 7", + "model": "Pixel 7", + "manufacturer": "Google", + "osName": "Android", + "osVersion": "14", + "locale": "zh_CN", + "timezone": "Asia/Shanghai", + "network": "wifi", + "isEmulator": false, + "freeMemoryMb": 512, + "buildType": "release" + }, + "tags": { + "context": "checkAppUpdate" + } + } + ] +} +``` + +#### 响应 + +```json +{ + "code": 200, + "message": "success", + "data": null +} +``` + +> **幂等说明**:服务端按 `eventId` 去重。相同 `appKey` + `eventId` 的二次提交直接返回 200,不重复写入。 + +#### cURL 示例 + +```bash +curl -X POST https://bugcollect.xuqinmin.com/bugcollect/v1/issues/batch \ + -H "Content-Type: application/json" \ + -d '{ + "sentAt": "2026-06-17T10:30:00Z", + "sdk": { "name": "bugcollect.android", "version": "1.0.0" }, + "events": [{ + "eventId": "550e8400-e29b-41d4-a716-446655440000", + "appKey": "2000111111110002", + "level": "error", + "platform": "android", + "release": "7.2.13", + "environment": "production", + "timestamp": 1718620200000, + "fingerprint": "abc123...", + "exception": { + "type": "NullPointerException", + "value": "Attempt to invoke virtual method on a null reference", + "stacktrace": "java.lang.NullPointerException: ...\n\tat ..." + } + }] + }' +``` + +--- + +### 4.2 Event 批量上报 + +自定义业务事件(页面访问、功能使用、漏斗步骤等)通过此端点上报。 + +``` +POST /bugcollect/v1/events/batch +Content-Type: application/json +``` + +#### 请求体 + +```json +{ + "sentAt": "2026-06-17T10:30:00Z", + "sdk": { + "name": "bugcollect.android", + "version": "1.0.0" + }, + "events": [ + { + "eventId": "660e8400-e29b-41d4-a716-446655440001", + "appKey": "2000111111110002", + "name": "page_view", + "properties": { + "page": "/home", + "referrer": "/login" + }, + "platform": "android", + "release": "7.2.13", + "environment": "production", + "timestamp": 1718620200000, + "user": { + "id": "user-123" + }, + "device": { + "osName": "Android", + "osVersion": "14", + "isEmulator": false + } + } + ] +} +``` + +#### 响应 + +```json +{ + "code": 200, + "message": "success", + "data": null +} +``` + +--- + +### 4.3 SourceMap / 符号文件上传 + +上传符号文件,用于堆栈还原。各平台文件格式不同: + +| 平台 | 文件类型 | 说明 | +|------|----------|------| +| `web` / `react-native` | `.map` | JavaScript SourceMap | +| `android` | `.txt` | ProGuard / R8 mapping 文件 | +| `ios` | `.zip` | dSYM 压缩包(含 DWARF 调试信息) | +| `harmonyos` | `.map` | 类 SourceMap 格式 | + +``` +POST /bugcollect/v1/sourcemaps/upload +Content-Type: multipart/form-data +``` + +#### 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `appKey` | string | ✅ | 应用标识 | +| `platform` | string | ✅ | 平台:`web` / `react-native` / `android` / `ios` / `harmonyos` | +| `appVersion` | string | ✅ | 应用版本,对应 `release` 字段 | +| `bundleName` | string | | JS Bundle 名称,默认 `index`(web/rn 专用) | +| `file` | file | ✅ | 符号文件 | + +#### cURL 示例 + +```bash +# React Native SourceMap +curl -X POST https://bugcollect.xuqinmin.com/bugcollect/v1/sourcemaps/upload \ + -F "appKey=2000111111110002" \ + -F "platform=react-native" \ + -F "appVersion=7.2.13" \ + -F "bundleName=index" \ + -F "file=@./index.bundle.map" + +# Android ProGuard mapping +curl -X POST https://bugcollect.xuqinmin.com/bugcollect/v1/sourcemaps/upload \ + -F "appKey=2000111111110002" \ + -F "platform=android" \ + -F "appVersion=7.2.13" \ + -F "file=@./mapping.txt" + +# iOS dSYM +curl -X POST https://bugcollect.xuqinmin.com/bugcollect/v1/sourcemaps/upload \ + -F "appKey=2000111111110002" \ + -F "platform=ios" \ + -F "appVersion=7.2.13" \ + -F "file=@./YwxApp.app.dSYM.zip" +``` + +#### 响应 + +```json +{ + "code": 200, + "message": "success", + "data": { + "id": 1, + "appKey": "2000111111110002", + "platform": "android", + "appVersion": "7.2.13", + "uploadedAt": "2026-06-17T10:30:00" + } +} +``` + +--- + +### 4.4 Session 上报 + +上报会话生命周期,用于计算 crash-free session rate。 + +``` +POST /bugcollect/v1/sessions/batch +Content-Type: application/json +``` + +#### 请求体 + +```json +{ + "sentAt": "2026-06-17T10:30:00Z", + "sdk": { "name": "bugcollect.android", "version": "1.0.0" }, + "sessions": [ + { + "sessionId": "sess-abc-123", + "appKey": "2000111111110002", + "status": "exited", + "platform": "android", + "release": "7.2.13", + "environment": "production", + "startedAt": 1718620100000, + "duration": 95000, + "user": { "id": "user-123" }, + "errors": 0 + } + ] +} +``` + +#### Session 状态 + +| `status` | 说明 | +|---------|------| +| `ok` | 进行中(心跳上报) | +| `exited` | 正常退出(用户主动离开或 App 进入后台超时) | +| `crashed` | 因未捕获异常终止 | +| `abnormal` | 进程被系统杀死(ANR、OOM、后台清理) | + +#### 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `sessionId` | string | ✅ | 会话唯一 ID(UUID) | +| `appKey` | string | ✅ | 应用标识 | +| `status` | string | ✅ | 会话状态,见上表 | +| `platform` | string | ✅ | 平台 | +| `release` | string | | 应用版本 | +| `environment` | string | | 运行环境 | +| `startedAt` | long | ✅ | 会话开始时间戳(毫秒) | +| `duration` | long | | 会话时长(毫秒) | +| `user.id` | string | | 用户 ID | +| `errors` | int | | 会话内捕获的 error 数量 | + +--- + +## 5. 数据模型 + +### 5.1 批量信封 Envelope + +所有批量上报端点共享统一的信封结构。信封层的 `sdk` 字段作为默认值,事件层不再重复携带。 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `sentAt` | string (ISO 8601) | | 客户端发送时间,用于时钟偏差校正 | +| `sdk` | object | | SDK 信息 | +| `sdk.name` | string | | SDK 标识,如 `bugcollect.android`、`bugcollect.ios`、`bugcollect.harmonyos`、`bugcollect.rn`、`bugcollect.web` | +| `sdk.version` | string | | SDK 版本号 | +| `events` | array | ✅ | 事件数组,不能为空 | + +--- + +### 5.2 IssueEvent + +错误事件数据模型,用于 `captureError()` 和 `captureCrash()`。 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `eventId` | string (UUID) | ✅ | 客户端生成的唯一事件 ID,服务端按此去重 | +| `appKey` | string | ✅ | 应用标识 | +| `level` | string | ✅ | 严重级别,见 [Level 枚举](#551-level) | +| `platform` | string | ✅ | 平台,见 [Platform 枚举](#552-platform) | +| `fingerprint` | string | ✅ | 错误指纹,64 字符 SHA-256 哈希 | +| `timestamp` | long | ✅ | 事件时间戳(毫秒) | +| `exception` | object | | 异常信息 | +| `exception.type` | string | | 异常类名,如 `NullPointerException` | +| `exception.value` | string | | 异常消息 | +| `exception.stacktrace` | string | | 完整堆栈文本 | +| `breadcrumbs` | array | | 崩溃前操作轨迹,见 [Breadcrumb](#54-breadcrumb),最多 50 条 | +| `release` | string | | 应用版本号 | +| `environment` | string | | 运行环境,默认 `production` | +| `user` | object | | 用户信息 | +| `user.id` | string | | 用户 ID | +| `device` | object | | 设备信息,见下表 | +| `tags` | object | | 自定义标签,key-value 均为 string | + +#### device 字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `name` | string | 设备名称,如 `Pixel 7` | +| `model` | string | 设备型号 | +| `manufacturer` | string | 制造商 | +| `osName` | string | 操作系统名称 | +| `osVersion` | string | 操作系统版本 | +| `locale` | string | 语言区域,如 `zh_CN` | +| `timezone` | string | 时区,如 `Asia/Shanghai` | +| `network` | string | 网络类型:`wifi` / `cellular` / `none` / `unknown` | +| `isEmulator` | boolean | 是否模拟器(过滤开发噪声) | +| `freeMemoryMb` | int | 可用内存(MB) | +| `buildType` | string | 构建类型:`debug` / `release` | + +#### 最小上报示例 + +```json +{ + "eventId": "550e8400-e29b-41d4-a716-446655440000", + "appKey": "2000111111110002", + "level": "error", + "platform": "android", + "fingerprint": "abc123...", + "timestamp": 1718620200000, + "exception": { + "type": "Exception", + "value": "something went wrong", + "stacktrace": "..." + } +} +``` + +--- + +### 5.3 LogEvent + +自定义业务事件数据模型,用于 `event()` 方法。 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `eventId` | string (UUID) | ✅ | 客户端生成的唯一事件 ID,服务端按此去重 | +| `appKey` | string | ✅ | 应用标识 | +| `name` | string | ✅ | 事件名称,如 `page_view`、`button_click` | +| `timestamp` | long | ✅ | 事件时间戳(毫秒) | +| `properties` | object | | 事件属性,任意 key-value | +| `platform` | string | | 平台 | +| `release` | string | | 应用版本号 | +| `environment` | string | | 运行环境 | +| `user` | object | | 用户信息 | +| `user.id` | string | | 用户 ID | +| `device` | object | | 设备信息(同 IssueEvent.device) | + +--- + +### 5.4 Breadcrumb + +崩溃前的操作轨迹条目,自动附带在 IssueEvent 的 `breadcrumbs` 数组中,最多保留最近 50 条。 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `timestamp` | long | ✅ | 轨迹时间戳(毫秒) | +| `category` | string | ✅ | 分类,见下表 | +| `message` | string | ✅ | 描述文本 | +| `level` | string | | `info` / `warning` / `error`,默认 `info` | +| `data` | object | | 额外键值对,如 `url`、`statusCode`、`method` | + +#### 标准 Category + +| category | 说明 | 典型 data 字段 | +|----------|------|---------------| +| `navigation` | 页面/路由切换 | `from`, `to` | +| `network` | HTTP 请求 | `url`, `method`, `statusCode`, `durationMs` | +| `ui` | 用户交互 | `action`(tap/swipe),`target` | +| `lifecycle` | App / Activity 生命周期 | `event`(foreground/background) | +| `console` | 手动打点日志 | — | +| `error` | SDK 内部捕获的非致命错误 | — | + +--- + +### 5.5 枚举定义 + +#### 5.5.1 Level + +错误严重级别,从高到低排列。 + +| 值 | 含义 | 对应 SDK 方法 | Web 展示颜色 | +|----|------|--------------|-------------| +| `fatal` | 未捕获崩溃,应用即将终止 | `captureCrash()` | 🔴 深红 | +| `error` | 捕获的错误,不影响应用存活 | `captureError()` | 🔴 红 | +| `warning` | 警告,潜在问题 | `warn()` | 🟡 黄 | +| `info` | 信息性记录 | `info()` | 🔵 蓝 | +| `debug` | 调试信息 | `debug()` | ⚪ 灰 | + +**服务端存储策略**: + +- `fatal` / `error` → 写入 `log_issues` 表参与 Issue 聚合,同时写入 `log_issue_events` 表记录明细 +- `warning` / `info` / `debug` → **仅写入** `log_issue_events` 表,不创建或更新 Issue,不触发告警 + +#### 5.5.2 Platform + +| 值 | 说明 | +|----|------| +| `android` | Android 原生 | +| `ios` | iOS 原生 | +| `harmonyos` | HarmonyOS 原生 | +| `react-native` | React Native 跨平台 | +| `web` | Web 浏览器 | +| `java-server` | Java 服务端 | +| `python-server` | Python 服务端 | +| `go-server` | Go 服务端 | + +#### 5.5.3 Environment + +| 值 | 说明 | +|----|------| +| `production` | 生产环境(默认) | +| `staging` | 预发布环境 | +| `integration` | 集成测试环境 | +| `development` | 开发环境 | + +#### 5.5.4 Issue Status + +| 值 | 说明 | +|----|------| +| `open` | 未处理(默认) | +| `resolved` | 已解决 | +| `ignored` | 已忽略,不再告警 | + +--- + +## 6. 查询端点 + +> 以下端点均为 `GET` 请求,返回数据格式统一为 `ApiResponse`。 + +### 通用响应格式 + +```json +{ + "code": 200, + "message": "success", + "data": { ... } +} +``` + +### 通用分页格式 + +```json +{ + "items": [], + "page": 1, + "size": 20, + "total": 100 +} +``` + +--- + +### 6.1 Issue 列表 + +``` +GET /bugcollect/v1/issues +``` + +#### 查询参数 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `appKey` | string | ✅ | | 应用标识 | +| `level` | string | | | 级别筛选:`fatal` / `error` / `warning` | +| `status` | string | | `open` | 状态筛选:`open` / `resolved` / `ignored` | +| `platform` | string | | | 平台筛选 | +| `release` | string | | | 版本筛选 | +| `q` | string | | | 按 Issue 标题全文搜索 | +| `from` | string | | | 起始日期 `YYYY-MM-DD` | +| `to` | string | | | 结束日期 `YYYY-MM-DD` | +| `page` | int | | 1 | 页码(从 1 开始) | +| `size` | int | | 20 | 每页条数 | + +#### 响应 + +```json +{ + "code": 200, + "message": "success", + "data": { + "items": [ + { + "id": 1, + "appKey": "2000111111110002", + "fingerprint": "abc123...", + "level": "error", + "status": "open", + "title": "IllegalStateException: 更新服务未开通", + "firstSeenAt": "2026-06-17T10:00:00", + "lastSeenAt": "2026-06-17T12:00:00", + "count": 15, + "affectedUsers": 8, + "platform": "android", + "release": "7.2.13" + } + ], + "page": 1, + "size": 20, + "total": 1 + } +} +``` + +--- + +### 6.2 Issue 详情 + +仅返回 Issue 元数据。事件明细通过 [6.3 Issue 事件列表](#63-issue-事件列表) 分页获取。 + +``` +GET /bugcollect/v1/issues/{id} +``` + +#### 路径参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `id` | long | Issue ID | + +#### 响应 + +```json +{ + "code": 200, + "message": "success", + "data": { + "id": 1, + "appKey": "2000111111110002", + "fingerprint": "abc123...", + "level": "error", + "status": "open", + "title": "IllegalStateException: 更新服务未开通", + "firstSeenAt": "2026-06-17T10:00:00", + "lastSeenAt": "2026-06-17T12:00:00", + "count": 15, + "affectedUsers": 8, + "platform": "android", + "release": "7.2.13", + "assignee": null + } +} +``` + +--- + +### 6.3 Issue 事件列表 + +分页获取某个 Issue 下的所有事件明细,含完整堆栈、面包屑、设备信息。 + +``` +GET /bugcollect/v1/issues/{id}/events +``` + +#### 查询参数 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `id` | long | ✅(路径) | | Issue ID | +| `from` | string | | | 起始日期 | +| `to` | string | | | 结束日期 | +| `page` | int | | 1 | 页码 | +| `size` | int | | 20 | 每页条数 | + +#### 响应 + +```json +{ + "code": 200, + "message": "success", + "data": { + "items": [ + { + "id": 101, + "issueId": 1, + "eventId": "550e8400-e29b-41d4-a716-446655440000", + "appKey": "2000111111110002", + "level": "error", + "userId": "user-123", + "sessionId": "sess-abc", + "exception": { + "type": "IllegalStateException", + "value": "更新服务未开通", + "stacktrace": "java.lang.IllegalStateException: ...", + "stackSymbolicated": null + }, + "breadcrumbs": [ + { + "timestamp": 1718620195000, + "category": "navigation", + "message": "进入首页", + "level": "info" + } + ], + "tags": { "context": "checkAppUpdate" }, + "device": { + "osName": "Android", + "osVersion": "14", + "isEmulator": false, + "network": "wifi" + }, + "platform": "android", + "release": "7.2.13", + "environment": "production", + "sdkName": "bugcollect.android", + "sdkVersion": "1.0.0", + "createdAt": "2026-06-17T10:30:00" + } + ], + "page": 1, + "size": 20, + "total": 15 + } +} +``` + +--- + +### 6.4 Issue 趋势 + +获取某个 Issue 在指定时间范围内的每日发生次数趋势,用于判断问题是在收敛还是扩散。 + +``` +GET /bugcollect/v1/issues/{id}/trend +``` + +#### 查询参数 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `from` | string | | 近 14 天 | 起始日期 `YYYY-MM-DD` | +| `to` | string | | 今天 | 结束日期 `YYYY-MM-DD` | + +#### 响应 + +```json +{ + "code": 200, + "message": "success", + "data": { + "issueId": 1, + "points": [ + { "date": "2026-06-11", "count": 2, "affectedUsers": 1 }, + { "date": "2026-06-12", "count": 5, "affectedUsers": 3 }, + { "date": "2026-06-17", "count": 8, "affectedUsers": 5 } + ] + } +} +``` + +--- + +### 6.5 Event 列表 + +``` +GET /bugcollect/v1/events +``` + +#### 查询参数 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `appKey` | string | ✅ | | 应用标识 | +| `name` | string | | | 事件名称筛选 | +| `userId` | string | | | 用户 ID 筛选 | +| `from` | string | | | 起始日期 | +| `to` | string | | | 结束日期 | +| `page` | int | | 1 | 页码 | +| `size` | int | | 20 | 每页条数 | + +--- + +### 6.6 概览统计 + +``` +GET /bugcollect/v1/overview?appKey={appKey} +``` + +#### 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `appKey` | string | ✅ | 应用标识 | +| `from` | string | | 起始日期 | +| `to` | string | | 结束日期 | + +#### 响应 + +```json +{ + "code": 200, + "message": "success", + "data": { + "totalIssues": 42, + "openIssues": 35, + "todayNewIssues": 3, + "affectedUsers": 128, + "crashFreeSessionRate": 0.9971, + "crashRate": 0.05, + "crashRateTrend": [ + { "date": "2026-06-11", "count": 5, "rate": 0.03 }, + { "date": "2026-06-12", "count": 8, "rate": 0.05 } + ], + "topIssues": [ + { + "id": 1, + "title": "NullPointerException: ...", + "level": "error", + "count": 15, + "affectedUsers": 8 + } + ] + } +} +``` + +> **crashFreeSessionRate**:`1 - (crashed_sessions / total_sessions)`,行业标准指标,需 [Session 上报](#44-session-上报) 数据支撑。 + +--- + +### 6.7 频率排行 + +``` +GET /bugcollect/v1/issues/rankings/frequency +``` + +#### 查询参数 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `appKey` | string | ✅ | | 应用标识 | +| `from` | string | | | 起始日期 | +| `to` | string | | | 结束日期 | +| `limit` | int | | 20 | 返回条数 | + +--- + +### 6.8 风险排行 + +``` +GET /bugcollect/v1/issues/rankings/risk +``` + +风险分 = `count × level_weight × recency_weight`,`fatal` 权重最高。 + +#### 查询参数 + +同 [频率排行](#67-频率排行)。 + +--- + +### 6.9 漏斗分析 + +``` +GET /bugcollect/v1/events/funnel +``` + +#### 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `appKey` | string | ✅ | 应用标识 | +| `steps` | string | ✅ | 逗号分隔的步骤名,如 `page_view,click_submit,order_created` | +| `from` | string | | 起始日期 | +| `to` | string | | 结束日期 | + +#### 响应 + +```json +{ + "code": 200, + "message": "success", + "data": { + "steps": ["page_view", "click_submit", "order_created"], + "counts": [1000, 600, 300], + "rates": [100.0, 60.0, 30.0] + } +} +``` + +--- + +## 7. Issue 管理端点 + +> 以下端点均需租户 Token 鉴权(Web 端调用)。 + +### 7.1 解决 Issue + +``` +PUT /bugcollect/v1/issues/{id}/resolve +``` + +将 Issue 状态置为 `resolved`。若该 Issue 后续有新 Event 上报,状态自动回滚到 `open` 并触发 `issue.regressed` Webhook。 + +#### 响应 + +```json +{ "code": 200, "message": "success", "data": null } +``` + +--- + +### 7.2 忽略 Issue + +``` +PUT /bugcollect/v1/issues/{id}/ignore +``` + +将 Issue 状态置为 `ignored`。忽略后不再触发告警,不影响数据记录。 + +--- + +### 7.3 分配责任人 + +``` +PUT /bugcollect/v1/issues/{id}/assign +Content-Type: application/json +``` + +#### 请求体 + +```json +{ "assignee": "user@example.com" } +``` + +传 `null` 取消分配。 + +--- + +### 7.4 批量操作 + +``` +POST /bugcollect/v1/issues/bulk +Content-Type: application/json +``` + +#### 请求体 + +```json +{ + "ids": [1, 2, 3], + "action": "resolve" +} +``` + +#### action 枚举 + +| 值 | 说明 | +|----|------| +| `resolve` | 批量解决 | +| `ignore` | 批量忽略 | +| `reopen` | 批量重新打开 | + +--- + +## 8. Webhook + +### 8.1 查询 Webhook 列表 + +``` +GET /bugcollect/v1/webhooks?appKey={appKey} +``` + +### 8.2 创建 Webhook + +``` +POST /bugcollect/v1/webhooks +Content-Type: application/json +``` + +#### 请求体 + +```json +{ + "appKey": "2000111111110002", + "url": "https://hooks.example.com/notify", + "events": ["issue.created", "issue.fatal"], + "secret": "webhook-secret-key" +} +``` + +### 8.3 更新 Webhook + +``` +PUT /bugcollect/v1/webhooks/{id} +``` + +### 8.4 删除 Webhook + +``` +DELETE /bugcollect/v1/webhooks/{id} +``` + +### Webhook 事件类型 + +| 事件 | 触发时机 | +|------|---------| +| `issue.created` | 新 Issue 首次出现 | +| `issue.fatal` | `fatal` 级别事件上报时立即触发(不等去重) | +| `issue.threshold` | Issue 发生次数超过配置阈值(默认 100 次) | +| `issue.regressed` | 已解决的 Issue 重新出现 | +| `issue.resolved` | Issue 被标记为已解决(用于 CI/CD 集成) | + +### Webhook 回调体 + +```json +{ + "event": "issue.created", + "timestamp": "2026-06-17T10:30:00Z", + "issue": { + "id": 1, + "appKey": "2000111111110002", + "level": "error", + "status": "open", + "title": "IllegalStateException: 更新服务未开通", + "platform": "android", + "count": 1, + "affectedUsers": 1, + "firstSeenAt": "2026-06-17T10:30:00" + } +} +``` + +> **签名验证**:Webhook 请求携带 `X-BugCollect-Signature: sha256=` 请求头,接收方用 `secret` 验证请求合法性。 + +--- + +## 9. 错误码 + +| HTTP 状态码 | code | 说明 | +|------------|------|------| +| 200 | 200 | 成功 | +| 400 | 400 | 请求参数校验失败 | +| 400 | 40001 | `appKey` 不能为空 | +| 400 | 40002 | `events` / `sessions` 数组为空 | +| 400 | 40003 | `fingerprint` 格式非法(非 64 字符十六进制) | +| 400 | 40004 | `level` 不在枚举范围内 | +| 400 | 40005 | `platform` 不在枚举范围内 | +| 403 | 40301 | `appKey` 对应的 BugCollect 服务未开通 | +| 404 | 40401 | Issue 不存在 | +| 429 | 42901 | 请求频率超限 | +| 500 | 500 | 服务端内部错误 | + +--- + +## 10. Fingerprint 去重机制 + +### 默认算法 + +SDK 端默认使用以下公式生成 fingerprint(注意:不含 `level`,同一错误不同严重程度归为同一 Issue): + +``` +SHA-256( normalize(exception.type + ":" + exception.value) + ":" + top_3_stack_lines ) +``` + +- `normalize` — 移除数字字面量、UUID、URL、内存地址等易变内容 +- `top_3_stack_lines` — 取堆栈中前 3 行(跳过 SDK 内部框架层) + +### 自定义 Fingerprint + +SDK 端可传入自定义 fingerprint,覆盖默认算法: + +```kotlin +BugCollect.captureError(exception, tags = mapOf("context" to "checkAppUpdate"), fingerprint = "custom-group-key") +``` + +### 分组优先级 + +1. **自定义 fingerprint** — 如果 SDK 传入,完全按此分组 +2. **堆栈分组** — 按 `exception.type` + 归一化堆栈前 3 行 +3. **消息分组** — 无堆栈时按 `exception.type` + `exception.value` 分组 + +### 混淆代码处理 + +Android Release 包经 ProGuard / R8 混淆后,堆栈行类名如 `a.b.c.d` 无法直接分组。建议: + +1. 上传 [ProGuard mapping 文件](#43-sourcemap--符号文件上传) +2. 服务端符号化后将还原堆栈写入 `stack_symbolicated` +3. 对已符号化的堆栈重新计算 fingerprint(服务端异步任务) + +--- + +## 11. SDK 集成指南 + +### 11.1 Android SDK + +#### 初始化 + +SDK 通过 `config.xuqm` 自动初始化,无需手动调用初始化代码。DSN Token 已内含于配置文件。 + +```kotlin +// 无需手动初始化,ContentProvider 自动完成 +// 若需手动初始化: +XuqmSDK.initialize(context, appKey = "2000111111110002") +``` + +#### 设置用户 + +通过 `XuqmSDK` 统一管理用户信息,BugCollect 自动跟随: + +```kotlin +XuqmSDK.setUserInfo(XuqmUserInfo(userId = "user-123")) +``` + +#### 捕获错误 + +```kotlin +try { + // 业务代码 +} catch (e: Exception) { + BugCollect.captureError(e, tags = mapOf("context" to "checkAppUpdate")) +} +``` + +#### 记录面包屑 + +```kotlin +BugCollect.addBreadcrumb(category = "navigation", message = "进入首页") +BugCollect.addBreadcrumb( + category = "network", + message = "GET /api/update → 404", + level = "warning", + data = mapOf("url" to url, "statusCode" to 404) +) +``` + +#### 记录事件 + +```kotlin +BugCollect.event("page_view", properties = mapOf("page" to "/home")) +``` + +#### 设置环境 + +```kotlin +BugCollect.setEnvironment("staging") +``` + +### 11.2 React Native SDK + +```typescript +import { BugCollect } from '@xuqm/rn-bugcollect'; + +// 捕获错误 +try { ... } catch (e) { + BugCollect.captureError(e, { tags: { context: 'checkAppUpdate' } }); +} + +// 记录面包屑 +BugCollect.addBreadcrumb({ category: 'navigation', message: '进入首页' }); + +// 记录事件 +BugCollect.event('page_view', { page: '/home' }); +``` + +### 11.3 Web SDK + +```javascript +import { BugCollect } from '@xuqm/bugcollect-web'; + +BugCollect.init({ appKey: '2000111111110002' }); + +// 自动捕获未处理异常(window.onerror / unhandledrejection) +// 手动捕获 +try { ... } catch (e) { + BugCollect.captureError(e); +} +``` + +### 11.4 批量上报策略 + +SDK 内部自动管理队列,开发者无需关心。 + +| 参数 | 值 | 说明 | +|------|------|------| +| 批量大小 | 30 条 | 积攒 30 条后立即发送 | +| 定时刷新 | 10 秒 | 未满 30 条时每 10 秒发送一次 | +| 最大队列 | 500 条 | 超出后丢弃最旧的 | +| 持久化范围 | `fatal` / `error` | 发送失败写入磁盘,下次启动补发;`warning` 及以下仅内存队列 | +| 重试 | 1 次 | 发送失败重试 1 次,失败后重新入队 | +| 幂等保障 | `eventId` | 每条 Event 携带 UUID,服务端按此去重,重试不会产生重复数据 | + +--- + +## 12. 数据库 Schema + +### log_issues — Issue 聚合表 + +```sql +CREATE TABLE log_issues ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + app_key VARCHAR(64) NOT NULL, + fingerprint VARCHAR(128) NOT NULL, + level VARCHAR(20) NOT NULL DEFAULT 'error', + status VARCHAR(20) NOT NULL DEFAULT 'open', -- open | resolved | ignored + assignee VARCHAR(200), + title VARCHAR(500), + first_seen_at DATETIME NOT NULL, + last_seen_at DATETIME NOT NULL, + count INT NOT NULL DEFAULT 1, + affected_users INT NOT NULL DEFAULT 0, + platform VARCHAR(30), + release VARCHAR(50), + UNIQUE KEY uk_app_fingerprint (app_key, fingerprint), + INDEX idx_app_key_last_seen (app_key, status, last_seen_at), + INDEX idx_app_key_level (app_key, level, last_seen_at) +); +``` + +### log_issue_events — Issue 事件明细表 + +```sql +CREATE TABLE log_issue_events ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + issue_id BIGINT NOT NULL, + event_id VARCHAR(64) NOT NULL, -- 客户端幂等 ID + app_key VARCHAR(64) NOT NULL, + level VARCHAR(20) NOT NULL DEFAULT 'error', + user_id VARCHAR(128), + session_id VARCHAR(128), + exception_type VARCHAR(200), + exception_value TEXT, + stack LONGTEXT, + stack_symbolicated LONGTEXT, + breadcrumbs JSON, + tags JSON, + device JSON, + platform VARCHAR(30), + release VARCHAR(50), + environment VARCHAR(50) DEFAULT 'production', + sdk_name VARCHAR(100), + sdk_version VARCHAR(20), + created_at DATETIME NOT NULL, + UNIQUE KEY uk_event_id (app_key, event_id), + INDEX idx_issue_created (issue_id, created_at), + INDEX idx_app_key (app_key) +); +``` + +### log_events — 自定义事件表 + +```sql +CREATE TABLE log_events ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + event_id VARCHAR(64) NOT NULL, -- 客户端幂等 ID + app_key VARCHAR(64) NOT NULL, + name VARCHAR(200) NOT NULL, + user_id VARCHAR(128), + session_id VARCHAR(128), + properties JSON, + platform VARCHAR(30), + release VARCHAR(50), + environment VARCHAR(50) DEFAULT 'production', + device JSON, + sdk_name VARCHAR(100), + sdk_version VARCHAR(20), + created_at DATETIME NOT NULL, + UNIQUE KEY uk_event_id (app_key, event_id), + INDEX idx_app_key_name (app_key, name), + INDEX idx_app_key_user (app_key, user_id), + INDEX idx_app_key_created (app_key, created_at) +); +``` + +### log_sessions — Session 表 + +```sql +CREATE TABLE log_sessions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(64) NOT NULL, + app_key VARCHAR(64) NOT NULL, + status VARCHAR(20) NOT NULL, -- ok | exited | crashed | abnormal + user_id VARCHAR(128), + platform VARCHAR(30), + release VARCHAR(50), + environment VARCHAR(50) DEFAULT 'production', + started_at DATETIME NOT NULL, + duration_ms BIGINT, + errors INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + UNIQUE KEY uk_session_id (app_key, session_id), + INDEX idx_app_key_started (app_key, started_at), + INDEX idx_app_key_status (app_key, status, started_at) +); +``` + +### sourcemaps — 符号文件表 + +```sql +CREATE TABLE sourcemaps ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + app_key VARCHAR(64) NOT NULL, + platform VARCHAR(30) NOT NULL, + app_version VARCHAR(50) NOT NULL, + bundle_name VARCHAR(100) DEFAULT 'index', + file_path VARCHAR(500) NOT NULL, + uploaded_at DATETIME NOT NULL, + UNIQUE KEY uk_app_version_bundle (app_key, platform, app_version, bundle_name) +); +``` + +--- + +## 13. 变更日志 + +### v1.1.0(2026-06-17) + +**New Features:** + +- 上报认证:明确认证链路,`appKey` 由 SDK 从 `config.xuqm` 自动读取;服务端新增 BugCollect 服务激活校验(`t_feature_service.enabled`),未开通的 appKey 请求返回 `403 / 40301` +- 幂等保障:IssueEvent / LogEvent 新增必填 `eventId`(UUID),服务端按此去重 +- Breadcrumbs:IssueEvent 新增 `breadcrumbs` 数组,携带崩溃前最近 50 条操作轨迹 +- device 字段扩充:新增 `locale`、`timezone`、`network`、`isEmulator`、`freeMemoryMb`、`buildType` +- Session 上报:新增 `POST /bugcollect/v1/sessions/batch`,支持 crash-free session rate 计算 +- 符号化扩展:SourceMap 上传新增 `android`(ProGuard mapping)、`ios`(dSYM)平台支持 +- Issue 管理端点:新增 resolve / ignore / assign / bulk 操作 +- Issue 状态:`isResolved: boolean` 替换为 `status: open | resolved | ignored`,新增 `affectedUsers` 字段 +- Issue 详情拆分:`GET /issues/{id}` 不再内嵌 Event 列表,新增 `GET /issues/{id}/events` 分页接口 +- Issue 趋势:新增 `GET /issues/{id}/trend` +- Webhook 事件扩充:新增 `issue.fatal`、`issue.threshold`、`issue.resolved`;回调体新增签名校验头 +- 概览统计:新增 `crashFreeSessionRate`、`openIssues`、`affectedUsers` +- 全文搜索:Issue 列表新增 `q` 参数 +- 版本筛选:Issue 列表新增 `release` 参数 +- Environment:新增 `integration` 枚举值 +- 数据库:新增 `log_sessions` 表;`log_issues` 新增 `status`/`affected_users`/`assignee` 列;`log_issue_events` 新增 `event_id`/`breadcrumbs` 列;补全缺失索引 + +**Breaking Changes:** + +- `isResolved` 字段已移除,改用 `status`(`isResolved: false` → `status: "open"`,`isResolved: true` → `status: "resolved"`) +- Issue 详情响应不再包含 `events` 数组,需改用 `GET /issues/{id}/events` +- 每条 Event 现在必须携带 `eventId`;缺失时返回 `400 / 40006` +- BugCollect 服务未激活的 appKey 上报请求返回 `403 / 40301` + +**Bug Fixes:** + +- 修复 Fingerprint 算法中包含 `level` 字段的问题:相同堆栈的 `error` 和 `warning` 现在归为同一 Issue +- 修复 Issue 列表查询参数使用废弃的 `type` 字段问题,统一改为 `level` +- 修复 SDK 集成指南中 `BugCollect.setUser()` 错误引用,统一改为 `XuqmSDK.setUserInfo()` +- 修复 Issue 详情示例响应中 `metadata` 字段未同步改名为 `tags` 的问题 + +### v1.0.0(2026-06-17) + +**Breaking Changes:** + +- 上报端点路径从 `/log/v1/` 统一为 `/bugcollect/v1/` +- Issue 上报请求体从裸 JSON 数组改为信封格式 `{ "sentAt", "sdk", "events" }` +- Issue 事件中 `type` 字段(`android_error` / `native_crash`)替换为 `level` 字段(`fatal` / `error` / `warning` / `info` / `debug`) +- Issue 事件中 `message` + `stack` 平铺字段归入 `exception` 对象(`exception.type` / `exception.value` / `exception.stacktrace`) +- Issue 事件中 `metadata` 重命名为 `tags` +- Issue 事件中 `appVersion` 重命名为 `release` +- Analytics 事件请求体同步改为信封格式 + +**New Features:** + +- Issue 事件新增 `user`(用户信息)、`device`(设备信息)、`sdk`(SDK 自标识)字段 +- Issue 事件新增 `environment` 字段(`production` / `staging` / `development`) +- Analytics 事件新增 `user`、`device`、`sdk`、`environment` 字段 +- 信封层新增 `sentAt`(时钟偏差校正)和 `sdk`(SDK 版本追踪) +- 查询端点新增 `level` 筛选参数 diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java index 72e519e..11c3bf7 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java @@ -28,6 +28,8 @@ public class LogController { this.webhookService = webhookService; } + // ── Ingestion ────────────────────────────────────────────────────────────── + @PostMapping("/issues/batch") public ApiResponse ingestIssues(@Valid @RequestBody IssueBatchRequest request) { logService.processIssueBatch(request); @@ -40,16 +42,20 @@ public class LogController { return ApiResponse.ok(); } + // ── Issues ───────────────────────────────────────────────────────────────── + @GetMapping("/issues") public ApiResponse> queryIssues( @RequestParam String appKey, - @RequestParam(required = false) String type, + @RequestParam(required = false) String level, @RequestParam(required = false) String platform, + @RequestParam(required = false) String status, + @RequestParam(required = false) String q, @RequestParam(required = false) String from, @RequestParam(required = false) String to, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { - Page result = logService.queryIssues(appKey, type, platform, from, to, page - 1, size); + Page result = logService.queryIssues(appKey, level, platform, status, q, from, to, page - 1, size); return ApiResponse.success(PageResult.of(result.getContent(), result.getTotalElements(), page, size)); } @@ -58,6 +64,63 @@ public class LogController { return ApiResponse.success(logService.getIssueDetail(id)); } + @GetMapping("/issues/{id}/events") + public ApiResponse> getIssueEvents( + @PathVariable Long id, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + Page result = logService.getIssueEvents(id, from, to, page - 1, size); + return ApiResponse.success(PageResult.of(result.getContent(), result.getTotalElements(), page, size)); + } + + @GetMapping("/issues/{id}/trend") + public ApiResponse getIssueTrend( + @PathVariable Long id, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to) { + return ApiResponse.success(logService.getIssueTrend(id, from, to)); + } + + @DeleteMapping("/issues/{id}") + public ApiResponse deleteIssue(@PathVariable Long id) { + logService.deleteIssueAndEvents(id); + return ApiResponse.ok(); + } + + // ── Issue management ─────────────────────────────────────────────────────── + + @PutMapping("/issues/{id}/resolve") + public ApiResponse resolveIssue(@PathVariable Long id) { + logService.resolveIssue(id); + return ApiResponse.ok(); + } + + @PutMapping("/issues/{id}/ignore") + public ApiResponse ignoreIssue(@PathVariable Long id) { + logService.ignoreIssue(id); + return ApiResponse.ok(); + } + + @PutMapping("/issues/{id}/assign") + public ApiResponse assignIssue( + @PathVariable Long id, + @RequestParam String assignee) { + logService.assignIssue(id, assignee); + return ApiResponse.ok(); + } + + @PostMapping("/issues/bulk") + public ApiResponse bulkUpdateIssues( + @RequestParam String appKey, + @Valid @RequestBody IssueActionRequest request) { + logService.bulkUpdateIssues(appKey, request.ids(), request.action()); + return ApiResponse.ok(); + } + + // ── Rankings ─────────────────────────────────────────────────────────────── + @GetMapping("/issues/rankings/frequency") public ApiResponse> getFrequencyRankings( @RequestParam String appKey, @@ -76,6 +139,8 @@ public class LogController { return ApiResponse.success(logService.getRiskRankings(appKey, from, to, limit)); } + // ── Analytics events ─────────────────────────────────────────────────────── + @GetMapping("/events") public ApiResponse> queryEvents( @RequestParam String appKey, @@ -95,10 +160,11 @@ public class LogController { @RequestParam String steps, @RequestParam(required = false) String from, @RequestParam(required = false) String to) { - List stepList = List.of(steps.split(",")); - return ApiResponse.success(logService.queryFunnel(appKey, stepList, from, to)); + return ApiResponse.success(logService.queryFunnel(appKey, List.of(steps.split(",")), from, to)); } + // ── Overview ─────────────────────────────────────────────────────────────── + @GetMapping("/overview") public ApiResponse getOverview( @RequestParam String appKey, @@ -107,6 +173,8 @@ public class LogController { return ApiResponse.success(logService.getOverview(appKey, from, to)); } + // ── Sourcemaps ───────────────────────────────────────────────────────────── + @PostMapping("/sourcemaps/upload") public ApiResponse uploadSourcemap( @RequestParam String appKey, @@ -117,6 +185,8 @@ public class LogController { return ApiResponse.success(sourcemapService.upload(appKey, platform, appVersion, bundleName, file)); } + // ── Webhooks ─────────────────────────────────────────────────────────────── + @GetMapping("/webhooks") public ApiResponse> listWebhooks(@RequestParam String appKey) { return ApiResponse.success(webhookService.listWebhooks(appKey)); diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/EventBatchRequest.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/EventBatchRequest.java index bb411e2..3c50350 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/EventBatchRequest.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/EventBatchRequest.java @@ -6,16 +6,21 @@ import jakarta.validation.constraints.NotEmpty; import java.util.List; public record EventBatchRequest( + @JsonProperty("sentAt") String sentAt, + @JsonProperty("sdk") IssueBatchRequest.SdkInfo sdk, @JsonProperty("events") @NotEmpty List events ) { public record EventItem( + @JsonProperty("eventId") String eventId, @NotBlank @JsonProperty("appKey") String appKey, @NotBlank String name, - @JsonProperty("userId") String userId, - @JsonProperty("sessionId") String sessionId, - String properties, + long timestamp, + @JsonProperty("properties") Object properties, String platform, - @JsonProperty("appVersion") String appVersion, - @JsonProperty("timestamp") long timestamp + String release, + String environment, + @JsonProperty("user") IssueBatchRequest.UserInfo user, + @JsonProperty("device") IssueBatchRequest.DeviceInfo device, + @JsonProperty("sdk") IssueBatchRequest.SdkInfo sdk ) {} } diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueActionRequest.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueActionRequest.java new file mode 100644 index 0000000..4ef9ad8 --- /dev/null +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueActionRequest.java @@ -0,0 +1,10 @@ +package com.xuqm.bugcollect.dto; + +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +public record IssueActionRequest( + @NotEmpty List ids, + String action, + String assignee +) {} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueBatchRequest.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueBatchRequest.java index 9bedaaa..557157c 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueBatchRequest.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueBatchRequest.java @@ -4,20 +4,63 @@ import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import java.util.List; +import java.util.Map; public record IssueBatchRequest( + @JsonProperty("sentAt") String sentAt, + @JsonProperty("sdk") SdkInfo sdk, @JsonProperty("events") @NotEmpty List events ) { + public record SdkInfo( + String name, + String version + ) {} + public record IssueEventItem( - @NotBlank String type, - String message, - String stack, - @NotBlank String fingerprint, - @JsonProperty("timestamp") long timestamp, - @JsonProperty("userId") String userId, - @JsonProperty("sessionId") String sessionId, + @JsonProperty("eventId") String eventId, @NotBlank @JsonProperty("appKey") String appKey, - String platform, - @JsonProperty("appVersion") String appVersion + @NotBlank String level, + @NotBlank String platform, + @NotBlank String fingerprint, + long timestamp, + @JsonProperty("exception") ExceptionInfo exception, + @JsonProperty("breadcrumbs") List breadcrumbs, + @JsonProperty("user") UserInfo user, + @JsonProperty("device") DeviceInfo device, + String release, + String environment, + @JsonProperty("tags") Object tags + ) {} + + public record ExceptionInfo( + String type, + String value, + String stacktrace + ) {} + + public record BreadcrumbItem( + long timestamp, + String category, + String message, + String level, + @JsonProperty("data") Map data + ) {} + + public record UserInfo( + String id + ) {} + + public record DeviceInfo( + String name, + String model, + String manufacturer, + String osName, + String osVersion, + String locale, + String timezone, + String network, + Boolean isEmulator, + Integer freeMemoryMb, + String buildType ) {} } diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueEventResponse.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueEventResponse.java index df978c1..b176368 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueEventResponse.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueEventResponse.java @@ -6,14 +6,22 @@ import java.time.LocalDateTime; public record IssueEventResponse( Long id, @JsonProperty("issueId") Long issueId, + @JsonProperty("eventId") String eventId, @JsonProperty("appKey") String appKey, @JsonProperty("userId") String userId, @JsonProperty("sessionId") String sessionId, + @JsonProperty("exceptionType") String exceptionType, + @JsonProperty("exceptionValue") String exceptionValue, String message, String stack, @JsonProperty("stackSymbolicated") String stackSymbolicated, - String metadata, + String breadcrumbs, + String tags, + String device, String platform, - @JsonProperty("appVersion") String appVersion, + String release, + String environment, + @JsonProperty("sdkName") String sdkName, + @JsonProperty("sdkVersion") String sdkVersion, @JsonProperty("createdAt") LocalDateTime createdAt ) {} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueResponse.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueResponse.java index db91738..dfb73d2 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueResponse.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueResponse.java @@ -8,13 +8,16 @@ public record IssueResponse( Long id, @JsonProperty("appKey") String appKey, String fingerprint, - String type, + String level, + String status, String title, @JsonProperty("firstSeenAt") LocalDateTime firstSeenAt, @JsonProperty("lastSeenAt") LocalDateTime lastSeenAt, int count, + @JsonProperty("affectedUsers") int affectedUsers, @JsonProperty("isResolved") boolean isResolved, + String assignee, String platform, - @JsonProperty("appVersion") String appVersion, + String release, List events ) {} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueTrendResponse.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueTrendResponse.java new file mode 100644 index 0000000..c0cc60a --- /dev/null +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueTrendResponse.java @@ -0,0 +1,10 @@ +package com.xuqm.bugcollect.dto; + +import java.util.List; + +public record IssueTrendResponse( + Long issueId, + List points +) { + public record TrendPoint(String date, long count, long affectedUsers) {} +} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogEventEntity.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogEventEntity.java index bdff55a..d310139 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogEventEntity.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogEventEntity.java @@ -11,6 +11,9 @@ public class LogEventEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(length = 64) + private String eventId; + @Column(nullable = false, length = 64) private String appKey; @@ -29,8 +32,20 @@ public class LogEventEntity { @Column(length = 16) private String platform; - @Column(length = 32) - private String appVersion; + @Column(name = "app_version", length = 32) + private String release; + + @Column(length = 50) + private String environment; + + @Column(columnDefinition = "JSON") + private String device; + + @Column(length = 100) + private String sdkName; + + @Column(length = 20) + private String sdkVersion; @Column(nullable = false) private LocalDateTime createdAt; @@ -38,6 +53,9 @@ public class LogEventEntity { public Long getId() { return id; } public void setId(Long id) { this.id = id; } + public String getEventId() { return eventId; } + public void setEventId(String eventId) { this.eventId = eventId; } + public String getAppKey() { return appKey; } public void setAppKey(String appKey) { this.appKey = appKey; } @@ -56,8 +74,20 @@ public class LogEventEntity { public String getPlatform() { return platform; } public void setPlatform(String platform) { this.platform = platform; } - public String getAppVersion() { return appVersion; } - public void setAppVersion(String appVersion) { this.appVersion = appVersion; } + public String getRelease() { return release; } + public void setRelease(String release) { this.release = release; } + + public String getEnvironment() { return environment; } + public void setEnvironment(String environment) { this.environment = environment; } + + public String getDevice() { return device; } + public void setDevice(String device) { this.device = device; } + + public String getSdkName() { return sdkName; } + public void setSdkName(String sdkName) { this.sdkName = sdkName; } + + public String getSdkVersion() { return sdkVersion; } + public void setSdkVersion(String sdkVersion) { this.sdkVersion = sdkVersion; } public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueEntity.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueEntity.java index c80951a..9cf6647 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueEntity.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueEntity.java @@ -20,6 +20,13 @@ public class LogIssueEntity { @Column(nullable = false, length = 32) private String type; + @Column(nullable = false, length = 20) + private String level; + + /** open | resolved | ignored */ + @Column(nullable = false, length = 20) + private String status = "open"; + @Column(nullable = false, length = 500) private String title; @@ -32,14 +39,20 @@ public class LogIssueEntity { @Column(nullable = false) private int count = 1; + @Column(nullable = false) + private int affectedUsers = 0; + @Column(nullable = false, columnDefinition = "TINYINT(1)") private boolean isResolved = false; + @Column(length = 200) + private String assignee; + @Column(length = 16) private String platform; - @Column(length = 32) - private String appVersion; + @Column(name = "app_version", length = 32) + private String release; public Long getId() { return id; } public void setId(Long id) { this.id = id; } @@ -53,6 +66,15 @@ public class LogIssueEntity { public String getType() { return type; } public void setType(String type) { this.type = type; } + public String getLevel() { return level; } + public void setLevel(String level) { this.level = level; } + + public String getStatus() { return status; } + public void setStatus(String status) { + this.status = status; + this.isResolved = "resolved".equals(status); + } + public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } @@ -65,12 +87,21 @@ public class LogIssueEntity { public int getCount() { return count; } public void setCount(int count) { this.count = count; } + public int getAffectedUsers() { return affectedUsers; } + public void setAffectedUsers(int affectedUsers) { this.affectedUsers = affectedUsers; } + public boolean isResolved() { return isResolved; } - public void setResolved(boolean resolved) { isResolved = resolved; } + public void setResolved(boolean resolved) { + isResolved = resolved; + if (resolved && !"resolved".equals(this.status)) this.status = "resolved"; + } + + public String getAssignee() { return assignee; } + public void setAssignee(String assignee) { this.assignee = assignee; } public String getPlatform() { return platform; } public void setPlatform(String platform) { this.platform = platform; } - public String getAppVersion() { return appVersion; } - public void setAppVersion(String appVersion) { this.appVersion = appVersion; } + public String getRelease() { return release; } + public void setRelease(String release) { this.release = release; } } diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueEventEntity.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueEventEntity.java index 25fde7c..a4f31b8 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueEventEntity.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/entity/LogIssueEventEntity.java @@ -14,15 +14,27 @@ public class LogIssueEventEntity { @Column(nullable = false) private Long issueId; + @Column(length = 64) + private String eventId; + @Column(nullable = false, length = 64) private String appKey; + @Column(length = 20) + private String level; + @Column(length = 128) private String userId; @Column(length = 128) private String sessionId; + @Column(length = 200) + private String exceptionType; + + @Column(columnDefinition = "TEXT") + private String exceptionValue; + @Column(columnDefinition = "TEXT") private String message; @@ -33,13 +45,28 @@ public class LogIssueEventEntity { private String stackSymbolicated; @Column(columnDefinition = "JSON") - private String metadata; + private String breadcrumbs; + + @Column(columnDefinition = "JSON") + private String tags; @Column(length = 16) private String platform; - @Column(length = 32) - private String appVersion; + @Column(name = "app_version", length = 32) + private String release; + + @Column(length = 50) + private String environment; + + @Column(columnDefinition = "JSON") + private String device; + + @Column(length = 100) + private String sdkName; + + @Column(length = 20) + private String sdkVersion; @Column(nullable = false) private LocalDateTime createdAt; @@ -50,15 +77,27 @@ public class LogIssueEventEntity { public Long getIssueId() { return issueId; } public void setIssueId(Long issueId) { this.issueId = issueId; } + public String getEventId() { return eventId; } + public void setEventId(String eventId) { this.eventId = eventId; } + public String getAppKey() { return appKey; } public void setAppKey(String appKey) { this.appKey = appKey; } + public String getLevel() { return level; } + public void setLevel(String level) { this.level = level; } + public String getUserId() { return userId; } public void setUserId(String userId) { this.userId = userId; } public String getSessionId() { return sessionId; } public void setSessionId(String sessionId) { this.sessionId = sessionId; } + public String getExceptionType() { return exceptionType; } + public void setExceptionType(String exceptionType) { this.exceptionType = exceptionType; } + + public String getExceptionValue() { return exceptionValue; } + public void setExceptionValue(String exceptionValue) { this.exceptionValue = exceptionValue; } + public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } @@ -68,14 +107,29 @@ public class LogIssueEventEntity { public String getStackSymbolicated() { return stackSymbolicated; } public void setStackSymbolicated(String stackSymbolicated) { this.stackSymbolicated = stackSymbolicated; } - public String getMetadata() { return metadata; } - public void setMetadata(String metadata) { this.metadata = metadata; } + public String getBreadcrumbs() { return breadcrumbs; } + public void setBreadcrumbs(String breadcrumbs) { this.breadcrumbs = breadcrumbs; } + + public String getTags() { return tags; } + public void setTags(String tags) { this.tags = tags; } public String getPlatform() { return platform; } public void setPlatform(String platform) { this.platform = platform; } - public String getAppVersion() { return appVersion; } - public void setAppVersion(String appVersion) { this.appVersion = appVersion; } + public String getRelease() { return release; } + public void setRelease(String release) { this.release = release; } + + public String getEnvironment() { return environment; } + public void setEnvironment(String environment) { this.environment = environment; } + + public String getDevice() { return device; } + public void setDevice(String device) { this.device = device; } + + public String getSdkName() { return sdkName; } + public void setSdkName(String sdkName) { this.sdkName = sdkName; } + + public String getSdkVersion() { return sdkVersion; } + public void setSdkVersion(String sdkVersion) { this.sdkVersion = sdkVersion; } public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogEventRepository.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogEventRepository.java index 1ebc47c..f126e10 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogEventRepository.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogEventRepository.java @@ -12,6 +12,8 @@ import java.util.List; public interface LogEventRepository extends JpaRepository { + boolean existsByAppKeyAndEventId(String appKey, String eventId); + Page findByAppKeyAndNameAndUserIdAndCreatedAtBetween( String appKey, String name, String userId, LocalDateTime from, LocalDateTime to, diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueEventRepository.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueEventRepository.java index 6e271fd..922f3d3 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueEventRepository.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueEventRepository.java @@ -4,17 +4,35 @@ import com.xuqm.bugcollect.entity.LogIssueEventEntity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; public interface LogIssueEventRepository extends JpaRepository { + boolean existsByAppKeyAndEventId(String appKey, String eventId); + List findTop20ByIssueIdOrderByCreatedAtDesc(Long issueId); + Page findByIssueIdOrderByCreatedAtDesc(Long issueId, Pageable pageable); + + Page findByIssueIdAndCreatedAtBetweenOrderByCreatedAtDesc( + Long issueId, LocalDateTime from, LocalDateTime to, Pageable pageable); + Page findByAppKeyAndCreatedAtBetween( String appKey, LocalDateTime from, LocalDateTime to, Pageable pageable); + @Query("SELECT e.createdAt, COUNT(e), COUNT(DISTINCT e.userId) FROM LogIssueEventEntity e " + + "WHERE e.issueId = :issueId AND e.createdAt BETWEEN :from AND :to " + + "GROUP BY FUNCTION('DATE', e.createdAt) ORDER BY FUNCTION('DATE', e.createdAt)") + List findDailyCountsByIssueId( + @Param("issueId") Long issueId, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + void deleteByIssueId(Long issueId); void deleteByAppKey(String appKey); diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueRepository.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueRepository.java index de48322..1e38334 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueRepository.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueRepository.java @@ -20,6 +20,25 @@ public interface LogIssueRepository extends JpaRepository @Query("UPDATE LogIssueEntity i SET i.count = i.count + 1, i.lastSeenAt = :now WHERE i.appKey = :appKey AND i.fingerprint = :fingerprint") int incrementCount(@Param("appKey") String appKey, @Param("fingerprint") String fingerprint, @Param("now") LocalDateTime now); + // v1.1: query by level instead of deprecated type + @Query("SELECT i FROM LogIssueEntity i WHERE i.appKey = :appKey " + + "AND (:level IS NULL OR i.level = :level) " + + "AND (:platform IS NULL OR i.platform = :platform) " + + "AND (:status IS NULL OR i.status = :status) " + + "AND (:q IS NULL OR LOWER(i.title) LIKE LOWER(CONCAT('%', :q, '%'))) " + + "AND (:from IS NULL OR i.lastSeenAt >= :from) " + + "AND (:to IS NULL OR i.lastSeenAt <= :to)") + Page findByFilters( + @Param("appKey") String appKey, + @Param("level") String level, + @Param("platform") String platform, + @Param("status") String status, + @Param("q") String q, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to, + Pageable pageable); + + // kept for legacy callers Page findByAppKeyAndTypeAndPlatformAndLastSeenAtBetween( String appKey, String type, String platform, LocalDateTime from, LocalDateTime to, @@ -30,16 +49,20 @@ public interface LogIssueRepository extends JpaRepository Page findByAppKey(String appKey, Pageable pageable); - @Query("SELECT i FROM LogIssueEntity i WHERE i.appKey = :appKey AND i.lastSeenAt BETWEEN :from AND :to ORDER BY i.count DESC") + @Query("SELECT i FROM LogIssueEntity i WHERE i.appKey = :appKey " + + "AND (:from IS NULL OR i.lastSeenAt >= :from) " + + "AND (:to IS NULL OR i.lastSeenAt <= :to) ORDER BY i.count DESC") List findTopByFrequency(@Param("appKey") String appKey, @Param("from") LocalDateTime from, @Param("to") LocalDateTime to, Pageable pageable); @Query(value = "SELECT i.*, " + - "(i.count * COALESCE((SELECT COUNT(DISTINCT e.user_id) FROM log_issue_events e WHERE e.issue_id = i.id AND e.user_id IS NOT NULL), 1) * " + - "CASE i.type WHEN 'native_crash' THEN 10 WHEN 'js_error' THEN 5 WHEN 'api_error' THEN 3 ELSE 1 END) AS risk_score " + - "FROM log_issues i WHERE i.app_key = :appKey AND i.last_seen_at BETWEEN :from AND :to " + + "(i.count * COALESCE(i.affected_users, 1) * " + + "CASE i.level WHEN 'fatal' THEN 10 WHEN 'error' THEN 5 WHEN 'warning' THEN 2 ELSE 1 END) AS risk_score " + + "FROM log_issues i WHERE i.app_key = :appKey " + + "AND (:from IS NULL OR i.last_seen_at >= :from) " + + "AND (:to IS NULL OR i.last_seen_at <= :to) " + "ORDER BY risk_score DESC", nativeQuery = true) List findTopByRisk(@Param("appKey") String appKey, @@ -51,6 +74,16 @@ public interface LogIssueRepository extends JpaRepository long countByAppKeyAndFirstSeenAtAfter(String appKey, LocalDateTime since); + long countByAppKeyAndStatus(String appKey, String status); + + @Modifying + @Query("UPDATE LogIssueEntity i SET i.status = :status, i.isResolved = (:status = 'resolved') WHERE i.id IN :ids AND i.appKey = :appKey") + int bulkUpdateStatus(@Param("appKey") String appKey, @Param("ids") List ids, @Param("status") String status); + + @Modifying + @Query("UPDATE LogIssueEntity i SET i.assignee = :assignee WHERE i.id = :id") + int updateAssignee(@Param("id") Long id, @Param("assignee") String assignee); + @Query("SELECT DISTINCT i.platform FROM LogIssueEntity i WHERE i.appKey = :appKey") List findDistinctPlatforms(@Param("appKey") String appKey); diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java index fa55e3e..5ff971c 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java @@ -19,9 +19,7 @@ import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; import java.util.*; -import java.util.stream.Collectors; @Service public class LogService { @@ -31,20 +29,17 @@ public class LogService { private final LogIssueRepository issueRepository; private final LogIssueEventRepository issueEventRepository; private final LogEventRepository eventRepository; - private final LogWebhookRepository webhookRepository; private final WebhookService webhookService; private final ObjectMapper objectMapper; public LogService(LogIssueRepository issueRepository, LogIssueEventRepository issueEventRepository, LogEventRepository eventRepository, - LogWebhookRepository webhookRepository, WebhookService webhookService, ObjectMapper objectMapper) { this.issueRepository = issueRepository; this.issueEventRepository = issueEventRepository; this.eventRepository = eventRepository; - this.webhookRepository = webhookRepository; this.webhookService = webhookService; this.objectMapper = objectMapper; } @@ -61,45 +56,75 @@ public class LogService { } private void processSingleIssue(IssueBatchRequest.IssueEventItem item) { + if (item.eventId() != null && !item.eventId().isBlank() + && issueEventRepository.existsByAppKeyAndEventId(item.appKey(), item.eventId())) { + log.debug("Skipping duplicate eventId={} appKey={}", item.eventId(), item.appKey()); + return; + } + LocalDateTime now = LocalDateTime.now(); - LocalDateTime eventTime = Instant.ofEpochMilli(item.timestamp()).atZone(ZoneOffset.UTC).toLocalDateTime(); + LocalDateTime eventTime = item.timestamp() > 0 + ? Instant.ofEpochMilli(item.timestamp()).atZone(ZoneOffset.UTC).toLocalDateTime() + : now; + + String exType = item.exception() != null ? item.exception().type() : ""; + String exValue = item.exception() != null ? item.exception().value() : ""; + String stack = item.exception() != null ? item.exception().stacktrace() : ""; + String title = truncate(exType + (!exValue.isEmpty() ? ": " + exValue : ""), 500); + String userId = item.user() != null ? item.user().id() : null; Optional existing = issueRepository.findByAppKeyAndFingerprint(item.appKey(), item.fingerprint()); LogIssueEntity issue; + boolean isNew = false; if (existing.isPresent()) { issue = existing.get(); - issueRepository.incrementCount(item.appKey(), item.fingerprint(), now); issue.setLastSeenAt(now); issue.setCount(issue.getCount() + 1); + if (userId != null) issue.setAffectedUsers(issue.getAffectedUsers() + 1); + if (!"open".equals(issue.getStatus())) issue.setStatus("open"); + issueRepository.save(issue); } else { issue = new LogIssueEntity(); issue.setAppKey(item.appKey()); issue.setFingerprint(item.fingerprint()); - issue.setType(item.type()); - issue.setTitle(truncate(item.message(), 500)); + issue.setLevel(item.level()); + issue.setType(item.level()); // backward compat column + issue.setStatus("open"); + issue.setTitle(title); issue.setFirstSeenAt(eventTime); issue.setLastSeenAt(now); issue.setCount(1); + issue.setAffectedUsers(userId != null ? 1 : 0); issue.setPlatform(item.platform()); - issue.setAppVersion(item.appVersion()); + issue.setRelease(item.release()); issue = issueRepository.save(issue); + isNew = true; } LogIssueEventEntity eventEntity = new LogIssueEventEntity(); eventEntity.setIssueId(issue.getId()); + eventEntity.setEventId(item.eventId()); eventEntity.setAppKey(item.appKey()); - eventEntity.setUserId(item.userId()); - eventEntity.setSessionId(item.sessionId()); - eventEntity.setMessage(item.message()); - eventEntity.setStack(item.stack()); + eventEntity.setLevel(item.level()); + eventEntity.setUserId(userId); + eventEntity.setExceptionType(exType); + eventEntity.setExceptionValue(exValue); + eventEntity.setMessage(exValue); + eventEntity.setStack(stack); eventEntity.setPlatform(item.platform()); - eventEntity.setAppVersion(item.appVersion()); + eventEntity.setRelease(item.release()); + eventEntity.setEnvironment(item.environment() != null ? item.environment() : "production"); + eventEntity.setDevice(item.device() != null ? toJson(item.device()) : null); + eventEntity.setTags(item.tags() != null ? toJson(item.tags()) : null); + if (item.breadcrumbs() != null && !item.breadcrumbs().isEmpty()) { + eventEntity.setBreadcrumbs(toJson(item.breadcrumbs())); + } eventEntity.setCreatedAt(eventTime); issueEventRepository.save(eventEntity); - triggerWebhookAsync(issue); - triggerSymbolicationAsync(issue.getId(), item.appKey(), item.platform(), item.appVersion()); + if (isNew) triggerWebhookAsync(issue); + triggerSymbolicationAsync(issue.getId(), item.appKey(), item.platform(), item.release()); } @Async @@ -112,23 +137,30 @@ public class LogService { } @Async - void triggerSymbolicationAsync(Long issueId, String appKey, String platform, String appVersion) { - // Symbolication is triggered asynchronously; actual implementation depends on SourceMap availability - log.debug("Symbolication triggered for issueId={}, appKey={}, platform={}, version={}", issueId, appKey, platform, appVersion); + void triggerSymbolicationAsync(Long issueId, String appKey, String platform, String release) { + log.debug("Symbolication triggered issueId={} appKey={} platform={} release={}", issueId, appKey, platform, release); } @Transactional public void processEventBatch(EventBatchRequest request) { for (EventBatchRequest.EventItem item : request.events()) { try { + if (item.eventId() != null && !item.eventId().isBlank() + && eventRepository.existsByAppKeyAndEventId(item.appKey(), item.eventId())) { + continue; + } LogEventEntity entity = new LogEventEntity(); + entity.setEventId(item.eventId()); entity.setAppKey(item.appKey()); entity.setName(item.name()); - entity.setUserId(item.userId()); - entity.setSessionId(item.sessionId()); - entity.setProperties(item.properties()); + entity.setUserId(item.user() != null ? item.user().id() : null); + entity.setProperties(item.properties() != null ? toJson(item.properties()) : null); entity.setPlatform(item.platform()); - entity.setAppVersion(item.appVersion()); + entity.setRelease(item.release()); + entity.setEnvironment(item.environment() != null ? item.environment() : "production"); + entity.setDevice(item.device() != null ? toJson(item.device()) : null); + entity.setSdkName(item.sdk() != null ? item.sdk().name() : null); + entity.setSdkVersion(item.sdk() != null ? item.sdk().version() : null); entity.setCreatedAt( item.timestamp() > 0 ? Instant.ofEpochMilli(item.timestamp()).atZone(ZoneOffset.UTC).toLocalDateTime() @@ -142,20 +174,12 @@ public class LogService { } @Transactional(readOnly = true) - public Page queryIssues(String appKey, String type, String platform, + public Page queryIssues(String appKey, String level, String platform, + String status, String q, String from, String to, int page, int size) { - LocalDateTime fromDate = parseDate(from); - LocalDateTime toDate = parseDate(to); Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt")); - - Page result; - if (fromDate != null && toDate != null) { - result = issueRepository.findByAppKeyAndTypeAndPlatformAndLastSeenAtBetween( - appKey, type, platform, fromDate, toDate, pageable); - } else { - result = issueRepository.findByAppKey(appKey, pageable); - } - + Page result = issueRepository.findByFilters( + appKey, level, platform, status, q, parseDate(from), parseDate(to), pageable); return result.map(this::toIssueResponse); } @@ -163,133 +187,151 @@ public class LogService { public IssueResponse getIssueDetail(Long id) { LogIssueEntity issue = issueRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Issue not found: " + id)); + return toIssueResponse(issue); + } - List events = issueEventRepository.findTop20ByIssueIdOrderByCreatedAtDesc(id); - List eventResponses = events.stream() - .map(this::toIssueEventResponse) + @Transactional(readOnly = true) + public Page getIssueEvents(Long issueId, String from, String to, int page, int size) { + if (!issueRepository.existsById(issueId)) { + throw new IllegalArgumentException("Issue not found: " + issueId); + } + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + LocalDateTime fromDate = parseDate(from); + LocalDateTime toDate = parseDate(to) != null ? parseDate(to).plusDays(1) : null; + Page result = (fromDate != null && toDate != null) + ? issueEventRepository.findByIssueIdAndCreatedAtBetweenOrderByCreatedAtDesc(issueId, fromDate, toDate, pageable) + : issueEventRepository.findByIssueIdOrderByCreatedAtDesc(issueId, pageable); + return result.map(this::toIssueEventResponse); + } + + @Transactional(readOnly = true) + public IssueTrendResponse getIssueTrend(Long issueId, String from, String to) { + if (!issueRepository.existsById(issueId)) { + throw new IllegalArgumentException("Issue not found: " + issueId); + } + LocalDate fromDate = from != null ? LocalDate.parse(from) : LocalDate.now().minusDays(13); + LocalDate toDate = to != null ? LocalDate.parse(to) : LocalDate.now(); + + List rows = issueEventRepository.findDailyCountsByIssueId( + issueId, fromDate.atStartOfDay(), toDate.plusDays(1).atStartOfDay()); + + Map dayMap = new LinkedHashMap<>(); + for (LocalDate c = fromDate; !c.isAfter(toDate); c = c.plusDays(1)) { + dayMap.put(c.toString(), new long[]{0, 0}); + } + for (Object[] row : rows) { + LocalDateTime dt = (LocalDateTime) row[0]; + String day = dt.toLocalDate().toString(); + dayMap.put(day, new long[]{((Number) row[1]).longValue(), ((Number) row[2]).longValue()}); + } + List points = dayMap.entrySet().stream() + .map(e -> new IssueTrendResponse.TrendPoint(e.getKey(), e.getValue()[0], e.getValue()[1])) .toList(); + return new IssueTrendResponse(issueId, points); + } - return new IssueResponse( - issue.getId(), issue.getAppKey(), issue.getFingerprint(), - issue.getType(), issue.getTitle(), - issue.getFirstSeenAt(), issue.getLastSeenAt(), - issue.getCount(), issue.isResolved(), - issue.getPlatform(), issue.getAppVersion(), - eventResponses - ); + @Transactional + public void resolveIssue(Long id) { + LogIssueEntity issue = issueRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Issue not found: " + id)); + issue.setStatus("resolved"); + issueRepository.save(issue); + } + + @Transactional + public void ignoreIssue(Long id) { + LogIssueEntity issue = issueRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Issue not found: " + id)); + issue.setStatus("ignored"); + issueRepository.save(issue); + } + + @Transactional + public void assignIssue(Long id, String assignee) { + issueRepository.updateAssignee(id, assignee); + } + + @Transactional + public void bulkUpdateIssues(String appKey, List ids, String action) { + String status = switch (action) { + case "resolve" -> "resolved"; + case "ignore" -> "ignored"; + case "reopen" -> "open"; + default -> throw new IllegalArgumentException("Unknown action: " + action); + }; + issueRepository.bulkUpdateStatus(appKey, ids, status); } @Transactional(readOnly = true) public List getFrequencyRankings(String appKey, String from, String to, int limit) { - LocalDateTime fromDate = parseDate(from); - LocalDateTime toDate = parseDate(to); - Pageable pageable = PageRequest.of(0, limit); - - List issues = issueRepository.findTopByFrequency(appKey, fromDate, toDate, pageable); - return issues.stream().map(this::toIssueResponse).toList(); + return issueRepository.findTopByFrequency(appKey, parseDate(from), parseDate(to), PageRequest.of(0, limit)) + .stream().map(this::toIssueResponse).toList(); } @Transactional(readOnly = true) public List getRiskRankings(String appKey, String from, String to, int limit) { - LocalDateTime fromDate = parseDate(from); - LocalDateTime toDate = parseDate(to); - Pageable pageable = PageRequest.of(0, limit); - - List issues = issueRepository.findTopByRisk(appKey, fromDate, toDate, pageable); - return issues.stream().map(this::toIssueResponse).toList(); + return issueRepository.findTopByRisk(appKey, parseDate(from), parseDate(to), PageRequest.of(0, limit)) + .stream().map(this::toIssueResponse).toList(); } @Transactional(readOnly = true) public Page queryEvents(String appKey, String name, String userId, String from, String to, int page, int size) { LocalDateTime fromDate = parseDate(from); - LocalDateTime toDate = parseDate(to); + LocalDateTime toDate = parseDate(to) != null ? parseDate(to).plusDays(1) : LocalDateTime.now(); + if (fromDate == null) fromDate = LocalDateTime.now().minusDays(7); Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); - Page result; - if (fromDate != null && toDate != null) { - if (name != null && userId != null) { - result = eventRepository.findByAppKeyAndNameAndUserIdAndCreatedAtBetween( - appKey, name, userId, fromDate, toDate, pageable); - } else if (name != null) { - result = eventRepository.findByAppKeyAndNameAndCreatedAtBetween( - appKey, name, fromDate, toDate, pageable); - } else { - result = eventRepository.findByAppKeyAndCreatedAtBetween(appKey, fromDate, toDate, pageable); - } + if (name != null && userId != null) { + result = eventRepository.findByAppKeyAndNameAndUserIdAndCreatedAtBetween(appKey, name, userId, fromDate, toDate, pageable); + } else if (name != null) { + result = eventRepository.findByAppKeyAndNameAndCreatedAtBetween(appKey, name, fromDate, toDate, pageable); } else { result = eventRepository.findByAppKeyAndCreatedAtBetween(appKey, fromDate, toDate, pageable); } - return result.map(e -> new IssueEventResponse( - e.getId(), null, e.getAppKey(), e.getUserId(), e.getSessionId(), - e.getName(), null, null, e.getProperties(), - e.getPlatform(), e.getAppVersion(), e.getCreatedAt() + e.getId(), null, null, e.getAppKey(), e.getUserId(), e.getSessionId(), + null, null, e.getName(), null, null, null, e.getProperties(), + null, e.getPlatform(), e.getRelease(), e.getEnvironment(), null, null, e.getCreatedAt() )); } @Transactional(readOnly = true) public FunnelResponse queryFunnel(String appKey, List steps, String from, String to) { - LocalDateTime fromDate = parseDate(from); - LocalDateTime toDate = parseDate(to); + List rawData = eventRepository.findFunnelData(appKey, steps, parseDate(from), parseDate(to)); - List rawData = eventRepository.findFunnelData(appKey, steps, fromDate, toDate); - - // Count unique sessions per step Map> sessionsPerStep = new LinkedHashMap<>(); - for (String step : steps) { - sessionsPerStep.put(step, new HashSet<>()); - } - + for (String step : steps) sessionsPerStep.put(step, new HashSet<>()); for (Object[] row : rawData) { - String sessionId = (String) row[0]; + String sid = (String) row[0]; String name = (String) row[1]; - if (sessionsPerStep.containsKey(name) && sessionId != null) { - sessionsPerStep.get(name).add(sessionId); - } + if (sessionsPerStep.containsKey(name) && sid != null) sessionsPerStep.get(name).add(sid); } - List counts = steps.stream() - .map(step -> (long) sessionsPerStep.get(step).size()) - .toList(); - - long firstCount = counts.isEmpty() ? 1 : Math.max(counts.getFirst(), 1); - List rates = counts.stream() - .map(c -> Math.round((double) c / firstCount * 1000.0) / 10.0) - .toList(); - + List counts = steps.stream().map(s -> (long) sessionsPerStep.get(s).size()).toList(); + long first = counts.isEmpty() ? 1 : Math.max(counts.getFirst(), 1); + List rates = counts.stream().map(c -> Math.round((double) c / first * 1000.0) / 10.0).toList(); return new FunnelResponse(steps, counts, rates); } @Transactional(readOnly = true) public OverviewResponse getOverview(String appKey, String from, String to) { LocalDateTime fromDate = parseDate(from); - LocalDateTime toDate = parseDate(to); + LocalDateTime toDate = parseDate(to); - long totalIssues = issueRepository.countByAppKeyAndFirstSeenAtBetween(appKey, fromDate, toDate); + long totalIssues = issueRepository.countByAppKeyAndFirstSeenAtBetween(appKey, fromDate, toDate); + long openIssues = issueRepository.countByAppKeyAndStatus(appKey, "open"); + long todayNewIssues = issueRepository.countByAppKeyAndFirstSeenAtAfter(appKey, LocalDate.now().atStartOfDay()); - LocalDateTime todayStart = LocalDate.now().atStartOfDay(); - long todayNewIssues = issueRepository.countByAppKeyAndFirstSeenAtAfter(appKey, todayStart); - - // Build daily crash trend List trend = new ArrayList<>(); if (fromDate != null && toDate != null) { - LocalDate current = fromDate.toLocalDate(); - LocalDate end = toDate.toLocalDate(); - while (!current.isAfter(end)) { - LocalDateTime dayStart = current.atStartOfDay(); - LocalDateTime dayEnd = current.plusDays(1).atStartOfDay(); - long dayCount = issueRepository.countByAppKeyAndFirstSeenAtBetween(appKey, dayStart, dayEnd); - trend.add(new OverviewResponse.DailyCrashRate( - current.toString(), - dayCount, - 0.0 // crash rate requires total session data, placeholder for now - )); - current = current.plusDays(1); + for (LocalDate c = fromDate.toLocalDate(), end = toDate.toLocalDate(); !c.isAfter(end); c = c.plusDays(1)) { + long dayCount = issueRepository.countByAppKeyAndFirstSeenAtBetween( + appKey, c.atStartOfDay(), c.plusDays(1).atStartOfDay()); + trend.add(new OverviewResponse.DailyCrashRate(c.toString(), dayCount, 0.0)); } } - - return new OverviewResponse(totalIssues, todayNewIssues, 0, trend); + return new OverviewResponse(totalIssues, todayNewIssues, openIssues, trend); } @Transactional @@ -301,22 +343,34 @@ public class LogService { private IssueResponse toIssueResponse(LogIssueEntity issue) { return new IssueResponse( issue.getId(), issue.getAppKey(), issue.getFingerprint(), - issue.getType(), issue.getTitle(), - issue.getFirstSeenAt(), issue.getLastSeenAt(), - issue.getCount(), issue.isResolved(), - issue.getPlatform(), issue.getAppVersion(), - null + issue.getLevel(), + issue.getStatus() != null ? issue.getStatus() : (issue.isResolved() ? "resolved" : "open"), + issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(), + issue.getCount(), issue.getAffectedUsers(), issue.isResolved(), + issue.getAssignee(), issue.getPlatform(), issue.getRelease(), null ); } private IssueEventResponse toIssueEventResponse(LogIssueEventEntity e) { return new IssueEventResponse( - e.getId(), e.getIssueId(), e.getAppKey(), e.getUserId(), e.getSessionId(), - e.getMessage(), e.getStack(), e.getStackSymbolicated(), e.getMetadata(), - e.getPlatform(), e.getAppVersion(), e.getCreatedAt() + e.getId(), e.getIssueId(), e.getEventId(), e.getAppKey(), e.getUserId(), e.getSessionId(), + e.getExceptionType(), e.getExceptionValue(), + e.getMessage(), e.getStack(), e.getStackSymbolicated(), + e.getBreadcrumbs(), e.getTags(), e.getDevice(), + e.getPlatform(), e.getRelease(), e.getEnvironment(), + e.getSdkName(), e.getSdkVersion(), e.getCreatedAt() ); } + private String toJson(Object obj) { + if (obj == null) return null; + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + return obj.toString(); + } + } + private String truncate(String s, int maxLen) { if (s == null) return ""; return s.length() <= maxLen ? s : s.substring(0, maxLen); @@ -324,10 +378,7 @@ public class LogService { private LocalDateTime parseDate(String dateStr) { if (dateStr == null || dateStr.isBlank()) return null; - try { - return LocalDate.parse(dateStr).atStartOfDay(); - } catch (Exception e) { - return null; - } + try { return LocalDate.parse(dateStr).atStartOfDay(); } + catch (Exception e) { return null; } } } diff --git a/xuqm-bugcollect-service/src/main/resources/db/migration/V3__bugcollect_v11_upgrade.sql b/xuqm-bugcollect-service/src/main/resources/db/migration/V3__bugcollect_v11_upgrade.sql new file mode 100644 index 0000000..ef504f9 --- /dev/null +++ b/xuqm-bugcollect-service/src/main/resources/db/migration/V3__bugcollect_v11_upgrade.sql @@ -0,0 +1,23 @@ +-- BugCollect API v1.1 upgrade + +-- log_issue_events: add eventId for idempotency +ALTER TABLE log_issue_events ADD COLUMN event_id VARCHAR(64) NULL; +ALTER TABLE log_issue_events ADD COLUMN exception_type VARCHAR(200) NULL; +ALTER TABLE log_issue_events ADD COLUMN exception_value TEXT NULL; +ALTER TABLE log_issue_events ADD COLUMN breadcrumbs JSON NULL; +ALTER TABLE log_issue_events ADD UNIQUE KEY uk_app_event_id (app_key, event_id); +ALTER TABLE log_issue_events ADD INDEX idx_issue_created (issue_id, created_at); + +-- log_issues: add status, affectedUsers, assignee +ALTER TABLE log_issues ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'open'; +ALTER TABLE log_issues ADD COLUMN affected_users INT NOT NULL DEFAULT 0; +ALTER TABLE log_issues ADD COLUMN assignee VARCHAR(200) NULL; +-- migrate existing is_resolved +UPDATE log_issues SET status = 'resolved' WHERE is_resolved = 1; +ALTER TABLE log_issues ADD INDEX idx_app_status_last (app_key, status, last_seen_at); +ALTER TABLE log_issues ADD INDEX idx_app_level_last (app_key, level, last_seen_at); + +-- log_events: add eventId for idempotency +ALTER TABLE log_events ADD COLUMN event_id VARCHAR(64) NULL; +ALTER TABLE log_events ADD UNIQUE KEY uk_app_event_id (app_key, event_id); +ALTER TABLE log_events ADD INDEX idx_app_key_created (app_key, created_at);