# 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` 定义了但从未使用 | `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 服务器上有旧 MySQL(35 表,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('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` 接口已定义(`src/common/types/index.ts:9-19`),但**从未被任何控制器使用**。CAPI 分页端点各自返回 raw 格式,iOS 需按端点适配。 ### Swagger 质量 - ✅ 所有 CAPI 控制器有 `@ApiTags`(中文标签) - ✅ 所有端点有 `@ApiOperation`(中文描述) - ❌ 无 `@ApiResponse({ type: SomeDto })` —— Swagger 无 response schema,iOS 客户端生成工具无法推断返回类型 - ✅ 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 [二进制文件数据] ───────────────────────────────────────────────────────► 文件存入 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 PUT,iOS 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: 杂项优化 ```