startup-plan/技术设计/iOS对接可行性审计报告.md
WangDL 0eb5f53873 docs: iOS integration audit report + 5 CAPI docs + nginx fix
- iOS integration readiness audit (blocker list, module map, deployment check)
- CAPI contract for iOS (15 modules, 80+ endpoints)
- CAPI error codes and status enums
- iOS auth flow, file upload import flow, learning review flow
- Web product page nginx fix (reenable longde.cloud)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:55:22 +08:00

469 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# iOS 端对接可行性审计报告
> 审计日期2026-05-24 | 范围api-server (NestJS 11) | 方式:只读排查,未修改任何代码
>
> 审计人Claude Code | 服务端:蜂驰云 8核32G (120.53.227.155) + 轻量云 4核4G (10.2.0.7)
---
## A. 总体结论:有条件可以 ✅
**在 P0 阻塞项解决之前,不能发布 iOS 对接任务。** P0 涉及 2 个安全/功能严重问题,预计修复工作量 1-2 天。P1 高风险项建议在 iOS 开发第一周内修复。
---
## B. 阻塞项清单
### 🔴 P0 阻塞 — 不解决不能开始 iOS 对接
| # | 问题 | 位置 | 风险 |
|---|------|------|------|
| 1 | **Apple 登录是假的**`APPLE_BUNDLE_ID` 未设,退回 mock 模式任何长度≥4 的字符串都当合法 token | `src/modules/auth/apple-auth.service.ts:28-30`,生产 env 缺 `APPLE_BUNDLE_ID` | iOS 用户可以用任意字符串登录,伪造 Apple ID |
| 2 | **`/internal/*` 7 个端点零认证暴露公网** — 无 Guard、无 API Key、无 IP 白名单 | `src/common/guards/jwt-auth.guard.ts:31` 跳过 internal`internal-rag.controller.ts``@Public()` 且无 `@UseGuards` | 攻击者可读取文档原文、写入 chunks/candidates、劫持导入作业 |
### 🟡 P1 高风险 — 可以开始,但必须尽快修(建议 iOS 开发第一周内)
| # | 问题 | 位置 | 风险 |
|---|------|------|------|
| 3 | **用户 Token 不检查账号状态** — JwtAuthGuard 不解码查库,禁用/已删用户 Token 仍然有效 | `src/common/guards/jwt-auth.guard.ts:42-46` | 禁用用户仍可访问所有 API |
| 4 | **Refresh Token 不检查用户状态** — 刷新时不验证用户是否被禁用 | `src/modules/auth/auth.service.ts:125-128` | 禁用用户可以无限刷新 Token |
| 5 | **用户和管理员 JWT 共用同一密钥**`ADMIN_JWT_ACCESS_SECRET` 未设,退回 `JWT_SECRET` | `src/config/jwt.config.ts:6`;生产 env 缺变量 | 降低 Token 隔离性,增加横向移动风险 |
| 6 | **请求 DTO 大量缺失** — iOS 核心流程import、source、learning-session`@Body() body: any` | `document-import.controller.ts:13``knowledge-source.controller.ts:18` 等 | iOS 可能发送错误字段,拿到 201 但后续失败 |
### 🟢 P2 优化项 — 不阻塞 iOS
| # | 问题 | 位置 |
|---|------|------|
| 7 | 无统一分页响应格式 — `PaginatedResponse<T>` 定义了但从未使用 | `src/common/types/index.ts:9-19` |
| 8 | Swagger 缺少 Response Schema — 所有 `@ApiResponse` 只有 description无 typed schema | 全部 CAPI 控制器 |
| 9 | 4 个端点双重包装响应 — logout/feedback/reports/waitlist 返回 `{success, data}` 被 ResponseInterceptor 再包一层 | `auth.controller.ts:51``feedback.controller.ts:20``content-safety.controller.ts:27``waitlist.controller.ts:19` |
| 10 | Refresh Token 用 SHA-256 而非 bcrypt 哈希 | `src/modules/auth/token.service.ts:19,24` |
| 11 | CI/CD 中硬编码了生产数据库密码 | `.gitea/workflows/deploy.yml:8,40` |
| 12 | 4核4G 服务器上有旧 MySQL35 表dev 密码),但不影响生产 | `10.2.0.7``mysql-zhixi` 容器 |
---
## C. 服务器与数据库部署
### 拓扑图
```
用户 (iOS App)
api.longde.cloud (Nginx)
蜂驰云 8核32G (120.53.227.155) ← 唯一生产服务器
├── zhixi-api.service NestJS API (端口 3000)
├── zhixi-worker.service BullMQ Worker
├── rag-worker.service Python RAG Worker (端口 8000)
├── Docker: mysql zhixi_prod (90 表) ← 唯一生产数据库
├── Docker: redis 缓存/队列/限流
├── Docker: qdrant 向量数据库
├── Docker: gitea-runner CI/CD Runner
└── Docker: nginx 反向代理
轻量云 4核4G (10.2.0.7) ← 辅助/开发残留
├── Docker: mysql-zhixi zhixi (35 表) ← 旧 dev 数据库,不服务生产
├── Docker: gitea Git 代码仓库
├── Docker: hermes-agent AI Agent
└── zhixi-worker.service 故障中 (auto-restart)
```
### 关键结论
| 问题 | 结论 |
|------|------|
| 是否存在数据分裂风险? | **否。** 生产仅一套 MySQL `zhixi_prod`,运行在蜂驰云 |
| iOS 是否会连错数据库? | **否。** API 服务固定连接 `zhixi_prod`,通过 env 文件配置 |
| 4核4G 的 MySQL 服务生产流量吗? | **否。** 数据库名不同(`zhixi` vs `zhixi_prod`),仅 35 张旧表,密码是 dev 环境密码 |
| JWT 用户/管理员密钥隔离吗? | **否。** `ADMIN_JWT_ACCESS_SECRET` 未设,退回与用户相同的 `JWT_SECRET` |
### 生产环境配置概况
配置文件:`/opt/zhixi/env/.env.production`28 行)
| 配置项 | 状态 | 说明 |
|--------|------|------|
| DATABASE_URL | ✅ 已设 | `mysql://zhixi_user:***@127.0.0.1:3306/zhixi_prod` |
| REDIS_HOST/PORT/PASSWORD | ✅ 已设 | 127.0.0.1:6379 |
| QDRANT_URL | ✅ 已设 | http://127.0.0.1:6333 |
| JWT_SECRET | ✅ 已设 | 64 位 hex |
| ADMIN_JWT_ACCESS_SECRET | ❌ 未设 | 退回 JWT_SECRET |
| APPLE_BUNDLE_ID | ❌ 未设 | 导致 Apple 登录退回 mock |
| ENABLE_SWAGGER | ✅ true | 已恢复Basic Auth 保护 |
| STORAGE_COS_* | ✅ 已设 | 腾讯云 COS (zhixi-1259685406, ap-beijing) |
| AI_PROVIDER / DEEPSEEK_API_KEY | ✅ 已设 | DeepSeek |
| SILICONFLOW_API_KEY | ✅ 已设 | SiliconFlow (备用) |
---
## D. 各问题详细诊断
### P0-1: Apple 登录 Mock 模式
**文件:** `src/modules/auth/apple-auth.service.ts`
**问题代码(第 23-32 行):**
```typescript
async verifyIdentityToken(identityToken: string): Promise<...> {
if (!this.appleBundleId) {
return this.verifyMock(identityToken); // ← 生产走这分支
}
return this.verifyReal(identityToken);
}
```
**Mock 逻辑(第 34-47 行):**
```typescript
private verifyMock(identityToken: string): { appleUserId: string } {
if (!identityToken || identityToken.trim().length < 4) {
throw new UnauthorizedException('identityToken 无效');
}
return {
appleUserId: crypto.createHash('sha256')
.update(`apple-mock:${identityToken}`)
.digest('hex')
.slice(0, 64),
};
}
```
**真实验证逻辑(第 49-78 行)** — 已实现但未启用:
- 使用 `jose.jwtVerify` 验证 JWT 签名
- 通过 `createRemoteJWKSet` 获取 Apple 公钥 (`https://appleid.apple.com/auth/keys`)
- 验证 `issuer`Apple`audience`Bundle ID
**当前状态:** 配置(`src/config/apple.config.ts`)中 `bundleId` 默认为空字符串。生产 env 未设 `APPLE_BUNDLE_ID`。任何人发送任意 ≥4 字符的字符串即可登录。
**修复方式:**
1. 在 Apple Developer Console 获取应用的 Bundle ID
2. 在生产 env 添加 `APPLE_BUNDLE_ID=com.longde.zhixi`(或实际值)
3. 建议同时在代码层加保护:当 `appleBundleId` 为空时直接拒绝而非退回 mock
---
### P0-2: /internal/* 零认证暴露
**文件:** `src/common/guards/jwt-auth.guard.ts:31` + `src/modules/rag/internal-rag.controller.ts`
**JwtAuthGuard 跳过 internal 路由:**
```typescript
if (request.path.startsWith('/admin-api') || request.path.startsWith('/internal')) {
return true; // ← 完全放行,不做任何认证
}
```
**控制器层:** `InternalRagController``@Public()` 装饰器,无 `@UseGuards()`
**暴露的端点(全部无需认证,实测确认):**
| 方法 | 路径 | 功能 | 实测 HTTP 状态 |
|------|------|------|----------------|
| GET | `/internal/rag/jobs/next` | 获取下一个待处理导入作业(含文档原文) | 200 |
| GET | `/internal/rag/jobs/:id` | 获取指定作业详情(含原文+元数据) | 200 |
| POST | `/internal/rag/jobs/:id/claim` | 认领作业(任意 workerId | 可访问 |
| POST | `/internal/rag/jobs/:id/heartbeat` | 发送心跳保持作业租约 | 可访问 |
| POST | `/internal/rag/jobs/:id/status` | 更新作业状态/进度/错误 | 可访问 |
| POST | `/internal/rag/chunks` | 批量写入 knowledge_chunk 表 | 可访问 |
| POST | `/internal/rag/candidates` | 批量写入导入候选 | 可访问 |
**攻击面:**
- 读取所有导入文档的原文(数据泄露)
-`knowledge_chunk` 表注入任意数据RAG 投毒)
- 认领/劫持作业,干扰合法 Worker拒绝服务
- 无速率限制,可无限写入
**修复方式:**
- 方案 A添加 InternalAuthGuard验证 `X-Internal-API-Key` 请求头(已有 `RAG_WORKER_SECRET` 在生产 env 中)
- 方案 B在 Nginx 层限制 IP仅允许 127.0.0.1),因为这些端点仅供本地 Worker 使用
- 建议A+B 组合
---
### P1-3: JwtAuthGuard 不检查用户状态
**文件:** `src/common/guards/jwt-auth.guard.ts:42-46`
```typescript
// 当前逻辑:只验证 JWT 签名,不检查用户状态
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('jwt.secret'),
});
request.user = { id: String(payload.sub), email: payload.email, role: payload.role };
return true; // ← 禁用的用户、已删除的用户 Token 都通过
```
**对比 AdminAuthGuard正确示范admin-auth.guard.ts:45-59**
```typescript
const adminUser = await this.prisma.adminUser.findUnique({
where: { id: payload.sub },
});
if (!adminUser || adminUser.deletedAt) {
throw new UnauthorizedException('管理员账号不存在');
}
if (adminUser.status !== 'ACTIVE') {
throw new UnauthorizedException('管理员账号已被禁用');
}
if (adminUser.lockedUntil && new Date(adminUser.lockedUntil) > new Date()) {
throw new UnauthorizedException('管理员账号已被锁定,请稍后再试');
}
```
**修复方式:** JwtAuthGuard 添加数据库查询,验证 `user.deletedAt === null && user.status === 'active'`
---
### P1-4: Refresh Token 不检查用户状态
**文件:** `src/modules/auth/auth.service.ts:125-128`
```typescript
// 刷新时不检查用户状态
const accessToken = await this.tokenService.generateAccessToken(stored.user);
```
刷新流程完整链路:验证 refreshToken → 撤销旧 token → 生成新 token → **未检查 stored.user 是否被禁用**
**修复方式:** 在生成新 access token 前添加 `if (stored.user.status !== 'active' || stored.user.deletedAt) throw new UnauthorizedException()`
---
### P1-5: 用户/管理员 JWT 共用密钥
**文件:** `src/config/jwt.config.ts:6`
```typescript
const adminSecret = process.env.ADMIN_JWT_ACCESS_SECRET || process.env.JWT_SECRET;
```
生产 env 仅设 `JWT_SECRET`,未设 `ADMIN_JWT_ACCESS_SECRET`。用户和管理员 Token 用同一密钥签名。
**Token 隔离分析:**
| 场景 | 结果 |
|------|------|
| 管理员 Token 访问 `/api/*` | JwtAuthGuard 验证通过(同密钥),管理员可以访问用户 API |
| 用户 Token 访问 `/admin-api/*` | AdminAuthGuard 发现 `payload.type !== 'admin'` 拒绝 |
| 用户 Token 的 payload | `{ sub, email, role }` — 无 `type` 字段 |
| 管理员 Token 的 payload | `{ sub, type: 'admin', role, sessionId }` — 有 `type` 字段 |
**当前状态:** AdminAuthGuard 通过 `type` 字段区分,即使密钥相同也能阻止用户 Token 访问管理员接口。但反之不行——管理员 Token 可以访问用户接口。且如果密钥泄露,攻击者可伪造管理员 Token。
**修复方式:** 在生产 env 添加 `ADMIN_JWT_ACCESS_SECRET=<独立密钥>`
---
### P1-6: iOS 核心流程缺少 DTO
**受影响的端点:**
| 端点 | 文件:行号 | 当前状态 |
|------|-----------|----------|
| `POST /api/imports` | `document-import.controller.ts:13` | `@Body() body: any` |
| `POST /api/knowledge-bases/:kbId/sources` | `knowledge-source.controller.ts:18` | `@Body() dto: any` |
| `POST /api/learning-sessions` | `learning-session.controller.ts` | `@Body() body: any` |
| `POST /api/active-recalls/:id/submit` | `active-recall.controller.ts` | `@Body() body: any` |
| `POST /api/focus-items` | `focus-items.controller.ts` | `@Body() body: any` |
**影响:** iOS 发送错误字段时,由于无 `class-validator` 校验,可能拿到 201 Created直到 AI 处理阶段才失败,调试成本高。
**修复方式:** 为上述端点创建 DTO 类,添加 `@IsString()`, `@IsOptional()`, `@IsEnum()` 等装饰器。
---
## E. CAPI 契约检查
### 响应格式
**统一成功响应**ResponseInterceptor 全局拦截):
```json
{
"success": true,
"data": <原始返回值>,
"timestamp": "2026-05-24T..."
}
```
**统一错误响应**GlobalExceptionFilter
```json
{
"success": false,
"statusCode": 401,
"message": "请先登录"
}
```
**已知异常:** 4 个端点手动返回 `{success, data}` 结构,被拦截器二次包装导致 `data.data` 嵌套:
- `POST /api/auth/logout`
- `POST /api/feedback`
- `POST /api/reports`
- `POST /api/waitlist`
### 分页
`PaginatedResponse<T>` 接口已定义(`src/common/types/index.ts:9-19`),但**从未被任何控制器使用**。CAPI 分页端点各自返回 raw 格式iOS 需按端点适配。
### Swagger 质量
- ✅ 所有 CAPI 控制器有 `@ApiTags`(中文标签)
- ✅ 所有端点有 `@ApiOperation`(中文描述)
- ❌ 无 `@ApiResponse({ type: SomeDto })` —— Swagger 无 response schemaiOS 客户端生成工具无法推断返回类型
- ✅ Bearer Auth 已全局配置
- ✅ 生产环境可通过 Basic Auth 访问 `/api-docs-json`
### 状态枚举
Prisma schema 使用 `String` 类型(无原生 enum状态值在应用层约定。以下是 CAPI 相关模型的状态枚举:
| 模型 | 字段 | 可能的值 |
|------|------|----------|
| User | status | `active`, `disabled`, `deleted` |
| User | role | `USER`, `ADMIN` |
| KnowledgeBase | status | `active`, `archived`, `deleted` |
| KnowledgeItem | status | `active`, `archived` |
| KnowledgeItem | itemType | `note`, `flashcard`, `concept` |
| KnowledgeSource | parseStatus | `pending`, `parsing`, `completed`, `failed` |
| KnowledgeSource | indexStatus | `pending`, `indexing`, `completed`, `failed` |
| KnowledgeSource | learningStatus | `pending`, `learning`, `completed`, `skipped` |
| DocumentImport | status | `QUEUED`, `CLAIMED`, `PARSING`, `COMPLETED`, `FAILED`, `FAILED_FINAL` (另有中间状态 10+ 个) |
| ImportCandidate | status | `PENDING`, `ACCEPTED`, `REJECTED` |
| LearningSession | status | `active`, `completed`, `cancelled` |
| LearningSession | mode | `active_recall`, `feynman`, `review`, `reading` |
| AiAnalysisJob | status | `pending`, `processing`, `completed`, `failed` |
| AiAnalysisJob | jobType | `active_recall_analysis`, `feynman_evaluation` |
| ReviewCard | status | `active`, `completed`, `archived` |
| ReviewCard | scheduleState | `new`, `learning`, `review`, `relearning` |
| ReviewCard | difficulty | 数值 (0.0-1.0, SM-2 算法) |
| FocusItem | status | `open`, `completed` |
| FocusItem | priority | `high`, `normal`, `low` |
| Feedback | status | `pending`, `reviewed`, `resolved`, `closed` |
| Feedback | type | `bug`, `feature`, `general` |
---
## F. 文件上传与导入流程iOS 适配分析)
### 完整流程图
```
iOS App API Server COS (腾讯云) BullMQ Worker
======= ========== ============ =============
1. POST /api/files/upload-url
{ filename, mimeType, sizeBytes }
──────────────────────────► 校验文件类型(9种)/大小(≤20MB)
生成预签名 PUT URL
◄────────────────────────── { uploadUrl, objectKey, expiresIn }
2. PUT <uploadUrl>
[二进制文件数据]
───────────────────────────────────────────────────────► 文件存入 COS
3. POST /api/files/complete
{ objectKey, checksum? }
──────────────────────────► 验证文件已到达 COS
创建 UploadedFile 记录
◄────────────────────────── { id, filename, sizeBytes }
4. POST /api/knowledge-bases/:kbId/sources
{ fileId, title, originalFilename }
──────────────────────────► 创建 KnowledgeSource
自动创建 DocumentImport (QUEUED)
◄────────────────────────── { id, parseStatus: "pending" }
5. GET /api/imports/:id/status (轮询)
──────────────────────────► 查 Redis 实时状态 → 回退 DB
◄────────────────────────── { status: "QUEUED", progress: 0 }
... Worker 处理中 ...
6. GET /api/imports/:id/status (轮询)
◄────────────────────────── { status: "COMPLETED", progress: 100,
message: "成功提取 N 个知识点" }
```
### 允许的文件类型
| 类型 | MIME |
|------|------|
| PDF | `application/pdf` |
| TXT | `text/plain` |
| Markdown | `text/markdown`, `text/x-markdown` |
| CSV | `text/csv` |
| Word | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` |
| Excel | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` |
| PNG | `image/png` |
| JPEG | `image/jpeg` |
| WebP | `image/webp` |
文件大小上限:**20MB**,全局 `StrictValidationPipe` 限制请求体 **10MB**
### iOS 适配评估
| 评估项 | 结论 | 说明 |
|--------|------|------|
| 预签名 URL 直传 | ✅ 可用 | 标准 HTTP PUTiOS URLSession/Alamofire 均可 |
| 请求/响应格式 | ✅ 可用 | 标准 JSON字段简单 |
| 认证方式 | ✅ 可用 | 标准 JWT Bearer Token |
| 分块上传/续传 | ❌ 不支持 | 仅支持完整文件一次 PUT大文件+弱网可能失败 |
| Checksum 校验 | ⚠️ 可选 | `CompleteUploadDto.checksum` 可选,建议 iOS 传 SHA-256 |
| 错误信息中文 | ⚠️ 注意 | 文件名校验等错误信息为中文iOS 需处理或本地化 |
| 导入状态 DTO | ❌ 缺失 | `POST /api/imports` 使用 `any`,无验证 |
---
## G. CAPI 端点认证测试(生产环境实测)
| 端点 | 无 Token | 结论 |
|------|----------|------|
| `GET /health` | 200 | 公开 |
| `GET /api/users/me` | 401 "请先登录" | 受保护 ✅ |
| `GET /api/knowledge-bases` | 401 "请先登录" | 受保护 ✅ |
| `GET /api/activity/streak` | 401 "请先登录" | 受保护 ✅ |
| `GET /api/reviews/due` | 401 "请先登录" | 受保护 ✅ |
| `GET /api/notifications` | 401 "请先登录" | 受保护 ✅ |
| `GET /api/workspace/dashboard` | 401 "请先登录" | 受保护 ✅ |
| `GET /internal/rag/jobs/next` | **200** | **未保护 🔴** |
| `POST /api/auth/dev-login` | 403 "生产环境禁用" | 已禁用 ✅ |
| `GET /api-docs-json` | 401 "Authentication required" | Basic Auth ✅ |
| `GET /api-docs-json` (with auth) | 200 | 正常 ✅ |
---
## H. 给 iOS 团队的接口文档清单
以下文档需要在 iOS 对接开始前编写交付:
| 文档 | 内容 | 优先级 |
|------|------|--------|
| `capi-contract-for-ios.md` | 完整 CAPI 端点清单、请求/响应格式、认证方式、通用约定 | P0 |
| `ios-auth-flow.md` | Apple 登录流程、Token 刷新策略、Keychain 存储建议、登出 | P0 |
| `ios-file-upload-import-flow.md` | 预签名 URL → COS 直传 → Source 创建 → 导入轮询 | P1 |
| `ios-learning-review-flow.md` | 学习会话 → 主动回忆 → AI 分析 → SM-2 复习 → 薄弱项 | P1 |
| `capi-error-codes-and-status-enums.md` | HTTP 错误码、业务错误消息、所有模型状态枚举值 | P1 |
---
## I. 修复优先级路线图
```
Week 0 (iOS 启动前)
├── P0-1: 配置 APPLE_BUNDLE_ID启用真实验证
├── P0-2: 添加 InternalAuthGuard 或 Nginx IP 限制
└── P1-5: 配置 ADMIN_JWT_ACCESS_SECRET独立密钥
Week 1 (iOS 开发启动)
├── P1-3: JwtAuthGuard 添加用户状态检查
├── P1-4: Refresh Token 添加用户状态检查
├── P1-6: 为核心端点创建 DTO
└── 编写 iOS 接口文档5 份)
Week 2+
├── P2-7: 统一分页响应格式
├── P2-8: Swagger 添加 Response Schema
├── P2-9: 修复双重响应包装
└── P2-10/11/12: 杂项优化
```