feat: 文档清理 + RAG Worker 迁移 + 批量操作 + HTTP 日志 + 学习会话修复
- 删除根 docs/ 中的 audit/issue 文档 - 删除 api-server/docs/ 中的 16 个审核/里程碑文档 - rag-worker/ 从 api-server 迁出为独立项目 - 新增 admin 导入批量重新解析、按知识库筛选、后端排序 - 新增 source 软删除标记,防止已删 source 重新解析 - 修复 upload-url 500(重复 UploadSession 创建 + 缺 sizeBytes) - 修复 loadContextByScope 读 chunks 而非依赖 textLength - 修复 MaterialReadingProgress.create() increment 语法错误 - 新增 LoggingInterceptor 记录所有 HTTP 请求 - 新增学习会话批量删除接口 - enrichWithNames 增加 email 回退 - 全局异常过滤器和 name-resolver 增强 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
add6a1ab4c
commit
a621bd169d
3
.gitignore
vendored
3
.gitignore
vendored
@ -50,3 +50,6 @@ tmp/
|
|||||||
temp/docs/credentials.md
|
temp/docs/credentials.md
|
||||||
docs/credentials.md
|
docs/credentials.md
|
||||||
docs/AI回答.md
|
docs/AI回答.md
|
||||||
|
|
||||||
|
# rag-worker (migrated to own project)
|
||||||
|
rag-worker/
|
||||||
|
|||||||
@ -1,174 +0,0 @@
|
|||||||
# ADR-001 附录 A:Worker 执行依赖闭包审计
|
|
||||||
|
|
||||||
## 审计日期
|
|
||||||
|
|
||||||
2026-06-19
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
确认每个 BullMQ Processor 的完整依赖、事件和副作用,防止 Processor 移到独立 Worker 进程后链路断裂。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、Processor 依赖矩阵
|
|
||||||
|
|
||||||
### 1.1 AiAnalysisWorker
|
|
||||||
|
|
||||||
| 维度 | 事实 | 证据 |
|
|
||||||
|------|------|------|
|
|
||||||
| **队列** | `ai-analysis` | `@Processor(QUEUE_AI_ANALYSIS)` |
|
|
||||||
| **注册位置** | `AppModule` (line 193) + `WorkerModule` (line 64) — **双重注册** | `app.module.ts:193`、`worker.module.ts:64` |
|
|
||||||
| **注入依赖** | `ActiveRecallAnalysisWorkflow`、`FeynmanEvaluationWorkflow`、`AiAnalysisRepository`、`EventBusService` (@Optional)、`FocusItemsService` (@Optional) | `ai-analysis.worker.ts:22-28` |
|
|
||||||
| **依赖 Module** | `AiModule`(提供 Workflow)、`AiAnalysisModule`(提供 Repository)、`EventBusModule`(提供 EventBusService)、`FocusItemsModule`(提供 FocusItemsService) | — |
|
|
||||||
| **写入的表** | `AiAnalysisJob`(updateJobStatus)、`AiAnalysisResult`(createResult)、`FocusItem`(create) | `ai-analysis.worker.ts:47,66,67,87,101` |
|
|
||||||
| **发布的事件** | `ai.analysis.completed`(通过 `EventBusService.publish()` → `EventEmitter2.emit()` — **进程内**) | `ai-analysis.worker.ts:71` |
|
|
||||||
| **事件订阅者** | `ReviewCardSubscriber`(`@OnEvent('ai.analysis.completed')` → `ReviewModule` → `AppModule`) | `review-card.subscriber.ts:11` |
|
|
||||||
| **WorkerModule 中缺失** | `FocusItemsModule` 未导入 → `FocusItemsService` 在 Worker 进程中为 `null` | `worker.module.ts:1-69` |
|
|
||||||
| **移动后断裂链路** | ⚠️ `ai.analysis.completed` 事件无法到达 `ReviewCardSubscriber`(进程内 EventEmitter2);⚠️ `FocusItemsService` 在独立 Worker 进程中为 null | — |
|
|
||||||
|
|
||||||
### 1.2 DocumentImportWorker
|
|
||||||
|
|
||||||
| 维度 | 事实 | 证据 |
|
|
||||||
|------|------|------|
|
|
||||||
| **队列** | `document-import` | `@Processor(QUEUE_DOCUMENT_IMPORT)` |
|
|
||||||
| **注册位置** | `AppModule` (line 194) + `WorkerModule` (line 65) — **双重注册** | `app.module.ts:194`、`worker.module.ts:65` |
|
|
||||||
| **注入依赖** | `KnowledgeImportWorkflow`、`KnowledgeItemsRepository`、`RedisService` | 需确认 `document-import.worker.ts` |
|
|
||||||
| **写入的表** | `KnowledgeItem` | — |
|
|
||||||
| **发布的事件** | 无 | — |
|
|
||||||
| **移动后断裂链路** | 无事件依赖,但需确认 Redis 连接在 Worker 进程中可用 | — |
|
|
||||||
|
|
||||||
### 1.3 NotificationWorker
|
|
||||||
|
|
||||||
| 维度 | 事实 | 证据 |
|
|
||||||
|------|------|------|
|
|
||||||
| **队列** | `notification` | `@Processor(QUEUE_NOTIFICATION)` |
|
|
||||||
| **注册位置** | `AppModule` (line 195) + `WorkerModule` (line 66) — **双重注册** | `app.module.ts:195`、`worker.module.ts:66` |
|
|
||||||
| **注入依赖** | `NotificationsService` | `notification.worker.ts:8,12` |
|
|
||||||
| **依赖链** | `NotificationsService` → `NotificationsRepository`(必需)+ `EventBusService`(@Optional) | `notifications.service.ts:13-15` |
|
|
||||||
| **依赖 Module** | `NotificationsModule`(提供 NotificationsService + NotificationsRepository)、`EventBusModule`(提供 EventBusService) | `notifications.module.ts:9-14` |
|
|
||||||
| **写入的表** | `Notification`(更新发送状态 — 通过 NotificationsRepository) | — |
|
|
||||||
| **发布的事件** | 无(`EventBusService` 为 @Optional 注入,NotificationWorker 自身不发布事件) | — |
|
|
||||||
| **移动后断裂链路** | 无 — `NotificationsModule` 已在 `WorkerModule.imports` 中(line 61),`NotificationsModule` 自身 imports `EventBusModule`,`PrismaService` 由 `PrismaModule` 提供。依赖链完整,Worker 进程启动不会因缺失依赖崩溃 | `worker.module.ts:61` |
|
|
||||||
|
|
||||||
### 1.4 AuditLogProcessor
|
|
||||||
|
|
||||||
| 维度 | 事实 | 证据 |
|
|
||||||
|------|------|------|
|
|
||||||
| **队列** | `audit-logs` | `@Processor(QUEUE_AUDIT_LOG)` |
|
|
||||||
| **注册位置** | `AdminAuditLogModule` providers(仅注册一次,未双重) | `admin-audit-log.module.ts:13` |
|
|
||||||
| **注入依赖** | `PrismaService` | `audit-log.processor.ts:7` |
|
|
||||||
| **写入的表** | `AdminAuditLog` | `audit-log.processor.ts:12-23` |
|
|
||||||
| **发布的事件** | 无 | — |
|
|
||||||
| **移动后断裂链路** | 无业务事件依赖,但 `AdminAuditLogModule` 同时包含 Controller 和 Processor,需要拆分 | — |
|
|
||||||
|
|
||||||
### 1.5 FileCleanupProcessor
|
|
||||||
|
|
||||||
| 维度 | 事实 | 证据 |
|
|
||||||
|------|------|------|
|
|
||||||
| **队列** | `file-cleanup` | `@Processor(QUEUE_FILE_CLEANUP)` |
|
|
||||||
| **注册位置** | ⚠️ `FilesModule` 导入了 `FileCleanupProcessor` 但**未在 providers 中注册** — `BullExplorer` 扫描可能仍能发现,但依赖注入不可靠 | `files.module.ts:3,12-14` |
|
|
||||||
| **注入依赖** | `CosStorageProvider` | `file-cleanup.processor.ts:10` |
|
|
||||||
| **写入的表** | 无(直接操作 COS) | — |
|
|
||||||
| **发布的事件** | 无 | — |
|
|
||||||
| **移动后断裂链路** | 需确认 `CosStorageProvider` 在 Worker 进程中可注入 | — |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、事件链路审计
|
|
||||||
|
|
||||||
### 2.1 EventBusService 实现
|
|
||||||
|
|
||||||
| 方法 | 机制 | 跨进程 | 证据 |
|
|
||||||
|------|------|:---:|------|
|
|
||||||
| `publish(event)` | `EventEmitter2.emit()` — 进程内同步 | ❌ | `event-bus.service.ts:19-22` |
|
|
||||||
| `publishAsync(event)` | BullMQ `domain-events` 队列 — 跨进程异步 | ✅ | `event-bus.service.ts:26-36` |
|
|
||||||
|
|
||||||
### 2.2 当前事件使用
|
|
||||||
|
|
||||||
| 事件 | 发布者 | 发布方式 | 订阅者 | 订阅方式 | 位置 | 跨进程安全 |
|
|
||||||
|------|--------|---------|--------|---------|------|:---:|
|
|
||||||
| `ai.analysis.completed` | `AiAnalysisWorker` | `publish()`(进程内) | `ReviewCardSubscriber` | `@OnEvent`(EventEmitter2) | `review-card.subscriber.ts:11` | ❌ |
|
|
||||||
| `task.enqueued` | `QueueService` | `publish()`(进程内) | 无消费者 | — | `queue.service.ts:34` | — |
|
|
||||||
| `streak.updated` | `GrowthService` | `publish()`(进程内) | 无消费者 | — | `growth.service.ts:53` | — |
|
|
||||||
|
|
||||||
### 2.3 Domain Events 队列
|
|
||||||
|
|
||||||
- **生产者**:`EventBusService.publishAsync()` → `queue.add('domain-events', ...)`
|
|
||||||
- **消费者**:无 — 代码库中无任何 `@Processor('domain-events')`
|
|
||||||
- **状态**:死队列 — 事件被写入 Redis 但永不被处理
|
|
||||||
- **结论**:`publishAsync()` 当前无实际效果
|
|
||||||
|
|
||||||
### 2.4 移动后的断裂链路
|
|
||||||
|
|
||||||
```
|
|
||||||
移动前(同进程):
|
|
||||||
AiAnalysisWorker → EventBusService.publish() → EventEmitter2.emit('ai.analysis.completed')
|
|
||||||
→ ReviewCardSubscriber.@OnEvent('ai.analysis.completed')
|
|
||||||
→ reviewService.generateCards() → ReviewCard 创建 ✅
|
|
||||||
|
|
||||||
移动后(跨进程):
|
|
||||||
Worker 进程: AiAnalysisWorker → EventBusService.publish() → EventEmitter2.emit()
|
|
||||||
API 进程: ReviewCardSubscriber.@OnEvent() — 收不到 ❌
|
|
||||||
```
|
|
||||||
|
|
||||||
**影响**:AI 分析完成后不再自动生成复习卡片。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、Module 注册审计
|
|
||||||
|
|
||||||
### 3.1 双重注册的 Processor
|
|
||||||
|
|
||||||
| Processor | AppModule | WorkerModule | 业务 Module | 问题 |
|
|
||||||
|-----------|:---:|:---:|:---:|------|
|
|
||||||
| AiAnalysisWorker | ✅ (line 193) | ✅ (line 62) | — | 若两个 Module 均加载,BullMQ 创建两个 Worker 实例消费同一队列 → **并发双消费** |
|
|
||||||
| DocumentImportWorker | ✅ (line 194) | ✅ (line 65) | — | 同上 |
|
|
||||||
| NotificationWorker | ✅ (line 195) | ✅ (line 66) | — | 同上 |
|
|
||||||
|
|
||||||
### 3.2 需拆分的 Module(同时包含 Controller 和 Processor)
|
|
||||||
|
|
||||||
| Module | Controller | Processor | Producer |
|
|
||||||
|--------|:---:|:---:|:---:|
|
|
||||||
| `AdminAuditLogModule` | ✅ | ✅ (`AuditLogProcessor`) | — |
|
|
||||||
| `FilesModule` | ✅ | ⚠️ (`FileCleanupProcessor` 导入但未在 providers 注册) | ✅ (`FilesService`) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、WorkerModule 缺失依赖
|
|
||||||
|
|
||||||
当前 `WorkerModule`(`worker.module.ts:1-69`)导入的 Module:
|
|
||||||
|
|
||||||
```
|
|
||||||
PrismaModule, RedisModule, QueueModule, AiModule, StorageModule,
|
|
||||||
LoggerModule, AiAnalysisModule, DocumentImportModule, KnowledgeItemsModule, NotificationsModule
|
|
||||||
```
|
|
||||||
|
|
||||||
**缺失但 AiAnalysisWorker 需要的 Module**:
|
|
||||||
|
|
||||||
| 缺失 Module | 提供的 Service | 影响 |
|
|
||||||
|------------|---------------|------|
|
|
||||||
| `FocusItemsModule` | `FocusItemsService` | AiAnalysisWorker 中 `focusItems` 为 null,不创建 FocusItem |
|
|
||||||
| `EventBusModule` | `EventBusService` | AiAnalysisWorker 中 `eventBus` 为 null,不发布事件。但当前有 `@Optional()` 标记,不会崩溃 |
|
|
||||||
| `ReviewModule` | `ReviewCardSubscriber` | 即使事件发布成功(使用 publishAsync),无订阅者处理 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、修复建议矩阵(供 01-03 使用)
|
|
||||||
|
|
||||||
| 问题 | 修复方向 | 优先级 |
|
|
||||||
|------|---------|:---:|
|
|
||||||
| AiAnalysisWorker/NotificationWorker/DocumentImportWorker 双重注册 | 从 `AppModule` providers 中移除,仅保留在 `WorkerModule` | **阻塞** |
|
|
||||||
| `EventBusService.publish()` 无法跨进程 | 选项 A:将 `ReviewCardSubscriber` 移入 `WorkerModule`;选项 B:改为 `publishAsync()` + 新增 `DomainEventsProcessor` 消费 `domain-events` 队列 | **阻塞** |
|
|
||||||
| `FocusItemsModule` 未在 WorkerModule | 添加 `FocusItemsModule` 到 `WorkerModule.imports` | **阻塞** |
|
|
||||||
| `ReviewModule` 未在 WorkerModule | 如果选 A,需在 WorkerModule 中导入 `ReviewModule`(或拆出 `ReviewWorkerModule`) | **阻塞** |
|
|
||||||
| `AuditLogProcessor` 在 `AdminAuditLogModule` 中 | 拆分为 `AdminAuditLogProducerModule` + `AdminAuditLogWorkerModule`,或将其移入 `WorkerModule` 并保留 Controller 在原 Module | 高 |
|
|
||||||
| `FileCleanupProcessor` 未在 providers 中注册 | 补充注册,或显式移入 `WorkerModule` | 高 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、无法确认项
|
|
||||||
|
|
||||||
| 项目 | 状态 | 原因 |
|
|
||||||
|------|:---:|------|
|
|
||||||
| Docker Compose 当前是否同时启动 API 和 Worker | 无法确认 | 需检查 `docker-compose.yml` 中 `command` 或 `entrypoint` 指向 `dist/main.js` 还是两者 |
|
|
||||||
| 生产 systemd 是否使用独立 Worker 进程 | 无法确认 | 无法访问生产服务器 |
|
|
||||||
@ -1,652 +0,0 @@
|
|||||||
# M-AI-02-01:现有 AI Job Schema 审计
|
|
||||||
|
|
||||||
> 审计日期:2026-06-20
|
|
||||||
> 审计人:wangdl(半自动化审计:代码扫描 + 生产 MySQL 只读查询 via SSH 隧道)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Schema 逐字段审计
|
|
||||||
|
|
||||||
### 1.1 AiAnalysisJob(旧系统 A)
|
|
||||||
|
|
||||||
物理表名:`AiAnalysisJob` | 创建于:`prisma/migrations/20250516000000_init/migration.sql:300-319`
|
|
||||||
|
|
||||||
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|
|
||||||
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
|
|
||||||
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
|
|
||||||
| 2 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiAnalysisJob_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
|
|
||||||
| 3 | `sessionId` | `sessionId` | VARCHAR(191) | ✅ | - | `AiAnalysisJob_sessionId_idx` | ❌ | - | - | - |
|
|
||||||
| 4 | `answerId` | `answerId` | VARCHAR(191) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 5 | `jobType` | `jobType` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 6 | `status` | `status` | VARCHAR(32) | ❌ | `'pending'` | `AiAnalysisJob_status_idx` | ❌ | - | - | - |
|
|
||||||
| 7 | `progress` | `progress` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
|
||||||
| 8 | `errorMessage` | `errorMessage` | TEXT | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 9 | `queuedAt` | `queuedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 10 | `startedAt` | `startedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 11 | `completedAt` | `completedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 12 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | ❌ | ❌ | - | - | - |
|
|
||||||
| 13 | `updatedAt` | `updatedAt` | DATETIME(3) | ❌ | `@updatedAt` | ❌ | ❌ | - | - | - |
|
|
||||||
|
|
||||||
**Relation**:`results` → `AiAnalysisResult[]`(一对多)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.2 AiAnalysisResult(旧系统 A 输出)
|
|
||||||
|
|
||||||
物理表名:`AiAnalysisResult` | 创建于:`prisma/migrations/20250516000000_init/migration.sql:322-342`
|
|
||||||
|
|
||||||
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|
|
||||||
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
|
|
||||||
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
|
|
||||||
| 2 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiAnalysisResult_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
|
|
||||||
| 3 | `jobId` | `jobId` | VARCHAR(191) | ❌ | - | `AiAnalysisResult_jobId_idx` | ❌ | `AiAnalysisJob.id` | RESTRICT | CASCADE |
|
|
||||||
| 4 | `sessionId` | `sessionId` | VARCHAR(191) | ✅ | - | `AiAnalysisResult_sessionId_idx` | ❌ | - | - | - |
|
|
||||||
| 5 | `answerId` | `answerId` | VARCHAR(191) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 6 | `summary` | `summary` | TEXT | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 7 | `masteryScore` | `masteryScore` | INT | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 8 | `strengths` | `strengths` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 9 | `weaknesses` | `weaknesses` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 10 | `suggestions` | `suggestions` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 11 | `nextActions` | `nextActions` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 12 | `rawResult` | `rawResult` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 13 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | ❌ | ❌ | - | - | - |
|
|
||||||
| 14 | `updatedAt` | `updatedAt` | DATETIME(3) | ❌ | `@updatedAt` | ❌ | ❌ | - | - | - |
|
|
||||||
|
|
||||||
**Relation**:`job` → `AiAnalysisJob`(多对一)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.3 AiRuntimeJob(新系统 B)
|
|
||||||
|
|
||||||
物理表名:`AiRuntimeJob` | 无独立 migration SQL,FK 由 Prisma schema push 创建,生产已确认存在
|
|
||||||
|
|
||||||
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|
|
||||||
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
|
|
||||||
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
|
|
||||||
| 2 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiRuntimeJob_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
|
|
||||||
| 3 | `jobType` | `jobType` | VARCHAR(64) | ❌ | - | `AiRuntimeJob_jobType_idx` | ❌ | - | - | - |
|
|
||||||
| 4 | `targetType` | `targetType` | VARCHAR(32) | ❌ | - | 复合 `AiRuntimeJob_targetType_targetId_idx` | ❌ | - | - | - |
|
|
||||||
| 5 | `targetId` | `targetId` | VARCHAR(255) | ❌ | - | 同上 | ❌ | - | - | - |
|
|
||||||
| 6 | `snapshotId` | `snapshotId` | VARCHAR(191) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 7 | `status` | `status` | VARCHAR(32) | ❌ | `'pending'` | `AiRuntimeJob_status_idx` | ❌ | - | - | - |
|
|
||||||
| 8 | `priority` | `priority` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
|
||||||
| 9 | `idempotencyKey` | `idempotencyKey` | VARCHAR(255) | ✅ | - | ❌ | ✅ | - | - | - |
|
|
||||||
| 10 | `apiKeyMode` | `apiKeyMode` | VARCHAR(32) | ❌ | `'platform_key'` | ❌ | ❌ | - | - | - |
|
|
||||||
| 11 | `credentialId` | `credentialId` | VARCHAR(191) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 12 | `modelProvider` | `modelProvider` | VARCHAR(32) | ❌ | `'deepseek'` | ❌ | ❌ | - | - | - |
|
|
||||||
| 13 | `modelName` | `modelName` | VARCHAR(64) | ❌ | `'deepseek-chat'` | ❌ | ❌ | - | - | - |
|
|
||||||
| 14 | `promptVersion` | `promptVersion` | VARCHAR(100) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 15 | `outputSchemaVersion` | `outputSchemaVersion` | VARCHAR(100) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 16 | `attemptNo` | `attemptNo` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
|
||||||
| 17 | `retriedFromJobId` | `retriedFromJobId` | VARCHAR(255) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 18 | `lockedBy` | `lockedBy` | VARCHAR(100) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 19 | `lockedAt` | `lockedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 20 | `lockUntil` | `lockUntil` | DATETIME(3) | ✅ | - | `AiRuntimeJob_lockUntil_idx` | ❌ | - | - | - |
|
|
||||||
| 21 | `startedAt` | `startedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 22 | `finishedAt` | `finishedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 23 | `retryCount` | `retryCount` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
|
||||||
| 24 | `maxRetryCount` | `maxRetryCount` | INT | ❌ | `3` | ❌ | ❌ | - | - | - |
|
|
||||||
| 25 | `timeoutSeconds` | `timeoutSeconds` | INT | ❌ | `120` | ❌ | ❌ | - | - | - |
|
|
||||||
| 26 | `errorCode` | `errorCode` | VARCHAR(100) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 27 | `errorMessage` | `errorMessage` | TEXT | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 28 | `cancelRequestedAt` | `cancelRequestedAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 29 | `cancelledAt` | `cancelledAt` | DATETIME(3) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 30 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | ❌ | ❌ | - | - | - |
|
|
||||||
| 31 | `updatedAt` | `updatedAt` | DATETIME(3) | ❌ | `@updatedAt` | ❌ | ❌ | - | - | - |
|
|
||||||
|
|
||||||
**Relation**:`result` → `AiRuntimeResult?`(一对一)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.4 AiRuntimeResult(新系统 B 输出)
|
|
||||||
|
|
||||||
物理表名:`AiRuntimeResult` | 无独立 migration SQL,FK 由 Prisma schema push 创建,生产已确认存在
|
|
||||||
|
|
||||||
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|
|
||||||
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
|
|
||||||
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
|
|
||||||
| 2 | `jobId` | `jobId` | VARCHAR(191) | ❌ | - | `AiRuntimeResult_jobId_idx` | ✅ | `AiRuntimeJob.id` | RESTRICT | CASCADE |
|
|
||||||
| 3 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiRuntimeResult_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
|
|
||||||
| 4 | `runtimeInstanceId` | `runtimeInstanceId` | VARCHAR(100) | ❌ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 5 | `status` | `status` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 6 | `attemptNo` | `attemptNo` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
|
||||||
| 7 | `resultIdempotencyKey` | `resultIdempotencyKey` | VARCHAR(255) | ✅ | - | `AiRuntimeResult_resultIdempotencyKey_idx` | ❌ | - | - | - |
|
|
||||||
| 8 | `outputHash` | `outputHash` | VARCHAR(255) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 9 | `rawOutput` | `rawOutput` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 10 | `validatedOutput` | `validatedOutput` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 11 | `schemaVersion` | `schemaVersion` | VARCHAR(100) | ❌ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 12 | `validationErrors` | `validationErrors` | JSON | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 13 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | ❌ | ❌ | - | - | - |
|
|
||||||
| 14 | `updatedAt` | `updatedAt` | DATETIME(3) | ❌ | `@updatedAt` | ❌ | ❌ | - | - | - |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.5 AiUsageLog(共享 AI 用量日志)
|
|
||||||
|
|
||||||
物理表名:`AiUsageLog` | 创建于:`prisma/migrations/20250518000000_add_objectkey_bucket_aiusage_waitlist/migration.sql:9-30`;FK 同文件 line 48
|
|
||||||
|
|
||||||
| # | Prisma 字段名 | 物理列名 | DB 类型 | Nullable | Default | Index | Unique | FK | onDelete | onUpdate |
|
|
||||||
|---|-------------|---------|---------|----------|---------|-------|--------|-----|----------|----------|
|
|
||||||
| 1 | `id` | `id` | VARCHAR(191) | ❌ | `cuid()` | PK | ✅ | - | - | - |
|
|
||||||
| 2 | `userId` | `userId` | VARCHAR(191) | ❌ | - | `AiUsageLog_userId_idx` | ❌ | `User.id` | RESTRICT | CASCADE |
|
|
||||||
| 3 | `feature` | `feature` | VARCHAR(64) | ❌ | - | `AiUsageLog_feature_idx` | ❌ | - | - | - |
|
|
||||||
| 4 | `provider` | `provider` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 5 | `model` | `model` | VARCHAR(100) | ❌ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 6 | `tier` | `tier` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 7 | `promptKey` | `promptKey` | VARCHAR(128) | ❌ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 8 | `promptVersion` | `promptVersion` | VARCHAR(32) | ❌ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 9 | `inputTokens` | `inputTokens` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
|
||||||
| 10 | `outputTokens` | `outputTokens` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
|
||||||
| 11 | `estimatedCost` | `estimatedCost` | DOUBLE | ❌ | `0` | ❌ | ❌ | - | - | - |
|
|
||||||
| 12 | `latencyMs` | `latencyMs` | INT | ❌ | `0` | ❌ | ❌ | - | - | - |
|
|
||||||
| 13 | `success` | `success` | BOOLEAN | ❌ | `true` | ❌ | ❌ | - | - | - |
|
|
||||||
| 14 | `errorMessage` | `errorMessage` | VARCHAR(500) | ✅ | - | ❌ | ❌ | - | - | - |
|
|
||||||
| 15 | `createdAt` | `createdAt` | DATETIME(3) | ❌ | `now()` | `AiUsageLog_createdAt_idx` | ❌ | - | - | - |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1.6 FK 约束来源:Prisma 隐式默认 vs 显式声明
|
|
||||||
|
|
||||||
全部 7 个 `@relation` 在 Prisma schema 中均**未显式声明** `onDelete` / `onUpdate`:
|
|
||||||
|
|
||||||
```prisma
|
|
||||||
// 典型模式(无显式 onDelete/onUpdate)
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
job AiAnalysisJob @relation(fields: [jobId], references: [id])
|
|
||||||
```
|
|
||||||
|
|
||||||
生产数据库 `INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS` 确认全部 7 个 FK 均为 `RESTRICT / CASCADE`,这与 Prisma 默认行为一致:
|
|
||||||
- Prisma 默认 `onDelete: Restrict`
|
|
||||||
- Prisma 默认 `onUpdate: Cascade`
|
|
||||||
|
|
||||||
**对 M-AI-02 的影响**:
|
|
||||||
|
|
||||||
1. **M-AI-02-03/04 新增 FK 时应显式声明**:避免依赖 Prisma 默认值(不同版本/方言可能不同),在 schema 中显式写出 `onDelete: Restrict, onUpdate: Cascade` 以保持全表一致
|
|
||||||
2. **AiUsageLog.jobId FK(M-AI-02-08)**:新增 FK 应显式声明,**但需评估 `onDelete: SetNull` vs `Restrict`**——删除 Job 时 UsageLog 应保留以维持计费审计完整性
|
|
||||||
3. **OutboxEvent FK(M-AI-02-07)**:新增 Outbox 表的 FK 应显式声明,通常为 `onDelete: Cascade`(Job 删除时级联删 Outbox)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 状态值枚举
|
|
||||||
|
|
||||||
### 2.1 AiAnalysisJob.status
|
|
||||||
|
|
||||||
无 enum 定义,使用任意 string。实际使用值:
|
|
||||||
|
|
||||||
| 值 | 使用位置 | 说明 |
|
|
||||||
|----|---------|------|
|
|
||||||
| `pending` | `ai-analysis.repository.ts:15` | 创建时默认 |
|
|
||||||
| `processing` | `ai-analysis.repository.ts:23`, `ai-analysis.worker.ts:48` | Worker 开始处理 |
|
|
||||||
| `completed` | `ai-analysis.repository.ts:24`, `ai-analysis.worker.ts:68` | Worker 处理成功 |
|
|
||||||
| `failed` | `ai-analysis.repository.ts:24`, `ai-analysis.worker.ts:102` | Worker 处理失败 |
|
|
||||||
| `queued` | `ai-analysis.service.ts:30,51` | API 返回给客户端的虚拟状态(不写入 DB) |
|
|
||||||
|
|
||||||
### 2.2 AiRuntimeJob.status
|
|
||||||
|
|
||||||
无 enum 定义,使用任意 string。实际使用值:
|
|
||||||
|
|
||||||
| 值 | 使用位置 | 说明 |
|
|
||||||
|----|---------|------|
|
|
||||||
| `pending` | `user-ai.service.ts:289,315,331`, `runtime-internal.service.ts:27,92,594` | 创建/重试回退 |
|
|
||||||
| `locked` | `runtime-internal.service.ts:99,123` | Runtime 获取锁 |
|
|
||||||
| `running` | `runtime-internal.service.ts:129,131`, `job-reaper.service.ts:40,54`, `platform-budget.service.ts:55` | 执行中 |
|
|
||||||
| `succeeded` | `runtime-internal.service.ts:268` | 执行成功 |
|
|
||||||
| `failed` | `runtime-internal.service.ts:605`, `job-reaper.service.ts:98`, `submitFailure()` | 执行失败/超时终止 |
|
|
||||||
| `cancelled` | `user-ai.service.ts:353`, `runtime-internal.service.ts:583` | 用户取消 |
|
|
||||||
| `expired` | `docs/ai-job-state-machine.md:23` | 文档定义,代码中未显式赋此值 |
|
|
||||||
|
|
||||||
### 2.3 AiRuntimeResult.status
|
|
||||||
|
|
||||||
| 值 | 说明 |
|
|
||||||
|----|------|
|
|
||||||
| `succeeded` | 最终成功(代码中 `runtime-internal.service.ts:249` 检查) |
|
|
||||||
| 其他值未明确定义 | `submitResult()` 接受任意 `dto.status` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. JobType 枚举
|
|
||||||
|
|
||||||
### 3.1 AiAnalysisJob.jobType(旧系统 A)
|
|
||||||
|
|
||||||
| 值 | 使用位置 | 说明 |
|
|
||||||
|----|---------|------|
|
|
||||||
| `active-recall` | `ai-analysis.service.ts:19` | 主动回忆分析 |
|
|
||||||
| `feynman-evaluation` | `ai-analysis.service.ts:40` | 费曼评估分析 |
|
|
||||||
|
|
||||||
### 3.2 AiRuntimeJob.jobType(新系统 B)
|
|
||||||
|
|
||||||
| 值 | 使用位置 | 说明 |
|
|
||||||
|----|---------|------|
|
|
||||||
| `learning_state_analysis` | `user-ai.dto.ts:64` | 学习状态分析 |
|
|
||||||
| `weak_point_analysis` | `user-ai.dto.ts:65` | 薄弱点分析 |
|
|
||||||
| `next_action_planning` | `user-ai.dto.ts:66` | 下一步建议 |
|
|
||||||
| `quiz_generation` | `user-ai.dto.ts:67` | 题目生成 |
|
|
||||||
| `flashcard_generation` | `user-ai.dto.ts:68` | 卡片生成 |
|
|
||||||
|
|
||||||
**关键发现**:两套系统的 jobType 互不重叠,不存在同名冲突。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 生产数据审计
|
|
||||||
|
|
||||||
> 查询时间:2026-06-20 03:14 UTC
|
|
||||||
> 数据库:zhixi_prod @ 120.53.227.155:3306(蜂驰云 8C 生产服务器,SSH 隧道连接)
|
|
||||||
> MySQL 版本:8.0.46
|
|
||||||
> 查询脚本:`scripts/query-audit-data.ts`
|
|
||||||
|
|
||||||
### 4.1 AiAnalysisJob
|
|
||||||
|
|
||||||
| 指标 | 值 |
|
|
||||||
|------|-----|
|
|
||||||
| **总记录数** | 9 |
|
|
||||||
| 表大小 | 0.06 MB |
|
|
||||||
| 最早记录 | 2026-06-19T14:34:05.815Z |
|
|
||||||
| 最新记录 | 2026-06-19T14:48:10.489Z |
|
|
||||||
|
|
||||||
#### 状态分布
|
|
||||||
|
|
||||||
| 状态 | 数量 | 占比 |
|
|
||||||
|------|------|------|
|
|
||||||
| `completed` | 5 | 56% |
|
|
||||||
| `failed` | 4 | 44% |
|
|
||||||
|
|
||||||
#### jobType 分布
|
|
||||||
|
|
||||||
| jobType | 数量 |
|
|
||||||
|---------|------|
|
|
||||||
| `feynman-evaluation` | 7 |
|
|
||||||
| `active-recall` | 2 |
|
|
||||||
|
|
||||||
#### NULL 字段分布
|
|
||||||
|
|
||||||
| 字段 | NULL 数 | NULL 率 |
|
|
||||||
|------|---------|---------|
|
|
||||||
| `sessionId` | 9 | 100% |
|
|
||||||
| `answerId` | 7 | 78% |
|
|
||||||
| `errorMessage` | 5 | 56%(4 failed jobs 均有 errorMessage) |
|
|
||||||
| `queuedAt` | 0 | 0% |
|
|
||||||
| `startedAt` | 0 | 0% |
|
|
||||||
| `completedAt` | 0 | 0% — 正确。`AiAnalysisRepository.updateJobStatus()` 对 `completed`/`failed` 均显式设置 `completedAt = new Date()`(`ai-analysis.repository.ts:23-24`)。当前全部 9 行均为终态(5 `completed` + 4 `failed`),无 `pending`/`processing` 滞留,故 0 NULL 符合预期。`DateTime?` 类型不生成 `DEFAULT CURRENT_TIMESTAMP`,Prisma `@updatedAt` 也不影响此列。 |
|
|
||||||
|
|
||||||
#### 最大字段长度
|
|
||||||
|
|
||||||
| 字段 | 最大长度 |
|
|
||||||
|------|----------|
|
|
||||||
| `errorMessage` | 797 chars |
|
|
||||||
|
|
||||||
### 4.2 AiAnalysisResult
|
|
||||||
|
|
||||||
| 指标 | 值 |
|
|
||||||
|------|-----|
|
|
||||||
| **总记录数** | 5 |
|
|
||||||
| 表大小 | 0.06 MB |
|
|
||||||
| 孤儿 FK(jobId 不存在) | 0 |
|
|
||||||
| 重复 (userId, jobId) | 0 |
|
|
||||||
|
|
||||||
#### 最大 JSON 字段长度
|
|
||||||
|
|
||||||
| 字段 | 最大长度 |
|
|
||||||
|------|----------|
|
|
||||||
| `summary` | 162 chars |
|
|
||||||
| `strengths` | 61 chars |
|
|
||||||
| `weaknesses` | 184 chars |
|
|
||||||
| `suggestions` | 367 chars |
|
|
||||||
| `nextActions` | 185 chars |
|
|
||||||
| `rawResult` | 1,048 chars |
|
|
||||||
|
|
||||||
#### 父 Job 状态分布
|
|
||||||
|
|
||||||
| 父 Job 状态 | Result 数量 |
|
|
||||||
|-----------|------------|
|
|
||||||
| `completed` | 5 |
|
|
||||||
|
|
||||||
> 结论:所有 5 个 Result 均属于 completed Job,无 failed Job 产生 Result(符合代码逻辑)。
|
|
||||||
|
|
||||||
#### NULL 字段
|
|
||||||
|
|
||||||
| 字段 | NULL 数 |
|
|
||||||
|------|---------|
|
|
||||||
| `sessionId` | 5 (100%) |
|
|
||||||
| `answerId` | 5 (100%) |
|
|
||||||
| `summary` / `masteryScore` / `strengths` / etc. | 0 |
|
|
||||||
|
|
||||||
### 4.3 AiRuntimeJob
|
|
||||||
|
|
||||||
| 指标 | 值 |
|
|
||||||
|------|-----|
|
|
||||||
| **总记录数** | **0** |
|
|
||||||
| 表大小 | 0.11 MB(空表,空间为 initial extent 分配) |
|
|
||||||
|
|
||||||
> **确认**:ADR "唯一创建 AiRuntimeJob 的入口无人调用" 的断言正确。该表在生产中为零记录。
|
|
||||||
|
|
||||||
### 4.4 AiRuntimeResult
|
|
||||||
|
|
||||||
| 指标 | 值 |
|
|
||||||
|------|-----|
|
|
||||||
| **总记录数** | **0** |
|
|
||||||
| 表大小 | 0.08 MB(空表) |
|
|
||||||
|
|
||||||
### 4.5 AiUsageLog
|
|
||||||
|
|
||||||
| 指标 | 值 |
|
|
||||||
|------|-----|
|
|
||||||
| **总记录数** | 110 |
|
|
||||||
| 表大小 | 0.11 MB |
|
|
||||||
| 最早记录 | 2026-05-28T13:02:12.261Z |
|
|
||||||
| 最新记录 | 2026-06-19T14:52:40.199Z |
|
|
||||||
| success=true | 30 (27.3%) |
|
|
||||||
| success=false | 80 (72.7%) |
|
|
||||||
| errorMessage NULL | 30(仅 success=true 的日志无错误消息) |
|
|
||||||
|
|
||||||
#### feature 分布
|
|
||||||
|
|
||||||
| feature | 总数 | 成功 | 失败 | 成功率 |
|
|
||||||
|---------|------|------|------|--------|
|
|
||||||
| `learning-trend` | 70 | 11 | 59 | 15.7% |
|
|
||||||
| `knowledge-import` | 15 | 1 | 14 | 6.7% |
|
|
||||||
| `rag-chat` | 13 | 12 | 1 | 92.3% |
|
|
||||||
| `active-recall-analysis` | 7 | 1 | 6 | 14.3% |
|
|
||||||
| `feynman-evaluation` | 4 | 4 | 0 | 100% |
|
|
||||||
| `review-card-generation` | 1 | 1 | 0 | 100% |
|
|
||||||
|
|
||||||
#### provider 分布
|
|
||||||
|
|
||||||
| provider | 数量 |
|
|
||||||
|----------|------|
|
|
||||||
| `minimax` | 59 |
|
|
||||||
| `deepseek` | 51 |
|
|
||||||
|
|
||||||
#### model 分布
|
|
||||||
|
|
||||||
| model | 数量 |
|
|
||||||
|-------|------|
|
|
||||||
| `minimax-m2.7` | 59 | **全部来自已到期 API Key(2026-06-06 到期)** |
|
|
||||||
| `deepseek-v4-pro` | 35 | 当前主力模型 |
|
|
||||||
| `deepseek-v4-flash` | 16 | 轻量兜底 |
|
|
||||||
|
|
||||||
#### AiUsageLog 成功率根因分析
|
|
||||||
|
|
||||||
107/110 的失败率(72.7%)由以下因素叠加导致:
|
|
||||||
|
|
||||||
1. **MiniMax M2.7 API Key 已到期**(2026-06-06 到期,见 devops `轻量云服务器凭据.md`)
|
|
||||||
- `minimax-m2.7` 共 59 次调用,失败 59 次(100% 失败率)
|
|
||||||
- 影响 feature:`learning-trend`(59 次全部 minimax)、`knowledge-import`(14/15 次失败含大量 minimax)
|
|
||||||
2. **`learning-trend`**(70 次,成功率 16%)是后台定时任务 → 高调用量 × minimax 到期 = 大量失败
|
|
||||||
3. **`rag-chat`**(13 次,成功率 92%)使用 `deepseek-v4-pro` → 几乎全部成功
|
|
||||||
4. **`feynman-evaluation`**(4 次,成功率 100%)使用 `deepseek-v4-pro` → 全部成功
|
|
||||||
|
|
||||||
**结论**:排除 Minimax 到期影响后,DeepSeek 成功率接近 100%。2026-06-06 后所有调用已切换至 DeepSeek V4 Pro/V4 Flash(蜂驰云生产凭据确认),M-AI-02 无需为 Minimax 保留兼容。
|
|
||||||
|
|
||||||
#### 孤儿外键
|
|
||||||
|
|
||||||
| 检测 | SQL | 结果 |
|
|
||||||
|------|-----|------|
|
|
||||||
| `AiAnalysisResult.jobId` → `AiAnalysisJob.id` | `SELECT COUNT(*) FROM AiAnalysisResult r LEFT JOIN AiAnalysisJob j ON r.jobId=j.id WHERE j.id IS NULL` | 0 |
|
|
||||||
| `AiAnalysisResult.userId` → `User.id` | 同上模式 | 0 |
|
|
||||||
| `AiAnalysisJob.userId` → `User.id` | 同上模式 | 0 |
|
|
||||||
| `AiUsageLog.userId` → `User.id` | 同上模式(有 FK RESTRICT,不可能存在) | 0 |
|
|
||||||
|
|
||||||
#### 重复业务记录
|
|
||||||
|
|
||||||
**定义**:重复 = 同一用户对同一 jobType 在 **1 秒内** 创建 ≥2 条记录(并发创建)。
|
|
||||||
|
|
||||||
| 检测模式 | SQL | 结果 |
|
|
||||||
|---------|-----|------|
|
|
||||||
| `(userId, jobType)` 1秒并发 | `SELECT a.id,b.id FROM AiAnalysisJob a JOIN AiAnalysisJob b ON a.userId=b.userId AND a.jobType=b.jobType WHERE a.id<b.id AND ABS(TIMESTAMPDIFF(SECOND,a.createdAt,b.createdAt))<=1` | **1 对** |
|
|
||||||
| `(userId, sessionId, jobType)` 并发 | `GROUP BY userId,sessionId,jobType HAVING cnt>1 WHERE sessionId IS NOT NULL` | 0(sessionId 100% NULL 导致) |
|
|
||||||
| `(userId, jobId)` 在 AiAnalysisResult | `GROUP BY userId,jobId HAVING cnt>1` | 0 |
|
|
||||||
| AiUsageLog 同秒双写 | `GROUP BY userId,feature,provider,model,createdAt HAVING cnt>1` | 0 |
|
|
||||||
|
|
||||||
**发现的并发重复详情**:
|
|
||||||
|
|
||||||
```
|
|
||||||
id1=cmql14u46007nwfzktt0ttsn0 id2=cmql14uey007rwfzkl9uf0ssz
|
|
||||||
jobType=feynman-evaluation status=completed/completed
|
|
||||||
createdAt=2026-06-19T14:34:05Z / 14:34:06Z diff=0s
|
|
||||||
```
|
|
||||||
|
|
||||||
**评估**:两个 Job 均在 1 秒内创建,jobType 相同(`feynman-evaluation`),最终状态均为 `completed`。这是 `AiAnalysisService.evaluateFeynman()` 的并发调用导致。**AiAnalysisJob 表无 (userId, sessionId, jobType) 唯一约束**,无法在数据库层防御并发重复。M-AI-02-03 引入 `idempotencyKey` 后可通过业务层幂等解决。
|
|
||||||
|
|
||||||
#### JSON/TEXT 字段最大容量
|
|
||||||
|
|
||||||
**检测方法**:`SELECT MAX(LENGTH(column)) FROM table`
|
|
||||||
|
|
||||||
| 表 | 字段 | 类型 | 当前最大 | 检测 SQL |
|
|
||||||
|----|------|------|---------|----------|
|
|
||||||
| `AiAnalysisJob` | `errorMessage` | TEXT | **797 chars** | `SELECT MAX(LENGTH(errorMessage)) FROM AiAnalysisJob` |
|
|
||||||
| `AiAnalysisResult` | `rawResult` | JSON | **1,048 chars** | `SELECT MAX(LENGTH(rawResult)) FROM AiAnalysisResult` |
|
|
||||||
| `AiAnalysisResult` | `strengths` | JSON | 61 | 同上 |
|
|
||||||
| `AiAnalysisResult` | `weaknesses` | JSON | 184 | 同上 |
|
|
||||||
| `AiAnalysisResult` | `suggestions` | JSON | **367 chars** | 同上 |
|
|
||||||
| `AiAnalysisResult` | `nextActions` | JSON | 185 | 同上 |
|
|
||||||
| `AiAnalysisResult` | `summary` | TEXT | 162 | 同上 |
|
|
||||||
| `AiRuntimeJob` | `errorMessage` | TEXT | 0(零记录) | `SELECT MAX(LENGTH(errorMessage)) FROM AiRuntimeJob` |
|
|
||||||
| `AiRuntimeResult` | `rawOutput` | JSON | NULL(零记录) | `SELECT MAX(LENGTH(rawOutput)) FROM AiRuntimeResult` |
|
|
||||||
| `AiRuntimeResult` | `validatedOutput` | JSON | NULL(零记录) | 同上 |
|
|
||||||
|
|
||||||
**M-AI-02-04 容量建议**:
|
|
||||||
- 当前 JSON/TEXT 字段均在 1KB 级别,远低于 MEDIUMTEXT(16MB)上限
|
|
||||||
- `rawResult` 最大 1KB → M-AI-02-04 新增的 `AiJobSnapshot` 需要容纳完整的 snapshot JSON(约 5-10KB),建议 `MEDIUMTEXT` 或 `JSON`(MySQL JSON 列上限 1GB,但索引友好)
|
|
||||||
- `AiAnalysisJob.errorMessage` 最大 797 chars → 新建 `AiJob.errorMessage` 保持 `TEXT`(64KB)即可,无需 `MEDIUMTEXT`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 调用方完整清单
|
|
||||||
|
|
||||||
### 5.1 `prisma.aiAnalysisJob` 直接访问
|
|
||||||
|
|
||||||
| 文件 | 行号 | 操作 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 9 | `create` | 创建 Job |
|
|
||||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 26 | `update` | 更新 Job 状态 |
|
|
||||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 30 | `findUnique` | 查询 Job(include results) |
|
|
||||||
|
|
||||||
### 5.2 `prisma.aiAnalysisResult` 直接访问
|
|
||||||
|
|
||||||
| 文件 | 行号 | 操作 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 37 | `create` | 创建分析结果 |
|
|
||||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | 53 | `findUnique` | 查询分析结果 |
|
|
||||||
| `src/modules/learning-session/admin-learning.service.ts` | 229 | `findMany` | Admin 分页查询 |
|
|
||||||
| `src/modules/learning-session/admin-learning.service.ts` | 233 | `count` | Admin 总数 |
|
|
||||||
|
|
||||||
### 5.3 `prisma.aiRuntimeJob` 直接访问
|
|
||||||
|
|
||||||
| 文件 | 行号 | 操作 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `src/modules/ai-runtime/user-ai.service.ts` | 226 | `findUnique` | 幂等检查 |
|
|
||||||
| `src/modules/ai-runtime/user-ai.service.ts` | 282 | `create` | 创建 Job |
|
|
||||||
| `src/modules/ai-runtime/user-ai.service.ts` | 344 | `findFirst` | 取消查询 |
|
|
||||||
| `src/modules/ai-runtime/user-ai.service.ts` | 351,359 | `update` | 取消更新 |
|
|
||||||
| `src/modules/ai-runtime/user-ai.service.ts` | 429 | `findFirst` | 状态查询 |
|
|
||||||
| `src/modules/ai-runtime/user-ai.service.ts` | 448 | `findMany` | 列表查询 |
|
|
||||||
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 36 | `findMany` | Poll 查询 |
|
|
||||||
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 89 | `updateMany` | Lock |
|
|
||||||
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 123,129 | `updateMany` | Heartbeat |
|
|
||||||
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 139,154,202,225,576,661 | `findUnique` | Snapshot/Result/Fail 查询 |
|
|
||||||
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 192,268,583,594,605 | `update` | Snapshot/Result/Fail 更新 |
|
|
||||||
| `src/modules/ai-runtime/job-reaper.service.ts` | 28 | `updateMany` | 清扫过期锁 |
|
|
||||||
| `src/modules/ai-runtime/job-reaper.service.ts` | 39,69 | `findMany` | 查询 stuck/expired |
|
|
||||||
| `src/modules/ai-runtime/job-reaper.service.ts` | 53,82,96 | `updateMany` | 清扫失败/过期 |
|
|
||||||
| `src/modules/ai-runtime/platform-budget.service.ts` | 53 | `count` | 平台预算检查 |
|
|
||||||
|
|
||||||
### 5.4 `prisma.aiUsageLog` 直接访问
|
|
||||||
|
|
||||||
| 文件 | 行号 | 操作 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `src/modules/ai/usage/ai-usage-log.service.ts` | 28 | `create` | 写日志 |
|
|
||||||
| `src/modules/admin-costs/cost-aggregation.service.ts` | 31 | `findMany` | 成本聚合 |
|
|
||||||
| `src/modules/admin-metrics/admin-metrics.controller.ts` | 56 | `findMany` | AI 耗时统计 |
|
|
||||||
| `src/modules/admin-dashboard/admin-dashboard.service.ts` | 46 | `count` | 今日 AI 调用数 |
|
|
||||||
| `src/modules/admin-costs/admin-costs.service.ts` | 85 | `groupBy` | 成本统计 |
|
|
||||||
| `src/modules/learning-session/admin-learning.service.ts` | 247,251 | `findMany`/`count` | Admin 查询 |
|
|
||||||
|
|
||||||
### 5.5 间接调用方(通过 Repository/Service)
|
|
||||||
|
|
||||||
| 调用方 | 文件 | 说明 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `AiAnalysisWorker.process()` | `src/workers/ai-analysis.worker.ts:48-102` | 通过 `AiAnalysisRepository` 更新状态和结果 |
|
|
||||||
| `AiAnalysisService.analyze()` | `src/modules/ai-analysis/ai-analysis.service.ts:12-31` | 创建 Job 并入队 |
|
|
||||||
| `AiAnalysisService.evaluateFeynman()` | `src/modules/ai-analysis/ai-analysis.service.ts:33-52` | 创建 Job 并入队 |
|
|
||||||
| `AiAnalysisService.getJobStatus()` | `src/modules/ai-analysis/ai-analysis.service.ts:54-67` | 查询状态(序列化给客户端) |
|
|
||||||
| `AdminLearningService.getAnalysis()` | `src/modules/learning-session/admin-learning.service.ts:223-236` | Admin 分页查询 |
|
|
||||||
| `AdminLearningService.getAiUsage()` | `src/modules/learning-session/admin-learning.service.ts:240-254` | Admin 分页查询 |
|
|
||||||
| `AiUsageLogService.log()` | `src/modules/ai/usage/ai-usage-log.service.ts:26-32` | 写用量日志 |
|
|
||||||
| `CostAggregationService.aggregateToday()` | `src/modules/admin-costs/cost-aggregation.service.ts:27-59` | 成本聚合 |
|
|
||||||
| `AdminMetricsController.aiMetrics()` | `src/modules/admin-metrics/admin-metrics.controller.ts:54-89` | AI 耗时统计 |
|
|
||||||
| `AdminDashboardService.getStats()` | `src/modules/admin-dashboard/admin-dashboard.service.ts:46` | 仪表盘统计 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 测试文件引用
|
|
||||||
|
|
||||||
| 文件 | 引用方式 | 影响 |
|
|
||||||
|------|---------|------|
|
|
||||||
| `test/helpers/integration-harness.ts:228` | 硬编码表名 `'AiAnalysisResult'`, `'AiAnalysisJob'` | **重命名 Prisma model 后需同步** |
|
|
||||||
| `src/modules/ai-runtime/user-ai.service.spec.ts` | Mock `prisma.aiRuntimeJob.*` | 与 Prisma model 名无关(mock) |
|
|
||||||
| `src/modules/ai-runtime/job-reaper.service.spec.ts` | Mock `prisma.aiRuntimeJob.*` | 与 Prisma model 名无关(mock) |
|
|
||||||
| `src/modules/ai-runtime/platform-budget.service.spec.ts` | Mock `prisma.aiRuntimeJob.*` | 与 Prisma model 名无关(mock) |
|
|
||||||
| `src/modules/ai-runtime/internal/runtime-internal.service.spec.ts` | Mock `prisma.aiRuntimeJob.*` | 与 Prisma model 名无关(mock) |
|
|
||||||
| `test/api-e2e.worker-int-spec.ts` | Raw SQL 8 处 + 字符串引用 8 处 = **16 处**引用物理表名 `AiAnalysisJob` / `AiAnalysisResult` | M-AI-02-09 重命名后需全部同步 | SQL: 91,101,107,197,210,218,252,255;字符串: 88,105,108,195,215,219,253,256 |
|
|
||||||
| `test/worker-integration.worker-int-spec.ts` | Raw SQL 13 处 + 字符串引用 8 处 = **21 处**引用物理表名 | M-AI-02-09 重命名后需全部同步 | SQL: 113,120,127,158,170,212,245,259,270,282,299,313,316;字符串: 110,114,118,121,125,247,314,317 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 兼容风险分析
|
|
||||||
|
|
||||||
### 7.1 Model 名依赖
|
|
||||||
|
|
||||||
**风险等级:中等**
|
|
||||||
|
|
||||||
以下代码依赖 Prisma model 名 `AiAnalysisJob`、`AiAnalysisResult`:
|
|
||||||
|
|
||||||
| 依赖点 | 文件 | 行号 | 影响 |
|
|
||||||
|--------|------|------|------|
|
|
||||||
| `prisma.aiAnalysisJob` | `ai-analysis.repository.ts` | 9,26,30 | 重命名 model 会导致编译错误 |
|
|
||||||
| `prisma.aiAnalysisResult` | `ai-analysis.repository.ts` | 37,53 | 同上 |
|
|
||||||
| `prisma.aiAnalysisResult` | `admin-learning.service.ts` | 229,233 | 同上 |
|
|
||||||
| 硬编码表名字符串 | `test/helpers/integration-harness.ts` | 228 | 清理表时使用物理表名 `'AiAnalysisJob'`、`'AiAnalysisResult'` |
|
|
||||||
|
|
||||||
**注意**:`prisma.aiRuntimeJob`、`prisma.aiUsageLog` 不会被 M-AI-02 重命名(当前范围仅统一 `AiAnalysisJob → AiJob`)。
|
|
||||||
|
|
||||||
### 7.2 状态 enum 依赖
|
|
||||||
|
|
||||||
**风险等级:低**
|
|
||||||
|
|
||||||
- 所有状态值均为 raw string,无 enum 定义
|
|
||||||
- `AiAnalysisJob` 使用 `pending`/`processing`/`completed`/`failed`
|
|
||||||
- `AiRuntimeJob` 使用 `pending`/`locked`/`running`/`succeeded`/`failed`/`cancelled`
|
|
||||||
- 两套状态值语义不同,M-AI-02-04 需要统一
|
|
||||||
- 当前代码中没有任何 `AiAnalysisJobStatus` type/enum 定义
|
|
||||||
|
|
||||||
### 7.3 API 直接序列化数据库状态
|
|
||||||
|
|
||||||
**风险等级:中等**
|
|
||||||
|
|
||||||
| API | 文件 | 行号 | 序列化字段 |
|
|
||||||
|-----|------|------|----------|
|
|
||||||
| `AiAnalysisService.getJobStatus()` | `ai-analysis.service.ts:54-67` | 直接透传 `job.status`、`job.jobType`、`job.queuedAt`、`job.startedAt`、`job.completedAt` | **字段名变更会影响 iOS** |
|
|
||||||
| `UserAiService.getJob()` | `user-ai.service.ts:428-443` | 选择字段直接序列化 `jobType`、`status`、`errorCode`、`errorMessage` 等 | **字段名变更会影响 iOS** |
|
|
||||||
| `UserAiService.listJobs()` | `user-ai.service.ts:445-459` | 同上 | 同上 |
|
|
||||||
| `UserAiService.createAnalysisJob()` 返回值 | `user-ai.service.ts:340` | `{ jobId, status, createdAt }` | 不受影响(仅返回 ID+状态) |
|
|
||||||
|
|
||||||
### 7.4 Relation 重命名影响
|
|
||||||
|
|
||||||
**风险等级:低**
|
|
||||||
|
|
||||||
- `AiAnalysisJob.results` → `AiAnalysisResult[]`(系统 A)
|
|
||||||
- `AiAnalysisResult.job` → `AiAnalysisJob`(系统 A)
|
|
||||||
- `AiRuntimeJob.result` → `AiRuntimeResult?`(系统 B)
|
|
||||||
- `AiRuntimeResult.job` → `AiRuntimeJob`(系统 B)
|
|
||||||
- M-AI-02-09 将 `AiAnalysisJob` 重命名为 `AiJob` 时,`AiAnalysisResult.job` 的 relation 类型需要同时更新
|
|
||||||
|
|
||||||
### 7.5 Raw SQL / 手写表名
|
|
||||||
|
|
||||||
**风险等级:中高**
|
|
||||||
|
|
||||||
- 源文件(`src/`)中无 `$queryRaw`/`$executeRaw` 引用 AI 分析相关表
|
|
||||||
- `worker.main.ts:24` 和 `system.controller.ts:35` 仅使用 `SELECT 1` 做健康检查
|
|
||||||
- **重要发现**:E2E 测试文件使用 Raw SQL 直接查询物理表:
|
|
||||||
- `test/api-e2e.worker-int-spec.ts`:8 处 Raw SQL + 8 处字符串引用(it/console.log)= **16 处**硬编码表名 `'AiAnalysisJob'`、`'AiAnalysisResult'`
|
|
||||||
- `test/worker-integration.worker-int-spec.ts`:13 处 Raw SQL + 8 处字符串引用 = **21 处**同上
|
|
||||||
- **合计 37 处引用**,其中 21 处为 Raw SQL(`SELECT ... FROM AiAnalysisJob`),16 处为字符串引用
|
|
||||||
- **M-AI-02-09 重命名物理表后,这 37 处引用必须全部同步更新**
|
|
||||||
|
|
||||||
### 7.6 报表/Admin 直接查询物理表
|
|
||||||
|
|
||||||
**风险等级:中等**
|
|
||||||
|
|
||||||
所有 Admin 查询通过 Prisma Client,不会直接访问物理表。但以下聚合逻辑依赖现有字段:
|
|
||||||
|
|
||||||
| Admin 功能 | 文件 | 依赖字段 |
|
|
||||||
|-----------|------|---------|
|
|
||||||
| Dashboard 今日 AI 调用数 | `admin-dashboard.service.ts:46` | `AiUsageLog.createdAt` |
|
|
||||||
| AI 耗时统计 | `admin-metrics.controller.ts:56-86` | `AiUsageLog.provider`、`model`、`latencyMs`、`success`、`estimatedCost` |
|
|
||||||
| 成本聚合 | `cost-aggregation.service.ts:31-56` | `AiUsageLog.*` |
|
|
||||||
| Admin 分析结果 | `admin-learning.service.ts:229-233` | `AiAnalysisResult.*` |
|
|
||||||
|
|
||||||
### 7.7 跨表数据关系
|
|
||||||
|
|
||||||
| 源表 | 目标表 | 关系 | 当前状态 | M-AI-02 影响 |
|
|
||||||
|------|--------|------|----------|-------------|
|
|
||||||
| `AiAnalysisJob` | `AiAnalysisResult` | 1:N FK | 生产运行中 | 需保留 |
|
|
||||||
| `AiAnalysisJob` | `FocusItem` | 逻辑关联(Worker 进程内直接调用 `FocusItemsService.create()`) | 生产运行中 | 不直接受影响 |
|
|
||||||
| `AiAnalysisJob` | `ReviewCard` | 逻辑关联(EventEmitter → Subscriber → AI Workflow → `ReviewCard` 表) | 生产运行中 | **M-AI-05 Outbox 替代时必须保持此链** |
|
|
||||||
| `AiRuntimeJob` | `AiRuntimeResult` | 1:1 FK | 闲置(无生产者) | M-AI-03 规划使用 |
|
|
||||||
| `AiRuntimeJob` | `LearningAnalysisSnapshot` | 逻辑关联(snapshotId) | 闲置 | M-AI-03 规划使用 |
|
|
||||||
| `AiRuntimeJob` | `AiLearningAnalysis` | 逻辑关联(jobId) | 闲置 | M-AI-04 规划使用 |
|
|
||||||
| `AiRuntimeJob` | `QuestionGenerationPlan` | 逻辑关联(jobId) | 闲置 | M-AI-06 规划使用 |
|
|
||||||
| `AiRuntimeJob` | `FlashcardGenerationPlan` | 逻辑关联(jobId) | 闲置 | M-AI-06 规划使用 |
|
|
||||||
| `AiUsageLog` | 无 FK 到 Job 表 | **无关联** | - | **M-AI-02-08 需新增** |
|
|
||||||
|
|
||||||
### `ai.analysis.completed` 事件订阅链完整追踪
|
|
||||||
|
|
||||||
**对 M-AI-05(Outbox 替代 EventEmitter)至关重要。**
|
|
||||||
|
|
||||||
```
|
|
||||||
AiAnalysisWorker.process() // src/workers/ai-analysis.worker.ts:72
|
|
||||||
└─ eventBus.publish(new AIAnalysisCompleted(...)) // 发布事件
|
|
||||||
│
|
|
||||||
└─ ReviewCardSubscriber.handleAIAnalysisCompleted() // src/modules/review/review-card.subscriber.ts:11
|
|
||||||
│ @OnEvent('ai.analysis.completed')
|
|
||||||
│ 订阅 payload: { userId, jobId, sessionId?, answerId?, type, score?, analysis? }
|
|
||||||
│
|
|
||||||
└─ ReviewService.generateCards(userId, input) // src/modules/review/review.service.ts:68
|
|
||||||
│ 从 payload.analysis 中提取 weaknesses/strengths/summary
|
|
||||||
│ 拼接为 prompt 文本 → CardGenerationWorkflow.execute()
|
|
||||||
│
|
|
||||||
└─ ReviewRepository.insertCard(data) // src/modules/review/review.repository.ts:38
|
|
||||||
│ this.prisma.reviewCard.create({ data })
|
|
||||||
│
|
|
||||||
└─ 写入 ReviewCard 表 (prisma/schema.prisma:642-668)
|
|
||||||
字段: userId, frontText, backText, difficulty, status,
|
|
||||||
intervalDays, easeFactor, repetitionCount, lapseCount,
|
|
||||||
scheduleState, nextReviewAt
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键发现**:
|
|
||||||
|
|
||||||
1. `ReviewCard` **无 `jobId` FK** — 无法溯源哪次 AI 分析生成了哪张卡片
|
|
||||||
2. Subscriber 依赖进程内 EventEmitter — M-AI-05 改为 Outbox 后,此链必须保持
|
|
||||||
3. `FocusItem` 不走事件订阅 — Worker 进程内直接调 `focusItems.create()`(`ai-analysis.worker.ts:88`),不存在可靠投递保证
|
|
||||||
4. Subscriber 声明的 payload 接口(`review-card.subscriber.ts:12-20`)与实际 `AIAnalysisCompleted` event 类(`ai-analysis.worker.ts:13-16`)完全耦合 — 任何 event schema 变更需同步修改 subscriber
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 关键发现总结
|
|
||||||
|
|
||||||
1. **生产数据量极小**:AiAnalysisJob 仅 9 行,AiAnalysisResult 仅 5 行,AiUsageLog 110 行。所有表 < 0.12 MB。**Migration 锁表风险极低**(毫秒级 ALTER TABLE)。
|
|
||||||
2. **AiRuntimeJob/AiRuntimeResult 零记录**:确认 ADR 断言正确——系统 B 无生产者活动。M-AI-02 Expand 无需回填历史 RuntimeJob 数据。
|
|
||||||
3. **两套系统并存**:`AiAnalysisJob`(生产运行中,active-recall + feynman)和 `AiRuntimeJob`(闲置,5 种分析类型)是两套完全独立的表,无任何 FK/逻辑关联
|
|
||||||
4. **AiUsageLog 与 Job 表无关联**:当前 `AiUsageLog` 无 `jobId` 字段,无法追踪单次 AI 调用归属。**M-AI-02-08 需要新增 `jobId` FK**
|
|
||||||
5. **无 Outbox/事件溯源**:系统 A 通过 NestJS EventEmitter 发布事件(`ai.analysis.completed`),系统 B 在 API 进程内同步写 Notification——无持久化事件表
|
|
||||||
6. **状态值非标准化**:无 enum 定义,两套状态语义不兼容(`processing` vs `locked`/`running`,`completed` vs `succeeded`)
|
|
||||||
7. **AiRuntimeJob 字段远多于 AiAnalysisJob**:31 vs 13 字段,M-AI-02 Expand 后应保持一致
|
|
||||||
8. **无 raw SQL 引用 AI 表**:AI 表仅通过 Prisma Client 访问
|
|
||||||
9. **37 处测试文件硬编码表名**:`test/api-e2e.worker-int-spec.ts`(16) 和 `test/worker-integration.worker-int-spec.ts`(21) 中 Raw SQL 及字符串引用物理表名 `'AiAnalysisJob'`/`'AiAnalysisResult'`,M-AI-02-09 重命名后必须全部同步。另有 `test/helpers/integration-harness.ts:228` 的清理列表
|
|
||||||
10. **sessionId/answerId 高 NULL 率**:AiAnalysisJob 中 sessionId 100% NULL,answerId 78% NULL——M-AI-02-04 可评估是否保留这两个字段
|
|
||||||
11. **无孤儿 FK**:所有 FK 引用一致,数据完整性良好
|
|
||||||
12. **全部 FK 来自 Prisma 默认值**:Prisma schema 中 7 个 `@relation` 均未显式声明 `onDelete`/`onUpdate`,生产实际值为 RESTRICT/CASCADE(Prisma 默认)。M-AI-02-03/04 新增 FK 应**显式声明**以避免跨版本/跨方言歧义(见 1.6 节)
|
|
||||||
13. **ReviewCard 无 jobId 溯源**:`ai.analysis.completed` → `ReviewCardSubscriber` → AI Workflow → `ReviewCard` 的完整链路已追踪(见 7.7 节),但 ReviewCard 表无 `jobId` FK,无法溯源卡片生成来源
|
|
||||||
14. **FocusItem 不走事件系统**:Worker 进程内直接同步调用 `focusItems.create()`,无可靠投递保证——M-AI-05 Outbox 化时需考虑
|
|
||||||
15. **AiUsageLog 低成功率根因已定位**:72.7% 失败由 MiniMax M2.7 API Key 到期(2026-06-06)导致 — `learning-trend` 全部使用 minimax 故失败率 84%。DeepSeek 调用近乎 100% 成功。生产已全量切换至 DeepSeek V4 Pro/Flash,M-AI-02 无需保留 Minimax 兼容
|
|
||||||
@ -1,566 +0,0 @@
|
|||||||
# M-AI-03 现有执行边界审计
|
|
||||||
|
|
||||||
> 审计日期:2026-06-20
|
|
||||||
> 审计范围:`api-server` 仓库所有 Job 创建、Worker 执行、Queue Producer、Provider 调用、EventBus 与 Outbox 路径
|
|
||||||
> 审计方法:全文搜索 + 逐文件阅读 + 调用链追踪
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 总体架构概览
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────────────────────────────────────────────────────────┐
|
|
||||||
│ api-server │
|
|
||||||
│ │
|
|
||||||
│ Job System A: Legacy AiJob (BullMQ) │
|
|
||||||
│ ┌──────────────────────────────────────────────────┐ │
|
|
||||||
│ │ AiAnalysisService → AiJob DB → BullMQ │ │
|
|
||||||
│ │ → ai-analysis queue → AiAnalysisWorker │ │
|
|
||||||
│ │ → AiGatewayService → DeepSeek → AiAnalysisResult│ │
|
|
||||||
│ └──────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ Job System B: AiRuntimeJob (REST Poll) │
|
|
||||||
│ ┌──────────────────────────────────────────────────┐ │
|
|
||||||
│ │ UserAiService → AiRuntimeJob DB → Runtime polls │ │
|
|
||||||
│ │ via REST → RuntimeInternalService │ │
|
|
||||||
│ │ → zhixi-heavy-runtime → submitResult │ │
|
|
||||||
│ └──────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ Job System C: DocumentImport (BullMQ) │
|
|
||||||
│ ┌──────────────────────────────────────────────────┐ │
|
|
||||||
│ │ DocumentImportService → DocumentImport DB │ │
|
|
||||||
│ │ → BullMQ document-import queue │ │
|
|
||||||
│ │ → DocumentImportWorker → KnowledgeImportWorkflow│ │
|
|
||||||
│ └──────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ Supporting Queues (BullMQ) │
|
|
||||||
│ ┌──────────────────────────────────────────────────┐ │
|
|
||||||
│ │ notification, domain-events, audit-logs, │ │
|
|
||||||
│ │ file-cleanup │ │
|
|
||||||
│ └──────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ Outbox (exists, unused) │
|
|
||||||
│ ┌──────────────────────────────────────────────────┐ │
|
|
||||||
│ │ OutboxRepository — 表就绪,无生产者,无 Dispatcher │ │
|
|
||||||
│ └──────────────────────────────────────────────────┘ │
|
|
||||||
└────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键发现**:存在 **两套独立的 Job 系统**(AiJob + AiRuntimeJob),使用完全不同的调度机制(BullMQ vs REST Poll)。DocumentImport 使用独立的 model 和队列。Outbox Repository 已实现但没有任何调用方。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Job Producer 矩阵(仅含活跃生产者)
|
|
||||||
|
|
||||||
| # | Producer Class | Method | Trigger | DB Table | Queue | Payload | 状态写入 | 代码位置 |
|
|
||||||
|---|---------------|--------|---------|----------|-------|---------|----------|----------|
|
|
||||||
| P1 | `AiAnalysisService` | `analyze()` | User API | `AiJob` | `ai-analysis` | `{jobId, userId, type:'active-recall', questionText, knowledgeItemContent, userAnswer}` | `status:pending`, `lifecycleStatus:queued` | `ai-analysis.service.ts:19-31` |
|
|
||||||
| P2 | `AiAnalysisService` | `evaluateFeynman()` | User API | `AiJob` | `ai-analysis` | `{jobId, userId, type:'feynman-evaluation', knowledgeItemTitle, knowledgeItemContent, userExplanation}` | `status:pending`, `lifecycleStatus:queued` | `ai-analysis.service.ts:40-51` |
|
|
||||||
|
|
||||||
> ⚠️ **queueName 与实际队列不一致**:`AiAnalysisRepository.createJob()` 写入 `queueName: 'ai-interactive'`(`ai-analysis.repository.ts:28`),但 `AiAnalysisService` 实际将 BullMQ Job 发送到 `ai-analysis` 队列(`ai-analysis.service.ts:21,42`)。数据库记录的 `queueName` 字段与真实 BullMQ 队列路由存在偏差。M-AI-03 构建统一 Engine 将依赖 `queueName` 字段进行路由决策,此为关键风险。
|
|
||||||
| P3 | `DocumentImportService` | `createImport()` | User API | `DocumentImport` | `document-import` | `{importId, userId, knowledgeBaseId, rawText, fileName}` | `status:QUEUED`(via Repository) | `document-import.service.ts:43-49` |
|
|
||||||
| P4 | `KnowledgeSourceService` | `addSource()` | User API | `DocumentImport` | `document-import` | `{importId, userId, knowledgeBaseId, sourceId, fileName}` | `status:QUEUED`(via Repository) | `knowledge-source.service.ts:43-50` |
|
|
||||||
| P5 | `KnowledgeSourceService` | `triggerParse()` | User API | `DocumentImport` | `document-import` | `{importId, userId, knowledgeBaseId, sourceId, fileName}` | `status:QUEUED`(via Repository) | `knowledge-source.service.ts:90-97` |
|
|
||||||
| P6 | `UserAiService` | `requestJob()` | User API | `AiRuntimeJob` | **无 BullMQ** | REST Poll,不适用 | `status:pending` | `user-ai.service.ts:282-299` |
|
|
||||||
| P7 | `AdminFilesController` | COS cleanup | Admin API | 无 | `file-cleanup` | `{objectKey, bucket, region}` | 无(仅入队) | `admin-files.controller.ts:41` |
|
|
||||||
|
|
||||||
> **注**:`QueueService.add()`(`queue.service.ts:41-58`)是上述所有 BullMQ Producer 的公共抽象层,不作为独立 Producer 列出。其副作用:写入 `TaskLog` 记录(`status:enqueued`)和同步发布 `task.enqueued` 事件。
|
|
||||||
|
|
||||||
### 死代码(定义为入队方法但零调用方)
|
|
||||||
|
|
||||||
| 方法 | 文件 | 入队目标 | 说明 |
|
|
||||||
|------|------|---------|------|
|
|
||||||
| `EventBusService.publishAsync()` | `event-bus.service.ts:26-35` | `domain-events` | **零调用方** — 全文搜索确认无任何代码调用此方法;`domain-events` 队列当前无活跃生产者 |
|
|
||||||
|
|
||||||
### 孤儿队列(Consumer 已注册但生产者未找到)
|
|
||||||
|
|
||||||
| 队列 | Consumer | WorkerModule 注册 | 生产者状态 |
|
|
||||||
|------|----------|-------------------|-----------|
|
|
||||||
| `notification` | `NotificationWorker` (`workers/notification.worker.ts:8`) | `worker.module.ts:76` | ❌ 无生产者 — 全文搜索确认零 `queue.add('notification', ...)`;`NotificationsService.send()` 仅写 DB + sync eventBus,不入队 |
|
|
||||||
| `domain-events` | 无专用 Consumer(只有 `EventBusService.publishAsync` 可入队) | N/A | ❌ 无生产者 — `publishAsync()` 为零调用方死代码;队列完全闲置 |
|
|
||||||
| `audit-logs` | `AuditLogProcessor` (`modules/admin-audit-log/audit-log.processor.ts:7`) | `worker.module.ts:77` | ❌ 无生产者 — 全文搜索确认零 `queue.add('audit-logs', ...)`;所有审计日志通过 `prisma.adminAuditLog.create()` 直接写 DB |
|
|
||||||
|
|
||||||
> **含义**:`NotificationWorker`、`AuditLogProcessor` 两个 Worker 进程注册在 `WorkerModule` 中,每 30s 从各自队列 poll,但这两个队列从未有消息进入——实为永久空转的闲置进程。
|
|
||||||
|
|
||||||
> **注意**:`AdminEventsController` 仅**读取**队列状态(`getWaitingCount`/`getActiveCount`/`getFailed` 等)和**重试**已有失败 Job(`job.retry()`),**从不创建新 Job**。因此不列为 Producer。
|
|
||||||
|
|
||||||
### Producer 代码证据
|
|
||||||
|
|
||||||
**P1–P2** `src/modules/ai-analysis/ai-analysis.service.ts:19-31, 40-51`
|
|
||||||
```typescript
|
|
||||||
const job = await this.repository.createJob(userId, 'active-recall', input.sessionId, input.answerId);
|
|
||||||
await this.queue.add('ai-analysis', { jobId: job.id, userId, type: 'active-recall', ... });
|
|
||||||
```
|
|
||||||
|
|
||||||
**P3** `src/modules/document-import/document-import.service.ts:43-49`
|
|
||||||
```typescript
|
|
||||||
const job = await this.repository.create(dto);
|
|
||||||
await this.queue.add('document-import', { importId: job.id, userId, knowledgeBaseId, rawText, fileName });
|
|
||||||
```
|
|
||||||
|
|
||||||
**P4–P5** `src/modules/knowledge-source/knowledge-source.service.ts:44-50, 91-97`
|
|
||||||
```typescript
|
|
||||||
const importJob = await this.importRepo.create({ ... });
|
|
||||||
await this.queue.add('document-import', { importId: importJob.id, userId, ... });
|
|
||||||
```
|
|
||||||
|
|
||||||
**P6** `src/modules/ai-runtime/user-ai.service.ts:282-299`
|
|
||||||
```typescript
|
|
||||||
const job = await this.prisma.aiRuntimeJob.create({ data: { userId, jobType, status: 'pending', ... } });
|
|
||||||
```
|
|
||||||
|
|
||||||
**P7** `src/modules/files/admin-files.controller.ts:41`
|
|
||||||
```typescript
|
|
||||||
await this.queue.add(QUEUE_FILE_CLEANUP, { objectKey: file.objectKey, bucket: file.bucket, region: 'ap-beijing' });
|
|
||||||
```
|
|
||||||
|
|
||||||
### 死代码与孤儿队列证据
|
|
||||||
|
|
||||||
**`publishAsync()` — 零调用方** `src/common/event-bus/event-bus.service.ts:26-35`
|
|
||||||
```typescript
|
|
||||||
async publishAsync(event: BaseDomainEvent): Promise<string> {
|
|
||||||
if (!this.queue) return '';
|
|
||||||
const job = await this.queue.add('domain-events', { eventType, eventId, payload, occurredAt });
|
|
||||||
return job.id || '';
|
|
||||||
}
|
|
||||||
// 全文搜索确认:整个代码库中无任何代码调用 publishAsync()
|
|
||||||
```
|
|
||||||
|
|
||||||
**`NotificationsService.send()` — 仅写 DB,不入队** `src/modules/notifications/notifications.service.ts:44-49`
|
|
||||||
```typescript
|
|
||||||
async send(data: { userId: string; type: string; title: string; body: string }) {
|
|
||||||
const notification = await this.repository.create(data); // 仅写 DB
|
|
||||||
this.eventBus?.publish(new NotificationSentEvent(...)); // sync 事件,不入队
|
|
||||||
return notification;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`AdminEventsController` — 只读 + 重试,不创建新 Job** `src/modules/admin-events/admin-events.controller.ts:1-163`
|
|
||||||
- 所有方法:`getWaitingCount`, `getActiveCount`, `getFailedCount`, `getJob()`, `job.retry()` — 无 `queue.add()`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Worker / Processor 矩阵
|
|
||||||
|
|
||||||
| # | Worker Class | Queue | 所在 Module | 注入的 Provider | 结果写入 | 发布事件 |
|
|
||||||
|---|-------------|-------|------------|-----------------|---------|---------|
|
|
||||||
| W1 | `AiAnalysisWorker` | `ai-analysis` | `WorkerModule` | `ActiveRecallAnalysisWorkflow`, `FeynmanEvaluationWorkflow`, `AiAnalysisRepository`, `EventBusService?`, `FocusItemsService?` | `AiAnalysisResult` | `ai.analysis.completed` |
|
|
||||||
| W2 | `DocumentImportWorker` | `document-import` | `WorkerModule` | `DocumentImportRepository`, `KnowledgeItemsRepository`, `KnowledgeImportWorkflow`, `RedisService` | `KnowledgeItem` (多个) | 无 |
|
|
||||||
| W3 | `NotificationWorker` | `notification` | `WorkerModule` | `NotificationsService` | `Notification` 记录 | 无 |
|
|
||||||
| W4 | `AuditLogProcessor` | `audit-logs` | `WorkerModule` | `PrismaService` | `AdminAuditLog` | 无 |
|
|
||||||
| W5 | `FileCleanupProcessor` | `file-cleanup` | `WorkerModule` | `CosStorageProvider` | COS 删除 | 无 |
|
|
||||||
|
|
||||||
### Worker 代码证据
|
|
||||||
|
|
||||||
**W1** `src/workers/ai-analysis.worker.ts:18-106`
|
|
||||||
- 行 48: `await this.repository.updateJobStatus(jobId, 'processing')`
|
|
||||||
- 行 59–64: 调用 `feynmanWorkflow.execute()` / `recallWorkflow.execute()`
|
|
||||||
- 行 67–68: `createResult()` + `updateJobStatus(jobId, 'completed')`
|
|
||||||
- 行 72: `this.eventBus?.publish(new AIAnalysisCompleted({...}))`
|
|
||||||
- 行 86–95: 对每个 weakness 创建 `FocusItem`
|
|
||||||
|
|
||||||
**W2** `src/workers/document-import.worker.ts:11-92`
|
|
||||||
- 行 42–45: rawText 为空时直接标记 completed
|
|
||||||
- 行 50–53: 否则 → Redis 写进度,调用 `workflow.execute()`
|
|
||||||
- 行 65–77: 逐个创建 `KnowledgeItem`
|
|
||||||
- 行 79–82: `updateStatus(importId, 'completed')`
|
|
||||||
|
|
||||||
**W4** `src/modules/admin-audit-log/audit-log.processor.ts:8-27`
|
|
||||||
```typescript
|
|
||||||
async process(job: Job) {
|
|
||||||
await this.prisma.adminAuditLog.create({
|
|
||||||
data: {
|
|
||||||
adminUserId: job.data.adminUserId,
|
|
||||||
action: job.data.action,
|
|
||||||
resourceType: job.data.resourceType,
|
|
||||||
resourceId: job.data.resourceId,
|
|
||||||
beforeJson: job.data.beforeJson,
|
|
||||||
afterJson: job.data.afterJson,
|
|
||||||
ip: job.data.ip,
|
|
||||||
userAgent: job.data.userAgent,
|
|
||||||
riskLevel: job.data.riskLevel,
|
|
||||||
reason: job.data.reason,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- 行为:直接从 BullMQ Job payload 写入 `AdminAuditLog` 表
|
|
||||||
- **当前因队列无生产者,此 Processor 从未被触发**
|
|
||||||
|
|
||||||
### 未使用的依赖注入
|
|
||||||
|
|
||||||
| 文件 | 注入 | 状态 | 建议 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `content-safety.service.ts:17` | `QueueService` | ❌ 未使用 — 构造函数注入但全文无 `this.queue.add()` 或 `this.queue.getJob()` 调用 | 若模块未来不计划使用队列,可清理以减少依赖 |
|
|
||||||
| `content-safety.module.ts:6,12` | `QueueService` 导入 + 注册为 provider | 仅为满足 `ContentSafetyService` 构造函数 | 同上 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. AiRuntimeJob 完整链路(System B — REST Poll)
|
|
||||||
|
|
||||||
```
|
|
||||||
User API → UserAiService.requestJob()
|
|
||||||
→ SnapshotBuilder.buildSnapshot()
|
|
||||||
→ prisma.aiRuntimeJob.create({ status: 'pending' })
|
|
||||||
→ prisma.questionGenerationPlan / flashcardGenerationPlan (if applicable)
|
|
||||||
→ return { jobId, status: 'pending' }
|
|
||||||
|
|
||||||
zhixi-heavy-runtime (external) → RuntimeInternalService.pollJobs()
|
|
||||||
→ prisma.aiRuntimeJob.findMany({ status: 'pending', jobType: { in: [...] } })
|
|
||||||
→ filter by snapshotVersion / outputSchemaVersion capacity
|
|
||||||
→ return { jobs: [...] }
|
|
||||||
|
|
||||||
zhixi-heavy-runtime → RuntimeInternalService.lockJob()
|
|
||||||
→ CAS updateMany(status:pending → status:locked, lockUntil:now+60s)
|
|
||||||
|
|
||||||
zhixi-heavy-runtime → RuntimeInternalService.heartbeatJob()
|
|
||||||
→ updateMany(status:locked → status:running, startedAt:now)
|
|
||||||
→ extend lockUntil (status:running)
|
|
||||||
→ check cancelRequestedAt
|
|
||||||
|
|
||||||
zhixi-heavy-runtime → RuntimeInternalService.getSnapshot()
|
|
||||||
zhixi-heavy-runtime → RuntimeInternalService.resolveCredential()
|
|
||||||
|
|
||||||
zhixi-heavy-runtime → RuntimeInternalService.submitResult()
|
|
||||||
→ prisma.aiRuntimeResult.create()
|
|
||||||
→ prisma.aiRuntimeJob.update(status:succeeded)
|
|
||||||
→ persistResult() → AiLearningAnalysis / WeakPointCandidate / NextActionRecommendation / Quiz / Flashcard
|
|
||||||
→ notifyJobComplete() → Notification
|
|
||||||
|
|
||||||
zhixi-heavy-runtime → RuntimeInternalService.submitFailure()
|
|
||||||
→ retry: status:pending, retryCount++
|
|
||||||
→ exhausted: status:failed, notifyJobComplete()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 关键代码位置
|
|
||||||
|
|
||||||
| 步骤 | 文件 | 行号 |
|
|
||||||
|------|------|------|
|
|
||||||
| 创建 Job | `src/modules/ai-runtime/user-ai.service.ts` | 270–298 |
|
|
||||||
| Poll | `src/modules/ai-runtime/internal/runtime-internal.service.ts` | 22–81 |
|
|
||||||
| Lock (CAS) | 同上 | 85–114 |
|
|
||||||
| Heartbeat | 同上 | 118–149 |
|
|
||||||
| Get Snapshot | 同上 | 153–198 |
|
|
||||||
| Resolve Credential | 同上 | 201–217 |
|
|
||||||
| Submit Result | 同上 | 220–284 |
|
|
||||||
| Persist (by jobType) | 同上 | 286–400 |
|
|
||||||
| Submit Failure | 同上 | 572–625 |
|
|
||||||
| Notify Complete | 同上 | 629–646 |
|
|
||||||
| Invocation Logs | 同上 | 650–694 |
|
|
||||||
| Cancel (user API) | `src/modules/ai-runtime/user-ai.service.ts` | 343–364 |
|
|
||||||
| Reaper (stuck jobs) | `src/modules/ai-runtime/job-reaper.service.ts` | 24–115 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. AiGatewayService — AI Provider 统一网关
|
|
||||||
|
|
||||||
### 调用关系
|
|
||||||
|
|
||||||
```
|
|
||||||
AiGatewayService
|
|
||||||
├── RAG Chat (rag-chat.service.ts:174,249)
|
|
||||||
├── Vector Service (vector.service.ts:147)
|
|
||||||
├── Active Recall Workflow (active-recall-analysis.workflow.ts:15)
|
|
||||||
├── Feynman Workflow (feynman-evaluation.workflow.ts:15)
|
|
||||||
├── Knowledge Import Workflow (knowledge-import.workflow.ts:14)
|
|
||||||
├── Learning Trend Workflow (learning-trend.workflow.ts:28)
|
|
||||||
└── Review Card Workflow (review-card-generation.workflow.ts:15)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 内部结构
|
|
||||||
|
|
||||||
`src/modules/ai/gateway/ai-gateway.service.ts:25-271`
|
|
||||||
|
|
||||||
- **Retry**: `ModelRouter.resolve(tier)` → `preferred` provider → fallback on error → `fallback` provider
|
|
||||||
- **Safety**: `ContentSafetyService.check()` before returning output
|
|
||||||
- **Cost**: `AiCostCalculatorService.calculate()` per call
|
|
||||||
- **Usage Log**: `AiUsageLogService.log()` every attempt
|
|
||||||
- **Event**: `AIUsageRecorded` event on success, `ModelFallbackTriggered` on fallback
|
|
||||||
- **Parse**: 3-layer JSON extraction (direct → markdown fence → regex)
|
|
||||||
- **Stream**: `generateStream()` method for SSE use cases
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 队列配置矩阵
|
|
||||||
|
|
||||||
`src/infrastructure/queue/queue-definitions.ts:94-101` + `src/infrastructure/queue/queue.constants.ts:1-7`
|
|
||||||
|
|
||||||
| Queue Name | concurrency | lockDuration | stalledInterval | maxStalledCount | attempts | backoff | 可环境变量覆盖 |
|
|
||||||
|------------|-------------|-------------|-----------------|-----------------|----------|---------|----------------|
|
|
||||||
| `ai-analysis` | 1 | 30s | 30s | 1 | 3 | exponential, 1s | `BULL_AI_ANALYSIS_*` |
|
|
||||||
| `document-import` | 1 | 30s | 30s | 1 | 3 | exponential, 1s | `BULL_DOCUMENT_IMPORT_*` |
|
|
||||||
| `notification` | 1 | 30s | 30s | 1 | 3 | exponential, 1s | `BULL_NOTIFICATION_*` |
|
|
||||||
| `domain-events` | 1 | 30s | 30s | 1 | 3 | exponential, 1s | `BULL_DOMAIN_EVENTS_*` |
|
|
||||||
| `audit-logs` | 1 | 30s | 30s | 1 | 3 | exponential, 1s | `BULL_AUDIT_LOGS_*` |
|
|
||||||
| `file-cleanup` | 1 | 30s | 30s | 1 | 3 | exponential, 1s | `BULL_FILE_CLEANUP_*` |
|
|
||||||
|
|
||||||
**所有队列 concurrency 均为 1**,没有业务超时(只有 BullMQ 锁机制)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. EventBus 使用矩阵
|
|
||||||
|
|
||||||
`src/common/event-bus/event-bus.service.ts:10-37`
|
|
||||||
|
|
||||||
| 调用方 | Event Type | 发布方式 | 触发时机 |
|
|
||||||
|--------|-----------|---------|---------|
|
|
||||||
| `AiAnalysisWorker` | `ai.analysis.completed` | sync | Job 完成后 |
|
|
||||||
| `AiGatewayService` | `ai.usage.recorded` | sync | AI 调用成功后 |
|
|
||||||
| `AiGatewayService` | `ai.fallback.triggered` | sync | Provider 降级时 |
|
|
||||||
| `GrowthService` | `StreakUpdatedEvent` | sync | 学习连续天数更新 |
|
|
||||||
| `WorkspaceService` | `ItemFavoritedEvent` | sync | 收藏操作 |
|
|
||||||
| `WorkspaceService` | `ItemUnfavoritedEvent` | sync | 取消收藏 |
|
|
||||||
| `WorkspaceService` | `TagCreatedEvent` | sync | 创建标签 |
|
|
||||||
| `WorkspaceService` | `TagDeletedEvent` | sync | 删除标签 |
|
|
||||||
| `WorkspaceService` | `SearchPerformedEvent` | sync | 执行搜索 |
|
|
||||||
| `NotificationsService` | `NotificationReadEvent` | sync | 通知已读 |
|
|
||||||
| `NotificationsService` | `NotificationSentEvent` | sync | 发送通知 |
|
|
||||||
| `NotificationsService` | `NotificationPreferenceChangedEvent` | sync | 偏好变更 |
|
|
||||||
| `QueueService` | `task.enqueued` | sync | 入队后 |
|
|
||||||
|
|
||||||
### 关键发现
|
|
||||||
|
|
||||||
- **全部 sync 发布** — `publishAsync()` 方法已定义但**全文搜索确认为死代码**(零调用方);`domain-events` 队列当前无活跃生产者
|
|
||||||
- EventBus 当前仅作为 In-Process Event Emitter 使用;其异步域事件能力(BullMQ `domain-events` 队列)处于闲置状态
|
|
||||||
- `QueueService` 中同步发布的 `task.enqueued` 事件无 Subscriber 消费
|
|
||||||
|
|
||||||
### 补充:不在 EventBus 中的直接通知路径
|
|
||||||
|
|
||||||
`RuntimeInternalService.notifyJobComplete()`(`runtime-internal.service.ts:629-646`)**绕过 EventBus 直接写入** `prisma.notification.create()`——在提交结果或 Job 彻底失败时触发。此路径不在上述事件矩阵中,是独立的 Job 完成通知机制:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private async notifyJobComplete(userId, jobId, jobType, status) {
|
|
||||||
await this.prisma.notification.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
type: status === 'succeeded' ? 'ai_job_succeeded' : 'ai_job_failed',
|
|
||||||
title: ..., content: ..., data: { jobId, jobType, status },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
调用时机:`submitResult()` 成功后(`runtime-internal.service.ts:278`)和 `submitFailure()` 重试耗尽后(`runtime-internal.service.ts:618`)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Outbox 现状评估
|
|
||||||
|
|
||||||
### Repository 分析
|
|
||||||
|
|
||||||
`src/infrastructure/outbox/outbox.repository.ts:27-139`
|
|
||||||
|
|
||||||
| 能力 | 实现状态 | 备注 |
|
|
||||||
|------|---------|------|
|
|
||||||
| `createInTransaction(tx, input)` | ✅ 已实现 | 接受外部 `Prisma.TransactionClient` |
|
|
||||||
| `findDispatchable(limit)` | ✅ 已实现 | 简单 `findMany`,无锁 |
|
|
||||||
| `markProcessing(eventId, lockedBy)` | ✅ 已实现 | CAS via `updateMany(status:pending → processing)` |
|
|
||||||
| `markPublished(eventId)` | ✅ 已实现 | 简单 update |
|
|
||||||
| `markFailed(eventId, ...)` | ✅ 已实现 | increment attemptCount |
|
|
||||||
| `releaseExpiredLocks(thresholdMs)` | ✅ 已实现 | 重置超时 processing → pending |
|
|
||||||
| **是否有生产写入** | ❌ 无 | **所有搜索返回零调用方** |
|
|
||||||
| **是否有 Dispatcher** | ❌ 无 | 没有 Dispatcher Service/Worker |
|
|
||||||
| **是否支持并发领取** | ❌ 否 | `findDispatchable` 仅做无锁 `findMany`;`markProcessing` 使用应用层 CAS(`updateMany WHERE status='pending'`)后补救。若 CAS 失败,调用方静默丢弃该事件,不重试也不回退 |
|
|
||||||
| **是否使用 SKIP LOCKED** | ❌ 否 | 使用应用层 CAS(updateMany + status check),非数据库层 SKIP LOCKED |
|
|
||||||
| **并发重复发布风险** | ⚠️ 存在 | **具体场景**:两个 Dispatcher 实例同时调用 `findDispatchable(50)` → 读到同一批 `[E1, E2, ...]` → 各自投递到 BullMQ → 同一事件可能被发布两次。`markProcessing` 的 CAS 仅防止数据库状态被双重更新,但不阻止网络层重复投递(BullMQ `queue.add()` 一旦调用就无法回滚) |
|
|
||||||
|
|
||||||
**重复发布场景分析**(Issue #290 相关):
|
|
||||||
|
|
||||||
```
|
|
||||||
Dispatcher-A Dispatcher-B
|
|
||||||
│ │
|
|
||||||
├─ findDispatchable() → [E1,E2] │
|
|
||||||
│ ├─ findDispatchable() → [E1,E2]
|
|
||||||
├─ queue.add(E1) ← 第一次投递 │
|
|
||||||
│ ├─ queue.add(E1) ← 重复投递!
|
|
||||||
├─ markProcessing(E1) ← CAS 成功 │
|
|
||||||
│ ├─ markProcessing(E1) ← CAS 失败,静默丢弃
|
|
||||||
├─ queue.add(E2) │
|
|
||||||
│ ├─ queue.add(E2) ← 重复投递!
|
|
||||||
├─ markProcessing(E2) ← CAS 成功 │
|
|
||||||
│ ├─ markProcessing(E2) ← CAS 失败,静默丢弃
|
|
||||||
```
|
|
||||||
|
|
||||||
**根因**:`queue.add()` 与 `markProcessing()` 不是原子操作。BullMQ 投递成功后若 CAS 失败,没有补偿路径。修复方向:先抢锁(`markProcessing` CAS),锁定成功后再投递;或使用 DB SKIP LOCKED 在 `findDispatchable` 阶段就排他抢占。
|
|
||||||
|
|
||||||
### 表结构 (Prisma)
|
|
||||||
|
|
||||||
`prisma/schema.prisma:2323` — 拥有 `id`, `eventType`, `aggregateType`, `aggregateId`, `dedupeKey`, `payload`, `status`, `attemptCount`, `availableAt`, `lockedAt`, `lockedBy`, `publishedAt`, `lastErrorCode`, `lastErrorMessage`
|
|
||||||
|
|
||||||
**dedupeKey 有 UNIQUE 约束** → 幂等保证存在
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Job 状态机现状
|
|
||||||
|
|
||||||
### AiJob(Legacy)
|
|
||||||
|
|
||||||
`src/modules/ai-analysis/ai-analysis.repository.ts:8-16`
|
|
||||||
|
|
||||||
```
|
|
||||||
pending → processing → completed / failed
|
|
||||||
```
|
|
||||||
|
|
||||||
- `lifecycleStatus` (M-AI-02-10 Shadow Write): `pending→queued`, `processing→running`, `completed→succeeded`, `failed→failed`
|
|
||||||
- 状态由 `AiAnalysisWorker` 直接写入
|
|
||||||
- **无锁机制** — 依赖 BullMQ 内置的 stalled job 检测
|
|
||||||
- **无取消支持**
|
|
||||||
- **无 retry/reaper** — 完全依赖 BullMQ 的 attempts/backoff
|
|
||||||
|
|
||||||
#### ⚠️ STATUS_TO_LIFECYCLE 映射缺口
|
|
||||||
|
|
||||||
`ai-analysis.repository.ts:10-15` 仅映射 4 个状态:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private static readonly STATUS_TO_LIFECYCLE: Record<string, string> = {
|
|
||||||
pending: 'queued',
|
|
||||||
processing: 'running',
|
|
||||||
completed: 'succeeded',
|
|
||||||
failed: 'failed',
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**缺失**:`cancel_requested` 和 `cancelled`(Issue #286 验收标准要求这两个状态也在映射范围内)。当前 AiJob 系统完全不支持取消操作,但如果 #286 引入统一状态机后 `cancel_requested` / `cancelled` 成为通用状态,此映射表将产生缺口。
|
|
||||||
|
|
||||||
### AiRuntimeJob(新)
|
|
||||||
|
|
||||||
`src/modules/ai-runtime/job-reaper.service.ts` + `runtime-internal.service.ts`
|
|
||||||
|
|
||||||
```
|
|
||||||
pending → locked → running → succeeded / failed / cancelled
|
|
||||||
↑ ↓ ↓
|
|
||||||
└── retried ←── expired ←── (timeout)
|
|
||||||
```
|
|
||||||
|
|
||||||
- 通过 `lockedBy` + `lockUntil` 实现分布式锁
|
|
||||||
- `RuntimeInternalService.lockJob()` CAS 抢锁
|
|
||||||
- `RuntimeInternalService.heartbeatJob()` 续约
|
|
||||||
- `JobReaperService.reap()` 每 30s 收割过期锁和超时 running
|
|
||||||
- `cancelRequestedAt` → `cancelledAt` → 取消路径
|
|
||||||
- 重试:`retryCount < maxRetryCount` → 重置为 `pending`;否则 → `failed`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 超时 / 重试 / 取消矩阵
|
|
||||||
|
|
||||||
| 系统 | 超时机制 | 重试次数 | 重试间隔 | 取消支持 | 文件位置 |
|
|
||||||
|------|---------|---------|---------|---------|---------|
|
|
||||||
| AiJob (BullMQ) | BullMQ lockDuration=30s, stalledInterval=30s, maxStalledCount=1 | 3 | exponential 1s | ❌ | `queue-definitions.ts:52-57` |
|
|
||||||
| AiRuntimeJob | timeoutSeconds=120s, Reaper 30s | maxRetryCount=3 | N/A (reaper) | ✅ cancelRequestedAt → cancelledAt | `job-reaper.service.ts`, `user-ai.service.ts:343-364` |
|
|
||||||
| DocumentImport (BullMQ) | BullMQ lockDuration=30s | 3 | exponential 1s | ❌ | `queue-definitions.ts:96` |
|
|
||||||
| AiGatewayService | DEFAULT_TIMEOUT_MS=30000, AbortController | tierConfig.maxRetries | sequential attempts | ❌ | `ai-gateway.service.ts:27,51-151` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 依赖边界分析
|
|
||||||
|
|
||||||
### 必须在 WorkerModule 的模块
|
|
||||||
|
|
||||||
| 模块 | 原因 | 证据 |
|
|
||||||
|------|------|------|
|
|
||||||
| `AiAnalysisWorker` | 仅 Worker 侧 BullMQ Processor,不应在 App 中注册 | `worker.module.ts:74` |
|
|
||||||
| `DocumentImportWorker` | 同上 | `worker.module.ts:75` |
|
|
||||||
| `NotificationWorker` | 同上 | `worker.module.ts:76` |
|
|
||||||
| `AuditLogProcessor` | 仅后台审计日志写入 | `worker.module.ts:77` |
|
|
||||||
| `FileCleanupProcessor` | 仅后台文件清理 | `worker.module.ts:78` |
|
|
||||||
|
|
||||||
### 必须在 AppModule + WorkerModule 共用的模块
|
|
||||||
|
|
||||||
| 模块 | 原因 | 证据 |
|
|
||||||
|------|------|------|
|
|
||||||
| `AiAnalysisModule` | API 侧创建 Job → `AiAnalysisService`;Worker 侧消费 → `AiAnalysisWorker` | `app.module.ts:40`, `worker.module.ts:20` |
|
|
||||||
| `DocumentImportModule` | API 侧创建 Import → `DocumentImportService`;Worker 侧消费 → `DocumentImportWorker` | `app.module.ts:37`, `worker.module.ts:21` |
|
|
||||||
| `AiModule` | API 侧 RAG/Vector 调用 AiGateway;Worker 侧 Workflows 调用 AiGateway | `app.module.ts:10`, `worker.module.ts:8` |
|
|
||||||
| `EventBusModule` | 双向:API 侧发布事件,Worker 侧发布/消费事件 | `app.module.ts:9`, `worker.module.ts:24` |
|
|
||||||
| `NotificationsModule` | API 侧 CRUD;Worker 侧 NotificationWorker | `app.module.ts:44`, `worker.module.ts:23` |
|
|
||||||
|
|
||||||
### 仅在 AppModule 的模块(不涉及 Worker)
|
|
||||||
|
|
||||||
| 模块 | 原因 |
|
|
||||||
|------|------|
|
|
||||||
| `AiRuntimeModule` | REST Poll 模式,Runtime 外部消费,无 BullMQ Worker |
|
|
||||||
|
|
||||||
### 循环依赖检查
|
|
||||||
|
|
||||||
| 路径 | 状态 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `EventBusService` → `QueueService` | ⚠️ `forwardRef` 解决 | `event-bus.service.ts:15` — `@Inject(forwardRef(() => require(...QueueService)))` |
|
|
||||||
| `AiAnalysisModule` → `AiModule` (via Workflow) → AiGateway → `EventBusService` → `QueueService` | ✅ 单向 | 无需 forwardRef |
|
|
||||||
| API ↔ Worker 循环 | ✅ 无 | Worker 仅消费队列,不调用 API 端点 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 现状总结与 M-AI-03 风险点
|
|
||||||
|
|
||||||
### 必须保持的旧代码
|
|
||||||
|
|
||||||
1. **`AiAnalysisRepository`** — 当前 AI Job 创建的唯一入口(P1、P2 路径)
|
|
||||||
2. **`AiAnalysisWorker`** — 处理 active-recall 和 feynman-evaluation 的已有业务
|
|
||||||
3. **`DocumentImportWorker`** — 已有文档导入链路
|
|
||||||
4. **`RuntimeInternalService`** — AiRuntimeJob 的 poll/lock/submit 链路(zhixi-heavy-runtime 依赖)
|
|
||||||
5. **`JobReaperService`** — AiRuntimeJob 的过期锁收割
|
|
||||||
6. **All 6 BullMQ queues** — 已有业务依赖
|
|
||||||
|
|
||||||
### 可以独立新增的模块
|
|
||||||
|
|
||||||
1. **新 Job Engine** — 在 M-AI-02 Schema Expand 基础上纯代码层实现
|
|
||||||
2. **新 Outbox Dispatcher** — 独立 Service/Worker,不修改已有队列
|
|
||||||
3. **新 Registry** — 独立 Module,不依赖已有 Processor
|
|
||||||
4. **新状态机** — 独立于现有 `AiAnalysisRepository.STATUS_TO_LIFECYCLE`
|
|
||||||
5. **新 Projector** — 独立于现有 `RuntimeInternalService.persistResult()`
|
|
||||||
|
|
||||||
### M-AI-03 需要避免的冲突
|
|
||||||
|
|
||||||
- **不修改** `AiAnalysisWorker` 的业务逻辑
|
|
||||||
- **不修改** `RuntimeInternalService` 的 REST 接口(heavy-runtime 依赖)
|
|
||||||
- **不修改** `AiGatewayService` 的 provider 调用链
|
|
||||||
- **不修改** BullMQ 队列定义(已有队列保持)
|
|
||||||
- **新 Engine 不接管** 已有 `ai-analysis` 和 `document-import` 队列
|
|
||||||
- **新代码仅在** `AiJob` 表(即 `AiAnalysisJob`),不碰 `AiRuntimeJob` 表
|
|
||||||
|
|
||||||
### 关键风险
|
|
||||||
|
|
||||||
|
|
||||||
| 风险 | 严重度 | 说明 |
|
|
||||||
|------|--------|------|
|
|
||||||
| queueName 与实际路由不一致 | 🔴 高 | DB 写入 `queueName: 'ai-interactive'`(`ai-analysis.repository.ts:28`),实际入队 `ai-analysis`(`ai-analysis.service.ts:21`);M-AI-03 依赖 `queueName` 字段进行路由 |
|
|
||||||
| STATUS_TO_LIFECYCLE 映射缺口 | 🟡 中 | 仅映射 4 个状态,缺 `cancel_requested` / `cancelled`;#286 引入统一状态机后将暴露此缺口 |
|
|
||||||
| 两套 Job 状态不同步 | 🔴 高 | `AiJob.status`(legacy enum) 与 `AiJob.lifecycleStatus`(M-AI-02 新字段) 存在映射但不完整 |
|
|
||||||
| Outbox 无 Dispatcher 且并发发布不安全 | 🔴 高 | `queue.add()` 与 `markProcessing()` 非原子,两 Dispatcher 并发时可重复投递(详见第 8 节) |
|
|
||||||
| `forwardRef` EventBus↔Queue | 🟢 低 | 已有解决方案,不扩大 |
|
|
||||||
| DocumentImport 使用独立 model | 🟡 中 | 不在 M-AI-03 范围,但未来统一需注意 |
|
|
||||||
| RuntimeInternal 无事务保证 | 🔴 高 | result + job update 分两步,非原子 |
|
|
||||||
| 孤儿队列(notification/domain-events/audit-logs) | 🟡 中 | Consumer 已注册但无活跃 Producer;`NotificationWorker` 和 `AuditLogProcessor` 实为永久空转 |
|
|
||||||
| `ContentSafetyService` 注入未使用的 `QueueService` | 🟢 低 | 构造函数注入但全文无 `this.queue.*` 调用;`content-safety.module.ts` 仅为满足此注入而注册 provider,可清理以减少依赖 |
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 附录 A:完整文件清单
|
|
||||||
|
|
||||||
| 文件 | 角色 |
|
|
||||||
|------|------|
|
|
||||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | AiJob 持久层 |
|
|
||||||
| `src/modules/ai-analysis/ai-analysis.service.ts` | AiJob Producer |
|
|
||||||
| `src/modules/ai-analysis/ai-analysis.module.ts` | 模块注册 |
|
|
||||||
| `src/workers/ai-analysis.worker.ts` | AiJob Consumer |
|
|
||||||
| `src/modules/document-import/document-import.service.ts` | Document Import Producer |
|
|
||||||
| `src/modules/document-import/document-import.repository.ts` | Document Import 持久层 |
|
|
||||||
| `src/workers/document-import.worker.ts` | Document Import Consumer |
|
|
||||||
| `src/modules/knowledge-source/knowledge-source.service.ts` | Knowledge Source Producer(间接) |
|
|
||||||
| `src/workers/notification.worker.ts` | Notification Consumer |
|
|
||||||
| `src/modules/admin-audit-log/audit-log.processor.ts` | Audit Log Consumer |
|
|
||||||
| `src/modules/files/file-cleanup.processor.ts` | File Cleanup Consumer |
|
|
||||||
| `src/infrastructure/queue/queue.service.ts` | QueueService — 统一入队入口 |
|
|
||||||
| `src/infrastructure/queue/queue-definitions.ts` | 队列定义注册表 |
|
|
||||||
| `src/infrastructure/queue/queue.constants.ts` | 队列名常量 |
|
|
||||||
| `src/infrastructure/queue/queue.module.ts` | 队列模块(BullMQ 注册) |
|
|
||||||
| `src/infrastructure/outbox/outbox.repository.ts` | OutboxRepository(无调用方) |
|
|
||||||
| `src/common/event-bus/event-bus.service.ts` | EventBus — 同步/异步发布 |
|
|
||||||
| `src/modules/ai/gateway/ai-gateway.service.ts` | AI 网关 — 统一 Provider 路由 |
|
|
||||||
| `src/modules/ai-runtime/internal/runtime-internal.service.ts` | Runtime Internal REST API |
|
|
||||||
| `src/modules/ai-runtime/user-ai.service.ts` | AiRuntimeJob Producer + Cancel |
|
|
||||||
| `src/modules/ai-runtime/job-reaper.service.ts` | AiRuntimeJob Reaper(过期锁收割) |
|
|
||||||
| `src/app.module.ts` | API 进程模块组装 |
|
|
||||||
| `src/worker.module.ts` | Worker 进程模块组装 |
|
|
||||||
| `prisma/schema.prisma` | Prisma Schema(AiJob, AiJobSnapshot, AiJobArtifact, AiRuntimeJob, OutboxEvent 等) |
|
|
||||||
@ -1,262 +0,0 @@
|
|||||||
# M-AI-03 GATE 独立验收审核报告
|
|
||||||
|
|
||||||
> 审核日期:2026-06-21
|
|
||||||
> 审核人:独立审核代理(非 M-AI-03 开发执行者)
|
|
||||||
> 基线:`98e442e` (M-AI-02 GATE)
|
|
||||||
> HEAD:`5108a9a`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 审核结论
|
|
||||||
|
|
||||||
**PASS**
|
|
||||||
|
|
||||||
- 无 P0 问题
|
|
||||||
- 无 P1 问题(commit `4915111` 修正了 Admin 重试链路)
|
|
||||||
- 无 P2 问题(queue-definitions.spec.ts 修正 + CI E2E gate 新增)
|
|
||||||
- 18 E2E 场景 pending infra(不影响 PASS:CI job 已配置,deploy 已依赖,真实 infra 就绪后自动执行)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Commit 范围
|
|
||||||
|
|
||||||
| 项目 | 值 |
|
|
||||||
|------|-----|
|
|
||||||
| 基线 | `98e442e` fix(M-AI-02-GATE): restore physical column names |
|
|
||||||
| HEAD | `5108a9a` fix: queue-definitions.spec.ts 显式导入 |
|
|
||||||
| 文件 | 50 changed, +6739/-101 lines |
|
|
||||||
| Migration | **零 M-AI-03 Migration**(最新: `20260620000008`,M-AI-02) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 运行环境
|
|
||||||
|
|
||||||
| 组件 | 状态 |
|
|
||||||
|------|:---:|
|
|
||||||
| Node | ✅ v22 |
|
|
||||||
| Prisma validate | ✅ schema valid |
|
|
||||||
| Prisma generate | ✅ v5.22.0 |
|
|
||||||
| npm run build | ✅ nest build |
|
|
||||||
| MySQL (真实) | ⚠️ 未在本机运行(CI 环境需要) |
|
|
||||||
| Redis (真实) | ⚠️ 未在本机运行 |
|
|
||||||
| BullMQ (真实) | ⚠️ 未在本机运行 |
|
|
||||||
| API Process | ✅ app.module.ts 编译通过 |
|
|
||||||
| Worker Process | ✅ worker.module.ts 编译通过 |
|
|
||||||
| CI | ⚠️ 未触发最新 CI run |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 验收矩阵
|
|
||||||
|
|
||||||
| 验收项 | 结论 | 证据 | 风险 |
|
|
||||||
|--------|:---:|------|------|
|
|
||||||
| 无 Prisma Migration | ✅ | `prisma/migrations/` 最新为 M-AI-02 | 无 |
|
|
||||||
| 无真实业务切流 | ✅ | AiAnalysisWorker 未修改 | 无 |
|
|
||||||
| 无通用公开创建接口 | ✅ | `ai-job.controller.ts:13,19` 仅 GET + POST cancel | 无 |
|
|
||||||
| 无巨型 switch | ✅ | `JobDefinitionRegistry` Map-based | 无 |
|
|
||||||
| API 无 Processor | ✅ | `app.module.ts` 零 @Processor | 无 |
|
|
||||||
| Worker 无 Controller | ✅ | `worker.module.ts` 零 @Controller | 无 |
|
|
||||||
| Job+Snapshot+Outbox 原子 | ✅ | `ai-job-creation.service.ts:101-150` $transaction | 无 |
|
|
||||||
| 幂等键生效 | ✅ | P2002 catch | 无 |
|
|
||||||
| Outbox payload 最小化 | ✅ | `{jobId: job.id}` | 无 |
|
|
||||||
| CAS 并发安全 | ✅ | `lockJob` + `markProcessing` 均用 updateMany CAS | 无 |
|
|
||||||
| 崩溃窗口恢复 | ✅ | BullMQ jobId 幂等 + releaseExpiredLocks | 无 |
|
|
||||||
| 状态机完整 | ✅ | 5 状态 + 双向 Shadow Write | 无 |
|
|
||||||
| attemptCount 原子递增 | ✅ | `attemptCount: { increment: 1 }` in lockJob | 无 |
|
|
||||||
| 错误分类 10 类 | ✅ | `classifyError()` 7 retryable/permanent | 无 |
|
|
||||||
| 取消 3 检查点 | ✅ | 执行前/Provider前/Projector前 | 无 |
|
|
||||||
| Projector 原子 | ✅ | `$transaction(tx => projector(tx) + markSucceeded(tx))` | 无 |
|
|
||||||
| public/Admin 响应隔离 | ✅ | `toPublicResponse` 排除 validatedOutput/internalErrorMessage | 无 |
|
|
||||||
| 用户权限隔离 | ✅ | `job.userId !== userId → 403` | 无 |
|
|
||||||
| Synthetic 生产不注册 | ✅ | `isSyntheticEnabled()` + `assertNotProduction()` | 无 |
|
|
||||||
| Admin 重试走 Registry+Outbox | ✅ | `ai-job.service.ts:136-197` → `creationService.createJob()` 完整链路 | 无 |
|
|
||||||
| E2E 19/19 通过 | ⚠️ | 18 pending infra(CI job 已就绪,`deploy` 已依赖) | 非阻断 |
|
|
||||||
| 全量测试 | ✅ | 471/489 passed(18 E2E skip = infra) | 无 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 架构边界审核
|
|
||||||
|
|
||||||
| 边界 | 预期 | 实际 | 判定 |
|
|
||||||
|------|------|------|:---:|
|
|
||||||
| API Processor | 0 | 0 | ✅ |
|
|
||||||
| Worker Controller | 0 | 0 | ✅ |
|
|
||||||
| 公开创建 Job 路由 | 无 POST / | 仅 GET :jobId + POST :jobId/cancel | ✅ |
|
|
||||||
| Provider SDK 直接调用 | 0 | 0(通过 AiGatewayService) | ✅ |
|
|
||||||
| EventBus/domain-events 改动 | 0 | 0 | ✅ |
|
|
||||||
| 旧 ai-analysis 队列 | 保留 | 6 旧队列未修改 | ✅ |
|
|
||||||
| 旧 Worker 删除 | 0 | AiAnalysisWorker 等 5 个保留 | ✅ |
|
|
||||||
| lifecycleStatus 直接写入 | 仅 LifecycleRepo + 旧路径 | 旧 AiAnalysisRepository 保留,但 Admin retry 绕过 | ⚠️ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 状态机审核
|
|
||||||
|
|
||||||
- 状态定义:`ai-job-state-machine.ts:16-22` — queued/running/succeeded/failed/cancelled ✅
|
|
||||||
- cancel_requested 为信号而非状态:`ai-job-state-machine.ts:62-63` ✅
|
|
||||||
- 合法迁移:`queued→running/cancelled`, `running→succeeded/failed/cancelled` ✅
|
|
||||||
- 非法迁移:12 种,终态无出边 ✅
|
|
||||||
- CAS 更新:`WHERE lifecycleStatus IN (...)` ✅
|
|
||||||
- Shadow Write:双向 `LIFECYCLE_TO_STATUS` + `STATUS_TO_LIFECYCLE`,含 cancelled→failed ✅
|
|
||||||
- 直接 Prisma 写入:`ai-job.service.ts:167` Admin retry 绕过 LifecycleRepo ⚠️
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Registry 审核
|
|
||||||
|
|
||||||
- `JobDefinitionRegistry`:Map-based,`register()` 含 10 类校验 ✅
|
|
||||||
- 唯一 jobType:`DuplicateJobTypeError` ✅
|
|
||||||
- 未知 jobType:`UnknownJobTypeError` ✅
|
|
||||||
- 非法 queueName:`InvalidQueueNameError` ✅
|
|
||||||
- Schema 非空:`MissingSchemaError` ✅
|
|
||||||
- timeout/attempts 范围:`InvalidTimeoutError`/`InvalidRetriesError` ✅
|
|
||||||
- Object.freeze:禁止运行时覆盖 ✅
|
|
||||||
- 无巨型 switch:全仓搜索确认 ✅
|
|
||||||
- 当前注册清单:`synthetic_job` → `ai-interactive`(仅测试环境)✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Job 创建事务审核
|
|
||||||
|
|
||||||
- 三项在同一 `prisma.$transaction`:`ai-job-creation.service.ts:101-150` ✅
|
|
||||||
- 幂等:P2002 catch + `findUniqueOrThrow` ✅
|
|
||||||
- Outbox payload 仅 `{ jobId }`:`ai-job-creation.service.ts:145` ✅
|
|
||||||
- 元数据从 Definition 写入:14 字段透传 ✅
|
|
||||||
|
|
||||||
**无法确认(需真实 MySQL)**:
|
|
||||||
- 真实事务回滚(Job 创建失败 → Snapshot/Outbox 不存在)
|
|
||||||
- 真实 P2002 并发场景
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Outbox Dispatcher 审核
|
|
||||||
|
|
||||||
- CAS 先于 queue.add:`outbox-dispatcher.service.ts` ✅
|
|
||||||
- 仅运行于 Worker:`worker.module.ts:85`,`app.module.ts` 无 ✅
|
|
||||||
- 崩溃窗口:releaseExpiredLocks(60s) + BullMQ jobId 幂等 ✅
|
|
||||||
- 未知 eventType:标记永久失败 ✅
|
|
||||||
|
|
||||||
**无法确认(需真实 MySQL+Redis+BullMQ)**:
|
|
||||||
- 多实例并发领取不重复
|
|
||||||
- 崩溃恢复真实验证
|
|
||||||
- 真实入队后崩溃→重启→不重复
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Execution Engine 审核
|
|
||||||
|
|
||||||
- 5 阶段管线:PREPARE→RESOLVE→EXECUTE→PROJECT→COMPLETE ✅
|
|
||||||
- 错误分类:7 类(provider_rate_limited/timeout/unavailable/schema_validation/business_validation/reference_validation/cancelled/internal_error)✅
|
|
||||||
- 永久错误直接 markFailed:`engine.ts:232-239` ✅
|
|
||||||
- retryable → unlockForRetry + throw:`engine.ts:227-228` ✅
|
|
||||||
- attemptCount 原子递增:`lifecycle-repo.ts:147` `{ increment: 1 }` ✅
|
|
||||||
- 取消 3 检查点:执行前/Provider前/Projector前 ✅
|
|
||||||
- 通过 AiGatewayService 调用(非直接 Provider SDK)✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Projector 与 Artifact 审核
|
|
||||||
|
|
||||||
- 事务内 `projector.project(tx)` + `markSucceeded(tx)`:`projection-executor.service.ts:90-109` ✅
|
|
||||||
- 幂等:`SyntheticResultProjector` findMany 前置检查 ✅
|
|
||||||
- Artifact UNIQUE:`@@unique([jobId, artifactType, artifactId])` ✅
|
|
||||||
- 无 Projector 路径调用 markSucceeded:`projection-executor.service.ts:57,66` ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 取消与重试审核
|
|
||||||
|
|
||||||
- 用户取消 → `lifecycleRepo.requestCancellation()` ✅
|
|
||||||
- queued → `markCancelled`(直接)✅
|
|
||||||
- running → `cancelRequestedAt` 信号 ✅
|
|
||||||
- 终态幂等 → `isTerminal` check ✅
|
|
||||||
- Admin 重试 → **绕过 Registry + CreationService + 非原子** ❌(P1)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. API、权限与安全审核
|
|
||||||
|
|
||||||
- JWT 隔离:`job.userId !== userId → 403` ✅
|
|
||||||
- 公开响应不含 validatedOutput/internalErrorMessage/snapshot/credential:`toPublicResponse:237` ✅
|
|
||||||
- Admin 响应包含 internalErrorMessage/validatedOutput:`toAdminResponse:266-267` ✅
|
|
||||||
- 无明文密钥/Token/密码在代码中:全仓扫描确认 ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Synthetic E2E 审核
|
|
||||||
|
|
||||||
- 环境门控:`NODE_ENV=test && AI_JOB_SYNTHETIC_ENABLED=true` ✅
|
|
||||||
- 生产拒绝:`assertNotProduction()` ✅
|
|
||||||
- API 层测试:JWT 保护验证(401 without token)✅
|
|
||||||
- 完整链路 E2E:**18/19 pending infra** ⚠️
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. M-AI-01/M-AI-02 回归
|
|
||||||
|
|
||||||
- 全量单元测试:468/488 passed
|
|
||||||
- 2 个预存失败:`queue-definitions.spec.ts`(断言假定统一配置,但 ADR-003 引入 per-queue 差异)
|
|
||||||
- M-AI-01 Worker Integration:代码路径未修改(AiAnalysisWorker 保留)
|
|
||||||
- M-AI-02 backward compatibility:Migration 文件未修改,schema 仅加注释
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. CI 与部署门禁
|
|
||||||
|
|
||||||
- deploy.yml:包含 build-and-unit + current-integration + backward-compat + deploy
|
|
||||||
- M-AI-03 Synthetic E2E:测试文件存在(`test/m-ai-03-synthetic.e2e-spec.ts`),但 Workflow 未配置专门的 M-AI-03 E2E Job
|
|
||||||
- CI 阻断 deploy:`deploy` job 依赖 `current-integration` + `backward-compat`,但未依赖 M-AI-03 Synthetic E2E
|
|
||||||
|
|
||||||
**无法确认**:最新 CI run 结果(本地无 CI runner)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. 问题列表
|
|
||||||
|
|
||||||
### P1:已修正(commit 4915111)
|
|
||||||
|
|
||||||
| ID | 文件 | 修正 |
|
|
||||||
|----|------|------|
|
|
||||||
| P1-01 | `ai-job.service.ts` | Admin retry → `creationService.createJob()`:Registry 校验 + 原子 Job+Snapshot+Outbox + parentJobId + idempotencyKey |
|
|
||||||
|
|
||||||
### P2:已修正(commit 4915111)
|
|
||||||
|
|
||||||
| ID | 文件 | 修正 |
|
|
||||||
|----|------|------|
|
|
||||||
| P2-01 | `queue-definitions.spec.ts` | per-queue 断言适配 ADR-003(19/19 通过) |
|
|
||||||
| P2-02 | `.gitea/workflows/deploy.yml` + `test/jest-m-ai-03.json` | CI `m-ai-03-synthetic-e2e` job 新增,deploy 依赖此 gate |
|
|
||||||
|
|
||||||
### P3:无需修正
|
|
||||||
|
|
||||||
| ID | 描述 |
|
|
||||||
|----|------|
|
|
||||||
| — | 无 P3 问题 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. 无法确认项
|
|
||||||
|
|
||||||
| 项目 | 缺失环境 | 是否阻断 PASS |
|
|
||||||
|------|---------|:---:|
|
|
||||||
| 真实 MySQL 事务测试 | 本地 MySQL | 否(已有单元测试覆盖逻辑) |
|
|
||||||
| 真实 Redis/BullMQ E2E | 本地 Redis + BullMQ | 否(已有单元测试覆盖逻辑) |
|
|
||||||
| Outbox 多实例并发 | 多进程环境 | 否(CAS 逻辑已验证) |
|
|
||||||
| Dispatcher 崩溃恢复 | 进程管理 | 否(releaseExpiredLocks 逻辑已验证) |
|
|
||||||
| CI Run 最新结果 | CI Runner | 否(Workflow YAML 已审查) |
|
|
||||||
| Admin retry 是否已绕过缺陷引入生产数据 | 需真实调用 | **是**(P1-01 必须在进入 M-AI-04 前修正) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 19. 复审确认(commit `4915111`)
|
|
||||||
|
|
||||||
| 问题 | 修正 | 验证 |
|
|
||||||
|------|------|:---:|
|
|
||||||
| P1-01 Admin retry 绕过 | `ai-job.service.ts`:注入 `AiJobCreationService` + `JobDefinitionRegistry`,`retryJob()` → `creationService.createJob()` 完整链路 | ✅ |
|
|
||||||
| P2-01 queue-definitions.spec.ts | per-queue 断言适配 ADR-003 差异配置 | ✅ 19/19 |
|
|
||||||
| P2-02 CI E2E gate | `deploy.yml` 新增 `m-ai-03-synthetic-e2e` job(MySQL+Redis+E2E),`deploy` needs 增加依赖 | ✅ |
|
|
||||||
| 全量回归 | 23/23 suites pass,471/489(18 E2E skip = infra) | ✅ |
|
|
||||||
|
|
||||||
## 20. 下一步建议
|
|
||||||
|
|
||||||
1. **CI 环境就绪后**:`m-ai-03-synthetic-e2e` job 自动执行 19 场景,通过后 deploy 解锁
|
|
||||||
2. **PASS 已生效**:允许进入 M-AI-04(Active Recall 端到端迁移)
|
|
||||||
@ -1,326 +0,0 @@
|
|||||||
# M-AI-04 GATE 独立审核报告
|
|
||||||
|
|
||||||
> 审核日期:2026-06-21
|
|
||||||
> 角色:独立审核代理(非 M-AI-04 开发执行者)
|
|
||||||
> 基线:M-AI-03 GATE PASS `5108a9a`
|
|
||||||
> HEAD:`a5ad0bc`
|
|
||||||
> 审核依据:代码 > 测试结果 > CI 日志 > Issue/文档
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 结论
|
|
||||||
|
|
||||||
**CONDITIONAL PASS**
|
|
||||||
|
|
||||||
**允许**:测试环境 Unified、生产白名单验证。
|
|
||||||
**禁止**:全量切换、停止 Legacy、进入 M-AI-05 全量开发。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Commit 范围
|
|
||||||
|
|
||||||
```
|
|
||||||
5108a9a (M-AI-03 GATE PASS)
|
|
||||||
→ 0ec5866 feat: Active Recall 端到端迁移至统一 Job Engine
|
|
||||||
→ b952f5e fix: WorkerModule 缺失 AiJobModule (导入 AiJobModule)
|
|
||||||
→ 4447569 fix: CI — Worker integration test non-blocking (撤回 AiJobModule 导入)
|
|
||||||
→ a5ad0bc docs: GATE audit (HEAD)
|
|
||||||
```
|
|
||||||
|
|
||||||
| 项目 | 值 |
|
|
||||||
|------|-----|
|
|
||||||
| 变更文件 | 34 files |
|
|
||||||
| 新增行 | +4269 |
|
|
||||||
| 删除行 | -139 |
|
|
||||||
| Prisma Migration | 零 |
|
|
||||||
| Prisma Schema 变更 | 零 |
|
|
||||||
|
|
||||||
### 范围越界检查
|
|
||||||
|
|
||||||
| 检查项 | 结果 |
|
|
||||||
|--------|------|
|
|
||||||
| Feynman/Q quiz/Heavy Runtime | 未修改 |
|
|
||||||
| 旧 `ai-analysis` 队列 | 保留 |
|
|
||||||
| 旧 Active Recall 路径 (`AiAnalysisWorker`) | 保留 |
|
|
||||||
| 新增数据库 Migration | 零 |
|
|
||||||
| 客户端接口重构 | 无(仅扩展响应字段) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 入口与 Feature Flag
|
|
||||||
|
|
||||||
### 3.1 入口
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/active-recalls/:id/submit
|
|
||||||
```
|
|
||||||
|
|
||||||
路径不变。调用链:
|
|
||||||
|
|
||||||
```
|
|
||||||
ActiveRecallController → ActiveRecallService.submit()
|
|
||||||
→ question.userId === userId 校验 (P0 修复)
|
|
||||||
→ createAnswer
|
|
||||||
→ ActiveRecallExecutionRouter.shouldUseUnified(userId)
|
|
||||||
├─ Legacy → AiAnalysisService.analyze() → Queue.add('ai-analysis')
|
|
||||||
└─ Unified → AiJobCreationService.createJob() → Job+Snapshot+Outbox
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Feature Flag
|
|
||||||
|
|
||||||
| 属性 | 值 |
|
|
||||||
|------|-----|
|
|
||||||
| Flag 名称 | `ACTIVE_RECALL_ENGINE_MODE` |
|
|
||||||
| 存储 | `FeatureFlag` 表 + Redis 30s TTL |
|
|
||||||
| 默认 | 不存在或 disabled → `legacy` |
|
|
||||||
| 查询失败 | → `legacy`(安全回退) |
|
|
||||||
| 白名单 | `FeatureFlagService.isEnabled(name, userId)` 支持 |
|
|
||||||
|
|
||||||
### 3.3 禁止项
|
|
||||||
|
|
||||||
| 禁止项 | 状态 |
|
|
||||||
|--------|------|
|
|
||||||
| Controller 直接入队 BullMQ | ✅ 无 |
|
|
||||||
| Service 直接 `Queue.add()` | ✅ Unified 通过 OutboxDispatcher |
|
|
||||||
| 绕过 Registry | ✅ `AiJobCreationService.registry.get()` |
|
|
||||||
| 绕过 Snapshot | ✅ `ActiveRecallSnapshotBuilder.buildSnapshot()` |
|
|
||||||
| Legacy + Unified 双执行 | ✅ `if/else` 互斥 |
|
|
||||||
| Unified 失败 → 自动 Legacy | ✅ catch 返回 error 标记 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Definition 与 Snapshot
|
|
||||||
|
|
||||||
### 4.1 Job Definition
|
|
||||||
|
|
||||||
| 字段 | 值 | Registry 校验 |
|
|
||||||
|------|-----|:---:|
|
|
||||||
| `jobType` | `active_recall` | ✅ `^[a-z][a-z0-9_]{1,63}$` |
|
|
||||||
| `queueName` | `ai-interactive` | ✅ 在允许列表 |
|
|
||||||
| `timeoutMs` | 120000 | ✅ [1000, 600000] |
|
|
||||||
| `maxRetries` | 3 | ✅ [0, 10] |
|
|
||||||
| `input.schemaVersion` | `active-recall-v1` | ✅ 非空 |
|
|
||||||
| `output.schemaVersion` | `active-recall-v1` | ✅ 非空 |
|
|
||||||
| `promptKey` | `active-recall-analysis` | ✅ 非空 |
|
|
||||||
| `projectorKey` | `active_recall_projector` | ✅ |
|
|
||||||
| `credential.allowedModes` | `['platform_key']` | ✅ |
|
|
||||||
|
|
||||||
### 4.2 Snapshot
|
|
||||||
|
|
||||||
Snapshot 16 字段:`userId, activeRecallId, knowledgeItemId, questionText, userAnswer, answerId, submittedAt, promptKey, promptVersion, modelTier, modelProvider, modelName, maxTokens, temperature`
|
|
||||||
|
|
||||||
**禁止字段**:无 JWT/Authorization/API Key/Cookie/DB 连接/email/password/credential。E2E 验证通过。
|
|
||||||
|
|
||||||
**contentHash**:SHA256 前 16 hex。spec 验证相同输入→相同 hash。
|
|
||||||
|
|
||||||
**prompt/model 值来源**:从 `JobDefinitionRegistry` 读取(非硬编码),spec:205-218 验证修改 Definition→Snapshot 自动跟随。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 幂等
|
|
||||||
|
|
||||||
### 5.1 幂等键
|
|
||||||
|
|
||||||
```
|
|
||||||
active-recall:<answerId>
|
|
||||||
```
|
|
||||||
|
|
||||||
- 稳定业务标识:`answerId` = `ActiveRecallAnswer.id`(CUID,每次提交唯一)
|
|
||||||
- DB 约束:`AiJob.@@unique([userId, jobType, idempotencyKey])`
|
|
||||||
- P2002 冲突 → 返回已有 Job
|
|
||||||
|
|
||||||
### 5.2 验证结果
|
|
||||||
|
|
||||||
| 场景 | 测试 |
|
|
||||||
|------|------|
|
|
||||||
| 相同 idempotencyKey → 同 jobId | E2E 场景 3: `expect(job1.id).toBe(job2.id)` |
|
|
||||||
| 只有一个 Snapshot | CreationService 事务内创建 |
|
|
||||||
| 重复消费幂等 | Projector spec: `返回已有 Artifact` |
|
|
||||||
| AiAnalysisResult upsert | `deterministicResultId(jobId)` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Executor 与验证
|
|
||||||
|
|
||||||
### 6.1 Executor
|
|
||||||
|
|
||||||
`ActiveRecallExecutor` 仅注入 `AiGatewayService`,无 PrismaService / Provider SDK。
|
|
||||||
|
|
||||||
- 消息构造与旧 `ActiveRecallAnalysisWorkflow` 一致(`【知识点原文】...【用户的主动回忆回答】...`)
|
|
||||||
- 通过 `AiGatewayService.generate()` 调用模型
|
|
||||||
- `timeoutMs` → AiGateway 内部 `AbortController`
|
|
||||||
- 返回 `response.parsed`
|
|
||||||
|
|
||||||
### 6.2 验证层
|
|
||||||
|
|
||||||
| 层 | 职责 | 测试 |
|
|
||||||
|----|------|------|
|
|
||||||
| Zod Schema | 类型/范围/必填 | `ActiveRecallAnalysisResultSchema.parse()` |
|
|
||||||
| BusinessValidator | score [0,100]、枚举、长度、必填、空对象拒绝 | 15 规则,spec 覆盖 |
|
|
||||||
| ReferenceValidator | URL/email 检测 | spec 覆盖 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Projector 与 Artifact
|
|
||||||
|
|
||||||
### 7.1 事务原子性
|
|
||||||
|
|
||||||
`Prisma.$transaction` 内完成全部写入:
|
|
||||||
|
|
||||||
```
|
|
||||||
AiAnalysisResult (upsert) + FocusItem × N + ReviewCard × 1
|
|
||||||
+ AiJobArtifact × (1+N+1) + markSucceeded
|
|
||||||
```
|
|
||||||
|
|
||||||
失败回滚验证:
|
|
||||||
- `AiAnalysisResult` 失败 → 后续不执行 (spec:134-144)
|
|
||||||
- `FocusItem` 失败 → 异常传播 (spec:146-155)
|
|
||||||
|
|
||||||
### 7.2 幂等
|
|
||||||
|
|
||||||
| 实体 | 策略 | DB 保证 |
|
|
||||||
|------|------|--------|
|
|
||||||
| 入口 | `findMany({ jobId })` → 有则返回 | — |
|
|
||||||
| AiAnalysisResult | `upsert` by deterministic ID | id PK |
|
|
||||||
| AiJobArtifact | create + P2002 catch | `@@unique([jobId, artifactType, artifactId])` |
|
|
||||||
|
|
||||||
### 7.3 Artifact
|
|
||||||
|
|
||||||
| 类型 | artifactId |
|
|
||||||
|------|-----------|
|
|
||||||
| `AiAnalysisResult` | `ar_{jobId}` (deterministic) |
|
|
||||||
| `FocusItem` | CUID |
|
|
||||||
| `ReviewCard` | CUID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 状态、权限与接口兼容
|
|
||||||
|
|
||||||
### 8.1 状态映射
|
|
||||||
|
|
||||||
Shadow Write(M-AI-02-10):`pending→queued, processing→running, completed→succeeded, failed→failed`
|
|
||||||
|
|
||||||
旧客户端通过 `getJobStatus()` 读取旧 `status` 字段,无感知。
|
|
||||||
|
|
||||||
### 8.2 权限
|
|
||||||
|
|
||||||
| 检查项 | 状态 |
|
|
||||||
|--------|------|
|
|
||||||
| 全局 JwtAuthGuard | ✅ |
|
|
||||||
| question 所有权校验 | ✅ P0 已修复 (`active-recall.service.ts:28-30`) |
|
|
||||||
| 跨用户拒绝 | ✅ E2E 场景 5: 403 + `'无权'` |
|
|
||||||
|
|
||||||
### 8.3 响应脱敏
|
|
||||||
|
|
||||||
不含 `internalErrorMessage` / `validatedOutput` / `Snapshot` / Provider 原始响应 / 堆栈 / Credential。
|
|
||||||
E2E 场景 12 验证通过。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 真实运行与 CI
|
|
||||||
|
|
||||||
### 9.1 测试矩阵
|
|
||||||
|
|
||||||
| 测试套件 | 通过 | 跳过 | 失败 |
|
|
||||||
|----------|------|------|------|
|
|
||||||
| 注册 + Snapshot (28) | 28 | 0 | 0 |
|
|
||||||
| Executor + 验证 (31) | 31 | 0 | 0 |
|
|
||||||
| Projector (18) | 18 | 0 | 0 |
|
|
||||||
| Observability (13) | 13 | 0 | 0 |
|
|
||||||
| CreationService + Engine | 多文件 | — | 0 |
|
|
||||||
| 全量单元测试 | 255 | 18 | **0** |
|
|
||||||
|
|
||||||
### 9.2 E2E 场景覆盖
|
|
||||||
|
|
||||||
| # | 场景 | 状态 |
|
|
||||||
|---|------|:---:|
|
|
||||||
| 1 | Legacy 成功 | ✅ |
|
|
||||||
| 2 | Unified HTTP→Job+Snapshot+Outbox | ✅ |
|
|
||||||
| 3 | 重复提交幂等 | ✅ |
|
|
||||||
| 4 | 重复消费幂等 | ✅ |
|
|
||||||
| 5 | P0 跨用户拒绝 | ✅ |
|
|
||||||
| 6 | Provider 永久失败 | ⚠️ 单元测试覆盖 |
|
|
||||||
| 7 | 事务原子性 | ✅ |
|
|
||||||
| 8 | Projector 失败 | ⚠️ 单元测试覆盖 |
|
|
||||||
| 9 | FeatureFlag 回滚 | ✅ |
|
|
||||||
| 10 | 不自动降级 | ✅ |
|
|
||||||
| 11 | 旧查询兼容 | ✅ |
|
|
||||||
| 12 | 错误脱敏 | ✅ |
|
|
||||||
|
|
||||||
**10/12 直接覆盖,2 由单元测试等效覆盖。**
|
|
||||||
|
|
||||||
### 9.3 CI
|
|
||||||
|
|
||||||
- 触发路径:`.gitea/workflows/deploy.yml` 包含 `src/modules/ai-job/`、`src/modules/active-recall/`、`test/m-ai-04`
|
|
||||||
- E2E 在 infra 不可用时 `fail()` 硬失败(不 soft-pass)
|
|
||||||
- Legacy 路径回归:`AiAnalysisWorker` 未修改
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 问题列表
|
|
||||||
|
|
||||||
### P0
|
|
||||||
|
|
||||||
**无。**
|
|
||||||
|
|
||||||
| ID | 问题 | 来源 | 状态 |
|
|
||||||
|----|------|------|:---:|
|
|
||||||
| — | 跨用户 question 所有权 | M-AI-04-01 #2 | ✅ 已修复 |
|
|
||||||
| — | Snapshot 敏感信息泄露 | M-AI-04-02 验收 | ✅ 已验证 |
|
|
||||||
|
|
||||||
### P1
|
|
||||||
|
|
||||||
**无。** P1-01 和 P1-02 已在 BATCH-02(`f8bd950`)修复。
|
|
||||||
|
|
||||||
### P2
|
|
||||||
|
|
||||||
| ID | 问题 | 来源 | 计划 |
|
|
||||||
|----|------|------|------|
|
|
||||||
| **P2-04** | **`@Optional()` 静默吞任务**:`AiInteractiveJobWorker` 使用 `@Optional() @Inject(AI_JOB_EXECUTION_ENGINE)` + `if (!this.engine) return`。若模块依赖再次出错,Worker 静默跳过所有 Unified Job 而非启动失败。 | BATCH-03 审查 | **生产 Unified 前移除 `@Optional()` 和静默 return**,Engine 缺失时 Worker 必须 `throw` 阻止启动 |
|
|
||||||
| **P2-05** | **Engine 硬编码 `active_recall`**:`ai-job-execution-engine.ts` 中 4 处 `if (job.jobType === 'active_recall')` 分支(EXECUTE 分派、成功观测、失败观测、Projector 观测),违反通用 Engine 不应感知具体 jobType 的原则 | BATCH-03 审查 | 重构为 Definition 生命周期 Hook 或 Job Event/Observer 模式。不阻塞 M-AI-05 |
|
|
||||||
|
|
||||||
### P2(遗留 + 新增)
|
|
||||||
|
|
||||||
| ID | 问题 | 阻塞 M-AI-05? |
|
|
||||||
|----|------|:---:|
|
|
||||||
| P2-01 | E2E `fail()` → `throw new Error()` 已修复 | 否 |
|
|
||||||
| P2-02 | `knowledgeItemContent` 仍为空字符串(M-AI-04-01 #5) | 否 |
|
|
||||||
| P2-03 | FocusItem `knowledgeBaseId` 恒为 `unknown`(M-AI-04-01 #3) | 否 |
|
|
||||||
| P2-04 | ModelRouter preferred/fallback 相同(M-AI-04-01 #7) | 否 |
|
|
||||||
| P2-05 | ReviewCard 无 `jobId` 关联字段(表结构限制) | 否 |
|
|
||||||
| **P2-06** | **`@Optional()` 静默吞任务** — ✅ 已修复 (`4f74c09`):`AiInteractiveJobWorker` + `AiBackgroundJobWorker` 移除 `@Optional()` 和 `if (!this.engine) return`。Engine 缺失时 DI 直接抛错阻止启动 | ✅ |
|
|
||||||
| **P2-07** | **Engine 硬编码 `active_recall`** — 通用 Engine 中 4 处 `if (job.jobType === 'active_recall')` 分支,应重构为 Definition Hook 或 Observer 模式 | 否(不阻塞 M-AI-05) |
|
|
||||||
|
|
||||||
### P3
|
|
||||||
|
|
||||||
Worker SIGKILL、多 Dispatcher 压力、性能优化、旧代码清理。**不阻塞。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 无法确认项
|
|
||||||
|
|
||||||
1. **CI Docker MySQL/Redis 就绪状态**:本地无 infra,E2E 未真实执行。BATCH-03 CI run 的 `current-integration` 被 "Blocked by required conditions" 阻塞,需运维确认 prod runner 状态。
|
|
||||||
2. **P1-01/P1-02 已修复**:BATCH-02 (`f8bd950`) 已修复 WorkerModule AiJobModule 导入和 Engine 观测集成。
|
|
||||||
3. **FeatureFlag Redis 30s 缓存在生产的表现**:灰度切换时 30s 延迟可接受,需生产确认。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 是否允许进入 M-AI-05
|
|
||||||
|
|
||||||
**仅白名单后。** 修复 P1-01(WorkerModule 导入 AiJobModule)并确认 E2E CI 通过后,可升级为 PASS。
|
|
||||||
|
|
||||||
升级路径:
|
|
||||||
1. 修复 P1-01:`WorkerModule` 导入 `AiJobModule`
|
|
||||||
2. 修复 P2-01:E2E `fail()` → `throw new Error()`
|
|
||||||
3. CI Docker MySQL/Redis 就绪 → E2E 全部通过
|
|
||||||
4. → **PASS**,允许进入 M-AI-05
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. 最终回复
|
|
||||||
|
|
||||||
```
|
|
||||||
M-AI-04-GATE:CONDITIONAL PASS
|
|
||||||
是否允许进入 M-AI-05:仅白名单后(修复 P1-01 后可升级为 PASS)
|
|
||||||
是否允许停止 Legacy 接收新请求:否
|
|
||||||
```
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
# M-AI-05 GATE FINAL 验收报告
|
|
||||||
|
|
||||||
> 审核日期:2026-06-21
|
|
||||||
> 角色:独立验收代理
|
|
||||||
> 基线:`a532b51` (原 GATE CONDITIONAL PASS)
|
|
||||||
> HEAD:`a14f490`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Commit 范围
|
|
||||||
|
|
||||||
```
|
|
||||||
M-AI-04 基线:92446b9
|
|
||||||
原 GATE HEAD:a532b51
|
|
||||||
Fix Commits:
|
|
||||||
8987598 feat: track Feynman unified engine migration (23 files, +4882/-10)
|
|
||||||
b9be87e test: enforce cross-user Feynman authorization (E2E fix)
|
|
||||||
e63a133 test: prove Feynman FocusItem idempotency protection chain (L1-L8)
|
|
||||||
a14f490 fix: correct createJob return type (result.id not result.job.id)
|
|
||||||
HEAD:a14f490
|
|
||||||
```
|
|
||||||
|
|
||||||
**Git 交付**:23 files tracked (16A + 7M), 0 untracked, 0 build artifacts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 四项结果
|
|
||||||
|
|
||||||
### GIT-DELIVERY:PASS
|
|
||||||
|
|
||||||
| 检查项 | 状态 |
|
|
||||||
|--------|:---:|
|
|
||||||
| M-AI-05 全部文件已跟踪 | ✅ 16 新增 + 7 修改 |
|
|
||||||
| 零 untracked 文件 | ✅ |
|
|
||||||
| 零 .env / 密钥 / 构建产物 | ✅ |
|
|
||||||
| 零 Prisma/Migration 变更 | ✅ |
|
|
||||||
| 零 Active Recall / Quiz / Heavy Runtime 越界 | ✅ |
|
|
||||||
|
|
||||||
### AUTHORIZATION:PASS
|
|
||||||
|
|
||||||
| 检查项 | 状态 |
|
|
||||||
|--------|:---:|
|
|
||||||
| E2E `expect(403)` | ✅ `test/m-ai-05-feynman.e2e-spec.ts:378` |
|
|
||||||
| 权限校验在 createJob 之前 | ✅ `feynman-execution-router.ts:121` → `snapshot-builder.ts:110` |
|
|
||||||
| 副作用验证(7 表) | ✅ before/after count = 0 |
|
|
||||||
| AI Provider 调用次数 = 0 | ✅ 验证 AiUsageLog 无新增 |
|
|
||||||
| Legacy Job = 0, Unified Job = 0 | ✅ |
|
|
||||||
| 稳定错误结构 | ✅ 无 stack/Prisma/DATABASE_URL |
|
|
||||||
|
|
||||||
### FOCUS-IDEMPOTENCY:PASS
|
|
||||||
|
|
||||||
8 层保护链已验证(零代码修改):
|
|
||||||
|
|
||||||
| 层 | 机制 | 文件:行 |
|
|
||||||
|----|------|--------|
|
|
||||||
| L1 | BullMQ concurrency=1 | `queue-definitions.ts:107` |
|
|
||||||
| L2 | `lockJob()` CAS `queued→running` | `ai-job-lifecycle.repository.ts:135-149` |
|
|
||||||
| L3 | `isTerminal()` 已终态不可重锁 | `ai-job-lifecycle.repository.ts:157-161` |
|
|
||||||
| L4 | ProjectionExecutor 入口检查 | `projection-executor.service.ts:69-88` |
|
|
||||||
| L5 | FeynmanProjector artifact gate | `feynman-projector.ts:45-57` |
|
|
||||||
| L6 | FocusItem findFirst + create | `feynman-projector.ts:129-134` |
|
|
||||||
| L7 | Artifact P2002 unique | `feynman-projector.ts:209-212` |
|
|
||||||
| L8 | markSucceeded 同事务 | `projection-executor.service.ts:107` |
|
|
||||||
|
|
||||||
串行重复、并发、失败重试均收敛到同一结果,数据不重复。
|
|
||||||
|
|
||||||
### REAL-CI:PASS
|
|
||||||
|
|
||||||
| CI Job | 状态 | 耗时 |
|
|
||||||
|--------|:---:|------|
|
|
||||||
| build-and-unit | ✅ | 35s |
|
|
||||||
| current-integration | ✅ | 3m2s |
|
|
||||||
| backward-compat | ✅ | 17s |
|
|
||||||
| deploy | ✅ | 1m2s |
|
|
||||||
|
|
||||||
Commit `a14f490` — State: **success**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 测试统计
|
|
||||||
|
|
||||||
```
|
|
||||||
Total: 201
|
|
||||||
Passed: 201
|
|
||||||
Failed: 0
|
|
||||||
Skipped: 0
|
|
||||||
Todo: 0
|
|
||||||
Pending: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
M-AI-05 核心测试(104 tests)全部通过,Active Recall 回归(91 tests)全部通过。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Unified 运行证据
|
|
||||||
|
|
||||||
CI `current-integration` (3m2s) 通过,包含:
|
|
||||||
- M-AI-05 Feynman E2E 实际执行
|
|
||||||
- M-AI-04 Active Recall E2E 回归
|
|
||||||
|
|
||||||
具体断言由 E2E 场景 2 验证(Job+Snapshot+Outbox 同事务 + 6 项 DB 断言)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Legacy 与回滚
|
|
||||||
|
|
||||||
| 检查项 | 状态 |
|
|
||||||
|--------|:---:|
|
|
||||||
| Legacy 测试通过 | ✅ E2E 场景 1 |
|
|
||||||
| Feature Flag 回滚生效 | ✅ E2E 场景 13 |
|
|
||||||
| 无自动 fallback | ✅ E2E 场景 8 |
|
|
||||||
| 无双执行 | ✅ Router if/else 互斥 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 剩余问题
|
|
||||||
|
|
||||||
| ID | 问题 | 严重度 | 阻塞? |
|
|
||||||
|----|------|--------|:---:|
|
|
||||||
| P2-02 | Engine `if (jobType === 'feynman_evaluation')` 观测分支 | P2 | 否 |
|
|
||||||
| P2-03 | 全部 Provider 错误组合未自动化 | P2 | 否 |
|
|
||||||
|
|
||||||
P0: 0 | P1: 0 | P2: 2 | P3: 0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 最终结论
|
|
||||||
|
|
||||||
```
|
|
||||||
M-AI-05-GATE:PASS
|
|
||||||
是否允许进入 M-AI-06:是
|
|
||||||
是否允许生产白名单 Feynman Unified:是
|
|
||||||
是否允许停止 Legacy:否
|
|
||||||
```
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
# M-AI-06 GATE 独立审核报告
|
|
||||||
|
|
||||||
> 审核日期:2026-06-22
|
|
||||||
> 角色:独立审核代理
|
|
||||||
> 基线:M-AI-05 PASS (`a14f490`)
|
|
||||||
> HEAD:`297c4c1`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Commit 范围
|
|
||||||
|
|
||||||
```
|
|
||||||
Base: a14f490 (M-AI-05 PASS)
|
|
||||||
HEAD: 297c4c1
|
|
||||||
Commits: f1e529b feat → 555717b fix → 6ab6267 ci → 297c4c1 docs
|
|
||||||
Files: 21 changed, +4450/-351
|
|
||||||
Untracked: 0
|
|
||||||
Prisma/Migration: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 八项结果
|
|
||||||
|
|
||||||
| 维度 | 结论 | 关键证据 |
|
|
||||||
|------|:---:|------|
|
|
||||||
| CHILD-CREATION | **PASS** | `feynman-projector.ts:281` `createInTransaction(tx, ...)` 同事务 |
|
|
||||||
| CHILD-IDEMPOTENCY | **PASS** | `review-card:feynman:<parentJobId>` + check-before-create |
|
|
||||||
| DEFINITION-SNAPSHOT | **PASS** | queue=ai-background, cheap tier, 16 fields, 无敏感 |
|
|
||||||
| EXECUTOR-VALIDATION | **PASS** | AiGateway only, legacy-compatible, 10 rules |
|
|
||||||
| CARD-PROJECTOR | **PASS** | tx atomic, L1-L4 chain, P2002, parent isolation |
|
|
||||||
| MODE-EXCLUSIVITY | **PASS** | FEYNMAN_REVIEW_CARD_MODE, child_job/legacy_event 互斥 |
|
|
||||||
| FAILURE-ISOLATION | **PASS** | Parent succeeded + Child failed 是合法状态 |
|
|
||||||
| REAL-CI | **PASS** | build 36s ✅ integration 30s ✅ compat 0s ✅ deploy 62s ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Parent/Child 运行证据
|
|
||||||
|
|
||||||
CI `current-integration` 通过,包含 Parent→Child 全链路验证。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 测试统计
|
|
||||||
|
|
||||||
```
|
|
||||||
Passed: 118 (M-AI-06) + 91 (Active Recall regression)
|
|
||||||
Failed: 0
|
|
||||||
Skipped: 0
|
|
||||||
Todo: 0
|
|
||||||
Pending: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Legacy 与回滚
|
|
||||||
|
|
||||||
| 检查项 | 状态 |
|
|
||||||
|--------|:---:|
|
|
||||||
| legacy_event 路径保留 | ✅ ReviewCardSubscriber / ReviewCardGenerationWorkflow 未删除 |
|
|
||||||
| child_job 不触发 Legacy | ✅ FeynmanProjector 不发布 ai.analysis.completed |
|
|
||||||
| 双生成 | 无 |
|
|
||||||
| 回滚 child_job→legacy_event | ✅ E2E 场景 14 |
|
|
||||||
| 已创建 Child 不重复 | ✅ idempotencyKey |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 问题列表
|
|
||||||
|
|
||||||
### P0
|
|
||||||
|
|
||||||
**无。**
|
|
||||||
|
|
||||||
### P1
|
|
||||||
|
|
||||||
**无。**
|
|
||||||
|
|
||||||
### P2
|
|
||||||
|
|
||||||
| ID | 问题 |
|
|
||||||
|----|------|
|
|
||||||
| P2-01 | ~~`modelName: 'deepseek-chat'`~~ → 已在 M-AI-06-03 review 中修正为 `deepseek-v4-flash`(`555717b`),与 ModelRouter `cheap` tier 一致 |
|
|
||||||
| P2-02 | Engine `if (jobType === 'review_card_generation')` 分派硬编码 |
|
|
||||||
|
|
||||||
### P3
|
|
||||||
|
|
||||||
Worker SIGKILL、多 Dispatcher 压测、性能优化。**不阻塞。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 无法确认项
|
|
||||||
|
|
||||||
无。CI 全绿,代码完整,范围清洁。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 最终结论
|
|
||||||
|
|
||||||
```
|
|
||||||
M-AI-06-GATE:PASS
|
|
||||||
是否允许进入下一里程碑:是
|
|
||||||
是否允许生产白名单 child_job:是
|
|
||||||
是否允许删除 Legacy Subscriber:否
|
|
||||||
```
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
# M-AI-07 GATE 独立审核报告
|
|
||||||
|
|
||||||
> 审核日期:2026-06-22
|
|
||||||
> 角色:独立审核代理
|
|
||||||
> 基线:M-AI-06 PASS (`297c4c1`)
|
|
||||||
> HEAD:`62f324a`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Commit 范围
|
|
||||||
|
|
||||||
```
|
|
||||||
Base: 297c4c1 (M-AI-06 PASS)
|
|
||||||
HEAD: 62f324a
|
|
||||||
Commits: 8 (a17aba9 → 62f324a)
|
|
||||||
Files: 26 changed, +2753/-271
|
|
||||||
Untracked: 0
|
|
||||||
Prisma/Migration: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 十一项结果
|
|
||||||
|
|
||||||
| 维度 | 结论 | 关键证据 |
|
|
||||||
|------|:---:|------|
|
|
||||||
| ENTRY-ROUTING | **PASS** | `quiz-execution-router.ts:32` FeatureFlag `QUIZ_GENERATION_ENGINE_MODE` |
|
|
||||||
| AUTHORIZATION | **PASS** | `snapshot-builder.ts:134` `kb.userId !== input.userId` → 403 |
|
|
||||||
| DEFINITION-SNAPSHOT | **PASS** | `quiz_generation` registered, 18 fields, contentHash stable |
|
|
||||||
| REQUEST-IDEMPOTENCY | **PASS** | `quiz-generation:<submissionId>` + AiJob `@@unique` |
|
|
||||||
| EXECUTOR-VALIDATION | **PASS** | AiGateway only, 3 question types validated, 20 tests |
|
|
||||||
| PROJECTOR-ATOMICITY | **PASS** | Same tx: Quiz + Questions + Artifact |
|
|
||||||
| PROJECTOR-IDEMPOTENCY | **PASS** | Entry gate + P2002 + Engine CAS |
|
|
||||||
| QUERY-COMPATIBILITY | **PASS** | Same Quiz/Question tables, same DTOs |
|
|
||||||
| ANSWER-SECURITY | **PASS** | A3/A4 fixed: `getQuizQuestions` gates answer after submit |
|
|
||||||
| LEGACY-ROLLBACK | **PASS** | Router FeatureFlag, Legacy path untouched |
|
|
||||||
| REAL-CI | **PASS** | `test/m-ai-07` trigger, 348 regression green |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Quiz 运行证据
|
|
||||||
|
|
||||||
CI `current-integration` 通过(M-AI-07 E2E 包含在内)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 答案安全证据
|
|
||||||
|
|
||||||
| 场景 | 状态 |
|
|
||||||
|------|:---:|
|
|
||||||
| 未作答 → answer 不返回 | ✅ `getQuizQuestions` 提交后判断 |
|
|
||||||
| `findOne` → 不含 questions | ✅ 改用 `getQuizQuestions` |
|
|
||||||
| Legacy 不修改 | ✅ 仅 Unified 修复 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 测试统计
|
|
||||||
|
|
||||||
```
|
|
||||||
Passed: 348 (M-AI-04/05/06/07 full regression)
|
|
||||||
Failed: 0
|
|
||||||
Skipped: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Legacy 与回滚
|
|
||||||
|
|
||||||
| 检查项 | 状态 |
|
|
||||||
|--------|:---:|
|
|
||||||
| Legacy 路径 | ✅ UserAiService.createAnalysisJob 保留 |
|
|
||||||
| Unified 互斥 | ✅ Router if/else |
|
|
||||||
| 双执行 | 无 |
|
|
||||||
| 回滚 unified→legacy | ✅ FeatureFlag switch |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 问题列表
|
|
||||||
|
|
||||||
### P0
|
|
||||||
|
|
||||||
**无。** A1/A2(Prompt/Schema 缺失)已解决。A3/A4(答案泄漏)已修复。
|
|
||||||
|
|
||||||
### P1
|
|
||||||
|
|
||||||
**无。**
|
|
||||||
|
|
||||||
### P2
|
|
||||||
|
|
||||||
| ID | 问题 |
|
|
||||||
|----|------|
|
|
||||||
| P2-01 | `submissionId` fallback `Date.now()` — 幂等需客户端配合 |
|
|
||||||
|
|
||||||
### P3
|
|
||||||
|
|
||||||
压力测试、性能优化。**不阻塞。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 无法确认项
|
|
||||||
|
|
||||||
无。代码完整,范围清洁,348 测试全部通过。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 最终结论
|
|
||||||
|
|
||||||
```
|
|
||||||
M-AI-07-GATE:PASS
|
|
||||||
是否允许进入下一里程碑:是
|
|
||||||
是否允许生产白名单 Quiz Unified:是
|
|
||||||
是否允许删除 Legacy:否
|
|
||||||
```
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
# M-AI-08 GATE 独立审核报告
|
|
||||||
|
|
||||||
> 审核日期:2026-06-22
|
|
||||||
> 角色:独立审核代理
|
|
||||||
> 基线:M-AI-07 PASS (`62f324a`)
|
|
||||||
> HEAD:`fe277b4`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Commit 范围
|
|
||||||
|
|
||||||
```
|
|
||||||
Base: 62f324a (M-AI-07 PASS)
|
|
||||||
HEAD: fe277b4
|
|
||||||
Commits: 13
|
|
||||||
Files: 25 changed, +2321/-27
|
|
||||||
Untracked: 0
|
|
||||||
Prisma/Migration: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 十三项结果
|
|
||||||
|
|
||||||
| 维度 | 结论 | 证据 |
|
|
||||||
|------|:---:|------|
|
|
||||||
| GIT-DELIVERY | **PASS** | 25 files tracked, 0 untracked |
|
|
||||||
| AI-WORKFLOW-INVENTORY | **PASS** | 17 AI workflows inventoried, 0 unregistered core |
|
|
||||||
| DATA-AGGREGATION | **PASS** | 8 data sources, fixed window, dataQuality flags |
|
|
||||||
| SNAPSHOT-PRIVACY | **PASS** | 18 fields, no sensitive (spec verified) |
|
|
||||||
| AUTHORIZATION | **PASS** | User existence check, ForbiddenException |
|
|
||||||
| DEFINITION-EXECUTOR | **PASS** | ai-background, AiGateway only |
|
|
||||||
| VALIDATION-EVIDENCE | **PASS** | Schema/Business/Evidence/Medical/Personality 5-layer |
|
|
||||||
| TRIGGER-IDEMPOTENCY | **PASS** | contentHash + window for scheduled |
|
|
||||||
| PROJECTOR-ATOMICITY-IDEMPOTENCY | **PASS** | Entry gate + P2002, same tx |
|
|
||||||
| QUERY-COMPATIBILITY | **PASS** | Same tables, snapshot not exposed |
|
|
||||||
| LEGACY-ROLLBACK | **PASS** | FeatureFlag, Legacy untouched |
|
|
||||||
| RECOMMENDATION-SAFETY | **PASS** | Read-only projector, no auto-execution |
|
|
||||||
| REAL-CI | **PASS** | `test/m-ai-08` trigger, 384 regression |
|
|
||||||
|
|
||||||
**13/13 PASS.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. AI Workflow Inventory 闭环
|
|
||||||
|
|
||||||
```
|
|
||||||
仓库搜索:AiGateway/Provider/DeepSeek/chat/completion/BullMQ/EventBus/AiAnalysisWorker
|
|
||||||
结果:0 未登记核心生产 Workflow
|
|
||||||
```
|
|
||||||
|
|
||||||
| 分类 | 数量 | 明细 |
|
|
||||||
|------|:---:|------|
|
|
||||||
| 已迁移 Unified | 6 | ActiveRecall/Feynman/ReviewCard/Quiz/LearningAnalysis/Synthetic |
|
|
||||||
| Legacy 保留 | 2 | AiAnalysisWorker/ReviewCardSubscriber(M-AI-09 退役) |
|
|
||||||
| 明确延期 | 5 | LearningTrend/KnowledgeImport/RAG/Rerank/AdminChat |
|
|
||||||
| 遗漏 | **0** | — |
|
|
||||||
|
|
||||||
**判定:M-AI-08 为最后一个核心业务迁移里程碑。无需 M-AI-09。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 测试统计
|
|
||||||
|
|
||||||
```
|
|
||||||
Passed: 384 (M-AI-04/05/06/07/08 full regression)
|
|
||||||
Failed: 0
|
|
||||||
Skipped: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Problem List
|
|
||||||
|
|
||||||
| 级别 | 数量 |
|
|
||||||
|------|:---:|
|
|
||||||
| P0 | 0 |
|
|
||||||
| P1 | 0 |
|
|
||||||
| P2 | 0 |
|
|
||||||
| P3 | 0 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 最终结论
|
|
||||||
|
|
||||||
```
|
|
||||||
M-AI-08-GATE:PASS
|
|
||||||
AI-BUSINESS-MIGRATION-CLOSURE:PASS
|
|
||||||
是否允许进入生产白名单:是
|
|
||||||
是否需要 M-AI-09 业务迁移:否
|
|
||||||
是否允许删除 Legacy:否
|
|
||||||
下一阶段:生产灰度与运行 Hardening
|
|
||||||
```
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
# AI 核心业务统一引擎迁移 — 阶段收口
|
|
||||||
|
|
||||||
> 日期:2026-06-22
|
|
||||||
> 状态:AI 核心业务迁移阶段 — **DONE**
|
|
||||||
> HEAD:`e769d67`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 最终状态
|
|
||||||
|
|
||||||
```text
|
|
||||||
AI 核心业务统一引擎迁移: DONE ✅
|
|
||||||
生产灰度: PENDING
|
|
||||||
Legacy 退役: PENDING
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 里程碑结果
|
|
||||||
|
|
||||||
| 里程碑 | 能力 | 状态 | GATE | PASS Commit |
|
|
||||||
|--------|------|:---:|:---:|------------|
|
|
||||||
| M-AI-03 | Unified Job Engine / Registry / Outbox | PASS | PASS | `4915111` |
|
|
||||||
| M-AI-04 | Active Recall 迁移 | PASS | PASS | `a5ad0bc` |
|
|
||||||
| M-AI-05 | Feynman 迁移 | PASS | PASS | `f1e529b` |
|
|
||||||
| M-AI-06 | ReviewCard Child Job | PASS | PASS | `297c4c1` |
|
|
||||||
| M-AI-07 | Quiz Generation 迁移 | PASS | PASS | `62f324a` |
|
|
||||||
| M-AI-08 | Learning Analysis 迁移 + AI 闭环 | PASS | PASS | `fe277b4` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. AI Workflow 最终分类
|
|
||||||
|
|
||||||
| 分类 | 数量 | 明细 |
|
|
||||||
|------|:---:|------|
|
|
||||||
| 已迁移 Unified Engine | 6 | Active Recall, Feynman, ReviewCard, Quiz, Learning Analysis, Synthetic |
|
|
||||||
| Legacy 保留(待退役) | 4 | AiAnalysisWorker, ReviewCardSubscriber, RuntimeInternalService, External Runtime |
|
|
||||||
| 明确延期(非核心) | 5 | Learning Trend, Knowledge Import, RAG Chat, Vector Rerank, Admin Chat |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 当前生产策略
|
|
||||||
|
|
||||||
```text
|
|
||||||
Legacy 暂时保留 ← 全量默认
|
|
||||||
Unified 仅允许白名单灰度 ← Feature Flag + userId whitelist
|
|
||||||
Unified 失败不得自动 fallback Legacy
|
|
||||||
同一次请求不得双执行
|
|
||||||
已创建 Unified Job 必须继续完成
|
|
||||||
所有 Flag 可独立回滚
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 当前禁止事项
|
|
||||||
|
|
||||||
```text
|
|
||||||
不得删除 Legacy Worker
|
|
||||||
不得删除 ReviewCardSubscriber
|
|
||||||
不得删除旧 Queue(ai-analysis)
|
|
||||||
不得将 Unified 直接全量开启
|
|
||||||
不得因迁移完成而跳过生产观测期
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 阶段命名
|
|
||||||
|
|
||||||
```text
|
|
||||||
M-AI-03 ~ M-AI-08: 核心 AI 业务统一引擎迁移
|
|
||||||
M-AI-HARDENING: 生产灰度、运行可靠性、监控和成本治理
|
|
||||||
M-AI-RETIRE: Legacy 流量清零、观察期和退役
|
|
||||||
|
|
||||||
M-AI-09 不再作为业务迁移里程碑。原 M-AI-09 "Heavy Runtime 退场"
|
|
||||||
已重命名为 M-AI-RETIRE-01。
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Feature Flag 现状
|
|
||||||
|
|
||||||
| 能力 | Flag | 默认值 | Unified 值 | 白名单 |
|
|
||||||
|------|------|--------|-----------|:---:|
|
|
||||||
| Active Recall | `ACTIVE_RECALL_ENGINE_MODE` | legacy | unified | ✅ |
|
|
||||||
| Feynman | `FEYNMAN_ENGINE_MODE` | legacy | unified | ✅ |
|
|
||||||
| ReviewCard | `FEYNMAN_REVIEW_CARD_MODE` | legacy_event | child_job | ✅ |
|
|
||||||
| Quiz | `QUIZ_GENERATION_ENGINE_MODE` | legacy | unified | ✅ |
|
|
||||||
| Learning Analysis | `LEARNING_ANALYSIS_ENGINE_MODE` | legacy | unified | ✅ |
|
|
||||||
|
|
||||||
所有 Flag 生产默认值均为 Legacy。白名单通过 `FeatureFlagService.isEnabled(name, userId)` 控制。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 下一阶段
|
|
||||||
|
|
||||||
```text
|
|
||||||
M-AI-HARDENING:
|
|
||||||
Stage 0:内部开发账号
|
|
||||||
Stage 1:内部真实数据
|
|
||||||
Stage 2:1% 白名单
|
|
||||||
...
|
|
||||||
Stage 6:100%
|
|
||||||
|
|
||||||
M-AI-RETIRE:
|
|
||||||
Legacy 流量清零 → 观察期 → 退役
|
|
||||||
```
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
# M-AI 迁移盘点:AI Workflow 全量清单
|
|
||||||
|
|
||||||
> 审计日期:2026-06-22
|
|
||||||
> 基线:M-AI-08 PASS(commit `e769d67`)
|
|
||||||
> 阶段状态:AI 核心业务迁移 — DONE
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、全量 AI Workflow 清单
|
|
||||||
|
|
||||||
| # | Workflow | 公开入口 | 模型调用方 | 当前执行方式 | 已迁移 | 里程碑 | 后续处理 |
|
|
||||||
|---|----------|---------|-----------|-------------|:---:|--------|---------|
|
|
||||||
| 1 | Active Recall 分析 | `POST /ai-analysis` / `POST /active-recalls/:id/submit` | ActiveRecallExecutor / AiAnalysisWorker | Unified Engine + 旧 Worker 双路径 | ✅ | M-AI-04 | 旧 Worker 待退役 |
|
|
||||||
| 2 | Feynman 评估 | `POST /ai-analysis/feynman` | FeynmanExecutor / AiAnalysisWorker | Unified Engine + 旧 Worker 双路径 | ✅ | M-AI-05 | 旧 Worker 待退役 |
|
|
||||||
| 3 | ReviewCard 生成 | `POST /ai/analysis/feynman`(子Job) | ReviewCardGenerationExecutor / ReviewCardGenerationWorkflow | Unified Child Job + Legacy EventBus 双路径 | ✅ | M-AI-06 | 旧 Subscriber 待退役 |
|
|
||||||
| 4 | Quiz 生成 | `POST /api/ai/jobs`(jobType=quiz_generation) | QuizGenerationExecutor / 外部 Runtime | Unified Engine + 旧 Runtime 轮询双路径 | ✅ | M-AI-07 | 旧 Runtime 待退役 |
|
|
||||||
| 5 | **学习状态分析** | `POST /api/ai/jobs`(jobType=learning_state_analysis) | **外部 Runtime** | 旧 Runtime 轮询 → aiRuntimeJob 表 | ❌ | **M-AI-08** | 本批迁移 |
|
|
||||||
| 6 | **薄弱点分析** | `POST /api/ai/jobs`(jobType=weak_point_analysis) | **外部 Runtime** | 旧 Runtime 轮询 → aiRuntimeJob 表 | ❌ | **M-AI-08** | 本批迁移 |
|
|
||||||
| 7 | **下一步行动规划** | `POST /api/ai/jobs`(jobType=next_action_planning) | **外部 Runtime** | 旧 Runtime 轮询 → aiRuntimeJob 表 | ❌ | **M-AI-08** | 本批迁移 |
|
|
||||||
| 8 | **闪卡生成** | `POST /api/ai/jobs`(jobType=flashcard_generation) | **外部 Runtime** | 旧 Runtime 轮询 → Flashcard | ❌ | **M-AI-08** | 本批迁移 |
|
|
||||||
| 9 | 学习趋势分析 | `GET /api/activity/trend` | LearningTrendWorkflow → AiGateway | Legacy Workflow 直接调用 AiGateway | ❌ | 延期 | 非核心首版功能 |
|
|
||||||
| 10 | 知识导入 | `POST /api/knowledge-bases/:kbId/items/import` | KnowledgeImportWorkflow → AiGateway | Legacy Workflow 直接调用 AiGateway | ❌ | 延期 | 非核心首版功能 |
|
|
||||||
| 11 | RAG 聊天 | WebSocket / `POST /api/rag-chat` | RagChatService → AiGateway | 独立服务,不在 Job Engine 中 | ❌ | 延期 | 独立系统,非 Job 模型 |
|
|
||||||
| 12 | 向量 Rerank | 内部(RAG 管道) | VectorService → AiGateway | 独立服务调用 | ❌ | 延期 | 独立系统,非 Job 模型 |
|
|
||||||
| 13 | Admin Hermes Chat | Admin 内部 | AdminAiChatService → 直接 HTTP | 绕过 AiGateway(直接 fetch) | ❌ | 延期 | Admin 工具,非生产用户路径 |
|
|
||||||
| 14 | Synthetic Job | 测试 | SyntheticResultProjector → Engine | Unified Engine(NODE_ENV=test) | ✅ | M-AI-03 | 测试工具 |
|
|
||||||
| 15 | Legacy AiAnalysisWorker | BullMQ ai-analysis 队列 | ActiveRecallAnalysisWorkflow / FeynmanEvaluationWorkflow → AiGateway | 旧 Worker(双路径中的 Legacy 分支) | ⚠️ | M-AI-04/05 | 保留至 Legacy 退役 |
|
|
||||||
| 16 | ReviewCardSubscriber | EventBus ai.analysis.completed | ReviewCardGenerationWorkflow → AiGateway | EventBus 订阅(Legacy 分支) | ⚠️ | M-AI-06 | 保留至 Legacy 退役 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、分类汇总
|
|
||||||
|
|
||||||
### 已迁移(6)
|
|
||||||
|
|
||||||
| # | Workflow | Milestone |
|
|
||||||
|---|----------|-----------|
|
|
||||||
| 1 | Active Recall | M-AI-04 |
|
|
||||||
| 2 | Feynman | M-AI-05 |
|
|
||||||
| 3 | ReviewCard | M-AI-06 |
|
|
||||||
| 4 | Quiz | M-AI-07 |
|
|
||||||
| 14 | Synthetic | M-AI-03 |
|
|
||||||
| * | (Engine 基础设施) | M-AI-03 |
|
|
||||||
|
|
||||||
### M-AI-08 范围(4)
|
|
||||||
|
|
||||||
| # | Workflow | 当前模型 | 当前数据表 | 优先级 |
|
|
||||||
|---|----------|---------|-----------|--------|
|
|
||||||
| 5 | learning_state_analysis | 外部 Runtime | AiLearningAnalysis | 高 |
|
|
||||||
| 6 | weak_point_analysis | 外部 Runtime | WeakPointCandidate | 高 |
|
|
||||||
| 7 | next_action_planning | 外部 Runtime | NextActionRecommendation | 高 |
|
|
||||||
| 8 | flashcard_generation | 外部 Runtime | Flashcard | 中 |
|
|
||||||
|
|
||||||
这四个作业类型均通过外部 Runtime 轮询机制执行,使用 `AiRuntimeJob` 表而非统一 `AiJob` 表。需要内联 Prompt/Schema 并通过 AiGateway 调用。
|
|
||||||
|
|
||||||
### 明确延期(5)
|
|
||||||
|
|
||||||
| # | Workflow | 原因 |
|
|
||||||
|---|----------|------|
|
|
||||||
| 9 | 学习趋势 | 短期可视化,非核心分析 |
|
|
||||||
| 10 | 知识导入 | 独立功能链 |
|
|
||||||
| 11 | RAG 聊天 | 独立系统,非 Job 模型 |
|
|
||||||
| 12 | 向量 Rerank | 独立管道 |
|
|
||||||
| 13 | Admin Chat | Admin 工具 |
|
|
||||||
|
|
||||||
### 待退役(2)
|
|
||||||
|
|
||||||
| # | Workflow | 何时退役 |
|
|
||||||
|---|----------|---------|
|
|
||||||
| 15 | Legacy AiAnalysisWorker | M-AI-09 Heavy Runtime 退场 |
|
|
||||||
| 16 | ReviewCardSubscriber (Legacy) | M-AI-09 Heavy Runtime 退场 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、Legacy 保留清单与退役前置条件
|
|
||||||
|
|
||||||
### 当前保留的 Legacy 链路
|
|
||||||
|
|
||||||
| Legacy 组件 | 当前用途 | Unified 替代 | 默认流量 | Feature Flag | 退役前置条件 |
|
|
||||||
|------------|---------|-------------|---------|-------------|------------|
|
|
||||||
| AiAnalysisWorker | active-recall / feynman-evaluation 旧队列 | ActiveRecallExecutor / FeynmanExecutor | Legacy 默认 | ACTIVE_RECALL_ENGINE_MODE / FEYNMAN_ENGINE_MODE | Unified 生产 100%,Legacy 流量=0 |
|
|
||||||
| ReviewCardSubscriber | ai.analysis.completed 事件 → ReviewCard 生成 | ReviewCardGenerationExecutor (Child Job) | Legacy 默认 | FEYNMAN_REVIEW_CARD_MODE | 同上 |
|
|
||||||
| RuntimeInternalService | learning_state/weak_point/next_action 轮询 | LearningAnalysisExecutor | Legacy 默认 | LEARNING_ANALYSIS_ENGINE_MODE | 同上 |
|
|
||||||
| External Runtime | quiz_generation 轮询 | QuizGenerationExecutor | Legacy 默认 | QUIZ_GENERATION_ENGINE_MODE | 同上 |
|
|
||||||
|
|
||||||
**所有 Legacy 组件当前不得删除。** 必须满足 M-AI-RETIRE Gate 全部条件后方可退役。
|
|
||||||
|
|
||||||
### 退役前置条件(冻结)
|
|
||||||
|
|
||||||
```text
|
|
||||||
- Unified 生产流量达到 100%
|
|
||||||
- 连续观察期内无 P0/P1
|
|
||||||
- 失败率在允许范围
|
|
||||||
- 无双执行 / 无重复产物 / 无重复扣费
|
|
||||||
- 无跨用户事件
|
|
||||||
- 成本符合预算
|
|
||||||
- 所有 Flag 可回滚
|
|
||||||
- Legacy 流量为 0
|
|
||||||
- Legacy Queue 无新任务
|
|
||||||
- 历史 Legacy Job 已处理完成
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、闭环判定
|
|
||||||
|
|
||||||
### 核心生产 AI Workflow 状态
|
|
||||||
|
|
||||||
```text
|
|
||||||
已迁移到 Unified Engine: 6 个
|
|
||||||
M-AI-08 范围内(本批): 4 个
|
|
||||||
明确延期(非阻塞): 5 个
|
|
||||||
待退役(Legacy 双路径): 2 个
|
|
||||||
─────────────────────────────────
|
|
||||||
总计: 17 个
|
|
||||||
```
|
|
||||||
|
|
||||||
### 判定
|
|
||||||
|
|
||||||
```
|
|
||||||
M-AI-08 是否为最后一个核心业务迁移里程碑:是
|
|
||||||
|
|
||||||
理由:
|
|
||||||
- M-AI-08 覆盖剩余 4 个核心生产 AI Workflow(均通过 Runtime 轮询)
|
|
||||||
- 延期的 5 个为非核心/独立系统,不阻塞业务迁移主线
|
|
||||||
- Legacy 双路径(Active Recall/Feynman)在 Engine 层面已完成迁移,
|
|
||||||
仅待 M-AI-09 退役旧 Worker
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、未登记模型调用检查
|
|
||||||
|
|
||||||
| 检查项 | 状态 | 证据 |
|
|
||||||
|--------|:--:|------|
|
|
||||||
| 是否存在未登记的生产模型调用 | **否** | 全部 17 个 Workflow 已盘点 |
|
|
||||||
| 是否存在 Controller 直连 Provider | **否** | 所有调用通过 AiGateway 或外部 Runtime |
|
|
||||||
| 是否存在 Service 绕过 AiGateway | **仅 Admin Chat** | Admin 工具,非生产用户路径 |
|
|
||||||
| 是否存在生产 Worker 绕过统一 Job | **Runtime 4 个** | 本批 M-AI-08 迁移 |
|
|
||||||
| 是否存在无主人的 Legacy AI Queue | **ai-analysis** | 仍有双路径,M-AI-09 退役 |
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
# M-MEMBER-01 Migration 证据
|
|
||||||
|
|
||||||
> 生成时间:2026-06-27
|
|
||||||
> 本文件记录 M-MEMBER-01 相关 Migration 的本地验证证据。
|
|
||||||
> 禁止写入真实密码和密钥。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 环境
|
|
||||||
|
|
||||||
```text
|
|
||||||
OS: macOS 26.5.1
|
|
||||||
Node.js: v26.0.0
|
|
||||||
Prisma: 5.22.0
|
|
||||||
MySQL: 8.0.46 (Docker)
|
|
||||||
Redis: 7.4.9 (Docker)
|
|
||||||
Commit: 87317e0
|
|
||||||
```
|
|
||||||
|
|
||||||
## 1. Prisma Schema 验证
|
|
||||||
|
|
||||||
```text
|
|
||||||
PRISMA-VALIDATE = PASS
|
|
||||||
PRISMA-GENERATE = PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
Schema 文件 `prisma/schema.prisma` 通过 validate,Prisma Client 生成成功。
|
|
||||||
|
|
||||||
## 2. 空库 Migration
|
|
||||||
|
|
||||||
- 创建空数据库 `zhixi_migration_test`
|
|
||||||
- 执行 `npx prisma migrate deploy`
|
|
||||||
- 33 个 Migration 全部执行,耗时 4 秒
|
|
||||||
- 生成 75 张表
|
|
||||||
- 失败数量:0
|
|
||||||
|
|
||||||
```text
|
|
||||||
MIGRATION-EMPTY = PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 现有库升级
|
|
||||||
|
|
||||||
- 现有数据库 `zhixi_prod`(从生产同步,63 张表)
|
|
||||||
- 执行 `npx prisma migrate deploy`
|
|
||||||
- 结果:No pending migrations to apply
|
|
||||||
- 用户数据不变(7 用户,5 知识库,5 知识点,5 学习会话)
|
|
||||||
|
|
||||||
```text
|
|
||||||
MIGRATION-LOCAL-UPGRADE = PASS(无需升级)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 已知差异
|
|
||||||
|
|
||||||
生产同步数据库中 `_prisma_migrations` 记录包含 M-MEMBER-01 的 3 个迁移,但实际表结构未包含对应列(如 `User.appAccountToken`、`StoreProduct`)。空库 Migration 证明 Schema 和 Migration 本身正确。差异原因为生产服务器 Migration 记录与物理表不同步,需在生产端修复。
|
|
||||||
|
|
||||||
## 4. appAccountToken 验证
|
|
||||||
|
|
||||||
生产同步库中 `User.appAccountToken` 列不存在(同上述差异)。空库 Migration 验证:
|
|
||||||
- 列定义:`appAccountToken CHAR(36) UNIQUE NULL`
|
|
||||||
- UUID 格式约束由应用层实现
|
|
||||||
- UNIQUE 约束通过重复插入测试确认生效
|
|
||||||
|
|
||||||
```text
|
|
||||||
APP-ACCOUNT-TOKEN = PASS(空库 Migration 验证通过)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Seed 幂等性
|
|
||||||
|
|
||||||
- AdminUser upsert 通过(两次 seed 执行后 ID 不变)
|
|
||||||
- AdminApiKey 创建因表不存在跳过(生产同步差异)
|
|
||||||
- MembershipPlan / StoreProduct / PlanQuota 表在空库 Migration 中存在,生产同步库中不存在
|
|
||||||
|
|
||||||
```text
|
|
||||||
SEED-IDEMPOTENCY = PASS(AdminUser upsert 确认幂等)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 数据库约束
|
|
||||||
|
|
||||||
空库 Migration 库中验证:
|
|
||||||
|
|
||||||
| 约束 | 结果 |
|
|
||||||
|------|------|
|
|
||||||
| User.appAccountToken UNIQUE | ✅ 重复插入被拒绝 |
|
|
||||||
| StoreProduct(platform, productId) UNIQUE | ✅ 由 @@unique 定义 |
|
|
||||||
| PlanQuota(planId, quotaType) UNIQUE | ✅ 由 @@unique 定义 |
|
|
||||||
| UserMembership.originalTransactionId UNIQUE | ✅ 由 schema 定义 |
|
|
||||||
| AppStoreTransaction.transactionId UNIQUE | ✅ 由 schema 定义 |
|
|
||||||
| AppStoreNotification.notificationUuid UNIQUE | ✅ 由 schema 定义 |
|
|
||||||
|
|
||||||
```text
|
|
||||||
DATABASE-CONSTRAINTS = PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 服务健康
|
|
||||||
|
|
||||||
| 检查项 | 结果 |
|
|
||||||
|--------|------|
|
|
||||||
| API Health | ✅ status: ok |
|
|
||||||
| MySQL Connection | ✅ connected |
|
|
||||||
| Redis Connection | ✅ connected |
|
|
||||||
| Worker Bootstrap | ✅ waiting for jobs |
|
|
||||||
| BullMQ Connection | ✅ 队列可见 |
|
|
||||||
| RAG Internal API | ✅ 200 OK |
|
|
||||||
| API 重启恢复 | ✅ |
|
|
||||||
| Worker 重启恢复 | ✅ |
|
|
||||||
|
|
||||||
```text
|
|
||||||
API-HEALTH = PASS
|
|
||||||
WORKER-HEALTH = PASS
|
|
||||||
REDIS-HEALTH = PASS
|
|
||||||
RAG-HEALTH = PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. M-MEMBER-01 相关迁移列表
|
|
||||||
|
|
||||||
| Migration | 内容 |
|
|
||||||
|-----------|------|
|
|
||||||
| `20260624000001_m_member_01_02_expand_schema` | MembershipPlan, StoreProduct, User.appAccountToken, 会员相关模型 |
|
|
||||||
| `20260624000002_m_member_01_03_transaction_notification` | AppStoreTransaction, AppStoreNotification |
|
|
||||||
| `20260624000003_m_member_01_07_quotausage_unique` | QuotaUsage 唯一约束 (operationId+resource) |
|
|
||||||
|
|
||||||
## 最终结论
|
|
||||||
|
|
||||||
```text
|
|
||||||
M-MEMBER-01-CLOSE-05A:PASS(本地环境)
|
|
||||||
|
|
||||||
注:生产服务器 Migration 记录与物理表不同步,需单独修复。
|
|
||||||
```
|
|
||||||
@ -1,326 +0,0 @@
|
|||||||
# M-MEMBER-01-CLOSE-06A: 生产 Schema Drift 修复 Runbook
|
|
||||||
|
|
||||||
> 生成时间:2026-06-27
|
|
||||||
> 目标服务器:120.53.227.155 (蜂驰云 8核32G)
|
|
||||||
> 数据库:zhixi_prod (MySQL 8.0.46 Docker)
|
|
||||||
>
|
|
||||||
> ⚠️ 生产执行前必须:全量备份 mysqldump
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、Drift 现状
|
|
||||||
|
|
||||||
生产 DB 虽然 `_prisma_migrations` 有 33 条记录(含 M-MEMBER-01 的 3 个迁移),但物理 schema 不匹配:
|
|
||||||
|
|
||||||
| 缺失项 | 迁移 |
|
|
||||||
|--------|------|
|
|
||||||
| `User.appAccountToken` (CHAR(36) UNIQUE) | `20260624000001_m_member_01_02_expand_schema` |
|
|
||||||
| `MembershipPlan.monthlyQuizCount` (INT) | 同上 |
|
|
||||||
| `StoreProduct` 表 | 同上 |
|
|
||||||
| `UserMembership` 扩展列 (source, status, originalTransactionId, transactionId, environment 等) | 同上 |
|
|
||||||
| `PlanQuota` 表 | 同上 |
|
|
||||||
| `AppStoreTransaction` 表 | `20260624000002_m_member_01_03_transaction_notification` |
|
|
||||||
| `AppStoreNotification` 表 | 同上 |
|
|
||||||
| `QuotaUsage.resource` UNIQUE 索引 | `20260624000003_m_member_01_07_quotausage_unique` |
|
|
||||||
|
|
||||||
本地环境已通过空库 Migration 验证 Migration SQL 本身正确(33 迁移 → 75 表)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、修复前检查清单
|
|
||||||
|
|
||||||
- [ ] **全量备份**:`docker exec mysql mysqldump -uroot -p... --single-transaction --routines --triggers zhixi_prod | gzip > /data/backups/pre_drift_fix_$(date +%Y%m%d_%H%M%S).sql.gz`
|
|
||||||
- [ ] **验证备份**:`gunzip -c backup.sql.gz | head -20`
|
|
||||||
- [ ] **确认无活跃事务**:`SHOW PROCESSLIST;`
|
|
||||||
- [ ] **停止 API/Worker**:`systemctl stop zhixi-nest-api.service zhixi-nest-worker.service`
|
|
||||||
- [ ] **通知相关人员**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、应用 Migration SQL(逐条执行,每步验证)
|
|
||||||
|
|
||||||
### Step 1: User.appAccountToken
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE `User`
|
|
||||||
ADD COLUMN `appAccountToken` CHAR(36) NULL,
|
|
||||||
ADD UNIQUE INDEX `User_appAccountToken_key` (`appAccountToken`);
|
|
||||||
```
|
|
||||||
|
|
||||||
验证:
|
|
||||||
```sql
|
|
||||||
SHOW COLUMNS FROM User LIKE 'appAccount%';
|
|
||||||
-- 应有 appAccountToken CHAR(36) UNIQUE
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: MembershipPlan.monthlyQuizCount
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE `MembershipPlan`
|
|
||||||
ADD COLUMN `monthlyQuizCount` INT NOT NULL DEFAULT 0;
|
|
||||||
```
|
|
||||||
|
|
||||||
验证:
|
|
||||||
```sql
|
|
||||||
SHOW COLUMNS FROM MembershipPlan LIKE 'monthlyQuiz%';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: StoreProduct 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE `StoreProduct` (
|
|
||||||
`id` VARCHAR(191) NOT NULL,
|
|
||||||
`platform` VARCHAR(16) NOT NULL,
|
|
||||||
`productId` VARCHAR(100) NOT NULL,
|
|
||||||
`planId` VARCHAR(191) NOT NULL,
|
|
||||||
`billingPeriod` VARCHAR(16) NOT NULL,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE INDEX `StoreProduct_platform_productId_key` (`platform`, `productId`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
```
|
|
||||||
|
|
||||||
验证:
|
|
||||||
```sql
|
|
||||||
SHOW TABLES LIKE 'StoreProduct';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: UserMembership 扩展
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE `UserMembership`
|
|
||||||
ADD COLUMN `source` VARCHAR(32) NOT NULL DEFAULT 'admin',
|
|
||||||
ADD COLUMN `status` VARCHAR(32) NOT NULL DEFAULT 'active',
|
|
||||||
ADD COLUMN `productId` VARCHAR(100) NULL,
|
|
||||||
ADD COLUMN `originalTransactionId` VARCHAR(255) NULL,
|
|
||||||
ADD COLUMN `transactionId` VARCHAR(255) NULL,
|
|
||||||
ADD COLUMN `environment` VARCHAR(16) NULL,
|
|
||||||
ADD COLUMN `purchaseDate` DATETIME(3) NULL,
|
|
||||||
ADD COLUMN `expiresDate` DATETIME(3) NULL,
|
|
||||||
ADD COLUMN `gracePeriodExpiresAt` DATETIME(3) NULL,
|
|
||||||
ADD COLUMN `autoRenewStatus` VARCHAR(32) NULL,
|
|
||||||
ADD COLUMN `revocationDate` DATETIME(3) NULL,
|
|
||||||
ADD COLUMN `revocationReason` VARCHAR(64) NULL,
|
|
||||||
ADD COLUMN `ownershipType` VARCHAR(32) NULL,
|
|
||||||
ADD COLUMN `appAccountToken` VARCHAR(255) NULL,
|
|
||||||
ADD COLUMN `serverNotificationId` VARCHAR(191) NULL,
|
|
||||||
ADD UNIQUE INDEX `UserMembership_originalTransactionId_key` (`originalTransactionId`),
|
|
||||||
ADD UNIQUE INDEX `UserMembership_transactionId_key` (`transactionId`),
|
|
||||||
ADD INDEX `UserMembership_expiresDate_idx` (`expiresDate`),
|
|
||||||
ADD INDEX `UserMembership_source_idx` (`source`),
|
|
||||||
ADD INDEX `UserMembership_status_idx` (`status`);
|
|
||||||
```
|
|
||||||
|
|
||||||
验证:
|
|
||||||
```sql
|
|
||||||
SHOW COLUMNS FROM UserMembership LIKE 'source';
|
|
||||||
SHOW COLUMNS FROM UserMembership LIKE 'status';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: PlanQuota 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE `PlanQuota` (
|
|
||||||
`id` VARCHAR(191) NOT NULL,
|
|
||||||
`planId` VARCHAR(191) NOT NULL,
|
|
||||||
`quotaType` VARCHAR(64) NOT NULL,
|
|
||||||
`limit` INT NOT NULL DEFAULT 0,
|
|
||||||
`unit` VARCHAR(32) NOT NULL,
|
|
||||||
`windowType` VARCHAR(32) NOT NULL,
|
|
||||||
`isUnlimited` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE INDEX `PlanQuota_planId_quotaType_key` (`planId`, `quotaType`),
|
|
||||||
INDEX `PlanQuota_quotaType_idx` (`quotaType`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
```
|
|
||||||
|
|
||||||
验证:
|
|
||||||
```sql
|
|
||||||
SHOW TABLES LIKE 'PlanQuota';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6: AppStoreTransaction 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE `AppStoreTransaction` (
|
|
||||||
`id` VARCHAR(191) NOT NULL,
|
|
||||||
`userId` VARCHAR(191) NOT NULL,
|
|
||||||
`membershipId` VARCHAR(191) NULL,
|
|
||||||
`productId` VARCHAR(100) NOT NULL,
|
|
||||||
`transactionId` VARCHAR(255) NOT NULL,
|
|
||||||
`originalTransactionId` VARCHAR(255) NOT NULL,
|
|
||||||
`appAccountToken` VARCHAR(255) NOT NULL,
|
|
||||||
`environment` VARCHAR(16) NOT NULL,
|
|
||||||
`purchaseDate` DATETIME(3) NOT NULL,
|
|
||||||
`originalPurchaseDate` DATETIME(3) NOT NULL,
|
|
||||||
`expiresDate` DATETIME(3) NULL,
|
|
||||||
`revocationDate` DATETIME(3) NULL,
|
|
||||||
`revocationReason` VARCHAR(64) NULL,
|
|
||||||
`ownershipType` VARCHAR(32) NULL,
|
|
||||||
`signedDate` DATETIME(3) NOT NULL,
|
|
||||||
`transactionReason` VARCHAR(64) NULL,
|
|
||||||
`webOrderLineItemId` VARCHAR(255) NULL,
|
|
||||||
`rawPayloadHash` CHAR(64) NOT NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE INDEX `AppStoreTransaction_transactionId_key` (`transactionId`),
|
|
||||||
INDEX `AppStoreTransaction_originalTransactionId_idx` (`originalTransactionId`),
|
|
||||||
INDEX `AppStoreTransaction_userId_idx` (`userId`),
|
|
||||||
INDEX `AppStoreTransaction_productId_idx` (`productId`),
|
|
||||||
INDEX `AppStoreTransaction_expiresDate_idx` (`expiresDate`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
```
|
|
||||||
|
|
||||||
验证:
|
|
||||||
```sql
|
|
||||||
SHOW TABLES LIKE 'AppStoreTransaction';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 7: AppStoreNotification 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE `AppStoreNotification` (
|
|
||||||
`id` VARCHAR(191) NOT NULL,
|
|
||||||
`notificationUuid` VARCHAR(255) NOT NULL,
|
|
||||||
`notificationType` VARCHAR(64) NOT NULL,
|
|
||||||
`subtype` VARCHAR(64) NULL,
|
|
||||||
`environment` VARCHAR(16) NOT NULL,
|
|
||||||
`signedDate` DATETIME(3) NOT NULL,
|
|
||||||
`originalTransactionId` VARCHAR(255) NULL,
|
|
||||||
`transactionId` VARCHAR(255) NULL,
|
|
||||||
`userId` VARCHAR(191) NULL,
|
|
||||||
`processedStatus` VARCHAR(32) NOT NULL DEFAULT 'received',
|
|
||||||
`processingStartedAt` DATETIME(3) NULL,
|
|
||||||
`processedAt` DATETIME(3) NULL,
|
|
||||||
`retryCount` INT NOT NULL DEFAULT 0,
|
|
||||||
`nextRetryAt` DATETIME(3) NULL,
|
|
||||||
`errorCode` VARCHAR(64) NULL,
|
|
||||||
`errorMessage` VARCHAR(1000) NULL,
|
|
||||||
`payloadHash` CHAR(64) NOT NULL,
|
|
||||||
`payloadObjectKey` VARCHAR(500) NULL,
|
|
||||||
`payloadExpiresAt` DATETIME(3) NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE INDEX `AppStoreNotification_notificationUuid_key` (`notificationUuid`),
|
|
||||||
INDEX `AppStoreNotification_originalTransactionId_idx` (`originalTransactionId`),
|
|
||||||
INDEX `AppStoreNotification_notificationType_idx` (`notificationType`),
|
|
||||||
INDEX `AppStoreNotification_processedStatus_idx` (`processedStatus`),
|
|
||||||
INDEX `AppStoreNotification_createdAt_idx` (`createdAt`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
```
|
|
||||||
|
|
||||||
验证:
|
|
||||||
```sql
|
|
||||||
SHOW TABLES LIKE 'AppStoreNotification';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 8: QuotaUsage.resource UNIQUE 约束
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE `QuotaUsage`
|
|
||||||
ADD UNIQUE INDEX `QuotaUsage_resource_key` (`resource`);
|
|
||||||
```
|
|
||||||
|
|
||||||
验证:
|
|
||||||
```sql
|
|
||||||
SHOW INDEXES FROM QuotaUsage WHERE Key_name = 'QuotaUsage_resource_key';
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、迁移后验证
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 表数: 应为 68+(含 UploadSession)
|
|
||||||
SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='zhixi_prod';
|
|
||||||
|
|
||||||
-- 关键表存在
|
|
||||||
SELECT TABLE_NAME FROM information_schema.tables WHERE table_schema='zhixi_prod'
|
|
||||||
AND TABLE_NAME IN ('AppStoreTransaction','AppStoreNotification','PlanQuota','StoreProduct')
|
|
||||||
ORDER BY TABLE_NAME;
|
|
||||||
|
|
||||||
-- appAccountToken 列
|
|
||||||
SHOW COLUMNS FROM User LIKE 'appAccount%';
|
|
||||||
|
|
||||||
-- UserMembership 扩展
|
|
||||||
SELECT COLUMN_NAME FROM information_schema.columns
|
|
||||||
WHERE table_schema='zhixi_prod' AND table_name='UserMembership'
|
|
||||||
AND column_name IN ('source','status','originalTransactionId','transactionId');
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、数据回填
|
|
||||||
|
|
||||||
### appAccountToken 回填
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 1. 先确认 NULL 数量
|
|
||||||
SELECT COUNT(*) FROM User WHERE appAccountToken IS NULL;
|
|
||||||
-- 预期: 7
|
|
||||||
|
|
||||||
-- 2. 回填
|
|
||||||
UPDATE User SET appAccountToken = UUID() WHERE appAccountToken IS NULL;
|
|
||||||
|
|
||||||
-- 3. 验证
|
|
||||||
SELECT COUNT(*) FROM User WHERE appAccountToken IS NULL; -- 0
|
|
||||||
SELECT COUNT(*) FROM User WHERE appAccountToken IS NOT NULL; -- 7
|
|
||||||
SELECT COUNT(*) FROM (SELECT appAccountToken, COUNT(*) AS cnt FROM User GROUP BY appAccountToken HAVING cnt > 1) AS d; -- 0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Seed 数据
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 上传 seed 文件到服务器
|
|
||||||
scp -i PJPZ/zhixi.pem prisma/seed-membership.sql ubuntu@120.53.227.155:/tmp/
|
|
||||||
|
|
||||||
# 执行
|
|
||||||
ssh ubuntu@120.53.227.155 "docker exec -i mysql mysql -uroot -p'...' zhixi_prod < /tmp/seed-membership.sql"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、恢复服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl start zhixi-nest-api.service
|
|
||||||
systemctl start zhixi-nest-worker.service
|
|
||||||
|
|
||||||
# 等待启动
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
# 检查健康
|
|
||||||
curl http://127.0.0.1:3000/health
|
|
||||||
curl http://127.0.0.1:3000/api/membership/plans
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、回滚预案
|
|
||||||
|
|
||||||
如果迁移失败,恢复备份:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 停止服务
|
|
||||||
systemctl stop zhixi-nest-api.service zhixi-nest-worker.service
|
|
||||||
|
|
||||||
# 恢复备份
|
|
||||||
gunzip -c /data/backups/pre_drift_fix_*.sql.gz | docker exec -i mysql mysql -uroot -p'...' zhixi_prod
|
|
||||||
|
|
||||||
# 重启
|
|
||||||
systemctl start zhixi-nest-api.service zhixi-nest-worker.service
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、已知限制
|
|
||||||
|
|
||||||
| 限制 | 说明 | 后续处理 |
|
|
||||||
|------|------|----------|
|
|
||||||
| PlanQuota.limit 是 INT(最大 2GB) | 10GB 存储配额无法在 PlanQuota.limit 中表达 | 后续迁移改为 BIGINT |
|
|
||||||
| UploadSession 无 migration | 表在 schema.prisma 中但无对应 migration 文件 | 需创建 migration |
|
|
||||||
| MembershipPlan 部分列无迁移新增 | monthlyQuizCount 等通过此 Runbook 补充 | 后续统一 migration |
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
# API-AI-R01: resolveSnapshot 并发竞争
|
|
||||||
|
|
||||||
## 基本信息
|
|
||||||
|
|
||||||
| 字段 | 值 |
|
|
||||||
|------|-----|
|
|
||||||
| Issue ID | API-AI-R01 |
|
|
||||||
| 类型 | Non-blocking / 优化 |
|
|
||||||
| 仓库 | api-server |
|
|
||||||
| 关联 Issue | API-AI-016 (Snapshot Builder) |
|
|
||||||
| 发现日期 | 2026-06-17 |
|
|
||||||
| 优先级 | P2 |
|
|
||||||
|
|
||||||
## 问题描述
|
|
||||||
|
|
||||||
`runtime-internal.service.ts` 中的 `resolveSnapshot()` 存在并发竞态窗口。
|
|
||||||
|
|
||||||
### 场景
|
|
||||||
|
|
||||||
两个 Runtime 实例同时对同一个 job 调用 `getSnapshot()`,且 job 当前没有有效 snapshot(未生成或已过期):
|
|
||||||
|
|
||||||
```
|
|
||||||
时间线 →
|
|
||||||
|
|
||||||
实例 A 实例 B
|
|
||||||
│ │
|
|
||||||
├─ resolveSnapshot(job) │
|
|
||||||
│ snapshotId=null → 进 else │
|
|
||||||
│ ├─ resolveSnapshot(job)
|
|
||||||
│ │ snapshotId=null → 进 else
|
|
||||||
│ │
|
|
||||||
├─ buildSnapshot() → snap-A │
|
|
||||||
│ ├─ buildSnapshot() → snap-B
|
|
||||||
│ │
|
|
||||||
├─ job.update(snapshotId=A) │
|
|
||||||
│ ├─ job.update(snapshotId=B) ← 覆盖 A
|
|
||||||
```
|
|
||||||
|
|
||||||
### 后果
|
|
||||||
|
|
||||||
1. 数据库产生 snap-A 孤儿行(无 job 引用)
|
|
||||||
2. 浪费一次全量聚合查询(buildSnapshot)
|
|
||||||
3. snap-A 在 24h TTL 后自动过期清理
|
|
||||||
|
|
||||||
### 为什么当前影响可接受
|
|
||||||
|
|
||||||
- 不会丢数据或返回错误
|
|
||||||
- snapshot 构建是幂等的,两份结果一致性高
|
|
||||||
- 触发条件苛刻:两个 Runtime 实例需同时 poll 到同一个 job(poll 时有 lock 机制大幅降低概率)
|
|
||||||
- 即使发生,额外开销仅为一次聚合查询
|
|
||||||
|
|
||||||
## 建议修复方案
|
|
||||||
|
|
||||||
方案:对 job 行加悲观锁后再判断 snapshot 状态。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// resolveSnapshot 改为:
|
|
||||||
private async resolveSnapshot(job) {
|
|
||||||
// SELECT ... FOR UPDATE 锁住 job 行
|
|
||||||
const locked = await this.prisma.aiRuntimeJob.findUnique({
|
|
||||||
where: { id: job.id },
|
|
||||||
// Prisma 不直接支持 FOR UPDATE,需用 $queryRaw
|
|
||||||
});
|
|
||||||
|
|
||||||
if (locked.snapshotId) {
|
|
||||||
const existing = await this.prisma.learningAnalysisSnapshot.findUnique({
|
|
||||||
where: { id: locked.snapshotId },
|
|
||||||
});
|
|
||||||
if (existing && (!existing.expiresAt || new Date(existing.expiresAt) >= new Date())) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = await this.snapshotBuilder.buildSnapshot(...);
|
|
||||||
await this.prisma.aiRuntimeJob.update({
|
|
||||||
where: { id: job.id },
|
|
||||||
data: { snapshotId: snapshot.id },
|
|
||||||
});
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
或者使用 `$transaction` 包裹读-判断-写逻辑,依赖数据库隔离级别保护。
|
|
||||||
|
|
||||||
## 相关文件
|
|
||||||
|
|
||||||
- `src/modules/ai-runtime/internal/runtime-internal.service.ts:resolveSnapshot()`
|
|
||||||
- `src/modules/ai-runtime/snapshot-builder.service.ts:buildSnapshot()`
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
# API-AI-R02: sourceDataVersion 增强
|
|
||||||
|
|
||||||
## 基本信息
|
|
||||||
|
|
||||||
| 字段 | 值 |
|
|
||||||
|------|-----|
|
|
||||||
| Issue ID | API-AI-R02 |
|
|
||||||
| 类型 | Non-blocking / 增强 |
|
|
||||||
| 仓库 | api-server |
|
|
||||||
| 关联 Issue | API-AI-021 (Snapshot 版本化与过期) |
|
|
||||||
| 发现日期 | 2026-06-17 |
|
|
||||||
| 优先级 | P2 |
|
|
||||||
|
|
||||||
## 问题描述
|
|
||||||
|
|
||||||
`LearningAnalysisSnapshot.sourceDataVersion` 字段当前写入固定值 `'1.0'`(`snapshot-builder.service.ts:122`),但缺乏自动递增/校验机制。
|
|
||||||
|
|
||||||
### 当前状态
|
|
||||||
|
|
||||||
- 字段已写入:`sourceDataVersion: SOURCE_DATA_VERSION`(常量 `'1.0'`)
|
|
||||||
- `resolveSnapshot` 已做版本匹配检查:`existing.sourceDataVersion === SOURCE_DATA_VERSION`
|
|
||||||
- 缺少:当聚合逻辑变更时自动检测并递增版本号的能力
|
|
||||||
|
|
||||||
### 期望增强
|
|
||||||
|
|
||||||
当以下任一变更发生时,`SOURCE_DATA_VERSION` 需要手动递增,但目前依赖开发者记忆:
|
|
||||||
|
|
||||||
1. `computeSignals` 信号计算公式变更
|
|
||||||
2. `getScoreWeights` 权重调整
|
|
||||||
3. `classifyMasteryLevel` 分类阈值变更
|
|
||||||
4. `BEHAVIOR_WINDOW_DAYS` / `SCORE_WINDOW_DAYS` 等窗口常量变更
|
|
||||||
5. 聚合查询字段增减
|
|
||||||
|
|
||||||
### 建议方案
|
|
||||||
|
|
||||||
- 方案 A:在 CI 中对信号计算相关文件做 hash,hash 变更时校验是否同步更新了 `SOURCE_DATA_VERSION`
|
|
||||||
- 方案 B:将 `SOURCE_DATA_VERSION` 改为从信号逻辑的语义版本自动推导
|
|
||||||
- 方案 C:在开发流程中增加 checklist,变更信号逻辑时必须更新版本号
|
|
||||||
|
|
||||||
## 相关文件
|
|
||||||
|
|
||||||
- `src/modules/ai-runtime/snapshot-builder.service.ts`
|
|
||||||
- `src/modules/ai-runtime/internal/runtime-internal.service.ts:resolveSnapshot()`
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
# API-OPS-001: 生产环境缺少 INTERNAL_API_KEY 和 CREDENTIAL_ENCRYPTION_KEY
|
|
||||||
|
|
||||||
## 基本信息
|
|
||||||
|
|
||||||
| 字段 | 值 |
|
|
||||||
|------|-----|
|
|
||||||
| Issue ID | API-OPS-001 |
|
|
||||||
| 类型 | Ops / 配置遗漏 |
|
|
||||||
| 仓库 | api-server + devops-projects |
|
|
||||||
| 优先级 | P1 - 阻塞 Heavy Runtime 部署 |
|
|
||||||
| 发现日期 | 2026-06-18 |
|
|
||||||
|
|
||||||
## 问题描述
|
|
||||||
|
|
||||||
8C32G 生产服务器(120.53.227.155)的 `/opt/zhixi/env/.env.production` 缺少两个必需环境变量:
|
|
||||||
|
|
||||||
| 缺失变量 | 用途 | 当前回退 |
|
|
||||||
|----------|------|----------|
|
|
||||||
| `INTERNAL_API_KEY` | Heavy Runtime 调用 Internal API 的鉴权 token | 回退到 `RAG_WORKER_SECRET`(安全边界模糊) |
|
|
||||||
| `CREDENTIAL_ENCRYPTION_KEY` | AES-256-GCM 用户 API Key 加密 | 未配置会导致加密/解密失败 |
|
|
||||||
|
|
||||||
## 后果
|
|
||||||
|
|
||||||
1. **INTERNAL_API_KEY**:当前 InternalAuthGuard 回退到 RAG_WORKER_SECRET,但这是设计缺陷——RAG Worker 和 Heavy Runtime 应使用独立 token
|
|
||||||
2. **CREDENTIAL_ENCRYPTION_KEY**:用户绑定 DeepSeek Key 时,加密会失败(getEncryptionKey() 抛 "CREDENTIAL_ENCRYPTION_KEY not configured")
|
|
||||||
|
|
||||||
## 修复方案
|
|
||||||
|
|
||||||
在 `.env.production` 中添加:
|
|
||||||
|
|
||||||
```env
|
|
||||||
INTERNAL_API_KEY=<生成32位随机字符串>
|
|
||||||
CREDENTIAL_ENCRYPTION_KEY=<生成32字节密钥>
|
|
||||||
```
|
|
||||||
|
|
||||||
并同步更新 docker-compose.yml 中 heavy-runtime 服务的 `RUNTIME_SERVICE_TOKEN` 指向 `INTERNAL_API_KEY`。
|
|
||||||
|
|
||||||
## 相关文件
|
|
||||||
|
|
||||||
- `devops-projects/凭据配置/蜂驰云服务器凭据.md` — 更新环境变量清单
|
|
||||||
- `api-server/docker-compose.yml` — 已正确定义(需同步到生产 env)
|
|
||||||
- `zhixi-heavy-runtime/docs/operations-manual.md` — 已文档化 `RUNTIME_SERVICE_TOKEN`
|
|
||||||
1
rag-worker/.gitignore
vendored
1
rag-worker/.gitignore
vendored
@ -1 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from config import API_BASE_URL, INTERNAL_API_KEY, WORKER_ID
|
|
||||||
|
|
||||||
_auth_headers = {
|
|
||||||
"x-internal-api-key": INTERNAL_API_KEY,
|
|
||||||
"X-Worker-Id": WORKER_ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def get_next_job() -> dict | None:
|
|
||||||
"""获取下一个 QUEUED 导入任务"""
|
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{API_BASE_URL}/internal/rag/jobs/next",
|
|
||||||
headers=_auth_headers,
|
|
||||||
)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
data = resp.json()
|
|
||||||
result = data.get("data") or data
|
|
||||||
if isinstance(result, dict):
|
|
||||||
return result.get("job")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def claim_job(job_id: str) -> bool:
|
|
||||||
"""认领任务"""
|
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{API_BASE_URL}/internal/rag/jobs/{job_id}/claim",
|
|
||||||
headers=_auth_headers,
|
|
||||||
json={"workerId": WORKER_ID},
|
|
||||||
)
|
|
||||||
return resp.status_code in (200, 201)
|
|
||||||
|
|
||||||
|
|
||||||
async def heartbeat(job_id: str) -> bool:
|
|
||||||
"""发送心跳"""
|
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{API_BASE_URL}/internal/rag/jobs/{job_id}/heartbeat",
|
|
||||||
headers=_auth_headers,
|
|
||||||
)
|
|
||||||
return resp.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
async def update_job_status(job_id: str, status: str, data: dict | None = None):
|
|
||||||
"""更新导入任务状态"""
|
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
|
||||||
await client.post(
|
|
||||||
f"{API_BASE_URL}/internal/rag/jobs/{job_id}/status",
|
|
||||||
headers=_auth_headers,
|
|
||||||
json={"status": status, **(data or {})},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def save_chunks(chunks: list[dict]):
|
|
||||||
"""批量保存 KnowledgeChunk"""
|
|
||||||
async with httpx.AsyncClient(timeout=60) as client:
|
|
||||||
await client.post(
|
|
||||||
f"{API_BASE_URL}/internal/rag/chunks",
|
|
||||||
headers=_auth_headers,
|
|
||||||
json={"chunks": chunks},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def save_candidates(
|
|
||||||
user_id: str,
|
|
||||||
kb_id: str,
|
|
||||||
source_id: str,
|
|
||||||
import_id: str,
|
|
||||||
candidates: list[dict],
|
|
||||||
):
|
|
||||||
"""保存候选知识点"""
|
|
||||||
async with httpx.AsyncClient(timeout=60) as client:
|
|
||||||
await client.post(
|
|
||||||
f"{API_BASE_URL}/internal/rag/candidates",
|
|
||||||
headers=_auth_headers,
|
|
||||||
json={
|
|
||||||
"userId": user_id,
|
|
||||||
"knowledgeBaseId": kb_id,
|
|
||||||
"sourceId": source_id,
|
|
||||||
"importId": import_id,
|
|
||||||
"candidates": candidates,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_job_detail(job_id: str) -> dict | None:
|
|
||||||
"""获取任务详情(含 source 信息)"""
|
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{API_BASE_URL}/internal/rag/jobs/{job_id}",
|
|
||||||
headers=_auth_headers,
|
|
||||||
)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
data = resp.json()
|
|
||||||
return data.get("data") or data
|
|
||||||
return None
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
"""候选知识点生成:调用 DeepSeek 分析文本,生成 ImportCandidate"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import httpx
|
|
||||||
from config import DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL
|
|
||||||
|
|
||||||
MAX_CANDIDATES = 30
|
|
||||||
MIN_CANDIDATES = 3
|
|
||||||
CHARS_PER_CANDIDATE = 2000
|
|
||||||
|
|
||||||
_PROMPT = """你是一个学习助手。请分析以下文档内容,提取关键知识点。
|
|
||||||
|
|
||||||
对于每个知识点,请提供:
|
|
||||||
- title: 知识点标题(简洁,不超过 30 字)
|
|
||||||
- summary: 一句话概述(不超过 80 字)
|
|
||||||
- content: 详细解释(基于原文,保持准确)
|
|
||||||
- tags: 2-4 个标签
|
|
||||||
- recallQuestions: 1-2 个主动回忆问题
|
|
||||||
- difficulty: 难度评估(easy/medium/hard)
|
|
||||||
- confidence: 你对这个知识点重要性的置信度(0.0-1.0)
|
|
||||||
|
|
||||||
请以 JSON 数组格式返回,每个元素是一个知识点:
|
|
||||||
```json
|
|
||||||
[{
|
|
||||||
"title": "知识点标题",
|
|
||||||
"summary": "一句话概述",
|
|
||||||
"content": "详细解释...",
|
|
||||||
"tags": ["标签1", "标签2"],
|
|
||||||
"recallQuestions": ["问题1?", "问题2?"],
|
|
||||||
"difficulty": "medium",
|
|
||||||
"confidence": 0.85
|
|
||||||
}]
|
|
||||||
```
|
|
||||||
|
|
||||||
文档内容:
|
|
||||||
{text}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_candidates(text: str) -> list[dict]:
|
|
||||||
"""用 DeepSeek 生成候选知识点"""
|
|
||||||
# 估算生成数量
|
|
||||||
text_len = len(text)
|
|
||||||
expected_count = max(MIN_CANDIDATES, min(MAX_CANDIDATES, text_len // CHARS_PER_CANDIDATE))
|
|
||||||
|
|
||||||
prompt = _PROMPT.replace("{text}", text[:16000]) # 限制上下文长度
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{DEEPSEEK_BASE_URL}/chat/completions",
|
|
||||||
headers={"Authorization": f"Bearer {DEEPSEEK_API_KEY}"},
|
|
||||||
json={
|
|
||||||
"model": DEEPSEEK_MODEL,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": "你是一个专业的学习内容分析师。请始终返回有效的 JSON 数组。"},
|
|
||||||
{"role": "user", "content": prompt},
|
|
||||||
],
|
|
||||||
"temperature": 0.3,
|
|
||||||
"max_tokens": 4096,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
raise RuntimeError(f"DeepSeek API error: {resp.status_code} {resp.text}")
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
raw = data["choices"][0]["message"]["content"]
|
|
||||||
|
|
||||||
# 提取 JSON
|
|
||||||
return _parse_json_response(raw, expected_count)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_response(raw: str, expected_count: int) -> list[dict]:
|
|
||||||
"""从 AI 回复中提取 JSON 数组"""
|
|
||||||
import re
|
|
||||||
|
|
||||||
# 1. 提取 ```json ... ``` 块
|
|
||||||
m = re.search(r"```(?:json)?\s*(.*?)\s*```", raw, re.DOTALL)
|
|
||||||
if m:
|
|
||||||
inner = m.group(1).strip()
|
|
||||||
try:
|
|
||||||
candidates = json.loads(inner)
|
|
||||||
if isinstance(candidates, list):
|
|
||||||
return candidates[:MAX_CANDIDATES]
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 2. 提取 [ ... ] 块(从第一个 [ 到最后一个 ])
|
|
||||||
start = raw.find("[")
|
|
||||||
end = raw.rfind("]")
|
|
||||||
if start != -1 and end != -1 and end > start:
|
|
||||||
try:
|
|
||||||
candidates = json.loads(raw[start:end + 1])
|
|
||||||
if isinstance(candidates, list):
|
|
||||||
return candidates[:MAX_CANDIDATES]
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 3. 直接解析整个回复
|
|
||||||
try:
|
|
||||||
candidates = json.loads(raw)
|
|
||||||
if isinstance(candidates, list):
|
|
||||||
return candidates[:MAX_CANDIDATES]
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
raise ValueError(f"无法解析 AI 候选知识点回复: {raw[:500]}")
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
"""文本切片:递归字符分割 + 中文分句保护"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
from config import CHUNK_SIZE, CHUNK_OVERLAP
|
|
||||||
|
|
||||||
|
|
||||||
# 中文分句模式
|
|
||||||
_CN_SENT_PATTERN = re.compile(
|
|
||||||
r"([。!?;\n]|(?<!\d)\.(?!\d)|!\?|\?!)"
|
|
||||||
)
|
|
||||||
# Markdown 标题
|
|
||||||
_MD_HEADING = re.compile(r"^#{1,6}\s+", re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
def _split_sentences(text: str) -> list[str]:
|
|
||||||
"""按中文标点分句,保留标点在句尾"""
|
|
||||||
parts = _CN_SENT_PATTERN.split(text)
|
|
||||||
sentences = []
|
|
||||||
buf = ""
|
|
||||||
for p in parts:
|
|
||||||
if not p:
|
|
||||||
continue
|
|
||||||
buf += p
|
|
||||||
if _CN_SENT_PATTERN.match(p):
|
|
||||||
sentences.append(buf)
|
|
||||||
buf = ""
|
|
||||||
if buf.strip():
|
|
||||||
sentences.append(buf)
|
|
||||||
return sentences
|
|
||||||
|
|
||||||
|
|
||||||
def _split_by_heading(md_text: str) -> list[dict]:
|
|
||||||
"""按 Markdown 标题分层切片,保留标题作为 sectionTitle"""
|
|
||||||
lines = md_text.split("\n")
|
|
||||||
chunks = []
|
|
||||||
current_title = ""
|
|
||||||
current_text = ""
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
m = _MD_HEADING.match(line)
|
|
||||||
if m:
|
|
||||||
# 保存前一段
|
|
||||||
if current_text.strip():
|
|
||||||
chunks.append({"sectionTitle": current_title, "text": current_text.strip()})
|
|
||||||
current_title = line.strip()
|
|
||||||
current_text = ""
|
|
||||||
else:
|
|
||||||
current_text += line + "\n"
|
|
||||||
|
|
||||||
if current_text.strip():
|
|
||||||
chunks.append({"sectionTitle": current_title, "text": current_text.strip()})
|
|
||||||
|
|
||||||
return chunks if chunks else [{"sectionTitle": "", "text": md_text}]
|
|
||||||
|
|
||||||
|
|
||||||
def _estimate_tokens(text: str) -> int:
|
|
||||||
"""粗略估算 token 数量(中文按字符数,英文按词数)"""
|
|
||||||
cn_chars = len(re.findall(r"[一-鿿]", text))
|
|
||||||
en_words = len(re.findall(r"[a-zA-Z]+", text))
|
|
||||||
# 中文约 1.5 字符/token,英文约 1 词/token
|
|
||||||
return int(cn_chars / 1.5) + en_words
|
|
||||||
|
|
||||||
|
|
||||||
def _chunk_text(text: str, section_title: str = "", page_number: int | None = None) -> list[dict]:
|
|
||||||
"""递归分割 + 重叠切块"""
|
|
||||||
sentences = _split_sentences(text)
|
|
||||||
chunks = []
|
|
||||||
buf = ""
|
|
||||||
buf_tokens = 0
|
|
||||||
|
|
||||||
for s in sentences:
|
|
||||||
s_tokens = _estimate_tokens(s)
|
|
||||||
if buf_tokens + s_tokens > CHUNK_SIZE and buf_tokens > 0:
|
|
||||||
chunks.append({"content": buf.strip(), "sectionTitle": section_title, "pageNumber": page_number})
|
|
||||||
# 重叠:保留最后 overlap tokens
|
|
||||||
if CHUNK_OVERLAP > 0:
|
|
||||||
overlap_text = buf[-int(CHUNK_OVERLAP * 2):] # 粗略估算
|
|
||||||
buf = overlap_text + s
|
|
||||||
buf_tokens = _estimate_tokens(overlap_text) + s_tokens
|
|
||||||
else:
|
|
||||||
buf = s
|
|
||||||
buf_tokens = s_tokens
|
|
||||||
else:
|
|
||||||
buf += s
|
|
||||||
buf_tokens += s_tokens
|
|
||||||
|
|
||||||
if buf.strip():
|
|
||||||
chunks.append({"content": buf.strip(), "sectionTitle": section_title, "pageNumber": page_number})
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
def chunk_document(text: str, source_type: str = "text") -> list[dict]:
|
|
||||||
"""
|
|
||||||
对文档进行切片,返回 chunk 列表。
|
|
||||||
每个 chunk: {content, sectionTitle, pageNumber, chunkType}
|
|
||||||
"""
|
|
||||||
if source_type in ("md", "markdown"):
|
|
||||||
sections = _split_by_heading(text)
|
|
||||||
else:
|
|
||||||
sections = [{"sectionTitle": "", "text": text}]
|
|
||||||
|
|
||||||
all_chunks = []
|
|
||||||
for sec in sections:
|
|
||||||
sec_chunks = _chunk_text(sec["text"], section_title=sec.get("sectionTitle", ""))
|
|
||||||
all_chunks.extend(sec_chunks)
|
|
||||||
|
|
||||||
# 添加 chunkType
|
|
||||||
for i, c in enumerate(all_chunks):
|
|
||||||
c["chunkIndex"] = i
|
|
||||||
# 检测表格/代码块
|
|
||||||
content = c["content"]
|
|
||||||
if content.count("|") > 5 and "---" in content:
|
|
||||||
c["chunkType"] = "table"
|
|
||||||
elif content.strip().startswith("```") or "```" in content:
|
|
||||||
c["chunkType"] = "code"
|
|
||||||
else:
|
|
||||||
c["chunkType"] = "text"
|
|
||||||
|
|
||||||
return all_chunks
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# 加载 .env 文件(systemd 可通过 EnvironmentFile 设置,此处作为手动运行的兜底)
|
|
||||||
_dotenv_path = Path(__file__).resolve().parent / ".env"
|
|
||||||
if _dotenv_path.exists():
|
|
||||||
try:
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
load_dotenv(_dotenv_path)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# NestJS 内部 API
|
|
||||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:3000")
|
|
||||||
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", os.getenv("RAG_WORKER_SECRET", ""))
|
|
||||||
|
|
||||||
# SiliconFlow
|
|
||||||
SILICONFLOW_API_KEY = os.getenv("SILICONFLOW_API_KEY", "")
|
|
||||||
SILICONFLOW_BASE_URL = os.getenv("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1")
|
|
||||||
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "BAAI/bge-m3")
|
|
||||||
EMBEDDING_DIM = int(os.getenv("EMBEDDING_DIM", "1024"))
|
|
||||||
RERANK_MODEL = os.getenv("RERANK_MODEL", "BAAI/bge-reranker-v2-m3")
|
|
||||||
|
|
||||||
# DeepSeek
|
|
||||||
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "")
|
|
||||||
DEEPSEEK_BASE_URL = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")
|
|
||||||
DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
|
|
||||||
|
|
||||||
# Qdrant
|
|
||||||
QDRANT_URL = os.getenv("QDRANT_URL", "http://127.0.0.1:6333")
|
|
||||||
QDRANT_COLLECTION = os.getenv("QDRANT_COLLECTION", "zhixi_chunks")
|
|
||||||
|
|
||||||
# Chunking
|
|
||||||
CHUNK_SIZE = int(os.getenv("CHUNK_SIZE", "512"))
|
|
||||||
CHUNK_OVERLAP = int(os.getenv("CHUNK_OVERLAP", "64"))
|
|
||||||
|
|
||||||
# Worker
|
|
||||||
WORKER_ID = os.getenv("WORKER_ID", f"worker-{os.getpid()}")
|
|
||||||
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "5"))
|
|
||||||
HEARTBEAT_INTERVAL = int(os.getenv("HEARTBEAT_INTERVAL", "30"))
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
"""Embedding 服务:调用硅基流动 bge-m3"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import httpx
|
|
||||||
from config import (
|
|
||||||
SILICONFLOW_API_KEY,
|
|
||||||
SILICONFLOW_BASE_URL,
|
|
||||||
EMBEDDING_MODEL,
|
|
||||||
EMBEDDING_DIM,
|
|
||||||
)
|
|
||||||
|
|
||||||
BATCH_SIZE = 50
|
|
||||||
MAX_RETRIES = 2
|
|
||||||
|
|
||||||
|
|
||||||
async def embed_single(text: str) -> list[float]:
|
|
||||||
"""单条文本 embedding"""
|
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{SILICONFLOW_BASE_URL}/embeddings",
|
|
||||||
headers={"Authorization": f"Bearer {SILICONFLOW_API_KEY}"},
|
|
||||||
json={
|
|
||||||
"model": EMBEDDING_MODEL,
|
|
||||||
"input": [text],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
raise RuntimeError(f"Embedding API error: {resp.status_code} {resp.text}")
|
|
||||||
data = resp.json()
|
|
||||||
return data["data"][0]["embedding"]
|
|
||||||
|
|
||||||
|
|
||||||
async def embed_batch(texts: list[str]) -> list[list[float]]:
|
|
||||||
"""批量 embedding,自动分批 + 重试"""
|
|
||||||
all_embeddings = []
|
|
||||||
|
|
||||||
for i in range(0, len(texts), BATCH_SIZE):
|
|
||||||
batch = texts[i:i + BATCH_SIZE]
|
|
||||||
for attempt in range(MAX_RETRIES + 1):
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=60) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{SILICONFLOW_BASE_URL}/embeddings",
|
|
||||||
headers={"Authorization": f"Bearer {SILICONFLOW_API_KEY}"},
|
|
||||||
json={
|
|
||||||
"model": EMBEDDING_MODEL,
|
|
||||||
"input": batch,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
data = resp.json()
|
|
||||||
all_embeddings.extend([d["embedding"] for d in data["data"]])
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
err = f"Status {resp.status_code}"
|
|
||||||
if attempt == MAX_RETRIES:
|
|
||||||
raise RuntimeError(f"Embedding batch failed after {MAX_RETRIES} retries: {err}")
|
|
||||||
except Exception as e:
|
|
||||||
if attempt == MAX_RETRIES:
|
|
||||||
raise RuntimeError(f"Embedding batch failed: {e}")
|
|
||||||
await asyncio.sleep(2 ** attempt)
|
|
||||||
|
|
||||||
return all_embeddings
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
"""Qdrant 索引服务"""
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from config import QDRANT_URL, QDRANT_COLLECTION
|
|
||||||
|
|
||||||
|
|
||||||
async def upsert_points(points: list[dict]):
|
|
||||||
"""批量写入 Qdrant points"""
|
|
||||||
async with httpx.AsyncClient(timeout=60) as client:
|
|
||||||
resp = await client.put(
|
|
||||||
f"{QDRANT_URL}/collections/{QDRANT_COLLECTION}/points",
|
|
||||||
params={"wait": "true"},
|
|
||||||
json={"points": points},
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
raise RuntimeError(f"Qdrant upsert failed: {resp.text}")
|
|
||||||
|
|
||||||
|
|
||||||
async def search(
|
|
||||||
vector: list[float],
|
|
||||||
user_id: str,
|
|
||||||
knowledge_base_id: str,
|
|
||||||
top_k: int = 5,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""语义检索"""
|
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{QDRANT_URL}/collections/{QDRANT_COLLECTION}/points/search",
|
|
||||||
json={
|
|
||||||
"vector": vector,
|
|
||||||
"filter": {
|
|
||||||
"must": [
|
|
||||||
{"key": "userId", "match": {"value": user_id}},
|
|
||||||
{"key": "knowledgeBaseId", "match": {"value": knowledge_base_id}},
|
|
||||||
{"key": "deleted", "match": {"value": False}},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"limit": top_k,
|
|
||||||
"with_payload": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
raise RuntimeError(f"Qdrant search failed: {resp.text}")
|
|
||||||
return resp.json()["result"]
|
|
||||||
|
|
||||||
|
|
||||||
async def mark_deleted(source_id: str):
|
|
||||||
"""将指定 source 的所有 points 标记为 deleted=true"""
|
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
|
||||||
await client.post(
|
|
||||||
f"{QDRANT_URL}/collections/{QDRANT_COLLECTION}/points/update",
|
|
||||||
json={
|
|
||||||
"filter": {
|
|
||||||
"must": [
|
|
||||||
{"key": "sourceId", "match": {"value": source_id}},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"set": {"deleted": True},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
"""知习 RAG Worker — 文档导入主进程"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
from config import WORKER_ID, POLL_INTERVAL, HEARTBEAT_INTERVAL
|
|
||||||
from api_client import get_next_job, claim_job, heartbeat, update_job_status
|
|
||||||
from pipelines.import_pipeline import run_import
|
|
||||||
|
|
||||||
running = True
|
|
||||||
|
|
||||||
|
|
||||||
def shutdown(sig, frame):
|
|
||||||
global running
|
|
||||||
print(f"[{WORKER_ID}] 收到信号 {sig},正在退出...")
|
|
||||||
running = False
|
|
||||||
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, shutdown)
|
|
||||||
signal.signal(signal.SIGTERM, shutdown)
|
|
||||||
|
|
||||||
|
|
||||||
async def heartbeat_loop():
|
|
||||||
"""心跳循环(所有活跃任务)"""
|
|
||||||
# 简化实现:worker 级心跳,后续可扩展到 per-job 心跳
|
|
||||||
while running:
|
|
||||||
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
|
||||||
|
|
||||||
|
|
||||||
async def work_loop():
|
|
||||||
"""主工作循环:轮询 → 认领 → 执行"""
|
|
||||||
print(f"[{WORKER_ID}] RAG Worker 已启动")
|
|
||||||
|
|
||||||
while running:
|
|
||||||
try:
|
|
||||||
job = await get_next_job()
|
|
||||||
if not job:
|
|
||||||
await asyncio.sleep(POLL_INTERVAL)
|
|
||||||
continue
|
|
||||||
|
|
||||||
job_id = job.get("id") or job.get("jobId")
|
|
||||||
if not job_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 认领任务
|
|
||||||
claimed = await claim_job(job_id)
|
|
||||||
if not claimed:
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(f"[{WORKER_ID}] 开始处理任务 {job_id}")
|
|
||||||
|
|
||||||
# 启动心跳(后台任务)
|
|
||||||
hb_task = asyncio.create_task(_per_job_heartbeat(job_id))
|
|
||||||
|
|
||||||
try:
|
|
||||||
await run_import(job)
|
|
||||||
print(f"[{WORKER_ID}] 任务 {job_id} 完成")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[{WORKER_ID}] 任务 {job_id} 失败: {e}")
|
|
||||||
await update_job_status(job_id, "FAILED_RETRYABLE", {
|
|
||||||
"errorMessage": str(e)[:500],
|
|
||||||
})
|
|
||||||
finally:
|
|
||||||
hb_task.cancel()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[{WORKER_ID}] 轮询异常: {e}")
|
|
||||||
await asyncio.sleep(POLL_INTERVAL)
|
|
||||||
|
|
||||||
print(f"[{WORKER_ID}] Worker 已停止")
|
|
||||||
|
|
||||||
|
|
||||||
async def _per_job_heartbeat(job_id: str):
|
|
||||||
"""单个任务的心跳上报"""
|
|
||||||
while running:
|
|
||||||
try:
|
|
||||||
await heartbeat(job_id)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(work_loop())
|
|
||||||
@ -1,137 +0,0 @@
|
|||||||
"""文档解析:PDF / DOCX / TXT / MD / CSV / XLSX"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import io
|
|
||||||
import base64
|
|
||||||
import httpx
|
|
||||||
from config import SILICONFLOW_API_KEY, SILICONFLOW_BASE_URL
|
|
||||||
|
|
||||||
|
|
||||||
async def download_file(url: str, local_path: str) -> str:
|
|
||||||
"""从 COS 预签名 URL 下载文件到本地"""
|
|
||||||
async with httpx.AsyncClient(timeout=120, follow_redirects=True) as client:
|
|
||||||
resp = await client.get(url)
|
|
||||||
resp.raise_for_status()
|
|
||||||
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
|
||||||
with open(local_path, "wb") as f:
|
|
||||||
f.write(resp.content)
|
|
||||||
return local_path
|
|
||||||
|
|
||||||
|
|
||||||
def parse_txt(file_path: str) -> str:
|
|
||||||
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_markdown(file_path: str) -> str:
|
|
||||||
return parse_txt(file_path)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_docx(file_path: str) -> str:
|
|
||||||
from docx import Document
|
|
||||||
doc = Document(file_path)
|
|
||||||
return "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def parse_pdf_text(file_path: str) -> str:
|
|
||||||
"""用 PyMuPDF 提取 PDF 文本层"""
|
|
||||||
import fitz
|
|
||||||
doc = fitz.open(file_path)
|
|
||||||
pages = []
|
|
||||||
for page in doc:
|
|
||||||
text = page.get_text()
|
|
||||||
if text.strip():
|
|
||||||
pages.append(text)
|
|
||||||
doc.close()
|
|
||||||
return "\n\n".join(pages)
|
|
||||||
|
|
||||||
|
|
||||||
def pdf_needs_ocr(file_path: str) -> bool:
|
|
||||||
"""判断 PDF 是否需要 OCR(文本层为空或极少文字)"""
|
|
||||||
import fitz
|
|
||||||
doc = fitz.open(file_path)
|
|
||||||
total_len = sum(len(page.get_text().strip()) for page in doc)
|
|
||||||
doc.close()
|
|
||||||
# 平均每页少于 50 字符 → 扫描件
|
|
||||||
page_count = max(doc.page_count if hasattr(doc, 'page_count') else len(doc), 1)
|
|
||||||
return (total_len / page_count) < 50
|
|
||||||
|
|
||||||
|
|
||||||
async def ocr_with_siliconflow(image_bytes: bytes) -> str:
|
|
||||||
"""用硅基流动多模态模型做 OCR / 图文识别"""
|
|
||||||
b64 = base64.b64encode(image_bytes).decode()
|
|
||||||
async with httpx.AsyncClient(timeout=60) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{SILICONFLOW_BASE_URL}/chat/completions",
|
|
||||||
headers={"Authorization": f"Bearer {SILICONFLOW_API_KEY}"},
|
|
||||||
json={
|
|
||||||
"model": "Qwen/Qwen3-VL-32B-Instruct",
|
|
||||||
"messages": [{
|
|
||||||
"role": "user",
|
|
||||||
"content": [
|
|
||||||
{"type": "text", "text": "请识别并提取这张图片中的所有文字内容。如果有表格,请用 Markdown 表格格式输出。不要添加任何解释。"},
|
|
||||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
"max_tokens": 4096,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
data = resp.json()
|
|
||||||
return data["choices"][0]["message"]["content"]
|
|
||||||
|
|
||||||
|
|
||||||
async def parse_image_with_ocr(file_path: str) -> str:
|
|
||||||
"""对图片进行 OCR"""
|
|
||||||
with open(file_path, "rb") as f:
|
|
||||||
image_bytes = f.read()
|
|
||||||
return await ocr_with_siliconflow(image_bytes)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_csv(file_path: str) -> str:
|
|
||||||
import pandas as pd
|
|
||||||
df = pd.read_csv(file_path)
|
|
||||||
return df.to_markdown(index=False)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_xlsx(file_path: str) -> str:
|
|
||||||
import pandas as pd
|
|
||||||
df = pd.read_excel(file_path)
|
|
||||||
return df.to_markdown(index=False)
|
|
||||||
|
|
||||||
|
|
||||||
async def parse_document(file_path: str, mime_type: str) -> str:
|
|
||||||
"""根据文件类型路由到合适的解析器"""
|
|
||||||
ext = os.path.splitext(file_path)[1].lower()
|
|
||||||
|
|
||||||
if ext in (".txt",):
|
|
||||||
return parse_txt(file_path)
|
|
||||||
elif ext in (".md", ".markdown"):
|
|
||||||
return parse_markdown(file_path)
|
|
||||||
elif ext in (".docx",):
|
|
||||||
return parse_docx(file_path)
|
|
||||||
elif ext in (".csv",):
|
|
||||||
return parse_csv(file_path)
|
|
||||||
elif ext in (".xlsx",):
|
|
||||||
return parse_xlsx(file_path)
|
|
||||||
elif ext in (".pdf",):
|
|
||||||
if pdf_needs_ocr(file_path):
|
|
||||||
# 扫描件——先尝试文本提取,空则走多模态
|
|
||||||
text = parse_pdf_text(file_path)
|
|
||||||
if len(text.strip()) < 100:
|
|
||||||
# 全扫描件,逐页 OCR
|
|
||||||
import fitz
|
|
||||||
doc = fitz.open(file_path)
|
|
||||||
results = []
|
|
||||||
for i, page in enumerate(doc):
|
|
||||||
pix = page.get_pixmap(dpi=150)
|
|
||||||
img_bytes = pix.tobytes("png")
|
|
||||||
page_text = await ocr_with_siliconflow(img_bytes)
|
|
||||||
results.append(page_text)
|
|
||||||
doc.close()
|
|
||||||
return "\n\n".join(results)
|
|
||||||
return text
|
|
||||||
return parse_pdf_text(file_path)
|
|
||||||
elif ext in (".png", ".jpg", ".jpeg", ".webp", ".heic", ".bmp"):
|
|
||||||
return await parse_image_with_ocr(file_path)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"不支持的文件类型: {ext}")
|
|
||||||
@ -1,133 +0,0 @@
|
|||||||
"""导入主流程:下载 → 解析 → 清洗 → 切片 → embedding → Qdrant → AI 候选"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
from parser import download_file, parse_document
|
|
||||||
from chunker import chunk_document
|
|
||||||
from embedder import embed_batch
|
|
||||||
from indexer import upsert_points
|
|
||||||
from candidate_generator import generate_candidates
|
|
||||||
from api_client import (
|
|
||||||
heartbeat as send_heartbeat,
|
|
||||||
update_job_status,
|
|
||||||
save_chunks,
|
|
||||||
save_candidates,
|
|
||||||
get_job_detail,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def run_import(job: dict):
|
|
||||||
"""执行完整的文档导入流程"""
|
|
||||||
job_id = job["id"]
|
|
||||||
source_id = job.get("sourceId") or job.get("source_id")
|
|
||||||
user_id = job["userId"] or job.get("user_id")
|
|
||||||
kb_id = job["knowledgeBaseId"] or job.get("knowledge_base_id")
|
|
||||||
file_id = job.get("fileId") or job.get("file_id")
|
|
||||||
|
|
||||||
if not source_id:
|
|
||||||
raise ValueError(f"任务 {job_id} 缺少 sourceId")
|
|
||||||
|
|
||||||
# 获取 source 详情(从 NestJS)
|
|
||||||
detail = await get_job_detail(job_id)
|
|
||||||
source = (detail or {}).get("source", {}) if detail else {}
|
|
||||||
mime_type = source.get("mimeType") or source.get("mime_type") or "text/plain"
|
|
||||||
original_filename = source.get("originalFilename") or source.get("original_filename") or "unknown"
|
|
||||||
|
|
||||||
tmp_dir = f"/data/tmp/imports/{job_id}"
|
|
||||||
file_path = os.path.join(tmp_dir, original_filename)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 1. 下载文件
|
|
||||||
await update_job_status(job_id, "DOWNLOADING", {"progress": 5})
|
|
||||||
file_url = source.get("downloadUrl") or (detail or {}).get("downloadUrl", "")
|
|
||||||
if file_url:
|
|
||||||
await download_file(file_url, file_path)
|
|
||||||
|
|
||||||
# 2. 解析
|
|
||||||
await update_job_status(job_id, "PARSING", {"progress": 20})
|
|
||||||
text = ""
|
|
||||||
if os.path.exists(file_path):
|
|
||||||
text = await parse_document(file_path, mime_type)
|
|
||||||
|
|
||||||
# 如果文件不在本地(纯文本导入),直接从 source/import 中取文本
|
|
||||||
detail_job = (detail or {}).get("job", {})
|
|
||||||
if not text:
|
|
||||||
text = job.get("rawText") or source.get("rawText") or detail_job.get("rawText") or ""
|
|
||||||
|
|
||||||
if not text or len(text.strip()) < 10:
|
|
||||||
raise ValueError("文档解析后内容过少,可能为空白或损坏文件")
|
|
||||||
|
|
||||||
# 3. 清洗
|
|
||||||
await update_job_status(job_id, "CLEANING", {"progress": 40, "textLength": len(text)})
|
|
||||||
|
|
||||||
# 4. 切片
|
|
||||||
await update_job_status(job_id, "CHUNKING", {"progress": 50})
|
|
||||||
source_type = source.get("type") or "text"
|
|
||||||
chunks = chunk_document(text, source_type)
|
|
||||||
|
|
||||||
# 5. Embedding
|
|
||||||
await update_job_status(job_id, "EMBEDDING", {"progress": 60})
|
|
||||||
texts = [c["content"] for c in chunks]
|
|
||||||
vectors = await embed_batch(texts)
|
|
||||||
|
|
||||||
# 6. Qdrant 索引
|
|
||||||
await update_job_status(job_id, "INDEXING", {"progress": 80})
|
|
||||||
points = []
|
|
||||||
chunk_records = []
|
|
||||||
for i, (chunk, vec) in enumerate(zip(chunks, vectors)):
|
|
||||||
chunk_id = str(uuid.uuid4())
|
|
||||||
points.append({
|
|
||||||
"id": chunk_id,
|
|
||||||
"vector": vec,
|
|
||||||
"payload": {
|
|
||||||
"userId": user_id,
|
|
||||||
"knowledgeBaseId": kb_id,
|
|
||||||
"sourceId": source_id,
|
|
||||||
"chunkId": chunk_id,
|
|
||||||
"pageNumber": chunk.get("pageNumber"),
|
|
||||||
"sectionTitle": chunk.get("sectionTitle", ""),
|
|
||||||
"deleted": False,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
chunk_records.append({
|
|
||||||
"userId": user_id,
|
|
||||||
"knowledgeBaseId": kb_id,
|
|
||||||
"sourceId": source_id,
|
|
||||||
"content": chunk["content"],
|
|
||||||
"chunkIndex": chunk["chunkIndex"],
|
|
||||||
"pageNumber": chunk.get("pageNumber"),
|
|
||||||
"sectionTitle": chunk.get("sectionTitle", ""),
|
|
||||||
"tokenCount": len(chunk["content"]),
|
|
||||||
"externalVectorId": chunk_id,
|
|
||||||
"embeddingModel": "bge-m3",
|
|
||||||
"embeddingStatus": "COMPLETED",
|
|
||||||
"metadataJson": {"chunkType": chunk.get("chunkType", "text")},
|
|
||||||
})
|
|
||||||
|
|
||||||
await upsert_points(points)
|
|
||||||
await save_chunks(chunk_records)
|
|
||||||
|
|
||||||
# 7. 生成候选知识点(非致命:失败不影响导入完成)
|
|
||||||
await update_job_status(job_id, "GENERATING_CANDIDATES", {"progress": 90})
|
|
||||||
try:
|
|
||||||
candidates = await generate_candidates(text)
|
|
||||||
if candidates:
|
|
||||||
await save_candidates(user_id, kb_id, source_id, job_id, candidates)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[worker] 候选知识点生成失败(非致命): {e}")
|
|
||||||
|
|
||||||
# 8. 完成
|
|
||||||
await update_job_status(job_id, "COMPLETED", {"progress": 100})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await update_job_status(job_id, "FAILED_RETRYABLE", {
|
|
||||||
"errorCode": "WORKER_ERROR",
|
|
||||||
"errorMessage": str(e)[:500],
|
|
||||||
})
|
|
||||||
raise
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# 清理临时文件
|
|
||||||
if os.path.exists(tmp_dir):
|
|
||||||
import shutil
|
|
||||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
httpx>=0.27
|
|
||||||
pydantic>=2.0
|
|
||||||
pymupdf>=1.24
|
|
||||||
python-docx>=1.1
|
|
||||||
markdown>=3.5
|
|
||||||
pandas>=2.0
|
|
||||||
openpyxl>=3.1
|
|
||||||
Pillow>=10.0
|
|
||||||
qdrant-client>=1.9
|
|
||||||
python-dotenv>=1.0
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
"""Rerank 服务:调用硅基流动 bge-reranker-v2-m3 对检索结果精排"""
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from config import SILICONFLOW_API_KEY, SILICONFLOW_BASE_URL, RERANK_MODEL
|
|
||||||
|
|
||||||
|
|
||||||
async def rerank(
|
|
||||||
query: str,
|
|
||||||
documents: list[str],
|
|
||||||
top_n: int = 5,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""对候选文档重新打分排序,返回 top_n 结果。
|
|
||||||
|
|
||||||
每个结果包含:
|
|
||||||
- index: 原始 documents 数组中的位置
|
|
||||||
- score: 相关性分数 (0.0-1.0)
|
|
||||||
- text: 文档原文
|
|
||||||
"""
|
|
||||||
if not documents:
|
|
||||||
return []
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{SILICONFLOW_BASE_URL}/rerank",
|
|
||||||
headers={"Authorization": f"Bearer {SILICONFLOW_API_KEY}"},
|
|
||||||
json={
|
|
||||||
"model": RERANK_MODEL,
|
|
||||||
"query": query,
|
|
||||||
"documents": documents,
|
|
||||||
"top_n": min(top_n, len(documents)),
|
|
||||||
"return_documents": True,
|
|
||||||
"max_chunks_per_doc": 1024,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
raise RuntimeError(f"Rerank API error: {resp.status_code} {resp.text}")
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"index": r["index"],
|
|
||||||
"score": r["relevance_score"],
|
|
||||||
"text": r.get("document", {}).get("text", documents[r["index"]]),
|
|
||||||
}
|
|
||||||
for r in data["results"]
|
|
||||||
]
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=ZhiXi RAG Worker
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=ubuntu
|
|
||||||
WorkingDirectory=/opt/zhixi/backend/rag-worker
|
|
||||||
Environment="PYTHONUNBUFFERED=1"
|
|
||||||
EnvironmentFile=/opt/zhixi/backend/rag-worker/.env
|
|
||||||
|
|
||||||
ExecStart=/usr/bin/python3.11 -u main.py
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@ -77,6 +77,7 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
|
|||||||
import { StrictValidationPipe } from './common/pipes/strict-validation.pipe';
|
import { StrictValidationPipe } from './common/pipes/strict-validation.pipe';
|
||||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||||
import { TraceIdInterceptor } from './common/interceptors/trace-id.interceptor';
|
import { TraceIdInterceptor } from './common/interceptors/trace-id.interceptor';
|
||||||
|
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
|
||||||
import { MetricsInterceptor } from './common/interceptors/metrics.interceptor';
|
import { MetricsInterceptor } from './common/interceptors/metrics.interceptor';
|
||||||
import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor';
|
import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor';
|
||||||
import { AppThrottleModule } from './common/throttle/throttle.module';
|
import { AppThrottleModule } from './common/throttle/throttle.module';
|
||||||
@ -188,6 +189,7 @@ import appleConfig from './config/apple.config';
|
|||||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||||
{ provide: APP_PIPE, useClass: StrictValidationPipe },
|
{ provide: APP_PIPE, useClass: StrictValidationPipe },
|
||||||
{ provide: APP_INTERCEPTOR, useClass: TraceIdInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: TraceIdInterceptor },
|
||||||
|
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
|
||||||
{ provide: APP_INTERCEPTOR, useClass: MetricsInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: MetricsInterceptor },
|
||||||
{ provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor },
|
||||||
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export async function enrichWithNames<T extends Record<string, any>>(
|
|||||||
|
|
||||||
const [users, kbs, materials] = await Promise.all([
|
const [users, kbs, materials] = await Promise.all([
|
||||||
userIds.length > 0
|
userIds.length > 0
|
||||||
? prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, nickname: true } })
|
? prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, nickname: true, email: true } })
|
||||||
: [],
|
: [],
|
||||||
kbIds.length > 0
|
kbIds.length > 0
|
||||||
? prisma.knowledgeBase.findMany({ where: { id: { in: kbIds } }, select: { id: true, title: true } })
|
? prisma.knowledgeBase.findMany({ where: { id: { in: kbIds } }, select: { id: true, title: true } })
|
||||||
@ -26,7 +26,7 @@ export async function enrichWithNames<T extends Record<string, any>>(
|
|||||||
: [],
|
: [],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const userMap = new Map<string, string>(users.map(u => [u.id, u.nickname || u.id] as [string, string]));
|
const userMap = new Map<string, string>(users.map(u => [u.id, u.nickname || u.email || u.id] as [string, string]));
|
||||||
const kbMap = new Map<string, string>(kbs.map(k => [k.id, k.title] as [string, string]));
|
const kbMap = new Map<string, string>(kbs.map(k => [k.id, k.title] as [string, string]));
|
||||||
const matMap = new Map<string, string>(materials.map(m => [m.id, m.originalFilename || m.title || m.id] as [string, string]));
|
const matMap = new Map<string, string>(materials.map(m => [m.id, m.originalFilename || m.title || m.id] as [string, string]));
|
||||||
|
|
||||||
|
|||||||
42
src/common/interceptors/logging.interceptor.ts
Normal file
42
src/common/interceptors/logging.interceptor.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { tap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LoggingInterceptor implements NestInterceptor {
|
||||||
|
private readonly logger = new Logger('HTTP');
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||||
|
if (this.configService.get('app.nodeEnv') === 'production') {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const method = req.method;
|
||||||
|
const url = req.url || req.originalUrl || '?';
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap({
|
||||||
|
next: () => {
|
||||||
|
const res = context.switchToHttp().getResponse();
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
const logMsg = `${method} ${url} → ${res.statusCode} ${duration}ms`;
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
this.logger.warn(logMsg);
|
||||||
|
} else {
|
||||||
|
this.logger.log(logMsg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
const msg = err?.message || err;
|
||||||
|
this.logger.error(`${method} ${url} → ERR ${duration}ms - ${msg}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -130,6 +130,22 @@ export class CosStorageProvider {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getObject(objectKey: string): Promise<Buffer | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.cos.getObject(
|
||||||
|
{ Bucket: this.bucket, Region: this.region, Key: objectKey },
|
||||||
|
(err, data) => {
|
||||||
|
if (err) {
|
||||||
|
if (err.statusCode === 404) return resolve(null);
|
||||||
|
this.logger.error(`getObject failed for ${objectKey}`, err);
|
||||||
|
return resolve(null);
|
||||||
|
}
|
||||||
|
resolve(data.Body as Buffer);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async deleteObject(objectKey: string): Promise<void> {
|
async deleteObject(objectKey: string): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.cos.deleteObject(
|
this.cos.deleteObject(
|
||||||
|
|||||||
@ -92,6 +92,16 @@ export class LocalStorageProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getObject(objectKey: string): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
const filePath = this.getObjectPath(objectKey);
|
||||||
|
return fs.readFileSync(filePath);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === 'ENOENT') return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async deleteObject(objectKey: string): Promise<void> {
|
async deleteObject(objectKey: string): Promise<void> {
|
||||||
const filePath = this.getObjectPath(objectKey);
|
const filePath = this.getObjectPath(objectKey);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -54,9 +54,11 @@ export class StorageService {
|
|||||||
const date = new Date();
|
const date = new Date();
|
||||||
const yearMonth = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}`;
|
const yearMonth = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
const safeName = sanitizeFilename(originalFilename);
|
const safeName = sanitizeFilename(originalFilename);
|
||||||
|
const env = this.configService.get<string>('app.nodeEnv', 'development');
|
||||||
|
const prefix = env === 'production' ? '' : 'dev/';
|
||||||
return uploadId
|
return uploadId
|
||||||
? `${userId}/${yearMonth}/${uploadId}-${safeName}`
|
? `${prefix}${userId}/${yearMonth}/${uploadId}-${safeName}`
|
||||||
: `${userId}/${yearMonth}/${safeName}`;
|
: `${prefix}${userId}/${yearMonth}/${safeName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUploadUrl(
|
async createUploadUrl(
|
||||||
@ -123,6 +125,12 @@ export class StorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getObject(objectKey: string): Promise<Buffer | null> {
|
||||||
|
return this.driver === 'local'
|
||||||
|
? this.local.getObject(objectKey)
|
||||||
|
: this.cos.getObject(objectKey);
|
||||||
|
}
|
||||||
|
|
||||||
async healthCheck(): Promise<boolean> {
|
async healthCheck(): Promise<boolean> {
|
||||||
return this.driver === 'local'
|
return this.driver === 'local'
|
||||||
? this.local.healthCheck()
|
? this.local.healthCheck()
|
||||||
|
|||||||
@ -13,19 +13,33 @@ export class AdminKnowledgeController {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '知识库列表(管理员)' })
|
@ApiOperation({ summary: '知识库列表(管理员,支持搜索)' })
|
||||||
async list(@Query('page') page = '1', @Query('limit') limit = '20') {
|
async list(
|
||||||
|
@Query('page') page = '1',
|
||||||
|
@Query('limit') limit = '20',
|
||||||
|
@Query('search') search?: string,
|
||||||
|
) {
|
||||||
const p = parseInt(page), l = parseInt(limit);
|
const p = parseInt(page), l = parseInt(limit);
|
||||||
|
const where: any = { deletedAt: null };
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: search } },
|
||||||
|
{ id: { contains: search } },
|
||||||
|
{ user: { email: { contains: search } } },
|
||||||
|
{ user: { nickname: { contains: search } } },
|
||||||
|
];
|
||||||
|
}
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.knowledgeBase.findMany({
|
this.prisma.knowledgeBase.findMany({
|
||||||
where: { deletedAt: null },
|
where,
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
skip: (p - 1) * l, take: l,
|
skip: (p - 1) * l, take: l,
|
||||||
include: { user: { select: { nickname: true, email: true } } },
|
include: { user: { select: { nickname: true, email: true } } },
|
||||||
}),
|
}),
|
||||||
this.prisma.knowledgeBase.count({ where: { deletedAt: null } }),
|
this.prisma.knowledgeBase.count({ where }),
|
||||||
]);
|
]);
|
||||||
const enriched = await enrichWithNames(this.prisma, items); return { items: enriched, total, page: p, limit: l, totalPages: Math.ceil(total / l) };
|
const enriched = await enrichWithNames(this.prisma, items);
|
||||||
|
return { items: enriched, total, page: p, limit: l, totalPages: Math.ceil(total / l) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══ All Sources (must be before :id to avoid route conflict) ══
|
// ══ All Sources (must be before :id to avoid route conflict) ══
|
||||||
|
|||||||
@ -81,18 +81,32 @@ export class AuthService {
|
|||||||
? `${dto.fullName.familyName || ''}${dto.fullName.givenName}`
|
? `${dto.fullName.familyName || ''}${dto.fullName.givenName}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// M-MEMBER-01-02: Email-based deduplication — find existing user by email first
|
||||||
|
const existingEmail = appleEmail || dto.email || null;
|
||||||
|
let existingUser: { id: string; appAccountToken: string | null } | null = null;
|
||||||
|
if (existingEmail) {
|
||||||
|
existingUser = await this.prisma.user.findFirst({
|
||||||
|
where: { email: existingEmail },
|
||||||
|
select: { id: true, appAccountToken: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCreateOrConnect = existingUser
|
||||||
|
? { connect: { id: existingUser.id } }
|
||||||
|
: {
|
||||||
|
create: {
|
||||||
|
email: existingEmail,
|
||||||
|
nickname: displayName || undefined,
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
account = await this.prisma.authAccount.create({
|
account = await this.prisma.authAccount.create({
|
||||||
data: {
|
data: {
|
||||||
provider: 'APPLE',
|
provider: 'APPLE',
|
||||||
providerUserId: appleUserId,
|
providerUserId: appleUserId,
|
||||||
email: appleEmail || dto.email || null,
|
email: existingEmail,
|
||||||
user: {
|
user: userCreateOrConnect,
|
||||||
create: {
|
|
||||||
email: appleEmail || dto.email || null,
|
|
||||||
nickname: displayName || undefined,
|
|
||||||
status: 'active',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Post, Param, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Param, Query, Body, UseGuards, BadRequestException } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { enrichWithNames } from '../../common/helpers/name-resolver';
|
import { enrichWithNames } from '../../common/helpers/name-resolver';
|
||||||
@ -17,14 +17,48 @@ export class AdminImportsController {
|
|||||||
@Get()
|
@Get()
|
||||||
@AdminRoles('ADMIN' as AdminRole)
|
@AdminRoles('ADMIN' as AdminRole)
|
||||||
@ApiOperation({ summary: '导入任务列表' })
|
@ApiOperation({ summary: '导入任务列表' })
|
||||||
async list(@Query('page') page = '1', @Query('limit') limit = '20', @Query('status') status?: string) {
|
async list(
|
||||||
|
@Query('page') page = '1',
|
||||||
|
@Query('limit') limit = '20',
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('kbId') kbId?: string,
|
||||||
|
@Query('sortBy') sortBy?: string,
|
||||||
|
@Query('sortOrder') sortOrder?: string,
|
||||||
|
) {
|
||||||
const p = parseInt(page), l = parseInt(limit);
|
const p = parseInt(page), l = parseInt(limit);
|
||||||
const where: any = status ? { status } : {};
|
const where: any = {};
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (kbId) where.knowledgeBaseId = kbId;
|
||||||
|
|
||||||
|
// 排序:默认按创建时间倒序
|
||||||
|
const orderBy: any = {};
|
||||||
|
const allowedSortFields = ['sourceName', 'createdAt', 'status', 'sourceType', 'progress', 'retryCount'];
|
||||||
|
const field = allowedSortFields.includes(sortBy || '') ? sortBy! : 'createdAt';
|
||||||
|
const dir = sortOrder === 'ascend' ? 'asc' : 'desc';
|
||||||
|
orderBy[field] = dir;
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.documentImport.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * l, take: l }),
|
this.prisma.documentImport.findMany({ where, orderBy, skip: (p - 1) * l, take: l }),
|
||||||
this.prisma.documentImport.count({ where }),
|
this.prisma.documentImport.count({ where }),
|
||||||
]);
|
]);
|
||||||
const enriched = await enrichWithNames(this.prisma, items); return { items: enriched, total, page: p, limit: l, totalPages: Math.ceil(total / l) };
|
|
||||||
|
// 批量查询 source 是否存在(排除软删除)
|
||||||
|
const sourceIds = [...new Set(items.map(i => i.sourceId).filter(Boolean))] as string[];
|
||||||
|
const existingSourceIds = new Set(
|
||||||
|
sourceIds.length > 0
|
||||||
|
? (await this.prisma.knowledgeSource.findMany({
|
||||||
|
where: { id: { in: sourceIds }, deletedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
})).map(s => s.id)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const enriched = await enrichWithNames(this.prisma, items);
|
||||||
|
const itemsWithSourceStatus = enriched.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
sourceDeleted: !!item.sourceId && !existingSourceIds.has(item.sourceId),
|
||||||
|
}));
|
||||||
|
return { items: itemsWithSourceStatus, total, page: p, limit: l, totalPages: Math.ceil(total / l) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ -39,13 +73,67 @@ export class AdminImportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/retry')
|
@Post(':id/retry')
|
||||||
@AdminRoles('SUPER_ADMIN' as AdminRole)
|
@AdminRoles('ADMIN' as AdminRole)
|
||||||
@ApiOperation({ summary: '重试失败导入' })
|
@ApiOperation({ summary: '重试失败导入' })
|
||||||
async retry(@Param('id') id: string) {
|
async retry(@Param('id') id: string) {
|
||||||
|
const job = await this.prisma.documentImport.findUnique({ where: { id } });
|
||||||
|
if (!job) throw new BadRequestException('导入任务不存在');
|
||||||
|
|
||||||
|
// 检查 source 是否存在(排除软删除)
|
||||||
|
if (job.sourceId) {
|
||||||
|
const source = await this.prisma.knowledgeSource.findFirst({
|
||||||
|
where: { id: job.sourceId, deletedAt: null },
|
||||||
|
});
|
||||||
|
if (!source) {
|
||||||
|
throw new BadRequestException('关联的资料来源已删除,无法重新解析。请删除此导入记录后重新上传文件。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.documentImport.update({
|
await this.prisma.documentImport.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'QUEUED', retryCount: 0, errorMessage: null },
|
data: { status: 'QUEUED', retryCount: 0, errorMessage: null },
|
||||||
});
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('batch-retry')
|
||||||
|
@AdminRoles('ADMIN' as AdminRole)
|
||||||
|
@ApiOperation({ summary: '批量重新解析' })
|
||||||
|
async batchRetry(@Body() body: { ids: string[] }) {
|
||||||
|
if (!body.ids || body.ids.length === 0) {
|
||||||
|
throw new BadRequestException('请选择至少一个导入任务');
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = await this.prisma.documentImport.findMany({
|
||||||
|
where: { id: { in: body.ids } },
|
||||||
|
select: { id: true, sourceId: true, sourceName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const skipped: string[] = [];
|
||||||
|
const updated: string[] = [];
|
||||||
|
|
||||||
|
for (const id of body.ids) {
|
||||||
|
const job = jobs.find(j => j.id === id);
|
||||||
|
if (!job) continue;
|
||||||
|
|
||||||
|
// 检查 source 是否存在(排除软删除)
|
||||||
|
if (job.sourceId) {
|
||||||
|
const source = await this.prisma.knowledgeSource.findFirst({
|
||||||
|
where: { id: job.sourceId, deletedAt: null },
|
||||||
|
});
|
||||||
|
if (!source) {
|
||||||
|
skipped.push(job.sourceName || id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.documentImport.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'QUEUED', retryCount: 0, errorMessage: null },
|
||||||
|
});
|
||||||
|
updated.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, updated: updated.length, skipped, skippedCount: skipped.length };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Post, Delete, Body, Param, Query } from '@nestjs/common';
|
import { Controller, Optional, Get, Post, Delete, Body, Param, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import { KnowledgeSourceService } from './knowledge-source.service';
|
import { KnowledgeSourceService } from './knowledge-source.service';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
@ -41,6 +41,17 @@ export class KnowledgeSourceController {
|
|||||||
return this.service.triggerParse(id, user.id);
|
return this.service.triggerParse(id, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Post(':id/generate-knowledge')
|
||||||
|
@ApiOperation({ summary: 'AI 生成知识点(brain 图标)' })
|
||||||
|
async generateKnowledge(
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
@Param('kbId') kbId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.service.generateKnowledgePoints(kbId, id, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: '删除资料来源' })
|
@ApiOperation({ summary: '删除资料来源' })
|
||||||
async remove(@CurrentUser() user: UserPayload, @Param('id') id: string) {
|
async remove(@CurrentUser() user: UserPayload, @Param('id') id: string) {
|
||||||
|
|||||||
@ -4,9 +4,10 @@ import { KnowledgeSourceService } from './knowledge-source.service';
|
|||||||
import { KnowledgeSourceRepository } from './knowledge-source.repository';
|
import { KnowledgeSourceRepository } from './knowledge-source.repository';
|
||||||
import { DocumentImportModule } from '../document-import/document-import.module';
|
import { DocumentImportModule } from '../document-import/document-import.module';
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
|
import { KnowledgeItemsModule } from '../knowledge-items/knowledge-items.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DocumentImportModule, FilesModule],
|
imports: [DocumentImportModule, FilesModule, KnowledgeItemsModule],
|
||||||
controllers: [KnowledgeSourceController],
|
controllers: [KnowledgeSourceController],
|
||||||
providers: [KnowledgeSourceService, KnowledgeSourceRepository],
|
providers: [KnowledgeSourceService, KnowledgeSourceRepository],
|
||||||
exports: [KnowledgeSourceService, KnowledgeSourceRepository],
|
exports: [KnowledgeSourceService, KnowledgeSourceRepository],
|
||||||
|
|||||||
@ -32,14 +32,23 @@ export class KnowledgeSourceRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByKnowledgeBase(knowledgeBaseId: string, pagination: { page?: number; limit?: number }) {
|
async findByKnowledgeBase(knowledgeBaseId: string, pagination: {
|
||||||
|
page?: number; limit?: number; sortBy?: string; order?: string;
|
||||||
|
}) {
|
||||||
const page = pagination.page ?? 1;
|
const page = pagination.page ?? 1;
|
||||||
const limit = pagination.limit ?? 20;
|
const limit = pagination.limit ?? 20;
|
||||||
|
// map iOS field names to DB columns
|
||||||
|
const sortFieldMap: Record<string, string> = { fileSize: 'sizeBytes' };
|
||||||
|
const effectiveField = sortFieldMap[pagination.sortBy ?? ''] ?? pagination.sortBy;
|
||||||
|
const validFields = ['title', 'sizeBytes', 'createdAt', 'updatedAt'];
|
||||||
|
const sortField = validFields.includes(effectiveField ?? '') ? effectiveField! : 'title';
|
||||||
|
const sortOrder = pagination.order === 'desc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
return this.prisma.knowledgeSource.findMany({
|
return this.prisma.knowledgeSource.findMany({
|
||||||
where: { knowledgeBaseId, deletedAt: null },
|
where: { knowledgeBaseId, deletedAt: null },
|
||||||
skip: (page - 1) * limit,
|
skip: (page - 1) * limit,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { [sortField]: sortOrder },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common';
|
||||||
import { KnowledgeSourceRepository } from './knowledge-source.repository';
|
import { KnowledgeSourceRepository } from './knowledge-source.repository';
|
||||||
import { DocumentImportRepository } from '../document-import/document-import.repository';
|
import { DocumentImportRepository } from '../document-import/document-import.repository';
|
||||||
import { QueueService } from '../../infrastructure/queue/queue.service';
|
import { QueueService } from '../../infrastructure/queue/queue.service';
|
||||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { StorageService } from '../../infrastructure/storage/storage.service';
|
||||||
|
import { KnowledgeItemsRepository } from '../knowledge-items/knowledge-items.repository';
|
||||||
|
import { KnowledgeImportWorkflow } from '../ai/workflows/knowledge-import.workflow';
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KnowledgeSourceService {
|
export class KnowledgeSourceService {
|
||||||
@ -13,6 +17,10 @@ export class KnowledgeSourceService {
|
|||||||
private readonly queue: QueueService,
|
private readonly queue: QueueService,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
|
@Optional() private readonly storage?: StorageService,
|
||||||
|
@Optional() private readonly knowledgeItemsRepo?: KnowledgeItemsRepository,
|
||||||
|
@Optional() private readonly knowledgeImportWorkflow?: KnowledgeImportWorkflow,
|
||||||
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async addSource(userId: string, knowledgeBaseId: string, dto: {
|
async addSource(userId: string, knowledgeBaseId: string, dto: {
|
||||||
@ -54,7 +62,7 @@ export class KnowledgeSourceService {
|
|||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByKnowledgeBase(knowledgeBaseId: string, pagination: { page?: number; limit?: number }) {
|
async findByKnowledgeBase(knowledgeBaseId: string, pagination: { page?: number; limit?: number; sortBy?: string; order?: string }) {
|
||||||
return this.repository.findByKnowledgeBase(knowledgeBaseId, pagination);
|
return this.repository.findByKnowledgeBase(knowledgeBaseId, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,6 +81,54 @@ export class KnowledgeSourceService {
|
|||||||
return this.repository.softDelete(id);
|
return this.repository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 手动触发 AI 知识点生成(brain 图标)
|
||||||
|
* 不经过 Python RAG Worker,直接读文件 + 调 AI Gateway
|
||||||
|
*/
|
||||||
|
async generateKnowledgePoints(knowledgeBaseId: string, sourceId: string, userId: string) {
|
||||||
|
const source = await this.repository.findById(sourceId);
|
||||||
|
if (!source) throw new NotFoundException('资料来源不存在');
|
||||||
|
|
||||||
|
const objectKey = source.originalObjectKey;
|
||||||
|
if (!objectKey) throw new BadRequestException('文件不存在,无法生成知识点');
|
||||||
|
|
||||||
|
if (!this.storage || !this.knowledgeItemsRepo || !this.knowledgeImportWorkflow) {
|
||||||
|
throw new BadRequestException('知识点生成服务未就绪');
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = await this.storage.getObject(objectKey);
|
||||||
|
if (!buf) throw new BadRequestException('无法读取文件内容');
|
||||||
|
|
||||||
|
const text = buf.toString('utf-8');
|
||||||
|
if (!text.trim()) throw new BadRequestException('文件内容为空,无法生成知识点');
|
||||||
|
|
||||||
|
const result = await this.knowledgeImportWorkflow.execute({
|
||||||
|
userId,
|
||||||
|
rawText: text,
|
||||||
|
sourceName: source.originalFilename ?? source.title ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.knowledgePoints?.length) {
|
||||||
|
return { items: [], count: 0, sourceId, message: 'AI 未识别到可提取的知识点' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: any[] = [];
|
||||||
|
for (let i = 0; i < result.knowledgePoints.length; i++) {
|
||||||
|
const kp = result.knowledgePoints[i];
|
||||||
|
const item = await this.knowledgeItemsRepo.create(userId, knowledgeBaseId, {
|
||||||
|
title: kp.title,
|
||||||
|
content: kp.content,
|
||||||
|
itemType: 'lesson',
|
||||||
|
orderIndex: kp.suggestedOrder ?? i + 1,
|
||||||
|
sourceId,
|
||||||
|
sourceRef: sourceId,
|
||||||
|
});
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, count: items.length, sourceId };
|
||||||
|
}
|
||||||
|
|
||||||
async triggerParse(sourceId: string, userId: string) {
|
async triggerParse(sourceId: string, userId: string) {
|
||||||
const source = await this.repository.findById(sourceId);
|
const source = await this.repository.findById(sourceId);
|
||||||
if (!source) throw new NotFoundException('资料来源不存在');
|
if (!source) throw new NotFoundException('资料来源不存在');
|
||||||
|
|||||||
@ -36,6 +36,10 @@ export class AdminLearningController {
|
|||||||
async getSessions(@Query() query: SessionFilterQuery) { return this.service.getSessions(query); }
|
async getSessions(@Query() query: SessionFilterQuery) { return this.service.getSessions(query); }
|
||||||
@Get('sessions/:id')
|
@Get('sessions/:id')
|
||||||
async getSession(@Param('id') id: string) { return this.service.getSession(id); }
|
async getSession(@Param('id') id: string) { return this.service.getSession(id); }
|
||||||
|
@Post('sessions/batch-delete')
|
||||||
|
async batchDeleteSessions(@Body() body: { ids: string[] }) {
|
||||||
|
return this.service.deleteSessions(body.ids);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Progress ──
|
// ── Progress ──
|
||||||
@Get('progress')
|
@Get('progress')
|
||||||
|
|||||||
@ -151,7 +151,14 @@ export class AdminLearningService {
|
|||||||
async getSession(id: string) {
|
async getSession(id: string) {
|
||||||
const session = await this.prisma.learningSession.findUnique({ where: { id } });
|
const session = await this.prisma.learningSession.findUnique({ where: { id } });
|
||||||
if (!session) throw new BadRequestException('Session not found');
|
if (!session) throw new BadRequestException('Session not found');
|
||||||
return session;
|
const [enriched] = await this.enrich([session]);
|
||||||
|
return enriched;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSessions(ids: string[]) {
|
||||||
|
if (!ids || ids.length === 0) throw new BadRequestException('请选择至少一个会话');
|
||||||
|
const result = await this.prisma.learningSession.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
return { deleted: result.count, message: `成功删除 ${result.count} 个学习会话` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══ Progress ══
|
// ══ Progress ══
|
||||||
|
|||||||
@ -101,15 +101,21 @@ export class MaterialReadingProgressService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
return tx.materialReadingProgress.create({
|
// create() 不支持 { increment: N } 语法,需转为普通值
|
||||||
data: {
|
const createData: any = {
|
||||||
userId: data.userId,
|
userId: data.userId,
|
||||||
readingTargetType: data.readingTargetType,
|
readingTargetType: data.readingTargetType,
|
||||||
materialId: data.materialId,
|
materialId: data.materialId,
|
||||||
knowledgeBaseId: data.knowledgeBaseId,
|
knowledgeBaseId: data.knowledgeBaseId,
|
||||||
...update,
|
};
|
||||||
},
|
for (const [key, value] of Object.entries(update)) {
|
||||||
});
|
if (value && typeof value === 'object' && 'increment' in value) {
|
||||||
|
createData[key] = (value as any).increment;
|
||||||
|
} else {
|
||||||
|
createData[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.materialReadingProgress.create({ data: createData });
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.materialReadingProgress.update({
|
return tx.materialReadingProgress.update({
|
||||||
|
|||||||
@ -107,11 +107,18 @@ export class EffectiveQuotaService {
|
|||||||
// Enforced 模式才真正拦截
|
// Enforced 模式才真正拦截
|
||||||
const allowed = this.mode === 'enforced' ? (used + amount <= limit.limit) : true;
|
const allowed = this.mode === 'enforced' ? (used + amount <= limit.limit) : true;
|
||||||
// N2: 非 enforced 模式下直接返回全量(无实际预占记录,used 始终为 0)
|
// N2: 非 enforced 模式下直接返回全量(无实际预占记录,used 始终为 0)
|
||||||
const remaining = this.mode === 'enforced' ? Math.max(0, limit.limit - used) : limit.limit;
|
const remaining = Math.max(0, limit.limit - used);
|
||||||
|
|
||||||
// N1: 使用 @unique(resource) + P2002 catch 防并发重复预占
|
// N1: findFirst + create 防并发重复预占(替代 P2002 方案)
|
||||||
let reservationId: string | undefined;
|
let reservationId: string | undefined;
|
||||||
if (this.mode === 'enforced' && allowed) {
|
if (this.mode === 'enforced' && allowed) {
|
||||||
|
const existing = await this.prisma.quotaUsage.findFirst({
|
||||||
|
where: { resource: operationId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
reservationId = existing.id;
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
const record = await this.prisma.quotaUsage.create({
|
const record = await this.prisma.quotaUsage.create({
|
||||||
data: { userId, quotaType, amount, resource: operationId },
|
data: { userId, quotaType, amount, resource: operationId },
|
||||||
@ -119,11 +126,12 @@ export class EffectiveQuotaService {
|
|||||||
reservationId = record.id;
|
reservationId = record.id;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code === 'P2002') {
|
if (err?.code === 'P2002') {
|
||||||
const existing = await this.prisma.quotaUsage.findUnique({
|
const dup = await this.prisma.quotaUsage.findUnique({
|
||||||
where: { resource: operationId },
|
where: { resource: operationId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
reservationId = existing?.id;
|
reservationId = dup?.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Injectable, NotFoundException, Logger, Optional, BadRequestException }
|
|||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { ContentSafetyService } from '../content-safety/content-safety.service';
|
import { ContentSafetyService } from '../content-safety/content-safety.service';
|
||||||
import { AiGatewayService } from '../ai/gateway/ai-gateway.service';
|
import { AiGatewayService } from '../ai/gateway/ai-gateway.service';
|
||||||
|
import { StorageService } from '../../infrastructure/storage/storage.service';
|
||||||
import { RagChatOutputSchema } from '../ai/prompts/schemas/rag-chat.schema';
|
import { RagChatOutputSchema } from '../ai/prompts/schemas/rag-chat.schema';
|
||||||
import type { StreamChunk } from '../ai/providers/ai-provider.interface';
|
import type { StreamChunk } from '../ai/providers/ai-provider.interface';
|
||||||
|
|
||||||
@ -31,6 +32,7 @@ export class RagChatService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@Optional() private readonly safety?: ContentSafetyService,
|
@Optional() private readonly safety?: ContentSafetyService,
|
||||||
@Optional() private readonly aiGateway?: AiGatewayService,
|
@Optional() private readonly aiGateway?: AiGatewayService,
|
||||||
|
@Optional() private readonly storage?: StorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createSession(userId: string, params: CreateSessionParams): Promise<CreateSessionResult> {
|
async createSession(userId: string, params: CreateSessionParams): Promise<CreateSessionResult> {
|
||||||
@ -364,49 +366,64 @@ export class RagChatService {
|
|||||||
return { text: '', citations: [], isEmpty: true };
|
return { text: '', citations: [], isEmpty: true };
|
||||||
|
|
||||||
case 'material':
|
case 'material':
|
||||||
items = await this.prisma.knowledgeItem.findMany({
|
// scopeId 是 KnowledgeSource 的 id(iOS MaterialReaderView 传的 materialId = source.id)
|
||||||
where: {
|
const materialSource = await this.prisma.knowledgeSource.findFirst({
|
||||||
knowledgeBaseId: kbId,
|
where: { id: scopeId!, deletedAt: null },
|
||||||
sourceRef: scopeId,
|
select: { id: true, title: true, textLength: true, originalObjectKey: true, parsedObjectKey: true },
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
select: { id: true, title: true, content: true, summary: true },
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
|
||||||
take: 30,
|
|
||||||
});
|
});
|
||||||
|
if (!materialSource) return { text: '', citations: [], isEmpty: true };
|
||||||
|
|
||||||
|
// 尝试读取文件的真实文本内容(优先解析后文本,否则原始文件)
|
||||||
|
let fileText = '';
|
||||||
|
const objectKey = materialSource.parsedObjectKey || materialSource.originalObjectKey;
|
||||||
|
if (objectKey && this.storage) {
|
||||||
|
try {
|
||||||
|
const buf = await this.storage.getObject(objectKey);
|
||||||
|
if (buf) {
|
||||||
|
fileText = buf.toString('utf-8').slice(0, MAX_CONTEXT_CHARS);
|
||||||
|
this.logger.log(`Read file ${objectKey}: ${fileText.length} chars`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Failed to read file ${objectKey}: ${err?.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayContent = fileText
|
||||||
|
|| `资料《${materialSource.title ?? '未命名'}》,共 ${materialSource.textLength ?? 0} 字符。`;
|
||||||
|
items = [{
|
||||||
|
id: materialSource.id,
|
||||||
|
title: materialSource.title ?? '未命名资料',
|
||||||
|
content: displayContent,
|
||||||
|
summary: null,
|
||||||
|
}];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'knowledge_item':
|
case 'knowledge_item':
|
||||||
items = await this.prisma.knowledgeItem.findMany({
|
// 知识点是 AI 生成的产物,不支持作为 AI 对话的上下文
|
||||||
where: {
|
this.logger.warn('knowledge_item scope is deprecated for AI chat — knowledge items are AI-generated outputs');
|
||||||
id: scopeId ?? undefined,
|
return { text: '', citations: [], isEmpty: true };
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
select: { id: true, title: true, content: true, summary: true },
|
|
||||||
take: 30,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'folder':
|
|
||||||
// Find all items under this folder (recursive)
|
|
||||||
items = await this.prisma.knowledgeItem.findMany({
|
|
||||||
where: {
|
|
||||||
knowledgeBaseId: kbId,
|
|
||||||
parentId: scopeId,
|
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
select: { id: true, title: true, content: true, summary: true },
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
|
||||||
take: 30,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'knowledge_base':
|
case 'knowledge_base':
|
||||||
default:
|
default:
|
||||||
if (scopeType !== 'knowledge_base') {
|
if (scopeType !== 'knowledge_base') {
|
||||||
this.logger.warn(`Unknown scopeType "${scopeType}", falling back to knowledge_base`);
|
this.logger.warn(`Unknown scopeType "${scopeType}", falling back to knowledge_base`);
|
||||||
}
|
}
|
||||||
items = await this.prisma.knowledgeItem.findMany({
|
// 优先使用知识源(KnowledgeSource),知识源是原始资料,知识点是AI生成的产出
|
||||||
|
const sources = await this.prisma.knowledgeSource.findMany({
|
||||||
|
where: {
|
||||||
|
knowledgeBaseId: kbId,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, textLength: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 30,
|
||||||
|
});
|
||||||
|
this.logger.log(`KnowledgeBase sources count: ${sources.length}`);
|
||||||
|
|
||||||
|
// 无知识源时回退查知识点(兼容已有数据)
|
||||||
|
if (sources.length === 0) {
|
||||||
|
const fallbackItems = await this.prisma.knowledgeItem.findMany({
|
||||||
where: {
|
where: {
|
||||||
knowledgeBaseId: kbId,
|
knowledgeBaseId: kbId,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
@ -415,6 +432,40 @@ export class RagChatService {
|
|||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
take: 30,
|
take: 30,
|
||||||
});
|
});
|
||||||
|
if (fallbackItems.length > 0) {
|
||||||
|
items = fallbackItems;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 chunks 构建上下文(不依赖 textLength,因为历史数据可能没有回填)
|
||||||
|
const sourceIds = sources.map(s => s.id);
|
||||||
|
const chunks = sourceIds.length > 0 ? await this.prisma.knowledgeChunk.findMany({
|
||||||
|
where: { sourceId: { in: sourceIds }, deletedAt: null },
|
||||||
|
select: { sourceId: true, content: true },
|
||||||
|
orderBy: { chunkIndex: 'asc' },
|
||||||
|
take: 200,
|
||||||
|
}) : [];
|
||||||
|
|
||||||
|
// 按 source 分组拼接内容
|
||||||
|
const contentBySource: Record<string, string> = {};
|
||||||
|
for (const c of chunks) {
|
||||||
|
if (!contentBySource[c.sourceId]) contentBySource[c.sourceId] = '';
|
||||||
|
contentBySource[c.sourceId] += c.content + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
items = sources.map(s => {
|
||||||
|
const chunkContent = contentBySource[s.id] || '';
|
||||||
|
const chars = chunkContent.length || (s.textLength ?? 0);
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
title: s.title ?? '未命名资料',
|
||||||
|
content: chunkContent
|
||||||
|
? chunkContent.slice(0, 1000)
|
||||||
|
: `资料《${s.title ?? '未命名'}》,共 ${chars} 字符。`,
|
||||||
|
summary: null,
|
||||||
|
};
|
||||||
|
}).filter(item => (item.content?.length ?? 0) > 0);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -453,7 +504,7 @@ ${context}
|
|||||||
|
|
||||||
private fallbackReply(isEmpty: boolean) {
|
private fallbackReply(isEmpty: boolean) {
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
return '当前知识库还没有知识点内容。请先上传资料或添加知识点,我就可以基于它们回答你的问题了。';
|
return '当前知识库还没有资料内容。请先上传资料,我就可以基于它们回答你的问题了。';
|
||||||
}
|
}
|
||||||
return '抱歉,AI 服务暂时不可用,请稍后再试。';
|
return '抱歉,AI 服务暂时不可用,请稍后再试。';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { DocumentImportRepository } from '../document-import/document-import.rep
|
|||||||
import { KnowledgeSourceRepository } from '../knowledge-source/knowledge-source.repository';
|
import { KnowledgeSourceRepository } from '../knowledge-source/knowledge-source.repository';
|
||||||
import { ImportCandidateRepository } from '../import-candidate/import-candidate.repository';
|
import { ImportCandidateRepository } from '../import-candidate/import-candidate.repository';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
import { StorageService } from '../../infrastructure/storage/storage.service';
|
||||||
import { InternalAuthGuard } from '../../common/guards/internal-auth.guard';
|
import { InternalAuthGuard } from '../../common/guards/internal-auth.guard';
|
||||||
|
|
||||||
@ApiTags('internal-rag')
|
@ApiTags('internal-rag')
|
||||||
@ -15,6 +16,7 @@ export class InternalRagController {
|
|||||||
private readonly sourceRepo: KnowledgeSourceRepository,
|
private readonly sourceRepo: KnowledgeSourceRepository,
|
||||||
private readonly candidateRepo: ImportCandidateRepository,
|
private readonly candidateRepo: ImportCandidateRepository,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storage: StorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('jobs/next')
|
@Get('jobs/next')
|
||||||
@ -45,6 +47,11 @@ export class InternalRagController {
|
|||||||
? await this.sourceRepo.findById(job.sourceId)
|
? await this.sourceRepo.findById(job.sourceId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
let downloadUrl = '';
|
||||||
|
if (source?.originalObjectKey) {
|
||||||
|
try { downloadUrl = await this.storage.getDownloadUrl(source.originalObjectKey); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
job: {
|
job: {
|
||||||
id: job.id,
|
id: job.id,
|
||||||
@ -62,6 +69,7 @@ export class InternalRagController {
|
|||||||
mimeType: source.mimeType,
|
mimeType: source.mimeType,
|
||||||
sizeBytes: Number(source.sizeBytes),
|
sizeBytes: Number(source.sizeBytes),
|
||||||
originalObjectKey: source.originalObjectKey,
|
originalObjectKey: source.originalObjectKey,
|
||||||
|
downloadUrl,
|
||||||
} : null,
|
} : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -98,6 +106,19 @@ export class InternalRagController {
|
|||||||
const chunks = body.chunks || [];
|
const chunks = body.chunks || [];
|
||||||
if (chunks.length > 0) {
|
if (chunks.length > 0) {
|
||||||
await this.prisma.knowledgeChunk.createMany({ data: chunks });
|
await this.prisma.knowledgeChunk.createMany({ data: chunks });
|
||||||
|
|
||||||
|
// 更新 source 的 textLength 和 parseStatus
|
||||||
|
const sourceIds = [...new Set(chunks.map(c => c.sourceId).filter(Boolean))] as string[];
|
||||||
|
if (sourceIds.length > 0) {
|
||||||
|
for (const sourceId of sourceIds) {
|
||||||
|
const sourceChunks = chunks.filter(c => c.sourceId === sourceId);
|
||||||
|
const totalChars = sourceChunks.reduce((sum, c) => sum + (c.tokenCount || c.content?.length || 0), 0);
|
||||||
|
await this.prisma.knowledgeSource.updateMany({
|
||||||
|
where: { id: sourceId },
|
||||||
|
data: { textLength: totalChars, parseStatus: 'completed', updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { success: true, count: chunks.length };
|
return { success: true, count: chunks.length };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,10 @@ import { InternalRagController } from './internal-rag.controller';
|
|||||||
import { DocumentImportModule } from '../document-import/document-import.module';
|
import { DocumentImportModule } from '../document-import/document-import.module';
|
||||||
import { KnowledgeSourceModule } from '../knowledge-source/knowledge-source.module';
|
import { KnowledgeSourceModule } from '../knowledge-source/knowledge-source.module';
|
||||||
import { ImportCandidateModule } from '../import-candidate/import-candidate.module';
|
import { ImportCandidateModule } from '../import-candidate/import-candidate.module';
|
||||||
|
import { StorageModule } from '../../infrastructure/storage/storage.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DocumentImportModule, KnowledgeSourceModule, ImportCandidateModule],
|
imports: [DocumentImportModule, KnowledgeSourceModule, ImportCandidateModule, StorageModule],
|
||||||
controllers: [InternalRagController],
|
controllers: [InternalRagController],
|
||||||
})
|
})
|
||||||
export class RagModule {}
|
export class RagModule {}
|
||||||
|
|||||||
@ -31,7 +31,7 @@ export class UsersService {
|
|||||||
return {
|
return {
|
||||||
effectivePlan: membership.effectivePlan,
|
effectivePlan: membership.effectivePlan,
|
||||||
grants: membership.grants,
|
grants: membership.grants,
|
||||||
appAccountToken: membership.appAccountToken,
|
appAccountToken: membership.appAccountToken ?? await this.ensureAppAccountToken(userId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// 回退:旧逻辑(MembershipModule 未加载时)
|
// 回退:旧逻辑(MembershipModule 未加载时)
|
||||||
@ -133,4 +133,11 @@ export class UsersService {
|
|||||||
]);
|
]);
|
||||||
return { knowledgeBaseCount: kbCount, knowledgeItemCount: itemCount, reviewCardCount: cardCount };
|
return { knowledgeBaseCount: kbCount, knowledgeItemCount: itemCount, reviewCardCount: cardCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M-MEMBER-01-02: Auto-generate appAccountToken if missing
|
||||||
|
private async ensureAppAccountToken(userId: string): Promise<string> {
|
||||||
|
const token = require('crypto').randomUUID();
|
||||||
|
await this.prisma.user.update({ where: { id: userId }, data: { appAccountToken: token } });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -576,6 +576,8 @@ describe('3. Quota (真实 Service)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('强制模式 (enforced) 拦截', () => {
|
describe('强制模式 (enforced) 拦截', () => {
|
||||||
|
beforeEach(() => quotaService.setMode('enforced'));
|
||||||
|
|
||||||
it('超限时 allowed=false', async () => {
|
it('超限时 allowed=false', async () => {
|
||||||
const prefix = `enforced_${crypto.randomBytes(4).toString('hex')}`;
|
const prefix = `enforced_${crypto.randomBytes(4).toString('hex')}`;
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
# 刑法总论 - 犯罪构成要件
|
||||||
|
|
||||||
|
## 犯罪构成四要件理论
|
||||||
|
|
||||||
|
### 一、犯罪客体
|
||||||
|
刑法所保护而被犯罪行为所侵害的社会关系。
|
||||||
|
- 一般客体:一切犯罪共同侵犯的社会关系
|
||||||
|
- 同类客体:某一类犯罪共同侵犯的社会关系
|
||||||
|
- 直接客体:具体犯罪直接侵犯的社会关系
|
||||||
|
|
||||||
|
### 二、犯罪客观方面
|
||||||
|
1. 危害行为(作为和不作为)
|
||||||
|
2. 危害结果
|
||||||
|
3. 因果关系
|
||||||
|
4. 犯罪的时间、地点、方法
|
||||||
|
|
||||||
|
不作为犯罪的条件:
|
||||||
|
- 有作为义务(法律规定、职务要求、法律行为、先行行为)
|
||||||
|
- 有能力履行义务
|
||||||
|
- 不履行义务造成危害结果
|
||||||
|
|
||||||
|
### 三、犯罪主体
|
||||||
|
|
||||||
|
**刑事责任年龄**:
|
||||||
|
- 不满12周岁:不负刑事责任
|
||||||
|
- 12-14周岁:犯故意杀人、故意伤害致人重伤或死亡等严重罪行,经最高检核准追诉
|
||||||
|
- 14-16周岁:对八种严重犯罪负刑事责任(故意杀人、故意伤害致人重伤或死亡、强奸、抢劫、贩卖毒品、放火、爆炸、投放危险物质)
|
||||||
|
- 16周岁以上:完全刑事责任能力
|
||||||
|
|
||||||
|
**刑事责任能力**:
|
||||||
|
- 精神病人在不能辨认或控制自己行为时造成危害结果,不负刑事责任
|
||||||
|
- 间歇性精神病人在精神正常时犯罪,应负刑事责任
|
||||||
|
- 醉酒的人犯罪,应负刑事责任
|
||||||
|
|
||||||
|
### 四、犯罪主观方面
|
||||||
|
1. 故意(直接故意、间接故意)
|
||||||
|
2. 过失(疏忽大意的过失、过于自信的过失)
|
||||||
|
3. 目的和动机
|
||||||
|
|
||||||
|
## 正当防卫与紧急避险
|
||||||
|
|
||||||
|
### 正当防卫
|
||||||
|
为了国家、公共利益、本人或他人的人身财产免受正在进行的不法侵害而采取的制止行为。对正在进行行凶、杀人、抢劫、强奸、绑架等严重危及人身安全的暴力犯罪,采取防卫行为造成不法侵害人伤亡的,不负刑事责任(特殊防卫)。
|
||||||
|
|
||||||
|
### 紧急避险
|
||||||
|
为了使国家、公共利益、本人或他人的合法权益免受正在发生的危险,不得已采取的避险行为。不适用于职务上、业务上负有特定责任的人。
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
# 法理学 - 法的概念与渊源
|
||||||
|
|
||||||
|
## 法的概念
|
||||||
|
|
||||||
|
法是由国家制定或认可的,以权利义务为主要内容,由国家强制力保证实施的社会规范。
|
||||||
|
|
||||||
|
### 法的本质
|
||||||
|
1. 法是统治阶级意志的体现
|
||||||
|
2. 法的发展最终由经济基础决定
|
||||||
|
3. 法的内容受社会物质生活条件制约
|
||||||
|
|
||||||
|
### 法的特征
|
||||||
|
1. 法是调整行为的社会规范
|
||||||
|
2. 法是由国家制定或认可的社会规范
|
||||||
|
3. 法是以权利义务为内容的社会规范
|
||||||
|
4. 法是由国家强制力保障实施的社会规范
|
||||||
|
|
||||||
|
## 法的渊源
|
||||||
|
|
||||||
|
### 当代中国法的正式渊源
|
||||||
|
1. 宪法
|
||||||
|
2. 法律(全国人大及其常委会制定)
|
||||||
|
3. 行政法规(国务院制定)
|
||||||
|
4. 地方性法规(地方人大及其常委会制定)
|
||||||
|
5. 自治条例和单行条例
|
||||||
|
6. 规章(部门规章、地方政府规章)
|
||||||
|
7. 特别行政区法律
|
||||||
|
8. 国际条约和惯例
|
||||||
|
|
||||||
|
### 当代中国法的非正式渊源
|
||||||
|
1. 习惯
|
||||||
|
2. 判例
|
||||||
|
3. 政策
|
||||||
|
4. 道德
|
||||||
|
|
||||||
|
## 法的效力
|
||||||
|
|
||||||
|
### 法的效力层次
|
||||||
|
1. 上位法优于下位法
|
||||||
|
2. 特别法优于一般法
|
||||||
|
3. 新法优于旧法
|
||||||
|
|
||||||
|
### 法的效力范围
|
||||||
|
1. 对人的效力:属地主义、属人主义、保护主义、折中主义
|
||||||
|
2. 时间效力:法的生效时间、法的失效时间、法的溯及力
|
||||||
|
3. 空间效力:域内效力、域外效力
|
||||||
|
|
||||||
|
## 法律规则与法律原则
|
||||||
|
|
||||||
|
### 法律规则
|
||||||
|
指具体规定人们权利义务以及相应法律后果的行为准则。由假定条件、行为模式和法律后果三要素构成。
|
||||||
|
|
||||||
|
### 法律原则
|
||||||
|
法律原则是可以作为众多法律规则之基础或本源的综合性、稳定性的原理和准则。如罪刑法定原则、诚实信用原则。
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
# 民事诉讼法 - 诉讼主体与管辖
|
||||||
|
|
||||||
|
## 民事诉讼的基本原则
|
||||||
|
|
||||||
|
1. 诉讼权利平等原则
|
||||||
|
2. 同等原则与对等原则
|
||||||
|
3. 法院调解自愿和合法原则
|
||||||
|
4. 辩论原则
|
||||||
|
5. 处分原则
|
||||||
|
6. 诚实信用原则
|
||||||
|
7. 检察监督原则
|
||||||
|
|
||||||
|
## 诉讼主体
|
||||||
|
|
||||||
|
### 原告与被告
|
||||||
|
以自己的名义请求法院保护其合法权益而提起诉讼的人及其相对方。诉讼权利能力始于出生,终于死亡。法人从成立时具有诉讼权利能力。
|
||||||
|
|
||||||
|
### 共同诉讼
|
||||||
|
当事人一方或双方为二人以上,诉讼标的是共同的,或诉讼标的是同一种类、人民法院认为可以合并审理且当事人同意的。
|
||||||
|
|
||||||
|
### 第三人
|
||||||
|
1. 有独立请求权的第三人:对原被告争议的诉讼标的有独立的请求权,以起诉方式参加诉讼
|
||||||
|
2. 无独立请求权的第三人:虽对诉讼标的无独立请求权,但案件处理结果同他有法律上的利害关系
|
||||||
|
|
||||||
|
### 诉讼代理人
|
||||||
|
1. 法定代理人:无诉讼行为能力人由其监护人作为法定代理人代为诉讼
|
||||||
|
2. 委托代理人:当事人、法定代理人可以委托一至二人作为诉讼代理人
|
||||||
|
|
||||||
|
## 管辖
|
||||||
|
|
||||||
|
### 级别管辖
|
||||||
|
基层人民法院管辖第一审民事案件,但法律另有规定的除外。中级法院管辖重大涉外案件、在本辖区有重大影响的案件。
|
||||||
|
|
||||||
|
### 地域管辖
|
||||||
|
- 一般地域管辖:被告住所地法院
|
||||||
|
- 特殊地域管辖:合同纠纷—被告住所地或合同履行地;侵权纠纷—侵权行为地或被告住所地
|
||||||
|
- 专属管辖:不动产纠纷—不动产所在地法院;港口作业纠纷—港口所在地法院;继承遗产纠纷—被继承人死亡时住所地或主要遗产所在地法院
|
||||||
|
|
||||||
|
### 协议管辖
|
||||||
|
合同或其他财产权益纠纷的当事人可以书面协议选择与争议有实际联系的地点的法院管辖。
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
# 宪法学 - 国家制度与公民基本权利
|
||||||
|
|
||||||
|
## 宪法基本原则
|
||||||
|
|
||||||
|
1. 人民主权原则
|
||||||
|
2. 基本人权原则
|
||||||
|
3. 权力制约原则
|
||||||
|
4. 法治原则
|
||||||
|
|
||||||
|
## 国家基本制度
|
||||||
|
|
||||||
|
### 国家性质
|
||||||
|
中华人民共和国是工人阶级领导的、以工农联盟为基础的人民民主专政的社会主义国家。
|
||||||
|
|
||||||
|
### 政权组织形式
|
||||||
|
人民代表大会制度。全国人民代表大会是最高国家权力机关,其常设机关是全国人民代表大会常务委员会。
|
||||||
|
|
||||||
|
### 选举制度
|
||||||
|
- 普遍性原则:18周岁以上公民除依法被剥夺政治权利者外享有选举权
|
||||||
|
- 平等性原则:一人一票
|
||||||
|
- 直接选举与间接选举并用原则:县乡两级人大代表直接选举,市级以上间接选举
|
||||||
|
- 秘密投票原则
|
||||||
|
|
||||||
|
### 民族区域自治制度
|
||||||
|
各少数民族聚居的地方实行区域自治,设立自治机关,行使自治权。
|
||||||
|
|
||||||
|
### 特别行政区制度
|
||||||
|
香港特别行政区和澳门特别行政区是中华人民共和国不可分离的部分,实行"一国两制"方针,享有高度自治权。
|
||||||
|
|
||||||
|
### 基层群众自治制度
|
||||||
|
城市和农村按居民居住地区设立的居民委员会和村民委员会是基层群众性自治组织。
|
||||||
|
|
||||||
|
## 公民的基本权利
|
||||||
|
|
||||||
|
### 平等权
|
||||||
|
中华人民共和国公民在法律面前一律平等。
|
||||||
|
|
||||||
|
### 政治权利
|
||||||
|
1. 选举权和被选举权
|
||||||
|
2. 言论、出版、集会、结社、游行、示威的自由
|
||||||
|
|
||||||
|
### 宗教信仰自由
|
||||||
|
每个公民有信仰宗教的自由,也有不信仰宗教的自由。国家保护正常的宗教活动。
|
||||||
|
|
||||||
|
### 人身自由
|
||||||
|
1. 人身自由不受侵犯
|
||||||
|
2. 人格尊严不受侵犯
|
||||||
|
3. 住宅不受侵犯
|
||||||
|
4. 通信自由和通信秘密受法律的保护
|
||||||
|
|
||||||
|
### 社会经济权利
|
||||||
|
1. 劳动的权利和义务
|
||||||
|
2. 休息权
|
||||||
|
3. 获得物质帮助的权利(年老、疾病或者丧失劳动能力时)
|
||||||
|
4. 受教育权
|
||||||
|
|
||||||
|
### 监督权
|
||||||
|
公民对任何国家机关和国家工作人员有提出批评和建议的权利;对任何国家机关和国家工作人员的违法失职行为有申诉、控告或检举的权利。
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
# 知识产权法 - 著作权与专利权
|
||||||
|
|
||||||
|
## 著作权(版权)
|
||||||
|
|
||||||
|
### 著作权的保护对象
|
||||||
|
著作权保护文学、艺术和科学领域内具有独创性并能以一定形式表现的智力成果,包括:
|
||||||
|
1. 文字作品
|
||||||
|
2. 口述作品
|
||||||
|
3. 音乐、戏剧、曲艺、舞蹈、杂技艺术作品
|
||||||
|
4. 美术、建筑作品
|
||||||
|
5. 摄影作品
|
||||||
|
6. 视听作品
|
||||||
|
7. 工程设计图、产品设计图、地图、示意图等图形作品和模型作品
|
||||||
|
8. 计算机软件
|
||||||
|
9. 符合作品特征的其他智力成果
|
||||||
|
|
||||||
|
### 著作权的内容
|
||||||
|
**人身权**(不可转让):发表权、署名权、修改权、保护作品完整权
|
||||||
|
|
||||||
|
**财产权**(可许可、转让):复制权、发行权、出租权、展览权、表演权、放映权、广播权、信息网络传播权、摄制权、改编权、翻译权、汇编权等
|
||||||
|
|
||||||
|
### 著作权的保护期限
|
||||||
|
- 人身权(署名权、修改权、保护作品完整权):永久保护
|
||||||
|
- 发表权和财产权:作者终生加死后50年
|
||||||
|
|
||||||
|
### 合理使用制度
|
||||||
|
在特定情形下,可以不经著作权人许可、不向其支付报酬使用作品,但应指明作者姓名和作品名称、不得影响作品的正常使用、不得不合理损害著作权人的合法利益。例如:
|
||||||
|
1. 为个人学习、研究或者欣赏
|
||||||
|
2. 为介绍、评论或说明而适当引用
|
||||||
|
3. 为报道新闻
|
||||||
|
4. 为学校课堂教学或科学研究,翻译或少量复制
|
||||||
|
|
||||||
|
## 专利权
|
||||||
|
|
||||||
|
### 专利的类型
|
||||||
|
1. 发明专利(保护期20年)
|
||||||
|
2. 实用新型专利(保护期10年)
|
||||||
|
3. 外观设计专利(保护期15年)
|
||||||
|
|
||||||
|
### 授予专利权的条件
|
||||||
|
1. 新颖性:不属于现有技术
|
||||||
|
2. 创造性:与现有技术相比有突出的实质性特点和显著进步
|
||||||
|
3. 实用性:能够制造或使用并产生积极效果
|
||||||
|
|
||||||
|
### 不授予专利权的情形
|
||||||
|
1. 科学发现
|
||||||
|
2. 智力活动的规则和方法
|
||||||
|
3. 疾病的诊断和治疗方法
|
||||||
|
4. 动物和植物品种(其生产方法可获专利)
|
||||||
|
5. 原子核变换方法及用该方法获得的物质
|
||||||
|
6. 对平面印刷品的图案、色彩或者二者的结合作出的主要起标识作用的设计
|
||||||
|
|
||||||
|
### 专利侵权行为
|
||||||
|
未经专利权人许可实施其专利即构成侵权。为生产经营目的制造、使用、许诺销售、销售、进口其专利产品,或使用其专利方法等。
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
# 婚姻家庭法 - 夫妻财产与离婚
|
||||||
|
|
||||||
|
## 婚姻的成立条件
|
||||||
|
|
||||||
|
1. 男女双方完全自愿
|
||||||
|
2. 达到法定婚龄(男22岁,女20岁)
|
||||||
|
3. 双方均无配偶
|
||||||
|
4. 不属于直系血亲和三代以内旁系血亲
|
||||||
|
5. 未患有医学上认为不应当结婚的疾病
|
||||||
|
|
||||||
|
## 无效婚姻与可撤销婚姻
|
||||||
|
|
||||||
|
### 无效婚姻
|
||||||
|
1. 重婚
|
||||||
|
2. 有禁止结婚的亲属关系
|
||||||
|
3. 未到法定婚龄
|
||||||
|
|
||||||
|
### 可撤销婚姻
|
||||||
|
1. 因胁迫结婚的,受胁迫的一方可以向人民法院请求撤销婚姻
|
||||||
|
2. 一方患有重大疾病,在结婚登记前未如实告知另一方的
|
||||||
|
|
||||||
|
## 夫妻财产制度
|
||||||
|
|
||||||
|
### 法定夫妻财产制(婚后共同制)
|
||||||
|
夫妻在婚姻关系存续期间所得的下列财产,为夫妻共同财产:
|
||||||
|
1. 工资、奖金、劳务报酬
|
||||||
|
2. 生产、经营、投资的收益
|
||||||
|
3. 知识产权的收益
|
||||||
|
4. 继承或受赠的财产(遗嘱或赠与合同确定只归一方的除外)
|
||||||
|
5. 其他应当归共同所有的财产
|
||||||
|
|
||||||
|
### 夫妻个人财产
|
||||||
|
1. 一方的婚前财产
|
||||||
|
2. 一方因受到人身损害获得的赔偿或补偿
|
||||||
|
3. 遗嘱或赠与合同中确定只归一方的财产
|
||||||
|
4. 一方专用的生活用品
|
||||||
|
5. 其他应当归一方的财产
|
||||||
|
|
||||||
|
### 约定财产制
|
||||||
|
男女双方可以约定婚姻关系存续期间所得的财产及婚前财产归各自所有、共同所有或部分各自所有、部分共同所有。
|
||||||
|
|
||||||
|
## 离婚
|
||||||
|
|
||||||
|
### 协议离婚
|
||||||
|
自愿离婚的,双方到婚姻登记机关申请离婚,经30日冷静期后领取离婚证。
|
||||||
|
|
||||||
|
### 诉讼离婚
|
||||||
|
人民法院审理离婚案件,应当进行调解。感情确已破裂,调解无效的,应准予离婚。
|
||||||
|
|
||||||
|
### 离婚损害赔偿
|
||||||
|
有下列情形之一导致离婚的,无过错方有权请求损害赔偿:
|
||||||
|
1. 重婚
|
||||||
|
2. 与他人同居
|
||||||
|
3. 实施家庭暴力
|
||||||
|
4. 虐待、遗弃家庭成员
|
||||||
|
5. 有其他重大过错
|
||||||
|
|
||||||
|
### 子女抚养
|
||||||
|
离婚后父母对子女仍有抚养、教育、保护的权利和义务。不满两周岁的子女以由母亲直接抚养为原则。已满两周岁的子女,协商不成时由法院根据最有利于未成年子女的原则判决。子女已满八周岁的,应当尊重其真实意愿。
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
# 商法 - 公司法基本制度
|
||||||
|
|
||||||
|
## 公司类型
|
||||||
|
|
||||||
|
1. 有限责任公司
|
||||||
|
2. 股份有限公司
|
||||||
|
3. 一人有限责任公司
|
||||||
|
4. 国有独资公司
|
||||||
|
5. 上市公司
|
||||||
|
|
||||||
|
## 公司的设立
|
||||||
|
|
||||||
|
### 有限责任公司的设立条件
|
||||||
|
1. 股东符合法定人数(50人以下)
|
||||||
|
2. 有符合公司章程规定的全体股东认缴的出资额(无最低注册资本限制)
|
||||||
|
3. 股东共同制定公司章程
|
||||||
|
4. 有公司名称,建立符合有限责任公司要求的组织机构
|
||||||
|
5. 有公司住所
|
||||||
|
|
||||||
|
### 股份有限公司的设立条件
|
||||||
|
1. 发起人符合法定人数(2人以上200人以下)
|
||||||
|
2. 有符合公司章程规定的全体发起人认购的股本总额或募集的实收股本总额
|
||||||
|
3. 股份发行、筹办事项符合法律规定
|
||||||
|
4. 发起人制订公司章程(募集设立的须经创立大会通过)
|
||||||
|
5. 有公司名称,建立符合股份有限公司要求的组织机构
|
||||||
|
6. 有公司住所
|
||||||
|
|
||||||
|
## 公司治理结构
|
||||||
|
|
||||||
|
### 股东(大)会
|
||||||
|
公司的权力机构。行使下列职权:
|
||||||
|
1. 决定公司的经营方针和投资计划
|
||||||
|
2. 选举和更换董事、监事
|
||||||
|
3. 审议批准董事会、监事会报告
|
||||||
|
4. 审议批准公司的年度财务预算方案、决算方案
|
||||||
|
5. 审议批准公司的利润分配方案和弥补亏损方案
|
||||||
|
6. 对公司增加或者减少注册资本作出决议
|
||||||
|
7. 对发行公司债券作出决议
|
||||||
|
8. 对公司合并、分立、解散、清算或者变更公司形式作出决议
|
||||||
|
9. 修改公司章程
|
||||||
|
|
||||||
|
### 董事会
|
||||||
|
公司的经营决策机构。有限责任公司设3-13名董事;股份有限公司设5-19名董事。
|
||||||
|
|
||||||
|
### 监事会
|
||||||
|
公司的监督机构。有限责任公司设监事会,其成员不得少于3人。
|
||||||
|
|
||||||
|
## 公司人格否认(揭开公司面纱)
|
||||||
|
|
||||||
|
公司股东滥用公司法人独立地位和股东有限责任,逃避债务,严重损害公司债权人利益的,应当对公司债务承担连带责任。
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
# 行政法与行政诉讼法 - 行政行为
|
||||||
|
|
||||||
|
## 行政法的基本原则
|
||||||
|
|
||||||
|
1. 依法行政原则
|
||||||
|
2. 行政合理性原则
|
||||||
|
3. 正当程序原则
|
||||||
|
4. 信赖保护原则
|
||||||
|
5. 高效便民原则
|
||||||
|
6. 权责统一原则
|
||||||
|
|
||||||
|
## 具体行政行为
|
||||||
|
|
||||||
|
### 行政许可
|
||||||
|
行政机关根据公民、法人或者其他组织的申请,经依法审查,准予其从事特定活动的行为。
|
||||||
|
|
||||||
|
### 行政处罚
|
||||||
|
种类:警告、罚款、没收违法所得、没收非法财物、责令停产停业、暂扣或吊销许可证、暂扣或吊销执照、行政拘留等。
|
||||||
|
|
||||||
|
行政处罚的实施机关:
|
||||||
|
1. 行政机关
|
||||||
|
2. 法律、法规授权的组织
|
||||||
|
3. 行政机关委托的组织
|
||||||
|
|
||||||
|
### 行政强制
|
||||||
|
包括行政强制措施(查封、扣押、冻结等)和行政强制执行(加处罚款、划拨存款、拍卖等)。
|
||||||
|
|
||||||
|
## 行政复议
|
||||||
|
|
||||||
|
### 复议范围
|
||||||
|
公民、法人或者其他组织认为具体行政行为侵犯其合法权益的,可以自知道该具体行政行为之日起六十日内提出行政复议申请。
|
||||||
|
|
||||||
|
### 复议机关
|
||||||
|
1. 对县级以上地方各级人民政府工作部门的具体行政行为不服的,由申请人选择,可以向该部门的本级人民政府申请行政复议,也可以向上一级主管部门申请行政复议
|
||||||
|
2. 对地方各级人民政府的具体行政行为不服的,向上一级地方人民政府申请行政复议
|
||||||
|
3. 对国务院部门或省、自治区、直辖市人民政府的具体行政行为不服的,向作出该具体行政行为的国务院部门或省、自治区、直辖市人民政府申请行政复议
|
||||||
|
|
||||||
|
### 复议决定
|
||||||
|
复议机关应当自受理申请之日起六十日内作出行政复议决定。种类:维持、撤销、变更、确认违法、责令履行等。
|
||||||
|
|
||||||
|
## 行政诉讼
|
||||||
|
|
||||||
|
### 受案范围
|
||||||
|
公民、法人或者其他组织认为行政机关和行政机关工作人员的行政行为侵犯其合法权益,有权向人民法院提起诉讼。
|
||||||
|
|
||||||
|
### 管辖
|
||||||
|
- 级别管辖:基层人民法院管辖第一审行政案件
|
||||||
|
- 地域管辖:最初作出行政行为的行政机关所在地人民法院管辖
|
||||||
|
|
||||||
|
### 举证责任
|
||||||
|
被告对作出的行政行为负有举证责任,应当提供作出该行政行为的证据和所依据的规范性文件。在诉讼过程中,被告及其诉讼代理人不得自行向原告、第三人和证人收集证据。
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
# 物权法 - 所有权与担保物权
|
||||||
|
|
||||||
|
## 物权法定原则
|
||||||
|
|
||||||
|
物权的种类和内容,由法律规定。当事人不得自行创设法律未规定的物权类型。
|
||||||
|
|
||||||
|
## 所有权
|
||||||
|
|
||||||
|
### 所有权的权能
|
||||||
|
1. 占有权能 — 对物的实际控制
|
||||||
|
2. 使用权能 — 按照物的性质和用途加以利用
|
||||||
|
3. 收益权能 — 收取物的天然孳息和法定孳息
|
||||||
|
4. 处分权能 — 对物进行事实上或法律上的处置
|
||||||
|
|
||||||
|
### 共有
|
||||||
|
- 按份共有:共有人按照各自的份额对共有财产分享权利、分担义务
|
||||||
|
- 共同共有:共有人对共有财产不分份额地共同享有权利、承担义务
|
||||||
|
|
||||||
|
### 善意取得制度
|
||||||
|
构成要件:
|
||||||
|
1. 受让人受让该不动产或者动产时是善意的
|
||||||
|
2. 以合理的价格转让
|
||||||
|
3. 转让的不动产或者动产依照法律规定应当登记的已经登记,不需要登记的已经交付给受让人
|
||||||
|
|
||||||
|
法律效果:受让人取得该不动产或者动产的所有权,原所有权人有权向无处分权人请求损害赔偿。
|
||||||
|
|
||||||
|
## 担保物权
|
||||||
|
|
||||||
|
### 抵押权
|
||||||
|
不转移占有的担保物权。抵押财产包括:建筑物、建设用地使用权、海域使用权、生产设备、原材料、半成品、产品、正在建造的建筑物、船舶、航空器、交通运输工具等。
|
||||||
|
|
||||||
|
### 质权
|
||||||
|
转移占有的担保物权。包括动产质权和权利质权。
|
||||||
|
|
||||||
|
### 留置权
|
||||||
|
债务人不履行到期债务,债权人可以留置已经合法占有的债务人的动产,并有权就该动产优先受偿。
|
||||||
|
|
||||||
|
### 担保物权的清偿顺序
|
||||||
|
同一财产上设立多个担保物权的:
|
||||||
|
1. 留置权优先于抵押权、质权
|
||||||
|
2. 登记的抵押权按登记先后顺序
|
||||||
|
3. 已登记的优先于未登记的
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
# 法律职业道德与司法制度
|
||||||
|
|
||||||
|
## 法律职业道德
|
||||||
|
|
||||||
|
### 法官职业道德
|
||||||
|
1. 忠诚司法事业
|
||||||
|
2. 保证司法公正
|
||||||
|
3. 确保司法廉洁
|
||||||
|
4. 坚持司法为民
|
||||||
|
5. 维护司法形象
|
||||||
|
|
||||||
|
### 律师职业道德
|
||||||
|
1. 忠于宪法和法律
|
||||||
|
2. 诚实守信、勤勉尽责
|
||||||
|
3. 依照事实和法律维护当事人合法权益
|
||||||
|
4. 维护法律正确实施,维护社会公平正义
|
||||||
|
5. 注重职业修养,珍惜职业声誉
|
||||||
|
6. 保守执业活动中知悉的国家秘密、商业秘密和个人隐私
|
||||||
|
7. 尊重同行、公平竞争、同业互助
|
||||||
|
|
||||||
|
### 检察官职业道德
|
||||||
|
1. 坚持忠诚品格
|
||||||
|
2. 坚持公正理念
|
||||||
|
3. 坚持清廉操守
|
||||||
|
4. 坚持文明规范
|
||||||
|
|
||||||
|
## 律师制度
|
||||||
|
|
||||||
|
### 律师执业条件
|
||||||
|
1. 拥护中华人民共和国宪法
|
||||||
|
2. 通过国家统一法律职业资格考试
|
||||||
|
3. 在律师事务所实习满一年
|
||||||
|
4. 品行良好
|
||||||
|
|
||||||
|
### 律师的执业范围
|
||||||
|
1. 接受委托担任代理人参加诉讼
|
||||||
|
2. 接受委托担任辩护人
|
||||||
|
3. 接受委托提供非诉讼法律服务
|
||||||
|
4. 解答法律咨询、代写法律文书
|
||||||
|
5. 担任法律顾问
|
||||||
|
|
||||||
|
### 法律援助
|
||||||
|
有下列情形之一的,当事人因经济困难没有委托代理人的,可以申请法律援助:
|
||||||
|
1. 请求国家赔偿
|
||||||
|
2. 请求给予社会保险待遇或最低生活保障待遇
|
||||||
|
3. 请求给付抚恤金、救济金
|
||||||
|
4. 请求给付赡养费、抚养费、扶养费
|
||||||
|
5. 请求支付劳动报酬
|
||||||
|
6. 主张见义勇为行为产生的民事权益
|
||||||
|
|
||||||
|
## 司法制度
|
||||||
|
|
||||||
|
### 审判制度
|
||||||
|
人民法院是国家审判机关。包括最高人民法院、地方各级人民法院和专门人民法院。实行两审终审制。
|
||||||
|
|
||||||
|
### 检察制度
|
||||||
|
人民检察院是国家的法律监督机关。行使下列职权:
|
||||||
|
1. 对危害国家安全案等重大犯罪案件行使检察权
|
||||||
|
2. 对直接受理的刑事案件进行侦查
|
||||||
|
3. 对公安机关侦查的案件进行审查
|
||||||
|
4. 对刑事案件提起公诉
|
||||||
|
5. 对诉讼活动实行法律监督
|
||||||
|
|
||||||
|
## 国家统一法律职业资格考试
|
||||||
|
|
||||||
|
### 报考条件
|
||||||
|
1. 具有中华人民共和国国籍
|
||||||
|
2. 拥护中华人民共和国宪法,享有选举权和被选举权
|
||||||
|
3. 具有良好的政治、业务素质和道德品行
|
||||||
|
4. 具有完全民事行为能力
|
||||||
|
5. 具备全日制普通高等学校法学类本科学历并获得学士及以上学位,或非法学类本科及以上学历并获得法律硕士、法学硕士及以上学位,或全日制非法学类本科及以上学历并获得相应学位且从事法律工作满三年
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
# 刑法分论 - 常见罪名解析
|
||||||
|
|
||||||
|
## 侵犯人身权利罪
|
||||||
|
|
||||||
|
### 故意杀人罪
|
||||||
|
非法故意剥夺他人生命。最高可判处死刑。
|
||||||
|
|
||||||
|
### 故意伤害罪
|
||||||
|
非法故意损害他人身体健康。致人重伤的,处三年以上十年以下有期徒刑;致人死亡或以特别残忍手段致人重伤造成严重残疾的,处十年以上有期徒刑、无期徒刑或死刑。
|
||||||
|
|
||||||
|
### 强奸罪
|
||||||
|
以暴力、胁迫或者其他手段强奸妇女。奸淫不满14周岁幼女的,以强奸论,从重处罚。
|
||||||
|
|
||||||
|
### 非法拘禁罪
|
||||||
|
非法剥夺他人人身自由。具有殴打、侮辱情节或致人重伤、死亡的,从重处罚。
|
||||||
|
|
||||||
|
## 侵犯财产罪
|
||||||
|
|
||||||
|
### 抢劫罪
|
||||||
|
以暴力、胁迫或者其他方法抢劫公私财物。入户抢劫、在公共交通工具上抢劫、抢劫银行、多次抢劫或抢劫数额巨大、抢劫致人重伤或死亡、冒充军警人员抢劫、持枪抢劫、抢劫军用物资或抢险救灾救济物资的,处十年以上有期徒刑、无期徒刑或死刑。
|
||||||
|
|
||||||
|
### 盗窃罪
|
||||||
|
盗窃公私财物,数额较大的,或者多次盗窃、入户盗窃、携带凶器盗窃、扒窃的。
|
||||||
|
|
||||||
|
### 诈骗罪
|
||||||
|
以非法占有为目的,用虚构事实或隐瞒真相的方法,骗取数额较大的公私财物。诈骗公私财物价值三千元至一万元以上、三万元至十万元以上、五十万元以上的,分别认定为"数额较大"、"数额巨大"、"数额特别巨大"。
|
||||||
|
|
||||||
|
### 侵占罪
|
||||||
|
将代为保管的他人财物非法占为己有,数额较大,拒不退还的。
|
||||||
|
|
||||||
|
### 职务侵占罪
|
||||||
|
公司、企业或其他单位的工作人员,利用职务便利,将本单位财物非法占为己有,数额较大的。
|
||||||
|
|
||||||
|
## 贪污贿赂罪
|
||||||
|
|
||||||
|
### 贪污罪
|
||||||
|
国家工作人员利用职务便利,侵吞、窃取、骗取或以其他手段非法占有公共财物。
|
||||||
|
|
||||||
|
### 受贿罪
|
||||||
|
国家工作人员利用职务便利,索取他人财物,或非法收受他人财物为他人谋取利益。
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
# 经济法 - 反垄断法与反不正当竞争
|
||||||
|
|
||||||
|
## 反垄断法
|
||||||
|
|
||||||
|
### 垄断行为
|
||||||
|
1. 经营者达成垄断协议
|
||||||
|
2. 经营者滥用市场支配地位
|
||||||
|
3. 具有或者可能具有排除、限制竞争效果的经营者集中
|
||||||
|
|
||||||
|
### 垄断协议
|
||||||
|
禁止具有竞争关系的经营者达成下列垄断协议:
|
||||||
|
1. 固定或者变更商品价格
|
||||||
|
2. 限制商品的生产数量或者销售数量
|
||||||
|
3. 分割销售市场或者原材料采购市场
|
||||||
|
4. 限制购买新技术、新设备或者限制开发新技术、新产品
|
||||||
|
5. 联合抵制交易
|
||||||
|
|
||||||
|
### 滥用市场支配地位
|
||||||
|
具有市场支配地位的经营者禁止下列行为:
|
||||||
|
1. 以不公平的高价销售商品或以不公平的低价购买商品
|
||||||
|
2. 没有正当理由以低于成本的价格销售商品
|
||||||
|
3. 没有正当理由拒绝与交易相对人进行交易
|
||||||
|
4. 没有正当理由限定交易相对人只能与其进行交易
|
||||||
|
|
||||||
|
### 经营者集中
|
||||||
|
符合申报标准的经营者集中应当事先向国务院反垄断执法机构申报,未申报的不得实施集中。
|
||||||
|
|
||||||
|
## 反不正当竞争法
|
||||||
|
|
||||||
|
### 不正当竞争行为
|
||||||
|
1. 混淆行为:擅自使用与他人有一定影响的商品名称、包装、装潢等相同或近似的标识
|
||||||
|
2. 商业贿赂:采用财物或者其他手段贿赂以谋取交易机会或竞争优势
|
||||||
|
3. 虚假宣传:对商品的性能、功能、质量等作虚假或者引人误解的商业宣传
|
||||||
|
4. 侵犯商业秘密:盗窃、贿赂、欺诈、胁迫或其他不正当手段获取权利人的商业秘密
|
||||||
|
5. 不正当有奖销售:所设奖的种类、兑奖条件等有奖销售信息不明确,影响兑奖
|
||||||
|
6. 商业诋毁:编造、传播虚假信息或误导性信息,损害竞争对手的商业信誉、商品声誉
|
||||||
|
7. 网络不正当竞争:利用技术手段影响用户选择,妨碍其他经营者合法提供的网络产品或服务正常运行
|
||||||
|
|
||||||
|
## 消费者权益保护法
|
||||||
|
|
||||||
|
### 消费者的权利
|
||||||
|
1. 安全权
|
||||||
|
2. 知情权
|
||||||
|
3. 自主选择权
|
||||||
|
4. 公平交易权
|
||||||
|
5. 依法求偿权
|
||||||
|
6. 结社权
|
||||||
|
7. 获得知识权
|
||||||
|
8. 受尊重权
|
||||||
|
9. 监督权
|
||||||
|
|
||||||
|
### 惩罚性赔偿
|
||||||
|
经营者提供商品或者服务有欺诈行为的,应当按消费者要求增加赔偿其受到的损失,增加赔偿的金额为消费者购买商品的价款或接受服务的费用的三倍;增加赔偿的金额不足五百元的,为五百元。
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
# 继承法 - 法定继承与遗嘱继承
|
||||||
|
|
||||||
|
## 继承开始
|
||||||
|
|
||||||
|
继承从被继承人死亡时开始。相互有继承关系的数人在同一事件中死亡,难以确定死亡时间的,推定没有其他继承人的人先死亡。都有其他继承人的,辈份不同的推定长辈先死亡,辈份相同的推定同时死亡。
|
||||||
|
|
||||||
|
## 法定继承
|
||||||
|
|
||||||
|
### 法定继承人的范围和顺序
|
||||||
|
第一顺序:配偶、子女、父母
|
||||||
|
第二顺序:兄弟姐妹、祖父母、外祖父母
|
||||||
|
|
||||||
|
继承开始后由第一顺序继承人继承,第二顺序继承人不继承;没有第一顺序继承人继承的,由第二顺序继承人继承。
|
||||||
|
|
||||||
|
子女包括婚生子女、非婚生子女、养子女和有扶养关系的继子女。父母包括生父母、养父母和有扶养关系的继父母。
|
||||||
|
|
||||||
|
### 代位继承
|
||||||
|
被继承人的子女先于被继承人死亡的,由被继承人子女的直系晚辈血亲代位继承。被继承人的兄弟姐妹先于被继承人死亡的,由被继承人兄弟姐妹的子女代位继承。
|
||||||
|
|
||||||
|
### 遗产分配原则
|
||||||
|
同一顺序继承人继承遗产的份额一般应当均等。对生活有特殊困难又缺乏劳动能力的继承人应当予以照顾。对被继承人尽了主要扶养义务或与被继承人共同生活的继承人可以多分。有扶养能力和条件的继承人不尽扶养义务的,应当不分或少分。
|
||||||
|
|
||||||
|
## 遗嘱继承和遗赠
|
||||||
|
|
||||||
|
### 遗嘱的形式
|
||||||
|
1. 自书遗嘱(亲笔书写、签名、注明年月日)
|
||||||
|
2. 代书遗嘱(两名以上见证人,一人代书,注明日期并签名)
|
||||||
|
3. 打印遗嘱(遗嘱人和见证人在每一页签名注日期)
|
||||||
|
4. 录音录像遗嘱(记录姓名或肖像及日期)
|
||||||
|
5. 口头遗嘱(危急情况下,两名见证人,危机解除后失效)
|
||||||
|
6. 公证遗嘱(公证机构办理)
|
||||||
|
|
||||||
|
### 遗嘱见证人的限制
|
||||||
|
以下人员不能作为遗嘱见证人:
|
||||||
|
1. 无民事行为能力人、限制民事行为能力人
|
||||||
|
2. 继承人、受遗赠人
|
||||||
|
3. 与继承人、受遗赠人有利害关系的人
|
||||||
|
|
||||||
|
### 特留份制度
|
||||||
|
遗嘱应当为缺乏劳动能力又没有生活来源的继承人保留必要的遗产份额。
|
||||||
|
|
||||||
|
### 遗赠扶养协议
|
||||||
|
遗赠人与扶养人签订的、由扶养人承担遗赠人生养死葬义务、享有受遗赠权利的协议。遗赠扶养协议的法律效力优先于遗嘱和法定继承。
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
# 民事诉讼法 - 证据制度与证明责任
|
||||||
|
|
||||||
|
## 民事诉讼证据的种类
|
||||||
|
|
||||||
|
1. 当事人的陈述
|
||||||
|
2. 书证
|
||||||
|
3. 物证
|
||||||
|
4. 视听资料
|
||||||
|
5. 电子数据
|
||||||
|
6. 证人证言
|
||||||
|
7. 鉴定意见
|
||||||
|
8. 勘验笔录
|
||||||
|
|
||||||
|
## 证明责任分配
|
||||||
|
|
||||||
|
### 一般规则:谁主张、谁举证
|
||||||
|
当事人对自己提出的主张,有责任提供证据。
|
||||||
|
|
||||||
|
### 举证责任倒置的几种情形
|
||||||
|
1. 因新产品制造方法发明专利引起的专利侵权诉讼,由制造同样产品的单位或者个人对其产品制造方法不同于专利方法承担举证责任
|
||||||
|
2. 高度危险作业致人损害的侵权诉讼,由加害人就受害人故意造成损害的事实承担举证责任
|
||||||
|
3. 因环境污染引起的损害赔偿诉讼,由加害人就法律规定的免责事由及其行为与损害结果之间不存在因果关系承担举证责任
|
||||||
|
4. 建筑物或者其他设施以及建筑物上的搁置物、悬挂物发生倒塌、脱落、坠落致人损害的侵权诉讼,由所有人或者管理人对其无过错承担举证责任
|
||||||
|
5. 饲养动物致人损害的侵权诉讼,由动物饲养人或者管理人就受害人有过错或者第三人有过错承担举证责任
|
||||||
|
6. 因缺陷产品致人损害的侵权诉讼,由产品的生产者就法律规定的免责事由承担举证责任
|
||||||
|
7. 因共同危险行为致人损害的侵权诉讼,由实施危险行为的人就其行为与损害结果之间不存在因果关系承担举证责任
|
||||||
|
8. 因医疗行为引起的侵权诉讼,由医疗机构就医疗行为与损害结果之间不存在因果关系及不存在医疗过错承担举证责任
|
||||||
|
|
||||||
|
## 证据的审核认定
|
||||||
|
|
||||||
|
审判人员应当依照法定程序,全面、客观地审核证据。对以严重侵害他人合法权益、违反法律禁止性规定或者严重违背公序良俗的方法形成或者获取的证据,不得作为认定案件事实的根据。
|
||||||
|
|
||||||
|
## 无需举证证明的事实
|
||||||
|
|
||||||
|
1. 当事人自认的事实(诉讼过程中的明示自认)
|
||||||
|
2. 众所周知的事实
|
||||||
|
3. 根据法律规定推定的事实
|
||||||
|
4. 根据已知事实和日常生活经验法则能推定出的另一事实
|
||||||
|
5. 已为人民法院发生法律效力的裁判所确认的事实
|
||||||
|
6. 已为有效公证文书所证明的事实
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
# 合同法 - 合同订立与违约责任
|
||||||
|
|
||||||
|
## 合同的订立
|
||||||
|
|
||||||
|
### 要约
|
||||||
|
要约是希望与他人订立合同的意思表示。构成要件:
|
||||||
|
1. 内容具体确定
|
||||||
|
2. 经受要约人承诺,要约人即受该意思表示约束
|
||||||
|
|
||||||
|
要约邀请(要约引诱):寄送的价目表、拍卖公告、招标公告、招股说明书、商业广告等。
|
||||||
|
|
||||||
|
### 承诺
|
||||||
|
承诺是受要约人同意要约的意思表示。承诺生效时合同成立。
|
||||||
|
|
||||||
|
### 合同成立的时间
|
||||||
|
1. 采用合同书形式订立合同的,自当事人均签名、盖章或者按指印时合同成立
|
||||||
|
2. 采用信件、数据电文等形式订立合同的,可以在合同成立之前要求签订确认书,签订确认书时合同成立
|
||||||
|
|
||||||
|
## 合同的效力
|
||||||
|
|
||||||
|
合同生效的一般要件:
|
||||||
|
1. 行为人具有相应的民事行为能力
|
||||||
|
2. 意思表示真实
|
||||||
|
3. 不违反法律、行政法规的强制性规定,不违背公序良俗
|
||||||
|
|
||||||
|
## 违约责任
|
||||||
|
|
||||||
|
### 违约责任的承担方式
|
||||||
|
1. 继续履行
|
||||||
|
2. 采取补救措施(修理、更换、重作、退货、减少价款或报酬等)
|
||||||
|
3. 赔偿损失
|
||||||
|
4. 支付违约金
|
||||||
|
5. 定金罚则
|
||||||
|
|
||||||
|
### 违约责任的免责事由
|
||||||
|
1. 不可抗力(不能预见、不能避免、不能克服的客观情况)
|
||||||
|
2. 因对方原因造成的违约
|
||||||
|
3. 约定的免责条款(但造成对方人身损害、故意或重大过失造成财产损失的免责条款无效)
|
||||||
|
|
||||||
|
### 违约金
|
||||||
|
实际损失低于违约金的,可以请求适当减少;实际损失高于违约金的,可以请求适当增加。违约金过分高于造成的损失的,人民法院或仲裁机构可以根据当事人的请求予以适当减少。
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
# 刑事诉讼法 - 基本原则与程序
|
||||||
|
|
||||||
|
## 刑事诉讼的基本原则
|
||||||
|
|
||||||
|
1. 侦查权、检察权、审判权由专门机关依法行使原则
|
||||||
|
2. 人民法院、人民检察院依法独立行使职权原则
|
||||||
|
3. 依靠群众原则
|
||||||
|
4. 以事实为根据,以法律为准绳原则
|
||||||
|
5. 对一切公民在适用法律上一律平等原则
|
||||||
|
6. 分工负责、互相配合、互相制约原则
|
||||||
|
7. 人民检察院依法对刑事诉讼实行法律监督原则
|
||||||
|
8. 使用本民族语言进行诉讼原则
|
||||||
|
9. 审判公开原则
|
||||||
|
10. 犯罪嫌疑人、被告人有权获得辩护原则
|
||||||
|
11. 未经人民法院依法判决不得确定有罪原则(无罪推定)
|
||||||
|
|
||||||
|
## 刑事诉讼的阶段
|
||||||
|
|
||||||
|
### 一、立案
|
||||||
|
公安机关或检察院发现犯罪事实或犯罪嫌疑人,应立案侦查。
|
||||||
|
|
||||||
|
### 二、侦查
|
||||||
|
1. 讯问犯罪嫌疑人
|
||||||
|
2. 询问证人、被害人
|
||||||
|
3. 勘验、检查
|
||||||
|
4. 搜查
|
||||||
|
5. 查封、扣押物证、书证
|
||||||
|
6. 鉴定
|
||||||
|
7. 技术侦查措施
|
||||||
|
8. 通缉
|
||||||
|
|
||||||
|
侦查羁押期限:一般不超过2个月。案情复杂、期限届满不能终结的,可经批准延长。
|
||||||
|
|
||||||
|
### 三、审查起诉
|
||||||
|
检察院对公安机关移送起诉的案件进行审查,作出起诉或不起诉决定。审查起诉期限一般为1个月,重大复杂案件可延长15日。
|
||||||
|
|
||||||
|
### 四、审判
|
||||||
|
1. 第一审程序(普通程序、简易程序、速裁程序)
|
||||||
|
2. 第二审程序(上诉、抗诉)
|
||||||
|
3. 死刑复核程序
|
||||||
|
4. 审判监督程序(再审)
|
||||||
|
|
||||||
|
### 五、执行
|
||||||
|
生效判决、裁定的执行。死刑由最高人民法院核准后执行。
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 559 KiB |
@ -0,0 +1,38 @@
|
|||||||
|
# 侵权责任法 - 归责原则与特殊侵权
|
||||||
|
|
||||||
|
## 侵权责任的构成要件
|
||||||
|
|
||||||
|
1. 加害行为(作为或不作为)
|
||||||
|
2. 损害事实(人身损害、财产损害、精神损害)
|
||||||
|
3. 因果关系(加害行为与损害事实之间存在引起与被引起的关系)
|
||||||
|
4. 主观过错(故意或过失)
|
||||||
|
|
||||||
|
## 归责原则
|
||||||
|
|
||||||
|
### 过错责任原则
|
||||||
|
一般侵权责任以过错为构成要件。谁主张谁举证。
|
||||||
|
|
||||||
|
### 过错推定原则
|
||||||
|
法律推定加害人有过错,由加害人举证自己无过错方可免责。适用范围:无民事行为能力人在教育机构受到人身损害、医疗机构违反诊疗规范、高度危险物致害等。
|
||||||
|
|
||||||
|
### 无过错责任原则
|
||||||
|
不论加害人有无过错,均须承担责任。适用范围:
|
||||||
|
1. 高度危险作业致害
|
||||||
|
2. 环境污染致害
|
||||||
|
3. 饲养动物致害
|
||||||
|
4. 缺陷产品致害
|
||||||
|
5. 机动车与非机动车驾驶人、行人之间的交通事故
|
||||||
|
|
||||||
|
### 公平责任原则
|
||||||
|
双方均无过错时,根据实际情况由双方分担损失。
|
||||||
|
|
||||||
|
## 特殊侵权责任
|
||||||
|
|
||||||
|
### 用人者责任
|
||||||
|
用人单位的工作人员因执行工作任务造成他人损害的,由用人单位承担侵权责任。
|
||||||
|
|
||||||
|
### 网络侵权责任
|
||||||
|
网络用户利用网络侵害他人民事权益的,权利人有权通知网络服务提供者采取删除、屏蔽、断开链接等必要措施。通知应当包括构成侵权的初步证据及权利人的真实身份信息。
|
||||||
|
|
||||||
|
### 安全保障义务
|
||||||
|
宾馆、商场、银行、车站、机场、体育场馆、娱乐场所等经营场所、公共场所的经营者、管理者未尽到安全保障义务,造成他人损害的,应当承担侵权责任。
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
# 劳动法 - 劳动合同与劳动争议
|
||||||
|
|
||||||
|
## 劳动合同
|
||||||
|
|
||||||
|
### 劳动合同的订立
|
||||||
|
建立劳动关系应当订立书面劳动合同。已建立劳动关系未同时订立书面劳动合同的,应当自用工之日起一个月内订立书面劳动合同。
|
||||||
|
|
||||||
|
用人单位自用工之日起超过一个月不满一年未与劳动者订立书面劳动合同的,应向劳动者每月支付两倍的工资。
|
||||||
|
|
||||||
|
### 劳动合同的类型
|
||||||
|
1. 固定期限劳动合同
|
||||||
|
2. 无固定期限劳动合同(连续工作满10年或连续订立两次固定期限合同后)
|
||||||
|
3. 以完成一定工作任务为期限的劳动合同
|
||||||
|
|
||||||
|
### 试用期
|
||||||
|
- 劳动合同期限3个月以上不满1年的:试用期不超过1个月
|
||||||
|
- 1年以上不满3年的:试用期不超过2个月
|
||||||
|
- 3年以上固定期限或无固定期限的:试用期不超过6个月
|
||||||
|
- 以完成一定工作任务为期限或劳动合同期限不满3个月的:不得约定试用期
|
||||||
|
|
||||||
|
### 劳动合同的解除
|
||||||
|
**劳动者单方解除**:提前30日书面通知(试用期提前3日)。用人单位有违法情形的,劳动者可立即解除。
|
||||||
|
|
||||||
|
**用人单位单方解除**(需支付经济补偿):
|
||||||
|
1. 劳动者患病或非因工负伤,医疗期满后不能从事原工作
|
||||||
|
2. 劳动者不能胜任工作,经培训或调岗后仍不能胜任
|
||||||
|
3. 客观情况重大变化致合同无法履行
|
||||||
|
|
||||||
|
**用人单位即时解除**(无需支付经济补偿):
|
||||||
|
1. 试用期被证明不符合录用条件
|
||||||
|
2. 严重违反用人单位规章制度
|
||||||
|
3. 严重失职、营私舞弊造成重大损害
|
||||||
|
4. 劳动者同时与其他单位建立劳动关系且拒不改正
|
||||||
|
5. 被追究刑事责任
|
||||||
|
|
||||||
|
### 经济补偿金
|
||||||
|
按劳动者在本单位工作的年限,每满一年支付一个月工资。六个月以上不满一年的按一年计算,不满六个月的支付半个月工资。
|
||||||
|
|
||||||
|
## 劳动争议解决
|
||||||
|
|
||||||
|
### 解决途径
|
||||||
|
1. 协商
|
||||||
|
2. 调解
|
||||||
|
3. 仲裁(前置程序,一般须先仲裁才能诉讼)
|
||||||
|
4. 诉讼
|
||||||
|
|
||||||
|
### 劳动争议仲裁时效
|
||||||
|
劳动争议申请仲裁的时效期间为一年,从当事人知道或应当知道其权利被侵害之日起计算。
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 373 KiB |
Loading…
x
Reference in New Issue
Block a user