Forráskód Böngészése

1. 实现了PDF与DOCX正文检索,上传提取内容并支持存量回填;

2. 新增了DOCX原文件预览能力,完善缓存校验与安全清理;

3. 实现了同名方案覆盖确认,使用版本校验保护文件替换;

4. 优化了分类业务规则,限制叶子分类并同步子方案归属;

5. 收紧了共享附件访问权限,按挂载主案授权查看和下载;

6. 收敛了系统角色与审计入口,仅保留管理员和普通用户;

7. 更新了接口契约与数据库迁移,补齐时区依赖和验证用例。
kaywxs 1 hete
szülő
commit
7117d41b5d
74 módosított fájl, 3153 hozzáadás és 608 törlés
  1. 16 5
      CODEX_BACKEND_PERSISTENCE_GUIDE.md
  2. 28 10
      DMS_API_CONTRACT.md
  3. 24 10
      DMS_FUNCTION_CONTRACT.md
  4. 14 8
      FUNCTION_AND_API_SPECIFICATION.md
  5. 4 5
      backend/dms/api/v1/audit.py
  6. 1 0
      backend/dms/api/v1/documents.py
  7. 127 0
      backend/dms/backfill_document_content.py
  8. 7 1
      backend/dms/common/enums.py
  9. 10 0
      backend/dms/common/errors.py
  10. 11 4
      backend/dms/config.py
  11. 19 1
      backend/dms/models/document.py
  12. 1 1
      backend/dms/models/user.py
  13. 1 2
      backend/dms/resources/ui-dictionaries.zh-CN.json
  14. 0 10
      backend/dms/security/decorators.py
  15. 1 13
      backend/dms/seed_dev.py
  16. 3 3
      backend/dms/seed_documents.py
  17. 26 12
      backend/dms/services/attachment_query_service.py
  18. 64 6
      backend/dms/services/authorization_service.py
  19. 117 11
      backend/dms/services/category_service.py
  20. 99 0
      backend/dms/services/document_content_service.py
  21. 249 51
      backend/dms/services/document_mutation_service.py
  22. 50 10
      backend/dms/services/document_query_service.py
  23. 49 12
      backend/dms/services/file_read_service.py
  24. 32 1
      backend/dms/services/recycle_bin_service.py
  25. 1 1
      backend/dms/services/user_service.py
  26. 130 0
      backend/migrations/versions/0003_q3_business_alignment_and_content_search.py
  27. 1 1
      backend/openapi/components/responses.yaml
  28. 63 12
      backend/openapi/components/schemas.yaml
  29. 1 1
      backend/openapi/openapi.yaml
  30. 10 4
      backend/openapi/paths/documents.yaml
  31. 1 0
      backend/requirements.txt
  32. 5 3
      backend/tests/dms/conftest.py
  33. 1 1
      backend/tests/dms/q1b_http_verify.py
  34. 523 0
      backend/tests/dms/q3b_http_verify.py
  35. 14 8
      backend/tests/dms/test_b2_auth_and_directory.py
  36. 2 2
      backend/tests/dms/test_b3_categories.py
  37. 50 23
      backend/tests/dms/test_b4_documents.py
  38. 121 11
      backend/tests/dms/test_b5_file_mutations.py
  39. 4 11
      backend/tests/dms/test_b6_bindings_permissions.py
  40. 32 27
      backend/tests/dms/test_b7_audit_statistics.py
  41. 30 23
      backend/tests/dms/test_b8_recycle_restore.py
  42. 25 1
      backend/tests/dms/test_migration.py
  43. 9 0
      backend/tests/dms/test_models.py
  44. 11 2
      backend/tests/dms/test_mysql_integration.py
  45. 1 1
      backend/tests/dms/test_openapi_storage.py
  46. 6 7
      backend/tests/dms/test_q1b_ui_dictionaries.py
  47. 2 17
      backend/tests/dms/test_q2b_updated_range.py
  48. 466 0
      backend/tests/dms/test_q3b_business_content_preview.py
  49. 2 0
      frontend/ATTRIBUTIONS.md
  50. 137 14
      frontend/package-lock.json
  51. 1 0
      frontend/package.json
  52. 77 67
      frontend/src/app/App.tsx
  53. 2 2
      frontend/src/features/audit/__tests__/f7Scope.test.ts
  54. 3 3
      frontend/src/features/auth/__tests__/authSession.test.ts
  55. 11 5
      frontend/src/features/auth/authSession.ts
  56. 2 2
      frontend/src/features/auth/authTypes.ts
  57. 1 1
      frontend/src/features/categories/__tests__/f3Scope.test.ts
  58. 2 0
      frontend/src/features/categories/categoryTree.ts
  59. 4 1
      frontend/src/features/dictionaries/__tests__/fixtures.ts
  60. 2 2
      frontend/src/features/dictionaries/__tests__/q1fScope.test.ts
  61. 4 1
      frontend/src/features/dictionaries/dictionaryTypes.ts
  62. 1 1
      frontend/src/features/documents/DocumentSearchToolbar.tsx
  63. 1 1
      frontend/src/features/documents/__tests__/documentApi.test.ts
  64. 1 0
      frontend/src/features/documents/documentTypes.ts
  65. 126 76
      frontend/src/features/files/FilePreviewModal.tsx
  66. 155 0
      frontend/src/features/files/__tests__/q3fScope.test.ts
  67. 32 11
      frontend/src/features/files/fileState.ts
  68. 63 47
      frontend/src/features/mutations/MutationModals.tsx
  69. 21 5
      frontend/src/features/mutations/__tests__/mutationApi.test.ts
  70. 3 2
      frontend/src/features/mutations/documentMutationApi.ts
  71. 37 31
      frontend/src/features/mutations/mutationTypes.ts
  72. 2 0
      frontend/src/features/recycle-bin/RestoreDocumentModal.tsx
  73. 1 3
      frontend/src/features/statistics/DocumentStatisticsCards.tsx
  74. 0 1
      frontend/src/shared/api/enums.ts

+ 16 - 5
CODEX_BACKEND_PERSISTENCE_GUIDE.md

@@ -1,6 +1,6 @@
 # 文档管理系统后端持久化实施指导(供 Codex 使用)
 
-> Q2-B更新时间范围检索同步版本:1.3。实现必须服从 `DMS_FUNCTION_CONTRACT.md` 1.3和 `DMS_API_CONTRACT.md` 1.3
+> Q3-B业务规则、正文检索和原文件预览同步版本:1.4。实现必须服从 `DMS_FUNCTION_CONTRACT.md` 1.4和 `DMS_API_CONTRACT.md` 1.4
 
 ## 1. 文档目的
 
@@ -275,7 +275,7 @@ WHERE id = ? AND is_deleted = 0 AND row_version = ?;
 
 ```text
 用户角色 role_code:
-USER, ADMIN, AUDITOR
+USER, ADMIN
 
 用户状态 status:
 ENABLED, LOCKED, DISABLED
@@ -855,7 +855,7 @@ GET /api/v1/statistics/documents
 第一阶段采用固定角色加文档 ACL:
 
 1. `ADMIN` 可以进入后台管理,但文档下载等敏感操作仍应记录审计。
-2. `AUDITOR` 可以查看审计日志,默认不能编辑文档
+2. `ADMIN` 可以查看审计日志并具有 `AUDIT_LOG` 模块;`USER`不能访问审计接口
 3. `USER` 只能访问拥有查看权限的文档。
 4. 用户直接权限优先于组织权限。
 5. 没有任何有效权限记录时默认拒绝,不得默认全员可见,除非文档明确设置为全员可见。
@@ -992,7 +992,7 @@ Codex 后续编码时按以下顺序进行,每一步完成后先验证,再
 
 ### 16.2 文件
 
-- [ ] 同名文件不会互相覆盖
+- [ ] 方案名称按正式唯一范围校验;未经用户确认不得覆盖,同名覆盖保留原文档ID和关系,并以事务方式替换文件
 - [ ] 非法扩展名和伪造文件头被拒绝。
 - [ ] 数据库写入失败不会留下正式孤儿文件。
 - [ ] 文件移动失败不会留下有效数据库记录。
@@ -1110,4 +1110,15 @@ RESTORE_RELATION_CONFLICT      409
 - 方案和子方案过滤 `doc_document.updated_at`;已挂载附件仍过滤附件对应的 `doc_document.updated_at`,禁止过滤 `doc_attachment_binding`时间或排序号。
 - SQLAlchemy `where`条件必须在查询执行和分页前形成,不得在Python中进行时间过滤。
 - 保持权限、密级、状态、排序、字符串ID、DTO、计数和审计不变。
-- 本轮不新增索引、迁移或数据库对象;OpenAPI升级为 `1.3.0`,Office预览使用后续版本。
+- Q2-B不新增索引、迁移或数据库对象;当时OpenAPI升级为 `1.3.0`。
+
+## 17. Q3-B 1.4持久化补充
+
+1. 使用唯一迁移`0003_q3_business_alignment_and_content_search`:`doc_document.search_text`升级为LONGTEXT,新增`content_text LONGTEXT NULL`、`content_extract_status VARCHAR(32)`、`content_extracted_at DATETIME(3) NULL`,并将用户角色CHECK收敛为USER、ADMIN。
+2. 历史AUDITOR在迁移中递增auth_version和row_version、置为DISABLED并逻辑删除;为满足新CHECK可降为USER,但不得提升为ADMIN,历史审计快照保持不变。
+3. 上传文件先在暂存区完成类型校验、哈希和正文提取,再在数据库事务内保存元数据,提交后原子移动;提取FAILED不得导致原文件丢失。编辑名称、摘要或标签时以已有content_text重建search_text。
+4. PDF正文从文本层提取;DOCX提取普通段落、表格及可安全读取的页眉页脚;DOC为UNSUPPORTED。正文与组合搜索文本只存MySQL,不存二进制或HTML,不引入Milvus、Elasticsearch或OpenSearch。
+5. 回填命令`python -m dms.backfill_document_content`要求显式数据库URL并校验`SELECT DATABASE()`严格等于`dms_test`,不得自动运行或连接dms主库。
+6. preview只读取存储根内的原始普通文件,校验相对路径、存在性、大小、SHA-256、扩展名、文件头和DOCX ZIP结构;不创建preview缓存,不调用Office/WPS/LibreOffice。
+7. 正式部署前必须备份数据库并依次应用`0002_add_restore_audit_action`与`0003_q3_business_alignment_and_content_search`。若正文已写入或LONGTEXT内容无法安全降为TEXT,0003 downgrade应明确阻止而非静默丢失。
+8. 当前正文检索使用参数化、转义后的`LIKE`满足现阶段数据规模;随着文档数量和正文体积增长,会出现大文本扫描性能风险。引入MySQL FULLTEXT或独立搜索服务前必须完成中文分词、相关性、权限过滤和迁移方案评审,不得在本阶段自行扩展。

+ 28 - 10
DMS_API_CONTRACT.md

@@ -1,7 +1,7 @@
 # 方案计划文档管理系统接口契约
 
-> 契约版本:1.3
-> 状态:已确认,Q2-B更新时间范围检索接口基线
+> 契约版本:1.5
+> 状态:已确认,统一方案上传与同名覆盖接口基线
 > 外部字段风格:`camelCase`  
 > 数据库字段风格:`snake_case`
 
@@ -159,7 +159,6 @@ X-Request-Id: ...
 ```text
 USER
 ADMIN
-AUDITOR
 ```
 
 ### 3.2 模块
@@ -314,8 +313,7 @@ ATTACHMENT_BINDING
 第一阶段模块映射固定为:
 
 - `USER`:`DOCUMENT_BROWSER`
-- `ADMIN`:`DOCUMENT_BROWSER`、`BACKEND_MANAGEMENT`
-- `AUDITOR`:`AUDIT_LOG`
+- `ADMIN`:`DOCUMENT_BROWSER`、`BACKEND_MANAGEMENT`、`AUDIT_LOG`
 
 前端必须使用后端返回的 `allowedModules`,不得根据角色自行补全模块。
 
@@ -651,8 +649,6 @@ rowVersion=<integer>
   "summary": "概述",
   "categoryId": "100",
   "securityLevel": "SECRET",
-  "visibilityType": "ORGANIZATION",
-  "status": "DRAFT",
   "tags": ["标签"]
 }
 ```
@@ -668,6 +664,16 @@ rowVersion=<integer>
 
 子方案 `parentDocumentId` 必须指向有效主案。返回 `DocumentDetail`,HTTP `201`。
 
+名称唯一性与覆盖:
+
+- 有效 `MAIN` 的 `documentName` 全局唯一;
+- 有效 `SUB_PLAN` 的 `documentName` 在同一 `parentDocumentId` 下唯一;
+- 首次上传重名返回 `409 DOCUMENT_NAME_CONFLICT`,`details` 包含 `existingDocumentId`、`existingDocumentName`、`existingRowVersion`、`documentType`、`parentDocumentId`;
+- 用户确认覆盖后,在原 metadata 中额外同时提交 `overwriteDocumentId` 和 `rowVersion`;只提交其中一个返回 `400 INVALID_ARGUMENT`;
+- 覆盖必须命中同一唯一范围内的当前记录并通过乐观锁,否则返回 `409 DATA_VERSION_CONFLICT`;
+- 覆盖保留文档ID、主案ACL、附件挂载和父子关系,替换原文件与本次填写的元数据,递增文档版本并写上传审计;事务提交成功后清理旧文件;
+- 批量导入不执行交互式覆盖,重名文件作为该项失败返回 `DOCUMENT_NAME_CONFLICT`。
+
 ### 9.3 `POST /api/v1/documents/batch-import`
 
 管理员接口,`multipart/form-data`:
@@ -998,7 +1004,7 @@ mainPlanRowVersion=<integer>
 ### 13.1 `GET /api/v1/documents/{id}/preview`
 
 - PDF成功返回 inline Blob;
-- Office文件返回 `415 PREVIEW_UNAVAILABLE`;
+- DOCX成功返回原始DOCX二进制和正式MIME;DOC、XLS、XLSX返回 `415 PREVIEW_UNAVAILABLE`;
 - 无查看权限返回 `403 DOCUMENT_VIEW_FORBIDDEN`;
 - 文件缺失返回 `404 FILE_NOT_FOUND`。
 
@@ -1011,7 +1017,7 @@ mainPlanRowVersion=<integer>
 
 ## 14. 回收站与文档恢复
 
-仅有效 `ADMIN` 可以访问本章接口。`USER`和`AUDITOR`返回 `403 FORBIDDEN`。
+仅有效 `ADMIN` 可以访问本章接口。`USER`返回 `403 FORBIDDEN`。
 
 本阶段只支持 `MAIN`、`SUB_PLAN`和`ATTACHMENT`的回收站查询与单条恢复。不支持分类、用户或组织恢复,不支持批量恢复、物理删除、自动清理、保留期限、ACL或挂载关系独立恢复,也不移动文件到 `recycle` 目录。
 
@@ -1316,7 +1322,7 @@ RESTORE_RELATION_CONFLICT
 GET /api/v1/config/ui-dictionaries
 ```
 
-- 认证:有效登录用户,`ADMIN`、`AUDITOR`、`USER`均可访问。
+- 认证:有效登录用户,`ADMIN`、`USER`均可访问。
 - 请求参数:无。
 - 成功:`200`,统一响应中的 `data` 为 `UiDictionaries`。
 - 失败:无Token或失效Token返回统一 `401`;启动期配置错误导致应用启动失败,运行期意外错误使用统一 `500`。
@@ -1394,3 +1400,15 @@ GET /api/v1/config/ui-dictionaries
 版本1.2经统领会话批准,增加 `GET /api/v1/config/ui-dictionaries` 只读接口和严格启动配置规则;既有业务路径、枚举代码及数据库值不变,OpenAPI同步升级为 `1.2.0`。
 
 版本1.3经统领会话批准,为方案、直属子方案和已挂载附件三个既有查询接口统一增加文档更新时间闭区间筛选;不新增路径、DTO、数据库字段或迁移,OpenAPI同步升级为 `1.3.0`。Office预览使用1.4或后续版本。
+
+## 20. Q3-B 1.4接口补充
+
+1. 正式`RoleCode`仅为`ADMIN`、`USER`。四个审计查询接口与文档统计接口均要求ADMIN。历史AUDITOR不能登录,旧Token必须因authVersion或用户状态校验返回401。
+2. `GET /api/v1/documents`新增可选布尔参数`includeDescendants`。非法布尔值返回`400 INVALID_ARGUMENT`。仅在传入`categoryId`时true表示包含该分类全部有效后代;缺省或false为精确分类;未传分类时查询当前用户全部可见MAIN。
+3. MAIN创建metadata仅接受`documentName`、`documentType=MAIN`、`categoryId`、`securityLevel`、`summary`、`tags`。SUB_PLAN创建仅接受`documentName`、`documentType=SUB_PLAN`、`parentDocumentId`、`securityLevel`、`summary`、`tags`。未知字段严格拒绝。单文件和批量导入一致。
+4. MAIN编辑仅接受`documentName`、`summary`、`categoryId`、`securityLevel`、`tags`、`rowVersion`;SUB_PLAN编辑不含`categoryId`。两者均不接受status或visibilityType。
+5. 非叶子分类用于MAIN创建或编辑时返回`409 CATEGORY_NOT_LEAF`,details至少包含字符串`categoryId`和整数`childCategoryCount`。
+6. `GET /api/v1/attachments`仅ADMIN可访问。附件详情、统一preview/download路径对USER执行“至少一个有效挂载主案及对应VIEW/DOWNLOAD权限”检查。
+7. `GET /api/v1/documents/{id}/preview`的200响应支持`application/pdf`与`application/vnd.openxmlformats-officedocument.wordprocessingml.document`,返回原文件并携带inline Content-Disposition、X-Content-Type-Options、Cache-Control和X-Request-Id。DOC、XLS、XLSX返回统一JSON `415 PREVIEW_UNAVAILABLE`。
+8. keyword继续检索名称、摘要和标签,并扩展检索数据库中的正文组合字段;LIKE通配字符必须转义,过滤在分页前执行,不在查询时逐文件读取磁盘。
+9. 本版本不新增路径;OpenAPI `info.version`为`1.4.0`,迁移为`0003_q3_business_alignment_and_content_search`。

+ 24 - 10
DMS_FUNCTION_CONTRACT.md

@@ -1,7 +1,7 @@
 # 方案计划文档管理系统功能契约
 
-> 契约版本:1.3
-> 状态:已确认,Q2-B更新时间范围检索执行基线
+> 契约版本:1.5
+> 状态:已确认,统一方案上传与同名覆盖执行基线
 > 适用范围:前端、后端、数据库、联调、测试与验收
 
 ## 1. 契约效力
@@ -64,8 +64,7 @@
 | 角色 | 代码 | 功能边界 |
 |---|---|---|
 | 普通用户 | `USER` | 浏览有权访问的已发布方案;查看、下载共享附件 |
-| 管理员 | `ADMIN` | 进入后台管理;管理分类、方案、附件、挂载和权限 |
-| 审计员 | `AUDITOR` | 进入日志审计;默认不能编辑文档 |
+| 管理员 | `ADMIN` | 浏览文档、进入后台管理及日志审计;管理分类、方案、附件、挂载和权限 |
 
 登录成功后,后端返回用户可访问模块 `allowedModules`。前端不得仅根据本地角色字符串猜测导航入口。
 
@@ -129,7 +128,7 @@
 
 1. 普通用户只可查询和读取 `PUBLISHED` 文档;
 2. 管理员后台可以查询全部状态;
-3. 审计员不因审计角色自动获得方案正文访问权;
+3. 普通用户不因登录而自动获得未授权方案正文访问权;
 4. 逻辑删除数据不出现在正常查询中;
 5. 前端按钮由后端返回的 `allowedActions` 决定;
 6. 前端隐藏按钮不能替代后端鉴权。
@@ -246,6 +245,9 @@
 方案管理:
 
 - 上传主案或子方案;
+- 单文件上传在同一界面选择“主案”或“子案”;选择子案后必须选择所属主案,分类由该主案自动继承;
+- 有效主案名称全局唯一;有效子案名称在同一所属主案内唯一;不同主案允许存在同名子案;
+- 上传发生名称冲突时先返回冲突信息,由用户明确确认后覆盖;覆盖保留原文档ID、权限、挂载及父子关系,仅替换文件与本次填写的元数据;
 - 编辑元数据;
 - 查看、下载;
 - 配置主案权限;
@@ -266,7 +268,7 @@
 
 ### 9.4 日志审计页
 
-- 仅具有 `AUDIT_LOG` 模块权限的用户可进入
+- 仅 `ADMIN` 可进入,管理员具有 `AUDIT_LOG` 模块;
 - 支持关键词、时间、用户、组织、动作、结果和目标筛选;
 - 支持分页;
 - 展示操作趋势、动作分布和活跃用户统计;
@@ -297,10 +299,10 @@
 
 预览规则:
 
-- PDF 支持浏览器在线预览
-- DOC、DOCX、XLS、XLSX 第一阶段不在线预览
-- Office 文件预览返回 `PREVIEW_UNAVAILABLE`,前端提供下载
-- 不得把原始 Office 文件伪装成可在线预览内容
+- PDF 和 DOCX 支持通过同一预览接口原样返回源二进制
+- DOC、XLS、XLSX 返回 `415 PREVIEW_UNAVAILABLE`
+- 不调用 Office、WPS 或 LibreOffice,不生成预览衍生文件
+- 不得把原始 Office 文件伪装成其他媒体类型
 
 ## 11. 删除规则
 
@@ -510,3 +512,15 @@ INDEX(is_deleted, deleted_at, id)
 版本1.3经统领会话批准,统一三个既有列表接口的文档更新时间范围检索,不新增路径、数据库字段、索引或迁移;Office预览不得占用1.3版本。
 
 Q2-B实现时必须同步将OpenAPI `info.version`升级为 `1.3.0`。
+
+## 18. Q3-B 1.4业务对齐
+
+1. 正式角色仅保留 `ADMIN` 和 `USER`。`ADMIN`返回`DOCUMENT_BROWSER`、`BACKEND_MANAGEMENT`、`AUDIT_LOG`,`USER`只返回`DOCUMENT_BROWSER`。四个审计查询接口和文档统计接口仅ADMIN可访问。历史AUDITOR必须失效旧Token、停用并逻辑删除,不得提升为ADMIN。
+2. 只有有效叶子分类可直接存放MAIN。选择存在有效子分类的分类创建或编辑MAIN时返回`409 CATEGORY_NOT_LEAF`,并在details返回`categoryId`和`childCategoryCount`。已直接存放有效MAIN的叶子分类不得新增有效子分类,也不得成为分类移动目标。
+3. `CategoryNode.documentCount`表示当前JWT用户在该分类完整有效子树内有权查看的有效MAIN数量。关键词过滤保留祖先节点时,计数仍基于完整业务子树,不按过滤后的可见节点截断。
+4. `GET /api/v1/documents`支持`includeDescendants`。未传`categoryId`时查询全部可见MAIN;传分类并设置true时查询该分类及全部有效后代;false或缺省时精确匹配分类。
+5. MAIN创建不接收status和visibilityType,服务端固定为`PUBLISHED`、`CUSTOM`且初始ACL为空。SUB_PLAN创建不接收categoryId、status或visibilityType,分类继承父MAIN,状态固定`PUBLISHED`,可见范围动态继承父MAIN。编辑同样不接收status和visibilityType,SUB_PLAN也不接收categoryId;主案改分类时同步有效子方案的分类冗余。
+6. 独立共享附件库仅ADMIN可枚举和管理。USER仅能通过至少一个有VIEW或DOWNLOAD权限的有效已挂载主案访问对应附件详情、预览或下载。
+7. PDF与DOCX预览返回原文件二进制及准确MIME,使用inline Content-Disposition、`nosniff`和`private, no-store`,并执行授权、相对路径、普通文件、存在性、大小、SHA-256、扩展名、文件头及Office ZIP结构校验。DOC、XLS、XLSX返回`415 PREVIEW_UNAVAILABLE`。预览不增加downloadCount且不写DOWNLOAD_DOCUMENT审计。
+8. `doc_document`保存`content_text`、`content_extract_status`和`content_extracted_at`;`search_text`升级为LONGTEXT并组合名称、摘要、标签和正文。DOCX提取段落、表格、页眉页脚,PDF提取文本层;DOC为UNSUPPORTED。提取失败不丢失原文件。回填命令只允许显式连接`dms_test`且不得自动执行。
+9. 业务时区来自`DMS_BUSINESS_TIMEZONE`,默认`Asia/Shanghai`,非法值在应用配置加载阶段失败。本版本数据库迁移为`0003_q3_business_alignment_and_content_search`,OpenAPI版本为`1.4.0`。

+ 14 - 8
FUNCTION_AND_API_SPECIFICATION.md

@@ -1,6 +1,6 @@
 # 方案计划文档管理系统功能与接口规格
 
-> Q2-B更新时间范围检索同步版本:1.3。实现以 `DMS_FUNCTION_CONTRACT.md` 1.3和 `DMS_API_CONTRACT.md` 1.3为最终依据。
+> Q3-B业务规则、正文检索和原文件预览同步版本:1.4。实现以 `DMS_FUNCTION_CONTRACT.md` 1.4和 `DMS_API_CONTRACT.md` 1.4为最终依据。
 
 ## 1. 文档目的
 
@@ -46,9 +46,8 @@
 
 | 角色 | 说明 | 默认功能 |
 |---|---|---|
-| 普通用户 `USER` | 浏览方案资料的已登录用户 | 浏览有权访问的主案和子方案;浏览、下载全部共享附件 |
-| 管理员 `ADMIN` | 系统和文档管理人员 | 分类、主案、子方案、共享附件、权限和文件管理 |
-| 审计员 `AUDITOR` | 日志审计人员 | 查询操作日志和统计,不默认拥有文档编辑权限 |
+| 普通用户 `USER` | 浏览方案资料的已登录用户 | 浏览有权访问的主案、子方案及其已挂载附件 |
+| 管理员 `ADMIN` | 系统、文档及审计管理人员 | 分类、主案、子方案、共享附件、权限、文件和审计管理 |
 
 第一阶段角色固定,不提供动态角色配置页面。
 
@@ -214,7 +213,9 @@
 
 返回文档元数据、文件信息、分类路径、标签、权限概要、子方案数量和附件数量。
 
-### F-MAIN-003 上传主案
+### F-MAIN-003 上传主案或子案
+
+单文件上传统一在一个弹框中选择方案类型。子案选择所属主案后自动继承主案分类。主案名称全局唯一,子案名称在所属主案内唯一;重名时必须经用户确认后覆盖原记录,不采用先删后传。
 
 管理员上传一个文件并填写文档元数据。文件和数据库记录必须具有失败补偿。
 
@@ -577,8 +578,9 @@ X-Request-Id: <uuid>
 |---|---|---|
 | GET | `/api/v1/documents/{id}/preview` | 预览文档 |
 | GET | `/api/v1/documents/{id}/download` | 下载文档 |
-| GET | `/api/v1/attachments/{id}/preview` | 预览共享附件 |
-| GET | `/api/v1/attachments/{id}/download` | 下载共享附件 |
+
+共享附件同样使用统一的 `/api/v1/documents/{id}/preview` 和
+`/api/v1/documents/{id}/download` 路径;不提供附件专用的重复文件读取路径。
 
 ## 8.10 审计和统计
 
@@ -903,4 +905,8 @@ HTTP状态、message和details结构以 `DMS_API_CONTRACT.md` 1.1为准,响应
 - 子方案使用子方案自身更新时间;挂载附件使用附件文档自身更新时间,不使用挂载关系时间或排序号。
 - 时间必须是包含明确时区的ISO 8601格式,前端统一发送UTC `Z`;空字符串、无时区、非法格式和反向区间返回 `400 INVALID_ARGUMENT`。
 - 时间条件在数据库查询和分页前执行,且不改变权限、密级、状态、排序、DTO、计数和审计语义。
-- 不新增数据库、字段、索引或迁移;OpenAPI升级为 `1.3.0`。
+- Q2-B不新增数据库、字段、索引或迁移;当时OpenAPI升级为 `1.3.0`。
+
+## 22. Q3-B 1.4同步说明
+
+Q3-B将正式角色收敛为ADMIN和USER,审计能力归入ADMIN;分类仅允许叶子节点直接存放MAIN,分类树计数按当前用户在完整子树中的可见MAIN计算;文档列表支持`includeDescendants`。MAIN/SUB_PLAN创建和编辑不再接收status、visibilityType,SUB_PLAN分类继承主案。独立附件库仅ADMIN可枚举,USER按已挂载主案权限访问附件。统一preview路径原样返回PDF或DOCX,DOC/XLS/XLSX返回415,不调用Office类软件。`0003_q3_business_alignment_and_content_search`增加正文提取字段并将search_text扩展为LONGTEXT,keyword组合检索元数据与正文;当前参数化LIKE适用于现阶段规模,但数据量增长后存在全表/大文本扫描风险,后续索引或搜索引擎方案必须另行评审。OpenAPI为1.4.0。

+ 4 - 5
backend/dms/api/v1/audit.py

@@ -8,7 +8,6 @@ from dms.api.v1.blueprint import api_v1
 from dms.common.response import success_response
 from dms.security.decorators import (
     admin_required,
-    auditor_required,
     bearer_auth_required,
 )
 from dms.services.audit_query_service import (
@@ -22,7 +21,7 @@ from dms.services.audit_query_service import (
 
 @api_v1.get("/audit/logs")
 @bearer_auth_required
-@auditor_required
+@admin_required
 def audit_logs_route():
     return success_response(audit_log_page(request.args))
 
@@ -36,20 +35,20 @@ def document_statistics_route():
 
 @api_v1.get("/audit/statistics/trend")
 @bearer_auth_required
-@auditor_required
+@admin_required
 def audit_trend_statistics_route():
     return success_response(trend_statistics(request.args))
 
 
 @api_v1.get("/audit/statistics/actions")
 @bearer_auth_required
-@auditor_required
+@admin_required
 def audit_action_statistics_route():
     return success_response(action_statistics(request.args))
 
 
 @api_v1.get("/audit/statistics/users")
 @bearer_auth_required
-@auditor_required
+@admin_required
 def audit_active_user_statistics_route():
     return success_response(active_user_statistics(request.args))

+ 1 - 0
backend/dms/api/v1/documents.py

@@ -220,6 +220,7 @@ def sub_plans_list_route(id: str):
 
 @api_v1.get("/attachments")
 @bearer_auth_required
+@admin_required
 def attachments_list_route():
     return success_response(list_attachments(request.args))
 

+ 127 - 0
backend/dms/backfill_document_content.py

@@ -0,0 +1,127 @@
+"""显式、幂等地回填既有PDF/DOCX正文及search_text。
+
+仅允许显式配置并连接既有 ``dms_test``:
+``python -m dms.backfill_document_content``。
+"""
+
+from __future__ import annotations
+
+import os
+
+from flask import Flask
+from sqlalchemy import select, text
+from sqlalchemy.engine import make_url
+
+from dms import init_dms
+from dms.common.enums import ContentExtractStatus
+from dms.extensions import db
+from dms.models import Document
+from dms.services.document_content_service import (
+    build_search_text,
+    extract_document_content,
+)
+from dms.services.recycle_bin_service import _verify_file
+
+
+def _database_gate() -> None:
+    configured = os.environ.get("DMS_DATABASE_URL", "").strip()
+    if not configured:
+        raise RuntimeError("必须显式配置DMS_DATABASE_URL并指向dms_test")
+    try:
+        configured_database = make_url(configured).database
+    except Exception as exc:
+        raise RuntimeError("DMS_DATABASE_URL不是有效数据库连接配置") from exc
+    if configured_database != "dms_test":
+        raise RuntimeError("正文回填命令只允许连接dms_test")
+    database_name = db.session.scalar(text("SELECT DATABASE()"))
+    if database_name != "dms_test":
+        raise RuntimeError("正文回填命令只允许连接dms_test")
+
+
+def backfill() -> dict[str, int]:
+    _database_gate()
+    result = {"processed": 0, "skipped": 0, "failed": 0}
+    document_ids = db.session.scalars(
+        select(Document.id).order_by(Document.id.asc())
+    ).all()
+    for document_id in document_ids:
+        document = db.session.get(Document, document_id)
+        assert document is not None
+        extension = document.file_extension.lower().lstrip(".")
+        if extension not in {"pdf", "docx"}:
+            if document.content_extract_status != ContentExtractStatus.UNSUPPORTED:
+                document.content_text = None
+                document.content_extract_status = (
+                    ContentExtractStatus.UNSUPPORTED.value
+                )
+                document.content_extracted_at = None
+                document.search_text = build_search_text(
+                    document.document_name,
+                    document.summary,
+                    document.tags,
+                    None,
+                )
+                db.session.commit()
+                result["processed"] += 1
+            else:
+                result["skipped"] += 1
+            continue
+        if document.content_extract_status in {
+            ContentExtractStatus.SUCCESS.value,
+            ContentExtractStatus.EMPTY.value,
+        } and document.content_extracted_at is not None:
+            result["skipped"] += 1
+            continue
+        verified = None
+        try:
+            verified = _verify_file(document)
+            extraction = extract_document_content(verified.path, extension)
+            document.content_text = extraction.text
+            document.content_extract_status = extraction.status
+            document.content_extracted_at = extraction.extracted_at
+            document.search_text = build_search_text(
+                document.document_name,
+                document.summary,
+                document.tags,
+                extraction.text,
+            )
+            db.session.commit()
+            result["processed"] += 1
+            if extraction.status == ContentExtractStatus.FAILED.value:
+                result["failed"] += 1
+        except Exception:
+            db.session.rollback()
+            document = db.session.get(Document, document_id)
+            if document is not None:
+                document.content_text = None
+                document.content_extract_status = ContentExtractStatus.FAILED.value
+                document.content_extracted_at = None
+                document.search_text = build_search_text(
+                    document.document_name,
+                    document.summary,
+                    document.tags,
+                    None,
+                )
+                db.session.commit()
+            result["processed"] += 1
+            result["failed"] += 1
+        finally:
+            if verified is not None:
+                verified.close()
+    return result
+
+
+def main() -> None:
+    app = Flask("dms-content-backfill")
+    init_dms(app)
+    with app.app_context():
+        outcome = backfill()
+    print(
+        "正文回填完成:"
+        f"处理{outcome['processed']}个,跳过{outcome['skipped']}个,"
+        f"失败{outcome['failed']}个。"
+    )
+
+
+if __name__ == "__main__":
+    main()

+ 7 - 1
backend/dms/common/enums.py

@@ -8,7 +8,6 @@ from enum import StrEnum
 class RoleCode(StrEnum):
     USER = "USER"
     ADMIN = "ADMIN"
-    AUDITOR = "AUDITOR"
 
 
 class AllowedModule(StrEnum):
@@ -118,3 +117,10 @@ class AuditTarget(StrEnum):
     ATTACHMENT = "ATTACHMENT"
     PERMISSION = "PERMISSION"
     ATTACHMENT_BINDING = "ATTACHMENT_BINDING"
+
+
+class ContentExtractStatus(StrEnum):
+    SUCCESS = "SUCCESS"
+    EMPTY = "EMPTY"
+    UNSUPPORTED = "UNSUPPORTED"
+    FAILED = "FAILED"

+ 10 - 0
backend/dms/common/errors.py

@@ -93,6 +93,11 @@ class CategoryInUseError(ConflictError):
     default_message = "分类仍被子分类或方案文档占用"
 
 
+class CategoryNotLeafError(ConflictError):
+    code = "CATEGORY_NOT_LEAF"
+    default_message = "只有有效叶子分类可以直接存放主案"
+
+
 class MainPlanNotFoundError(ResourceNotFoundError):
     code = "MAIN_PLAN_NOT_FOUND"
     default_message = "主案不存在"
@@ -119,6 +124,11 @@ class MainPlanHasChildrenError(ConflictError):
     default_message = "主案仍存在有效子方案"
 
 
+class DocumentNameConflictError(ConflictError):
+    code = "DOCUMENT_NAME_CONFLICT"
+    default_message = "同一范围内已存在同名方案"
+
+
 class AttachmentInUseError(ConflictError):
     code = "ATTACHMENT_IN_USE"
     default_message = "共享附件仍被主案挂载"

+ 11 - 4
backend/dms/config.py

@@ -5,6 +5,7 @@ from __future__ import annotations
 import os
 from pathlib import Path
 from typing import Any
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 
 
 BACKEND_ROOT = Path(__file__).resolve().parents[1]
@@ -50,6 +51,15 @@ def load_dms_config() -> dict[str, Any]:
         ui_dictionary_config_value or DEFAULT_UI_DICTIONARY_CONFIG_PATH
     ).expanduser()
 
+    business_timezone = (
+        os.environ.get("DMS_BUSINESS_TIMEZONE", "").strip()
+        or "Asia/Shanghai"
+    )
+    try:
+        ZoneInfo(business_timezone)
+    except (ZoneInfoNotFoundError, ValueError) as exc:
+        raise ValueError("DMS_BUSINESS_TIMEZONE必须是有效的IANA时区") from exc
+
     return {
         "SQLALCHEMY_DATABASE_URI": database_url or DEFAULT_DATABASE_URL,
         "SQLALCHEMY_TRACK_MODIFICATIONS": False,
@@ -66,10 +76,7 @@ def load_dms_config() -> dict[str, Any]:
         "DMS_MAX_FILE_SIZE": max_file_size,
         "MAX_CONTENT_LENGTH": max_upload_size,
         "DMS_BATCH_MAX_FILES": _positive_int("DMS_BATCH_MAX_FILES", 50),
-        "DMS_BUSINESS_TIMEZONE": (
-            os.environ.get("DMS_BUSINESS_TIMEZONE", "").strip()
-            or "Asia/Shanghai"
-        ),
+        "DMS_BUSINESS_TIMEZONE": business_timezone,
         "DMS_JWT_SECRET": os.environ.get("DMS_JWT_SECRET", ""),
         "DMS_JWT_ISSUER": "dms",
         "DMS_JWT_EXPIRES_SECONDS": 7200,

+ 19 - 1
backend/dms/models/document.py

@@ -2,6 +2,8 @@
 
 from __future__ import annotations
 
+from datetime import datetime
+
 from sqlalchemy import (
     BigInteger,
     CheckConstraint,
@@ -14,6 +16,7 @@ from sqlalchemy import (
     text,
 )
 from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.dialects import mysql
 
 from dms.database.base import BusinessTableMixin
 from dms.extensions import db
@@ -44,6 +47,11 @@ class Document(BusinessTableMixin, db.Model):
             "('POLICY', 'WORK_STANDARD', 'TABLE', 'DIAGRAM', 'OTHER')",
             name="document_attachment_type",
         ),
+        CheckConstraint(
+            "content_extract_status IN "
+            "('SUCCESS', 'EMPTY', 'UNSUPPORTED', 'FAILED')",
+            name="document_content_extract_status",
+        ),
         CheckConstraint(
             "("
             "document_type = 'MAIN' "
@@ -192,6 +200,16 @@ class Document(BusinessTableMixin, db.Model):
         nullable=False,
         server_default=text("0"),
     )
-    search_text: Mapped[str | None] = mapped_column(Text, nullable=True)
+    search_text: Mapped[str | None] = mapped_column(mysql.LONGTEXT, nullable=True)
+    content_text: Mapped[str | None] = mapped_column(mysql.LONGTEXT, nullable=True)
+    content_extract_status: Mapped[str] = mapped_column(
+        String(32),
+        nullable=False,
+        server_default=text("'UNSUPPORTED'"),
+    )
+    content_extracted_at: Mapped[datetime | None] = mapped_column(
+        mysql.DATETIME(fsp=3),
+        nullable=True,
+    )
     created_by_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
     updated_by_name: Mapped[str | None] = mapped_column(String(64), nullable=True)

+ 1 - 1
backend/dms/models/user.py

@@ -16,7 +16,7 @@ class User(BusinessTableMixin, db.Model):
     __tablename__ = "sys_user"
     __table_args__ = (
         CheckConstraint(
-            "role_code IN ('USER', 'ADMIN', 'AUDITOR')",
+            "role_code IN ('USER', 'ADMIN')",
             name="user_role_code",
         ),
         CheckConstraint(

+ 1 - 2
backend/dms/resources/ui-dictionaries.zh-CN.json

@@ -4,8 +4,7 @@
   "dictionaries": {
     "roleCodes": [
       {"code": "USER", "label": "普通用户", "sortNo": 10},
-      {"code": "ADMIN", "label": "管理员", "sortNo": 20},
-      {"code": "AUDITOR", "label": "审计员", "sortNo": 30}
+      {"code": "ADMIN", "label": "管理员", "sortNo": 20}
     ],
     "allowedModules": [
       {"code": "DOCUMENT_BROWSER", "label": "文档浏览", "sortNo": 10},

+ 0 - 10
backend/dms/security/decorators.py

@@ -82,13 +82,3 @@ def admin_required(view: F) -> F:
         return view(*args, **kwargs)
 
     return cast(F, wrapped)
-
-
-def auditor_required(view: F) -> F:
-    @wraps(view)
-    def wrapped(*args: Any, **kwargs: Any):
-        if get_auth_context().role_code != RoleCode.AUDITOR:
-            raise ForbiddenError()
-        return view(*args, **kwargs)
-
-    return cast(F, wrapped)

+ 1 - 13
backend/dms/seed_dev.py

@@ -1,7 +1,7 @@
 """显式、幂等的本地最小开发数据初始化。
 
 执行:python -m dms.seed_dev
-必须通过环境变量提供三个用户密码;默认不会覆盖已有用户密码。
+必须通过环境变量提供管理员和普通用户密码;默认不会覆盖已有用户密码。
 """
 
 from __future__ import annotations
@@ -20,7 +20,6 @@ from dms.security.passwords import hash_password
 
 PASSWORD_ENVIRONMENTS = {
     "admin": "DMS_SEED_ADMIN_PASSWORD",
-    "auditor": "DMS_SEED_AUDITOR_PASSWORD",
     "user": "DMS_SEED_USER_PASSWORD",
 }
 
@@ -146,17 +145,6 @@ def seed() -> tuple[int, int]:
             overwrite_password=overwrite_passwords,
         )
     )
-    created_users += int(
-        _user(
-            username="auditor",
-            real_name="审计员",
-            password=passwords["auditor"],
-            role_code=RoleCode.AUDITOR,
-            security_level=SecurityLevel.TOP_SECRET,
-            organization=root,
-            overwrite_password=overwrite_passwords,
-        )
-    )
     created_users += int(
         _user(
             username="user",

+ 3 - 3
backend/dms/seed_documents.py

@@ -236,7 +236,7 @@ def seed_documents(
             Category.status == "ENABLED",
             description=f"{code}分类",
         )
-        for code in ("SCENE_A", "SCENE_B", "SCENE_C")
+        for code in ("STYLE_A1", "SCENE_B", "SCENE_C")
     }
 
     created_count = 0
@@ -249,7 +249,7 @@ def seed_documents(
             (
                 "2024年度综合应急预案",
                 "年度综合应急响应和资源协调方案。",
-                "SCENE_A",
+                "STYLE_A1",
                 SecurityLevel.INTERNAL,
                 VisibilityType.ALL_AUTHENTICATED,
                 ["应急", "年度"],
@@ -304,7 +304,7 @@ def seed_documents(
                 status=DocumentStatus.PUBLISHED,
                 security=SecurityLevel.INTERNAL,
                 visibility=VisibilityType.ALL_AUTHENTICATED,
-                category=categories["SCENE_A"],
+                category=categories["STYLE_A1"],
                 actor=actor,
                 tags=["子方案"],
                 new_files=new_files,

+ 26 - 12
backend/dms/services/attachment_query_service.py

@@ -17,6 +17,7 @@ from dms.security.auth_context import AuthContext, get_auth_context
 from dms.services.authorization_service import (
     attachment_allowed_actions,
     evaluate_plan_access,
+    require_attachment_access,
 )
 from dms.services.document_query_service import (
     DOCUMENT_SORTS,
@@ -44,6 +45,8 @@ def mounted_plan_count(attachment_id: int) -> int:
 def attachment_summary(
     attachment: Document,
     context: AuthContext,
+    *,
+    plan_access=None,
 ) -> dict[str, object]:
     return {
         "id": serialize_id(attachment.id),
@@ -70,7 +73,7 @@ def attachment_summary(
         "createdAt": _iso(attachment.created_at),
         "updatedAt": _iso(attachment.updated_at),
         "rowVersion": attachment.row_version,
-        "allowedActions": attachment_allowed_actions(context),
+        "allowedActions": attachment_allowed_actions(context, plan_access),
         "mountedPlanCount": mounted_plan_count(attachment.id),
     }
 
@@ -116,13 +119,14 @@ def _filtered_attachments(params: Mapping[str, str]):
     )
     keyword = params.get("keyword")
     if keyword and keyword.strip():
-        pattern = f"%{keyword.strip()}%"
+        escaped = keyword.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+        pattern = f"%{escaped}%"
         statement = statement.where(
             or_(
-                Document.document_name.like(pattern),
-                Document.summary.like(pattern),
-                Document.search_text.like(pattern),
-                cast(Document.tags, String).like(pattern),
+                Document.document_name.like(pattern, escape="\\"),
+                Document.summary.like(pattern, escape="\\"),
+                Document.search_text.like(pattern, escape="\\"),
+                cast(Document.tags, String).like(pattern, escape="\\"),
             )
         )
     attachment_type = _enum(
@@ -161,7 +165,14 @@ def list_attachments(params: Mapping[str, str]) -> dict[str, object]:
 def get_attachment(attachment_id: int) -> dict[str, object]:
     context = get_auth_context()
     attachment = _attachment(attachment_id)
+    can_view, can_download = require_attachment_access(
+        attachment, context, download=False
+    )
     result = attachment_detail(attachment, context)
+    if context.role_code.value == "USER":
+        result["allowedActions"] = ["VIEW"] + (
+            ["DOWNLOAD"] if can_download else []
+        )
     result["viewCount"] = _record_view(attachment)
     return result
 
@@ -212,7 +223,7 @@ def list_main_plan_attachments(
     )
     if main is None:
         raise MainPlanNotFoundError()
-    _require_plan_access(main, context)
+    main_access = _require_plan_access(main, context)
     page = _page(params)
     statement = (
         select(AttachmentBinding, Document)
@@ -226,12 +237,13 @@ def list_main_plan_attachments(
     )
     keyword = params.get("keyword")
     if keyword and keyword.strip():
-        pattern = f"%{keyword.strip()}%"
+        escaped = keyword.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+        pattern = f"%{escaped}%"
         statement = statement.where(
             or_(
-                Document.document_name.like(pattern),
-                Document.summary.like(pattern),
-                Document.search_text.like(pattern),
+                Document.document_name.like(pattern, escape="\\"),
+                Document.summary.like(pattern, escape="\\"),
+                Document.search_text.like(pattern, escape="\\"),
             )
         )
     attachment_type = _enum(
@@ -254,7 +266,9 @@ def list_main_plan_attachments(
     selected = rows[page.offset : page.offset + page.page_size]
     items: list[dict[str, object]] = []
     for binding, attachment in selected:
-        item = attachment_summary(attachment, context)
+        item = attachment_summary(
+            attachment, context, plan_access=main_access
+        )
         item.update(
             {
                 "bindingId": serialize_id(binding.id),

+ 64 - 6
backend/dms/services/authorization_service.py

@@ -20,7 +20,11 @@ from dms.common.enums import (
 from dms.extensions import db
 from dms.models import AttachmentBinding, Document, Organization, Permission
 from dms.security.auth_context import AuthContext
-from dms.common.errors import SecurityLevelForbiddenError
+from dms.common.errors import (
+    DocumentDownloadForbiddenError,
+    DocumentViewForbiddenError,
+    SecurityLevelForbiddenError,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -109,8 +113,6 @@ def evaluate_plan_access(
         return PlanAccess(False, "STATUS", source, (), False)
     if not _security_allowed(document, context):
         return PlanAccess(False, "SECURITY", source, (), False)
-    if context.role_code == RoleCode.AUDITOR:
-        return PlanAccess(False, "ROLE", source, (), False)
     if context.role_code == RoleCode.ADMIN:
         return PlanAccess(True, None, source, (), True)
 
@@ -196,8 +198,64 @@ def plan_allowed_actions(
     return actions
 
 
-def attachment_allowed_actions(context: AuthContext) -> list[str]:
-    actions = [AllowedAction.VIEW.value, AllowedAction.DOWNLOAD.value]
+def attachment_allowed_actions(
+    context: AuthContext,
+    access: PlanAccess | None = None,
+) -> list[str]:
     if context.role_code == RoleCode.ADMIN:
-        actions.extend([AllowedAction.EDIT.value, AllowedAction.DELETE.value])
+        return [
+            AllowedAction.VIEW.value,
+            AllowedAction.DOWNLOAD.value,
+            AllowedAction.EDIT.value,
+            AllowedAction.DELETE.value,
+        ]
+    actions: list[str] = []
+    if access is not None and access.allowed:
+        actions.append(AllowedAction.VIEW.value)
+        if access.can_download:
+            actions.append(AllowedAction.DOWNLOAD.value)
     return actions
+
+
+def evaluate_attachment_access(
+    attachment: Document,
+    context: AuthContext,
+) -> tuple[bool, bool]:
+    """USER只能通过至少一个有权有效主案访问共享附件。"""
+    if context.role_code == RoleCode.ADMIN:
+        return True, True
+    mains = db.session.scalars(
+        select(Document)
+        .join(
+            AttachmentBinding,
+            AttachmentBinding.main_document_id == Document.id,
+        )
+        .where(
+            AttachmentBinding.attachment_document_id == attachment.id,
+            AttachmentBinding.is_deleted.is_(False),
+            Document.document_type == DocumentType.MAIN.value,
+            Document.is_deleted.is_(False),
+        )
+        .order_by(Document.id.asc())
+    ).all()
+    can_view = False
+    can_download = False
+    for main in mains:
+        access = evaluate_plan_access(main, context)
+        can_view = can_view or access.allowed
+        can_download = can_download or (access.allowed and access.can_download)
+    return can_view, can_download
+
+
+def require_attachment_access(
+    attachment: Document,
+    context: AuthContext,
+    *,
+    download: bool,
+) -> tuple[bool, bool]:
+    can_view, can_download = evaluate_attachment_access(attachment, context)
+    if download and not can_download:
+        raise DocumentDownloadForbiddenError()
+    if not download and not can_view:
+        raise DocumentViewForbiddenError()
+    return can_view, can_download

+ 117 - 11
backend/dms/services/category_service.py

@@ -6,18 +6,25 @@ from collections import deque
 from datetime import datetime, timezone
 from typing import Any
 
-from sqlalchemy import func, select, update
+from sqlalchemy import and_, exists, func, or_, select, update
 from sqlalchemy.exc import IntegrityError
 
 from dms.common.enums import (
     AuditAction,
     AuditTarget,
     CategoryType,
+    DocumentStatus,
     DocumentType,
     EnabledStatus,
+    RoleCode,
+    SECURITY_LEVEL_VALUES,
+    SecurityLevel,
+    SubjectType,
+    VisibilityType,
 )
 from dms.common.errors import (
     CategoryInUseError,
+    CategoryNotLeafError,
     ConflictError,
     InvalidArgumentError,
     ResourceNotFoundError,
@@ -25,9 +32,10 @@ from dms.common.errors import (
 from dms.common.response import serialize_id
 from dms.database.transaction import transaction
 from dms.extensions import db
-from dms.models import Category, Document
+from dms.models import Category, Document, Permission
 from dms.security.auth_context import get_auth_context
 from dms.services.audit_service import business_audit
+from dms.services.authorization_service import _active_organization_ancestor_ids
 
 
 CREATE_FIELDS = {
@@ -181,15 +189,57 @@ def category_tree(
         .where(Category.is_deleted.is_(False))
         .order_by(Category.sort_no.asc(), Category.id.asc())
     ).all()
-    main_document_counts = dict(
-        db.session.execute(
-            select(Document.category_id, func.count(Document.id))
-            .where(
-                Document.category_id.is_not(None),
-                Document.document_type == DocumentType.MAIN.value,
-                Document.is_deleted.is_(False),
+    context = get_auth_context()
+    visible_main = select(Document.category_id, func.count(Document.id)).where(
+        Document.category_id.is_not(None),
+        Document.document_type == DocumentType.MAIN.value,
+        Document.is_deleted.is_(False),
+    )
+    allowed_security_levels = [
+        level.value
+        for level, rank in SECURITY_LEVEL_VALUES.items()
+        if rank <= SECURITY_LEVEL_VALUES[context.security_level]
+    ]
+    visible_main = visible_main.where(
+        Document.security_level.in_(allowed_security_levels)
+    )
+    if context.role_code == RoleCode.USER:
+        ancestor_ids = _active_organization_ancestor_ids(context.organization_id)
+        org_permission = exists(
+            select(Permission.id).where(
+                Permission.document_id == Document.id,
+                Permission.is_deleted.is_(False),
+                Permission.can_view.is_(True),
+                Permission.subject_type == SubjectType.ORG.value,
+                Permission.subject_id.in_(ancestor_ids or {-1}),
+            )
+        )
+        user_permission = exists(
+            select(Permission.id).where(
+                Permission.document_id == Document.id,
+                Permission.is_deleted.is_(False),
+                Permission.can_view.is_(True),
+                Permission.subject_type == SubjectType.USER.value,
+                Permission.subject_id == context.user_id,
             )
-            .group_by(Document.category_id)
+        )
+        visible_main = visible_main.where(
+            Document.document_status == DocumentStatus.PUBLISHED.value,
+            or_(
+                Document.visibility_type == VisibilityType.ALL_AUTHENTICATED.value,
+                and_(
+                    Document.visibility_type == VisibilityType.ORGANIZATION.value,
+                    org_permission,
+                ),
+                and_(
+                    Document.visibility_type == VisibilityType.CUSTOM.value,
+                    or_(org_permission, user_permission),
+                ),
+            ),
+        )
+    direct_main_counts = dict(
+        db.session.execute(
+            visible_main.group_by(Document.category_id)
         ).all()
     )
     by_id = {item.id: item for item in categories}
@@ -223,6 +273,19 @@ def category_tree(
     else:
         included_ids = eligible_ids
 
+    subtree_counts = {
+        category.id: 0
+        for category in categories
+        if category.status == EnabledStatus.ENABLED.value
+    }
+    for category_id, count in direct_main_counts.items():
+        current_id = category_id
+        seen: set[int] = set()
+        while current_id in subtree_counts and current_id not in seen:
+            seen.add(current_id)
+            subtree_counts[current_id] += int(count)
+            current_id = by_id[current_id].parent_id
+
     nodes: dict[int, dict[str, object]] = {}
     for category in categories:
         if category.id in included_ids:
@@ -231,7 +294,7 @@ def category_tree(
             )
             nodes[category.id] = _dto(
                 category,
-                document_count=main_document_counts.get(category.id, 0),
+                document_count=subtree_counts.get(category.id, 0),
             )
             nodes[category.id]["parentId"] = serialize_id(response_parent_id)
 
@@ -261,6 +324,27 @@ def _valid_parent(session, parent_id: int | None) -> Category | None:
     )
     if parent is None:
         raise InvalidArgumentError("parentId必须指向有效且已启用的分类")
+    direct_main_count = session.scalar(
+        select(func.count(Document.id)).where(
+            Document.category_id == parent.id,
+            Document.document_type == DocumentType.MAIN.value,
+            Document.is_deleted.is_(False),
+        )
+    ) or 0
+    if direct_main_count:
+        child_count = session.scalar(
+            select(func.count(Category.id)).where(
+                Category.parent_id == parent.id,
+                Category.is_deleted.is_(False),
+                Category.status == EnabledStatus.ENABLED.value,
+            )
+        ) or 0
+        raise CategoryNotLeafError(
+            details={
+                "categoryId": serialize_id(parent.id),
+                "childCategoryCount": int(child_count),
+            }
+        )
     return parent
 
 
@@ -373,6 +457,28 @@ def update_category(category_id: int, payload: Any) -> dict[str, object]:
                 or parent.status != EnabledStatus.ENABLED.value
             ):
                 raise InvalidArgumentError("parentId必须指向有效且已启用的分类")
+            if parent_id != category.parent_id:
+                direct_main_count = session.scalar(
+                    select(func.count(Document.id)).where(
+                        Document.category_id == parent.id,
+                        Document.document_type == DocumentType.MAIN.value,
+                        Document.is_deleted.is_(False),
+                    )
+                ) or 0
+                if direct_main_count:
+                    child_count = session.scalar(
+                        select(func.count(Category.id)).where(
+                            Category.parent_id == parent.id,
+                            Category.is_deleted.is_(False),
+                            Category.status == EnabledStatus.ENABLED.value,
+                        )
+                    ) or 0
+                    raise CategoryNotLeafError(
+                        details={
+                            "categoryId": serialize_id(parent.id),
+                            "childCategoryCount": int(child_count),
+                        }
+                    )
 
         before = {
             "categoryName": category.category_name,

+ 99 - 0
backend/dms/services/document_content_service.py

@@ -0,0 +1,99 @@
+"""不依赖AI或Office进程的PDF/DOCX正文提取与检索文本构建。"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+
+import fitz
+from docx import Document as WordDocument
+
+from dms.common.enums import ContentExtractStatus
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True)
+class ContentExtraction:
+    text: str | None
+    status: str
+    extracted_at: datetime | None
+
+
+def build_search_text(
+    name: str,
+    summary: str | None,
+    tags: list[str] | None,
+    content_text: str | None,
+) -> str:
+    return " ".join(
+        part for part in [name, summary or "", *(tags or []), content_text or ""]
+        if part
+    ).strip()
+
+
+def _word_text(path: Path) -> str:
+    document = WordDocument(path)
+    values: list[str] = []
+
+    def add_paragraphs(paragraphs) -> None:
+        values.extend(
+            paragraph.text.strip()
+            for paragraph in paragraphs
+            if paragraph.text.strip()
+        )
+
+    def add_tables(tables) -> None:
+        for table in tables:
+            for row in table.rows:
+                for cell in row.cells:
+                    add_paragraphs(cell.paragraphs)
+                    add_tables(cell.tables)
+
+    add_paragraphs(document.paragraphs)
+    add_tables(document.tables)
+    for section in document.sections:
+        add_paragraphs(section.header.paragraphs)
+        add_tables(section.header.tables)
+        add_paragraphs(section.footer.paragraphs)
+        add_tables(section.footer.tables)
+    return "\n".join(values).strip()
+
+
+def _pdf_text(path: Path) -> str:
+    with fitz.open(stream=path.read_bytes(), filetype="pdf") as document:
+        return "\n".join(page.get_text("text") for page in document).strip()
+
+
+def extract_document_content(path: Path, extension: str) -> ContentExtraction:
+    normalized = extension.lower().lstrip(".")
+    if normalized not in {"pdf", "docx"}:
+        return ContentExtraction(
+            text=None,
+            status=ContentExtractStatus.UNSUPPORTED.value,
+            extracted_at=None,
+        )
+    extracted_at = datetime.now(timezone.utc).replace(tzinfo=None)
+    try:
+        text = _pdf_text(path) if normalized == "pdf" else _word_text(path)
+    except Exception:
+        logger.exception("文档正文提取失败:extension=%s", normalized)
+        return ContentExtraction(
+            text=None,
+            status=ContentExtractStatus.FAILED.value,
+            extracted_at=extracted_at,
+        )
+    return ContentExtraction(
+        text=text or None,
+        status=(
+            ContentExtractStatus.SUCCESS.value
+            if text
+            else ContentExtractStatus.EMPTY.value
+        ),
+        extracted_at=extracted_at,
+    )
+
+
+__all__ = ["ContentExtraction", "build_search_text", "extract_document_content"]

+ 249 - 51
backend/dms/services/document_mutation_service.py

@@ -3,8 +3,10 @@
 from __future__ import annotations
 
 from datetime import datetime, timezone
+import logging
 from typing import Any
 
+from flask import current_app
 from sqlalchemy import func, or_, select
 from werkzeug.datastructures import FileStorage
 
@@ -20,7 +22,9 @@ from dms.common.enums import (
 )
 from dms.common.errors import (
     AttachmentInUseError,
+    CategoryNotLeafError,
     ConflictError,
+    DocumentNameConflictError,
     InvalidArgumentError,
     MainPlanHasChildrenError,
     ResourceNotFoundError,
@@ -34,25 +38,44 @@ from dms.services.attachment_query_service import attachment_detail
 from dms.services.audit_service import business_audit
 from dms.services.authorization_service import evaluate_plan_access
 from dms.services.document_query_service import document_detail
+from dms.services.document_content_service import (
+    build_search_text,
+    extract_document_content,
+)
 from dms.storage.uploads import StagedUpload, stage_upload
+from dms.storage.paths import UnsafeStoragePathError, resolve_storage_path
+
+logger = logging.getLogger(__name__)
 
-PLAN_CREATE_FIELDS = {
+MAIN_CREATE_FIELDS = {
     "documentName",
     "documentType",
     "summary",
     "categoryId",
     "securityLevel",
-    "visibilityType",
-    "status",
     "tags",
 }
-PLAN_EDIT_FIELDS = {
+OVERWRITE_FIELDS = {"overwriteDocumentId", "rowVersion"}
+SUB_PLAN_CREATE_FIELDS = {
+    "documentName",
+    "documentType",
+    "summary",
+    "parentDocumentId",
+    "securityLevel",
+    "tags",
+}
+MAIN_EDIT_FIELDS = {
     "documentName",
     "summary",
     "categoryId",
     "securityLevel",
-    "visibilityType",
-    "status",
+    "tags",
+    "rowVersion",
+}
+SUB_PLAN_EDIT_FIELDS = {
+    "documentName",
+    "summary",
+    "securityLevel",
     "tags",
     "rowVersion",
 }
@@ -128,8 +151,23 @@ def _category(category_id: int) -> Category:
     return category
 
 
-def _search(name: str, summary: str | None, tags: list[str]) -> str:
-    return " ".join([name, summary or "", *tags]).strip()
+def _leaf_category(category_id: int) -> Category:
+    category = _category(category_id)
+    child_count = db.session.scalar(
+        select(func.count(Category.id)).where(
+            Category.parent_id == category.id,
+            Category.is_deleted.is_(False),
+            Category.status == EnabledStatus.ENABLED.value,
+        )
+    ) or 0
+    if child_count:
+        raise CategoryNotLeafError(
+            details={
+                "categoryId": serialize_id(category.id),
+                "childCategoryCount": int(child_count),
+            }
+        )
+    return category
 
 
 def _serialize(document: Document) -> dict[str, object]:
@@ -173,18 +211,21 @@ def _create_record(
             DocumentType.SUB_PLAN.value,
         }:
             raise InvalidArgumentError("documentType只允许MAIN或SUB_PLAN")
-        fields = PLAN_CREATE_FIELDS | (
-            {"parentDocumentId"}
+        create_fields = (
+            SUB_PLAN_CREATE_FIELDS
             if document_type == DocumentType.SUB_PLAN.value
-            else set()
+            else MAIN_CREATE_FIELDS
         )
-        _exact(payload, fields)
-        category = _category(_string_id(payload["categoryId"], "categoryId"))
+        if frozenset(payload) not in {
+            frozenset(create_fields),
+            frozenset(create_fields | OVERWRITE_FIELDS),
+        }:
+            raise InvalidArgumentError(
+                "metadata字段必须为新增字段全集,或额外同时包含overwriteDocumentId和rowVersion"
+            )
         security = _enum(payload["securityLevel"], SecurityLevel, "securityLevel")
-        visibility = _enum(
-            payload["visibilityType"], VisibilityType, "visibilityType"
-        )
-        status = _enum(payload["status"], DocumentStatus, "status")
+        visibility = VisibilityType.CUSTOM.value
+        status = DocumentStatus.PUBLISHED.value
         if document_type == DocumentType.SUB_PLAN.value:
             parent_id = _string_id(payload["parentDocumentId"], "parentDocumentId")
             parent = db.session.scalar(
@@ -197,15 +238,125 @@ def _create_record(
             if parent is None:
                 raise ResourceNotFoundError("父文档必须是有效主案")
             visibility = parent.visibility_type
+            category = _category(parent.category_id)
+        else:
+            category = _leaf_category(
+                _string_id(payload["categoryId"], "categoryId")
+            )
 
     name = _text(payload["documentName"], "documentName", 255)
     summary = _text(payload["summary"], "summary", 20000, nullable=True)
     tags = _tags(payload["tags"])
+    extraction = extract_document_content(upload.temporary_path, upload.extension)
     final_created = False
     committed = False
+    old_file_path = None
     try:
         with transaction() as session:
-            document = Document(
+            duplicate = None
+            if not attachment:
+                duplicate_conditions = [
+                    Document.document_name == name,
+                    Document.document_type == document_type,
+                    Document.is_deleted.is_(False),
+                ]
+                if document_type == DocumentType.SUB_PLAN.value:
+                    duplicate_conditions.append(Document.parent_document_id == parent.id)
+                duplicate = session.scalar(
+                    select(Document).where(*duplicate_conditions).with_for_update()
+                )
+            overwrite_id = payload.get("overwriteDocumentId")
+            if duplicate is not None and overwrite_id is None:
+                raise DocumentNameConflictError(
+                    details={
+                        "existingDocumentId": serialize_id(duplicate.id),
+                        "existingDocumentName": duplicate.document_name,
+                        "existingRowVersion": duplicate.row_version,
+                        "documentType": duplicate.document_type,
+                        "parentDocumentId": serialize_id(duplicate.parent_document_id),
+                    }
+                )
+            if overwrite_id is not None:
+                expected_id = _string_id(overwrite_id, "overwriteDocumentId")
+                expected_version = _version(payload["rowVersion"])
+                if duplicate is None or duplicate.id != expected_id:
+                    raise ConflictError(
+                        "同名方案已发生变化,请重新确认",
+                        details={"currentRowVersion": duplicate.row_version if duplicate else None},
+                    )
+                _check_version(duplicate, expected_version)
+                document = duplicate
+                old_category_id = document.category_id
+                if document_type == DocumentType.MAIN.value and old_category_id != category.id:
+                    old_category = session.get(Category, old_category_id)
+                    if old_category is not None:
+                        old_category.document_count = max(0, old_category.document_count - 1)
+                        old_category.row_version += 1
+                    category.document_count += 1
+                    category.row_version += 1
+                try:
+                    old_file_path = resolve_storage_path(
+                        document.file_relative_path,
+                        current_app.config["DMS_STORAGE_ROOT"],
+                    )
+                except UnsafeStoragePathError:
+                    logger.error("覆盖方案时检测到异常旧文件路径:document_id=%s", document.id)
+                    old_file_path = None
+                document.summary = summary
+                document.security_level = security
+                document.category_id = category.id if category else None
+                document.category_name = category.category_name if category else None
+                document.category_path = category.category_path if category else None
+                document.tags = tags
+                document.original_file_name = upload.original_file_name
+                document.file_relative_path = upload.relative_path
+                document.file_extension = upload.extension
+                document.mime_type = upload.mime_type
+                document.file_size = upload.file_size
+                document.file_hash = upload.file_hash
+                document.content_text = extraction.text
+                document.content_extract_status = extraction.status
+                document.content_extracted_at = extraction.extracted_at
+                document.search_text = build_search_text(name, summary, tags, extraction.text)
+                document.updated_by = actor_id
+                document.updated_by_name = context.real_name
+                document.updated_at = _now()
+                document.row_version += 1
+                if document_type == DocumentType.MAIN.value and old_category_id != category.id:
+                    session.query(Document).filter(
+                        Document.parent_document_id == document.id,
+                        Document.document_type == DocumentType.SUB_PLAN.value,
+                        Document.is_deleted.is_(False),
+                    ).update(
+                        {
+                            Document.category_id: category.id,
+                            Document.category_name: category.category_name,
+                            Document.category_path: category.category_path,
+                            Document.updated_by: actor_id,
+                            Document.updated_by_name: context.real_name,
+                            Document.updated_at: _now(),
+                            Document.row_version: Document.row_version + 1,
+                        },
+                        synchronize_session=False,
+                    )
+                session.add(
+                    business_audit(
+                        action=AuditAction.UPLOAD_DOCUMENT,
+                        target=AuditTarget.DOCUMENT,
+                        target_id=document.id,
+                        target_name=document.document_name,
+                        detail={
+                            "overwrite": True,
+                            "originalFileName": document.original_file_name,
+                            "fileSize": document.file_size,
+                            "rowVersion": document.row_version,
+                        },
+                    )
+                )
+                upload.promote()
+                final_created = True
+            else:
+                document = Document(
                 document_name=name,
                 summary=summary,
                 document_type=document_type,
@@ -225,25 +376,30 @@ def _create_record(
                 mime_type=upload.mime_type,
                 file_size=upload.file_size,
                 file_hash=upload.file_hash,
-                search_text=_search(name, summary, tags),
+                content_text=extraction.text,
+                content_extract_status=extraction.status,
+                content_extracted_at=extraction.extracted_at,
+                search_text=build_search_text(
+                    name, summary, tags, extraction.text
+                ),
                 created_by=actor_id,
                 updated_by=actor_id,
                 created_by_name=context.real_name,
                 updated_by_name=context.real_name,
             )
-            session.add(document)
-            session.flush()
-            if category is not None:
-                category.document_count += 1
-                category.row_version += 1
-                category.updated_by = actor_id
-            if parent is not None:
-                parent.child_count += 1
-                parent.row_version += 1
-                parent.updated_by = actor_id
-                parent.updated_by_name = context.real_name
-            session.add(
-                business_audit(
+                session.add(document)
+                session.flush()
+                if category is not None and document_type == DocumentType.MAIN.value:
+                    category.document_count += 1
+                    category.row_version += 1
+                    category.updated_by = actor_id
+                if parent is not None:
+                    parent.child_count += 1
+                    parent.row_version += 1
+                    parent.updated_by = actor_id
+                    parent.updated_by_name = context.real_name
+                session.add(
+                    business_audit(
                     action=(
                         AuditAction.BATCH_IMPORT if batch else AuditAction.UPLOAD_DOCUMENT
                     ),
@@ -259,11 +415,16 @@ def _create_record(
                         "originalFileName": document.original_file_name,
                         "fileSize": document.file_size,
                     },
+                    )
                 )
-            )
-            upload.promote()
-            final_created = True
+                upload.promote()
+                final_created = True
         committed = True
+        if old_file_path is not None and old_file_path != upload.final_path:
+            try:
+                old_file_path.unlink(missing_ok=True)
+            except OSError:
+                logger.exception("覆盖成功后清理旧文件失败:document_id=%s", document.id)
     except Exception:
         if final_created and not committed:
             upload.final_path.unlink(missing_ok=True)
@@ -305,7 +466,6 @@ def _check_version(document: Document, expected: int) -> None:
 
 
 def update_document(document_id: int, payload: dict[str, Any]):
-    _exact(payload, PLAN_EDIT_FIELDS)
     context = get_auth_context()
     with transaction() as session:
         document = session.scalar(
@@ -321,8 +481,28 @@ def update_document(document_id: int, payload: dict[str, Any]):
         )
         if document is None:
             raise ResourceNotFoundError("方案文档不存在或类型不匹配")
+        _exact(
+            payload,
+            MAIN_EDIT_FIELDS
+            if document.document_type == DocumentType.MAIN.value
+            else SUB_PLAN_EDIT_FIELDS,
+        )
         _check_version(document, _version(payload["rowVersion"]))
-        category = _category(_string_id(payload["categoryId"], "categoryId"))
+        if document.document_type == DocumentType.MAIN.value:
+            category = _leaf_category(
+                _string_id(payload["categoryId"], "categoryId")
+            )
+        else:
+            parent = session.scalar(
+                select(Document).where(
+                    Document.id == document.parent_document_id,
+                    Document.document_type == DocumentType.MAIN.value,
+                    Document.is_deleted.is_(False),
+                )
+            )
+            if parent is None:
+                raise ResourceNotFoundError("父文档必须是有效主案")
+            category = _category(parent.category_id)
         before = {
             "documentName": document.document_name,
             "categoryId": serialize_id(document.category_id),
@@ -330,7 +510,8 @@ def update_document(document_id: int, payload: dict[str, Any]):
             "visibilityType": document.visibility_type,
             "status": document.document_status,
         }
-        if document.category_id != category.id:
+        category_changed = document.category_id != category.id
+        if document.document_type == DocumentType.MAIN.value and category_changed:
             old = session.get(Category, document.category_id)
             if old is not None:
                 old.document_count = max(0, old.document_count - 1)
@@ -350,21 +531,35 @@ def update_document(document_id: int, payload: dict[str, Any]):
         document.security_level = _enum(
             payload["securityLevel"], SecurityLevel, "securityLevel"
         )
-        requested_visibility = _enum(
-            payload["visibilityType"], VisibilityType, "visibilityType"
-        )
-        if document.document_type == DocumentType.MAIN.value:
-            document.visibility_type = requested_visibility
-        document.document_status = _enum(
-            payload["status"], DocumentStatus, "status"
-        )
-        document.search_text = _search(
-            document.document_name, document.summary, document.tags
+        if document.document_type == DocumentType.SUB_PLAN.value:
+            document.visibility_type = parent.visibility_type
+        document.search_text = build_search_text(
+            document.document_name,
+            document.summary,
+            document.tags,
+            document.content_text,
         )
         document.updated_by = context.user_id
         document.updated_by_name = context.real_name
         document.updated_at = _now()
         document.row_version += 1
+        if document.document_type == DocumentType.MAIN.value and category_changed:
+            session.query(Document).filter(
+                Document.parent_document_id == document.id,
+                Document.document_type == DocumentType.SUB_PLAN.value,
+                Document.is_deleted.is_(False),
+            ).update(
+                {
+                    Document.category_id: category.id,
+                    Document.category_name: category.category_name,
+                    Document.category_path: category.category_path,
+                    Document.updated_by: context.user_id,
+                    Document.updated_by_name: context.real_name,
+                    Document.updated_at: _now(),
+                    Document.row_version: Document.row_version + 1,
+                },
+                synchronize_session=False,
+            )
         session.add(
             business_audit(
                 action=AuditAction.EDIT_DOCUMENT,
@@ -407,8 +602,11 @@ def update_attachment(document_id: int, payload: dict[str, Any]):
             payload["summary"], "summary", 20000, nullable=True
         )
         document.tags = _tags(payload["tags"])
-        document.search_text = _search(
-            document.document_name, document.summary, document.tags
+        document.search_text = build_search_text(
+            document.document_name,
+            document.summary,
+            document.tags,
+            document.content_text,
         )
         document.updated_by = context.user_id
         document.updated_by_name = context.real_name
@@ -491,7 +689,7 @@ def delete_document(document_id: int, row_version: int):
                 parent.updated_by_name = context.real_name
                 parent.row_version += 1
         category = session.get(Category, document.category_id)
-        if category is not None:
+        if category is not None and document.document_type == DocumentType.MAIN.value:
             category.document_count = max(0, category.document_count - 1)
             category.row_version += 1
             category.updated_by = context.user_id

+ 50 - 10
backend/dms/services/document_query_service.py

@@ -29,7 +29,7 @@ from dms.common.response import serialize_id
 from dms.common.time_range import parse_datetime, parse_updated_range
 from dms.database.transaction import transaction
 from dms.extensions import db
-from dms.models import Document, Permission
+from dms.models import Category, Document, Permission
 from dms.security.auth_context import AuthContext, get_auth_context
 from dms.services.audit_service import business_audit
 from dms.services.authorization_service import (
@@ -215,23 +215,57 @@ def _require_plan_access(document: Document, context: AuthContext) -> PlanAccess
 def _keyword(statement, keyword: str | None):
     if not keyword or not keyword.strip():
         return statement
-    pattern = f"%{keyword.strip()}%"
+    escaped = (
+        keyword.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+    )
+    pattern = f"%{escaped}%"
     return statement.where(
         or_(
-            Document.document_name.like(pattern),
-            Document.summary.like(pattern),
-            Document.search_text.like(pattern),
-            cast(Document.tags, String).like(pattern),
+            Document.document_name.like(pattern, escape="\\"),
+            Document.summary.like(pattern, escape="\\"),
+            Document.search_text.like(pattern, escape="\\"),
+            cast(Document.tags, String).like(pattern, escape="\\"),
         )
     )
 
 
+def _boolean(value: str | None, name: str, default: bool = False) -> bool:
+    if value is None:
+        return default
+    if value not in {"true", "false"}:
+        raise InvalidArgumentError(f"{name}必须是true或false")
+    return value == "true"
+
+
+def _category_scope(category_id: int, include_descendants: bool) -> set[int]:
+    categories = db.session.scalars(
+        select(Category).where(
+            Category.is_deleted.is_(False),
+            Category.status == "ENABLED",
+        )
+    ).all()
+    if not any(category.id == category_id for category in categories):
+        raise ResourceNotFoundError("方案分类不存在或不可用")
+    if not include_descendants:
+        return {category_id}
+    children: dict[int | None, list[int]] = {}
+    for category in categories:
+        children.setdefault(category.parent_id, []).append(category.id)
+    result = {category_id}
+    pending = [category_id]
+    while pending:
+        current = pending.pop()
+        for child_id in children.get(current, []):
+            if child_id not in result:
+                result.add(child_id)
+                pending.append(child_id)
+    return result
+
+
 def list_documents(params: Mapping[str, str]) -> dict[str, object]:
     context = get_auth_context()
-    if context.role_code == RoleCode.AUDITOR:
-        raise DocumentViewForbiddenError("审计员默认无方案浏览权限")
     page = _page(params)
-    raw_types = params.get("documentType", "MAIN,SUB_PLAN")
+    raw_types = params.get("documentType", "MAIN")
     document_types = [item.strip() for item in raw_types.split(",") if item.strip()]
     if (
         not document_types
@@ -243,10 +277,16 @@ def list_documents(params: Mapping[str, str]) -> dict[str, object]:
         Document.is_deleted.is_(False),
         Document.document_type.in_(document_types),
     )
+    include_descendants = _boolean(
+        params.get("includeDescendants"), "includeDescendants"
+    )
     category_id = params.get("categoryId")
     if category_id:
+        parsed_category_id = parse_string_id(category_id, field="categoryId")
         statement = statement.where(
-            Document.category_id == parse_string_id(category_id, field="categoryId")
+            Document.category_id.in_(
+                _category_scope(parsed_category_id, include_descendants)
+            )
         )
     statement = _keyword(statement, params.get("keyword"))
     visibility_filter = _enum(

+ 49 - 12
backend/dms/services/file_read_service.py

@@ -4,9 +4,11 @@ from __future__ import annotations
 
 import logging
 from pathlib import Path
+from urllib.parse import quote
 
 from flask import current_app, send_file
 from sqlalchemy import select
+from werkzeug.utils import secure_filename
 
 from dms.common.enums import AuditAction, AuditTarget, DocumentType
 from dms.common.errors import (
@@ -23,7 +25,9 @@ from dms.models import Document
 from dms.security.auth_context import get_auth_context
 from dms.services.audit_service import business_audit
 from dms.services.authorization_service import evaluate_plan_access
+from dms.services.authorization_service import require_attachment_access
 from dms.services.document_query_service import _require_plan_access
+from dms.services.recycle_bin_service import _verify_file
 from dms.storage.paths import UnsafeStoragePathError, resolve_storage_path
 
 logger = logging.getLogger(__name__)
@@ -49,12 +53,18 @@ def _document(document_id: int) -> Document:
 
 
 def _authorize_view(document: Document) -> None:
-    if document.document_type != DocumentType.ATTACHMENT.value:
-        _require_plan_access(document, get_auth_context())
+    context = get_auth_context()
+    if document.document_type == DocumentType.ATTACHMENT.value:
+        require_attachment_access(document, context, download=False)
+    else:
+        _require_plan_access(document, context)
 
 
 def _authorize_download(document: Document) -> None:
     if document.document_type == DocumentType.ATTACHMENT.value:
+        require_attachment_access(
+            document, get_auth_context(), download=True
+        )
         return
     access = evaluate_plan_access(document, get_auth_context())
     if access.reason == "SECURITY":
@@ -88,20 +98,47 @@ def _download_name(value: str) -> str:
     return Path(safe).name or "download"
 
 
+def _inline_content_disposition(value: str) -> str:
+    original = _download_name(value)
+    fallback = secure_filename(original) or "preview"
+    return (
+        f'inline; filename="{fallback}"; '
+        f"filename*=UTF-8''{quote(original, safe='')}"
+    )
+
+
 def preview_document(document_id: int):
     document = _document(document_id)
     _authorize_view(document)
-    if document.file_extension.lower() != "pdf":
-        raise PreviewUnavailableError()
-    path = _path(document)
-    response = send_file(
-        path,
-        mimetype="application/pdf",
-        as_attachment=False,
-        download_name=_download_name(document.original_file_name),
-        conditional=True,
-    )
+    verified = _verify_file(document)
+    try:
+        path = verified.path
+        extension = document.file_extension.lower().lstrip(".")
+        if extension == "pdf":
+            mime_type = "application/pdf"
+        elif extension == "docx":
+            mime_type = (
+                "application/vnd.openxmlformats-officedocument."
+                "wordprocessingml.document"
+            )
+        else:
+            raise PreviewUnavailableError()
+        verified.close()
+        response = send_file(
+            path,
+            mimetype=mime_type,
+            as_attachment=False,
+            download_name=_download_name(document.original_file_name),
+            conditional=True,
+        )
+    except Exception:
+        verified.close()
+        raise
     response.headers["X-Content-Type-Options"] = "nosniff"
+    response.headers["Cache-Control"] = "private, no-store"
+    response.headers["Content-Disposition"] = _inline_content_disposition(
+        document.original_file_name
+    )
     return response
 
 

+ 32 - 1
backend/dms/services/recycle_bin_service.py

@@ -666,8 +666,23 @@ def _restore_operation(
             )
 
         category: Category | None = None
-        if document.document_type != DocumentType.ATTACHMENT.value:
+        if document.document_type == DocumentType.MAIN.value:
             category = _lock_category(session, document)
+            active_child_count = session.scalar(
+                select(func.count(Category.id)).where(
+                    Category.parent_id == category.id,
+                    Category.is_deleted.is_(False),
+                    Category.status == EnabledStatus.ENABLED.value,
+                )
+            ) or 0
+            if active_child_count:
+                raise RestoreCategoryInvalidError(
+                    details={
+                        "categoryId": serialize_id(category.id),
+                        "reason": "NOT_LEAF",
+                        "childCategoryCount": int(active_child_count),
+                    }
+                )
 
         if document.document_type == DocumentType.SUB_PLAN.value:
             if (
@@ -695,6 +710,22 @@ def _restore_operation(
                         "reason": reason,
                     }
                 )
+            category = session.scalar(
+                select(Category)
+                .where(Category.id == parent.category_id)
+                .with_for_update()
+            )
+            category_reason = _category_reason(category)
+            if category_reason:
+                raise RestoreCategoryInvalidError(
+                    details={
+                        "categoryId": serialize_id(parent.category_id),
+                        "reason": category_reason,
+                    }
+                )
+            document.category_id = parent.category_id
+            document.category_name = parent.category_name
+            document.category_path = parent.category_path
 
         counts = (0, 0, 0, 0)
         now = _now()

+ 1 - 1
backend/dms/services/user_service.py

@@ -17,8 +17,8 @@ ALLOWED_MODULES = {
     RoleCode.ADMIN: [
         AllowedModule.DOCUMENT_BROWSER.value,
         AllowedModule.BACKEND_MANAGEMENT.value,
+        AllowedModule.AUDIT_LOG.value,
     ],
-    RoleCode.AUDITOR: [AllowedModule.AUDIT_LOG.value],
 }
 
 

+ 130 - 0
backend/migrations/versions/0003_q3_business_alignment_and_content_search.py

@@ -0,0 +1,130 @@
+"""Q3业务规则对齐、角色收敛和正文检索字段。
+
+Revision ID: 0003_q3_business_alignment_and_content_search
+Revises: 0002_add_restore_audit_action
+Create Date: 2026-08-07
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+revision: str = "0003_q3_business_alignment_and_content_search"
+down_revision: str | None = "0002_add_restore_audit_action"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+ROLE_CONSTRAINT = "user_role_code"
+CONTENT_STATUS_CONSTRAINT = "document_content_extract_status"
+
+
+def upgrade() -> None:
+    # 本阶段按契约使用可读revision名,长度超过Alembic默认VARCHAR(32)。
+    op.alter_column(
+        "alembic_version",
+        "version_num",
+        existing_type=sa.String(length=32),
+        type_=sa.String(length=128),
+        existing_nullable=False,
+    )
+    op.add_column(
+        "doc_document",
+        sa.Column("content_text", mysql.LONGTEXT(), nullable=True),
+    )
+    op.add_column(
+        "doc_document",
+        sa.Column(
+            "content_extract_status",
+            sa.String(length=32),
+            server_default=sa.text("'UNSUPPORTED'"),
+            nullable=False,
+        ),
+    )
+    op.add_column(
+        "doc_document",
+        sa.Column("content_extracted_at", mysql.DATETIME(fsp=3), nullable=True),
+    )
+    op.alter_column(
+        "doc_document",
+        "search_text",
+        existing_type=sa.Text(),
+        type_=mysql.LONGTEXT(),
+        existing_nullable=True,
+    )
+    op.create_check_constraint(
+        CONTENT_STATUS_CONSTRAINT,
+        "doc_document",
+        "content_extract_status IN "
+        "('SUCCESS', 'EMPTY', 'UNSUPPORTED', 'FAILED')",
+    )
+
+    # 历史审计员必须先使全部旧Token失效,再禁用并逻辑删除;绝不升级为管理员。
+    op.execute(
+        sa.text(
+            "UPDATE sys_user SET "
+            "auth_version = auth_version + 1, "
+            "row_version = row_version + 1, "
+            "status = 'DISABLED', "
+            "is_deleted = 1, "
+            "deleted_at = COALESCE(deleted_at, CURRENT_TIMESTAMP(3)), "
+            "updated_at = CURRENT_TIMESTAMP(3), "
+            "role_code = 'USER' "
+            "WHERE role_code = 'AUDITOR'"
+        )
+    )
+    op.drop_constraint(ROLE_CONSTRAINT, "sys_user", type_="check")
+    op.create_check_constraint(
+        ROLE_CONSTRAINT,
+        "sys_user",
+        "role_code IN ('USER', 'ADMIN')",
+    )
+
+
+def downgrade() -> None:
+    bind = op.get_bind()
+    extracted_count = bind.scalar(
+        sa.text(
+            "SELECT COUNT(*) FROM doc_document WHERE "
+            "content_text IS NOT NULL OR content_extracted_at IS NOT NULL OR "
+            "content_extract_status <> 'UNSUPPORTED'"
+        )
+    )
+    oversized_search_count = bind.scalar(
+        sa.text(
+            "SELECT COUNT(*) FROM doc_document "
+            "WHERE OCTET_LENGTH(search_text) > 65535"
+        )
+    )
+    if extracted_count or oversized_search_count:
+        raise RuntimeError(
+            "0003已包含正文提取结果或超出TEXT容量的检索文本,拒绝有损降级;"
+            "请先备份并按数据治理流程处理"
+        )
+
+    op.drop_constraint(ROLE_CONSTRAINT, "sys_user", type_="check")
+    op.create_check_constraint(
+        ROLE_CONSTRAINT,
+        "sys_user",
+        "role_code IN ('USER', 'ADMIN', 'AUDITOR')",
+    )
+    # 已失效的历史AUDITOR不会被自动复活或重新授权。
+
+    op.drop_constraint(
+        CONTENT_STATUS_CONSTRAINT,
+        "doc_document",
+        type_="check",
+    )
+    op.alter_column(
+        "doc_document",
+        "search_text",
+        existing_type=mysql.LONGTEXT(),
+        type_=sa.Text(),
+        existing_nullable=True,
+    )
+    op.drop_column("doc_document", "content_extracted_at")
+    op.drop_column("doc_document", "content_extract_status")
+    op.drop_column("doc_document", "content_text")

+ 1 - 1
backend/openapi/components/responses.yaml

@@ -53,7 +53,7 @@ NotFound:
         $ref: './schemas.yaml#/ErrorResponse'
 
 Conflict:
-  description: 数据版本冲突、分类编码重复或分类仍被占用
+  description: 数据版本冲突、方案名称冲突、分类编码重复或分类仍被占用
   content:
     application/json:
       schema:

+ 63 - 12
backend/openapi/components/schemas.yaml

@@ -11,7 +11,7 @@ UtcDateTime:
 
 RoleCode:
   type: string
-  enum: [USER, ADMIN, AUDITOR]
+  enum: [USER, ADMIN]
 
 AllowedModule:
   type: string
@@ -176,7 +176,9 @@ ErrorResponse:
         - PAYLOAD_TOO_LARGE
         - BATCH_MANIFEST_MISMATCH
         - DATA_VERSION_CONFLICT
+        - DOCUMENT_NAME_CONFLICT
         - CATEGORY_IN_USE
+        - CATEGORY_NOT_LEAF
         - MAIN_PLAN_HAS_CHILDREN
         - ATTACHMENT_IN_USE
         - ATTACHMENT_HAS_NO_ACL
@@ -447,6 +449,7 @@ CategoryNode:
     documentCount:
       type: integer
       minimum: 0
+      description: 当前用户可见的、该分类完整有效子树内的MAIN数量。
     rowVersion:
       type: integer
       minimum: 0
@@ -863,26 +866,57 @@ MainPlanBriefCollectionResponse:
       type: string
       format: uuid
 
-DocumentCreateMetadata:
+MainDocumentCreateMetadata:
   type: object
   additionalProperties: false
-  required: [documentName, documentType, summary, categoryId, securityLevel, visibilityType, status, tags]
+  required: [documentName, documentType, summary, categoryId, securityLevel, tags]
   properties:
     documentName: {type: string, minLength: 1, maxLength: 255}
-    documentType: {type: string, enum: [MAIN, SUB_PLAN]}
+    documentType: {type: string, enum: [MAIN]}
     summary: {type: string, nullable: true}
     categoryId: {$ref: '#/StringId'}
-    parentDocumentId:
+    securityLevel: {$ref: '#/SecurityLevel'}
+    overwriteDocumentId:
       allOf: [{$ref: '#/StringId'}]
-      description: SUB_PLAN必填,MAIN禁止提交。
+      description: 仅在用户确认覆盖同名主案后提交,并必须与rowVersion同时出现。
+    rowVersion:
+      type: integer
+      minimum: 0
+      description: 被覆盖文档的当前版本;仅与overwriteDocumentId同时提交。
+    tags:
+      type: array
+      maxItems: 50
+      items: {type: string, minLength: 1, maxLength: 64}
+
+SubPlanCreateMetadata:
+  type: object
+  additionalProperties: false
+  required: [documentName, documentType, summary, parentDocumentId, securityLevel, tags]
+  properties:
+    documentName: {type: string, minLength: 1, maxLength: 255}
+    documentType: {type: string, enum: [SUB_PLAN]}
+    summary: {type: string, nullable: true}
+    parentDocumentId: {$ref: '#/StringId'}
     securityLevel: {$ref: '#/SecurityLevel'}
-    visibilityType: {$ref: '#/VisibilityType'}
-    status: {$ref: '#/DocumentStatus'}
+    overwriteDocumentId:
+      allOf: [{$ref: '#/StringId'}]
+      description: 仅在用户确认覆盖同一主案下同名子案后提交,并必须与rowVersion同时出现。
+    rowVersion:
+      type: integer
+      minimum: 0
+      description: 被覆盖文档的当前版本;仅与overwriteDocumentId同时提交。
     tags:
       type: array
       maxItems: 50
       items: {type: string, minLength: 1, maxLength: 64}
 
+DocumentCreateMetadata:
+  oneOf:
+    - $ref: '#/MainDocumentCreateMetadata'
+    - $ref: '#/SubPlanCreateMetadata'
+  discriminator:
+    propertyName: documentType
+
 AttachmentCreateMetadata:
   type: object
   additionalProperties: false
@@ -896,23 +930,40 @@ AttachmentCreateMetadata:
       maxItems: 50
       items: {type: string, minLength: 1, maxLength: 64}
 
-UpdateDocumentRequest:
+MainDocumentUpdateRequest:
   type: object
   additionalProperties: false
-  required: [documentName, summary, categoryId, securityLevel, visibilityType, status, tags, rowVersion]
+  required: [documentName, summary, categoryId, securityLevel, tags, rowVersion]
   properties:
     documentName: {type: string, minLength: 1, maxLength: 255}
     summary: {type: string, nullable: true}
     categoryId: {$ref: '#/StringId'}
     securityLevel: {$ref: '#/SecurityLevel'}
-    visibilityType: {$ref: '#/VisibilityType'}
-    status: {$ref: '#/DocumentStatus'}
     tags:
       type: array
       maxItems: 50
       items: {type: string, minLength: 1, maxLength: 64}
     rowVersion: {type: integer, minimum: 0}
 
+SubPlanUpdateRequest:
+  type: object
+  additionalProperties: false
+  required: [documentName, summary, securityLevel, tags, rowVersion]
+  properties:
+    documentName: {type: string, minLength: 1, maxLength: 255}
+    summary: {type: string, nullable: true}
+    securityLevel: {$ref: '#/SecurityLevel'}
+    tags:
+      type: array
+      maxItems: 50
+      items: {type: string, minLength: 1, maxLength: 64}
+    rowVersion: {type: integer, minimum: 0}
+
+UpdateDocumentRequest:
+  oneOf:
+    - $ref: '#/MainDocumentUpdateRequest'
+    - $ref: '#/SubPlanUpdateRequest'
+
 UpdateAttachmentRequest:
   type: object
   additionalProperties: false

+ 1 - 1
backend/openapi/openapi.yaml

@@ -1,7 +1,7 @@
 openapi: 3.0.3
 info:
   title: 方案计划文档管理系统 API
-  version: 1.3.0
+  version: 1.5.0
   description: DMS第一阶段接口;Q2-B统一方案、直属子方案和已挂载附件的更新时间范围检索。
 servers:
   - url: /

+ 10 - 4
backend/openapi/paths/documents.yaml

@@ -4,8 +4,12 @@ Documents:
     summary: 分页查询主案和子方案
     parameters:
       - $ref: '../components/parameters.yaml#/XRequestId'
-      - {name: documentType, in: query, schema: {type: string, default: 'MAIN,SUB_PLAN'}}
+      - {name: documentType, in: query, schema: {type: string, default: MAIN}}
       - {name: categoryId, in: query, schema: {$ref: '../components/schemas.yaml#/StringId'}}
+      - name: includeDescendants
+        in: query
+        description: 与categoryId同时使用时,是否包含全部有效后代分类;默认false。
+        schema: {type: boolean, default: false}
       - {name: keyword, in: query, schema: {type: string}}
       - {name: securityLevel, in: query, schema: {$ref: '../components/schemas.yaml#/SecurityLevel'}}
       - {name: visibilityType, in: query, schema: {$ref: '../components/schemas.yaml#/VisibilityType'}}
@@ -50,6 +54,7 @@ Documents:
       '401': {$ref: '../components/responses.yaml#/AuthenticationError'}
       '403': {$ref: '../components/responses.yaml#/Forbidden'}
       '404': {$ref: '../components/responses.yaml#/NotFound'}
+      '409': {$ref: '../components/responses.yaml#/Conflict'}
       '413': {$ref: '../components/responses.yaml#/PayloadTooLarge'}
       '415': {$ref: '../components/responses.yaml#/UnsupportedMedia'}
       '500': {$ref: '../components/responses.yaml#/InternalError'}
@@ -125,7 +130,7 @@ SubPlans:
 Attachments:
   get:
     operationId: listAttachments
-    summary: 分页查询共享附件
+    summary: ADMIN分页查询独立共享附件
     parameters:
       - $ref: '../components/parameters.yaml#/XRequestId'
       - {name: keyword, in: query, schema: {type: string}}
@@ -255,14 +260,15 @@ DocumentPreview:
     - {name: id, in: path, required: true, schema: {$ref: '../components/schemas.yaml#/StringId'}}
   get:
     operationId: previewDocument
-    summary: 预览PDF文档
+    summary: 原样预览PDF或DOCX文档
     responses:
       '200':
-        description: PDF文件流
+        description: PDF或DOCX原文件流
         headers:
           X-Request-Id: {schema: {type: string, format: uuid}}
         content:
           application/pdf: {schema: {type: string, format: binary}}
+          application/vnd.openxmlformats-officedocument.wordprocessingml.document: {schema: {type: string, format: binary}}
       '401': {$ref: '../components/responses.yaml#/AuthenticationError'}
       '403': {$ref: '../components/responses.yaml#/Forbidden'}
       '404': {$ref: '../components/responses.yaml#/NotFound'}

+ 1 - 0
backend/requirements.txt

@@ -16,3 +16,4 @@ python-docx>=1.1.0
 olefile>=0.47,<1.0
 pymupdf>=1.23.0
 pypinyin>=0.50.0
+tzdata>=2026.3

+ 5 - 3
backend/tests/dms/conftest.py

@@ -129,12 +129,14 @@ def b2_app():
                 User(
                     username="auditor",
                     password_hash=hash_password(password),
-                    real_name="审计员",
+                    real_name="历史审计员",
                     organization_id=root.id,
                     organization_name=root.org_name,
-                    role_code="AUDITOR",
+                    role_code="USER",
                     security_level="TOP_SECRET",
-                    status="ENABLED",
+                    status="DISABLED",
+                    auth_version=1,
+                    is_deleted=True,
                 ),
                 User(
                     username="user",

+ 1 - 1
backend/tests/dms/q1b_http_verify.py

@@ -45,7 +45,7 @@ def main() -> None:
 
     try:
         tokens: dict[str, str] = {}
-        for username in ("admin", "auditor", "user"):
+        for username in ("admin", "user"):
             response = requests.post(
                 f"{base_url}/auth/login",
                 json={

+ 523 - 0
backend/tests/dms/q3b_http_verify.py

@@ -0,0 +1,523 @@
+"""Q3-B 8755短生命周期真实HTTP闭环验证工具。"""
+
+from __future__ import annotations
+
+import io
+import json
+import os
+import struct
+import sys
+import threading
+from pathlib import Path
+from urllib.parse import urlparse
+
+import fitz
+import requests
+from docx import Document as WordDocument
+from sqlalchemy import func, select, text
+from werkzeug.serving import make_server
+
+BACKEND_ROOT = Path(__file__).resolve().parents[2]
+if str(BACKEND_ROOT) not in sys.path:
+    sys.path.insert(0, str(BACKEND_ROOT))
+
+
+def _assert_test_database(database_url: str) -> None:
+    if urlparse(database_url).path.lstrip("/") != "dms_test":
+        raise RuntimeError("Q3-B真实HTTP验证只允许连接dms_test")
+
+
+def _docx_bytes(term: str) -> bytes:
+    stream = io.BytesIO()
+    document = WordDocument()
+    document.add_paragraph(term)
+    table = document.add_table(rows=1, cols=1)
+    table.cell(0, 0).text = "Q3HttpDocxTableTerm"
+    document.save(stream)
+    return stream.getvalue()
+
+
+def _pdf_bytes(term: str) -> bytes:
+    document = fitz.open()
+    page = document.new_page()
+    page.insert_text((72, 72), term)
+    content = document.tobytes()
+    document.close()
+    return content
+
+
+def _doc_bytes() -> bytes:
+    def entry(name: str, kind: int, child: int = 0xFFFFFFFF) -> bytes:
+        value = bytearray(128)
+        encoded = (name + "\0").encode("utf-16le")
+        value[: len(encoded)] = encoded
+        struct.pack_into("<H", value, 64, len(encoded))
+        value[66] = kind
+        value[67] = 1
+        struct.pack_into("<III", value, 68, 0xFFFFFFFF, 0xFFFFFFFF, child)
+        struct.pack_into("<I", value, 116, 0xFFFFFFFE)
+        return bytes(value)
+
+    header = bytearray(512)
+    header[:8] = bytes.fromhex("D0CF11E0A1B11AE1")
+    struct.pack_into("<HH", header, 24, 0x003E, 3)
+    struct.pack_into("<H", header, 28, 0xFFFE)
+    struct.pack_into("<HH", header, 30, 9, 6)
+    struct.pack_into("<I", header, 44, 1)
+    struct.pack_into("<I", header, 48, 0)
+    struct.pack_into("<I", header, 56, 4096)
+    struct.pack_into("<I", header, 60, 0xFFFFFFFE)
+    struct.pack_into("<I", header, 68, 0xFFFFFFFE)
+    struct.pack_into("<I", header, 76, 1)
+    for index in range(1, 109):
+        struct.pack_into("<I", header, 76 + 4 * index, 0xFFFFFFFF)
+    directory = bytearray(512)
+    directory[:128] = entry("Root Entry", 5, 1)
+    directory[128:256] = entry("WordDocument", 2)
+    fat = bytearray(b"\xff" * 512)
+    struct.pack_into("<I", fat, 0, 0xFFFFFFFE)
+    struct.pack_into("<I", fat, 4, 0xFFFFFFFD)
+    return bytes(header + directory + fat)
+
+
+def _token(base_url: str, username: str, password: str) -> str:
+    response = requests.post(
+        f"{base_url}/auth/login",
+        json={
+            "username": username,
+            "password": password,
+            "keepSignedIn": False,
+        },
+        timeout=10,
+    )
+    response.raise_for_status()
+    return response.json()["data"]["accessToken"]
+
+
+def _headers(token: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {token}"}
+
+
+def _tree_count(nodes: list[dict], category_id: str) -> int:
+    pending = list(nodes)
+    while pending:
+        node = pending.pop()
+        if node["id"] == category_id:
+            return node["documentCount"]
+        pending.extend(node["children"])
+    raise AssertionError(f"category {category_id} not found")
+
+
+def _upload(
+    base_url: str,
+    token: str,
+    path: str,
+    filename: str,
+    content: bytes,
+    metadata: dict,
+) -> dict:
+    response = requests.post(
+        f"{base_url}{path}",
+        headers=_headers(token),
+        files={"file": (filename, content)},
+        data={"metadata": json.dumps(metadata, ensure_ascii=False)},
+        timeout=20,
+    )
+    response.raise_for_status()
+    return response.json()["data"]
+
+
+def main() -> None:
+    database_url = os.environ.get("DMS_DATABASE_URL", "")
+    _assert_test_database(database_url)
+    admin_password = os.environ["DMS_HTTP_ADMIN_PASSWORD"]
+    user_password = os.environ["DMS_HTTP_USER_PASSWORD"]
+
+    from app import app
+    from dms.extensions import db
+    from dms.models import AuditLog, Category, Document, User
+
+    with app.app_context():
+        assert db.session.execute(text("SELECT DATABASE()" )).scalar() == "dms_test"
+        leaf = db.session.scalar(
+            select(Category).where(Category.category_code == "STYLE_A1")
+        )
+        parent = db.session.get(Category, leaf.parent_id)
+        user = db.session.scalar(select(User).where(User.username == "user"))
+        leaf_id, parent_id, user_id = str(leaf.id), str(parent.id), str(user.id)
+
+    server = make_server("127.0.0.1", 8755, app, threaded=True)
+    thread = threading.Thread(target=server.serve_forever, daemon=True)
+    thread.start()
+    base_url = "http://127.0.0.1:8755/api/v1"
+    created: list[tuple[str, str, int]] = []
+    main_version = 0
+    result: dict[str, object] = {"port": 8755, "database": "dms_test"}
+
+    try:
+        admin_token = _token(base_url, "admin", admin_password)
+        user_token = _token(base_url, "user", user_password)
+        for path in ("attachments", "documents"):
+            stale = requests.get(
+                f"{base_url}/{path}",
+                params={"keyword": "Q3_HTTP_", "pageSize": 100},
+                headers=_headers(admin_token),
+                timeout=10,
+            )
+            stale.raise_for_status()
+            for item in stale.json()["data"]["items"]:
+                if item["documentName"].startswith("Q3_HTTP_"):
+                    removed = requests.delete(
+                        f"{base_url}/{path}/{item['id']}",
+                        params={"rowVersion": item["rowVersion"]},
+                        headers=_headers(admin_token),
+                        timeout=10,
+                    )
+                    removed.raise_for_status()
+        auditor = requests.post(
+            f"{base_url}/auth/login",
+            json={
+                "username": "auditor",
+                "password": admin_password,
+                "keepSignedIn": False,
+            },
+            timeout=10,
+        )
+        assert auditor.status_code == 401
+
+        me = requests.get(
+            f"{base_url}/users/me", headers=_headers(admin_token), timeout=10
+        )
+        me.raise_for_status()
+        assert me.json()["data"]["allowedModules"] == [
+            "DOCUMENT_BROWSER",
+            "BACKEND_MANAGEMENT",
+            "AUDIT_LOG",
+        ]
+        audit_api = requests.get(
+            f"{base_url}/audit/logs", headers=_headers(admin_token), timeout=10
+        )
+        audit_api.raise_for_status()
+        user_library = requests.get(
+            f"{base_url}/attachments", headers=_headers(user_token), timeout=10
+        )
+        assert user_library.status_code == 403
+        baseline_all = requests.get(
+            f"{base_url}/documents", headers=_headers(admin_token), timeout=10
+        )
+        baseline_all.raise_for_status()
+        baseline_recursive = requests.get(
+            f"{base_url}/documents",
+            params={"categoryId": parent_id, "includeDescendants": "true"},
+            headers=_headers(admin_token),
+            timeout=10,
+        )
+        baseline_recursive.raise_for_status()
+        baseline_tree_counts = {}
+        for username, token in (("admin", admin_token), ("user", user_token)):
+            response = requests.get(
+                f"{base_url}/categories/tree", headers=_headers(token), timeout=10
+            )
+            response.raise_for_status()
+            baseline_tree_counts[username] = _tree_count(
+                response.json()["data"], parent_id
+            )
+
+        docx_content = _docx_bytes("Q3HttpDocxBodyOnlyTerm")
+        main = _upload(
+            base_url,
+            admin_token,
+            "/documents",
+            "q3-http.docx",
+            docx_content,
+            {
+                "documentName": "Q3_HTTP_DOCX",
+                "documentType": "MAIN",
+                "categoryId": leaf_id,
+                "securityLevel": "INTERNAL",
+                "summary": "真实HTTP验证",
+                "tags": ["Q3HTTP"],
+            },
+        )
+        created.append(("document", main["id"], main["rowVersion"]))
+        assert main["status"] == "PUBLISHED"
+        assert main["visibilityType"] == "CUSTOM"
+
+        sub = _upload(
+            base_url,
+            admin_token,
+            "/documents",
+            "q3-http-sub.docx",
+            _docx_bytes("Q3HttpSubBodyTerm"),
+            {
+                "documentName": "Q3_HTTP_SUB",
+                "documentType": "SUB_PLAN",
+                "parentDocumentId": main["id"],
+                "securityLevel": "INTERNAL",
+                "summary": "真实HTTP子方案",
+                "tags": ["Q3HTTP"],
+            },
+        )
+        created.append(("document", sub["id"], sub["rowVersion"]))
+        assert sub["categoryId"] == leaf_id
+
+        pdf_content = _pdf_bytes("Q3HttpPdfBodyOnlyTerm")
+        pdf = _upload(
+            base_url,
+            admin_token,
+            "/documents",
+            "q3-http.pdf",
+            pdf_content,
+            {
+                "documentName": "Q3_HTTP_PDF",
+                "documentType": "MAIN",
+                "categoryId": leaf_id,
+                "securityLevel": "INTERNAL",
+                "summary": "真实HTTP PDF",
+                "tags": ["Q3HTTP"],
+            },
+        )
+        created.append(("document", pdf["id"], pdf["rowVersion"]))
+
+        attachment = _upload(
+            base_url,
+            admin_token,
+            "/attachments",
+            "q3-http-attachment.docx",
+            _docx_bytes("Q3HttpAttachmentTerm"),
+            {
+                "documentName": "Q3_HTTP_ATTACHMENT",
+                "attachmentType": "OTHER",
+                "summary": "真实HTTP附件",
+                "tags": ["Q3HTTP"],
+            },
+        )
+        created.append(("attachment", attachment["id"], attachment["rowVersion"]))
+
+        legacy = _upload(
+            base_url,
+            admin_token,
+            "/attachments",
+            "q3-http.doc",
+            _doc_bytes(),
+            {
+                "documentName": "Q3_HTTP_DOC",
+                "attachmentType": "OTHER",
+                "summary": "旧版DOC",
+                "tags": ["Q3HTTP"],
+            },
+        )
+        created.append(("attachment", legacy["id"], legacy["rowVersion"]))
+
+        current_main = requests.get(
+            f"{base_url}/documents/{main['id']}",
+            headers=_headers(admin_token),
+            timeout=10,
+        )
+        current_main.raise_for_status()
+        main_version = current_main.json()["data"]["rowVersion"]
+        permission = requests.put(
+            f"{base_url}/documents/{main['id']}/permissions",
+            headers=_headers(admin_token),
+            json={
+                "visibilityType": "CUSTOM",
+                "documentRowVersion": main_version,
+                "entries": [
+                    {
+                        "subjectType": "USER",
+                        "subjectId": user_id,
+                        "canView": True,
+                        "canDownload": True,
+                        "canEdit": False,
+                        "canManagePermission": False,
+                        "canDelete": False,
+                    }
+                ],
+            },
+            timeout=10,
+        )
+        permission.raise_for_status()
+        main_version = permission.json()["data"]["documentRowVersion"]
+        binding = requests.post(
+            f"{base_url}/main-plans/{main['id']}/attachments/bind",
+            headers=_headers(admin_token),
+            json={
+                "attachmentIds": [attachment["id"]],
+                "mainPlanRowVersion": main_version,
+            },
+            timeout=10,
+        )
+        binding.raise_for_status()
+        main_version = binding.json()["data"]["mainPlanRowVersion"]
+        mounted = requests.get(
+            f"{base_url}/attachments/{attachment['id']}",
+            headers=_headers(user_token),
+            timeout=10,
+        )
+        mounted.raise_for_status()
+
+        with app.app_context():
+            audit_before_queries = db.session.scalar(
+                select(func.count()).select_from(AuditLog)
+            )
+        recursive = requests.get(
+            f"{base_url}/documents",
+            params={"categoryId": parent_id, "includeDescendants": "true"},
+            headers=_headers(admin_token),
+            timeout=10,
+        )
+        recursive.raise_for_status()
+        assert recursive.json()["data"]["total"] == (
+            baseline_recursive.json()["data"]["total"] + 2
+        )
+        all_documents = requests.get(
+            f"{base_url}/documents", headers=_headers(admin_token), timeout=10
+        )
+        all_documents.raise_for_status()
+        assert all_documents.json()["data"]["total"] == (
+            baseline_all.json()["data"]["total"] + 2
+        )
+        current_tree_counts = {}
+        for username, token in (("admin", admin_token), ("user", user_token)):
+            tree_response = requests.get(
+                f"{base_url}/categories/tree", headers=_headers(token), timeout=10
+            )
+            tree_response.raise_for_status()
+            current_tree_counts[username] = _tree_count(
+                tree_response.json()["data"], parent_id
+            )
+        assert current_tree_counts["admin"] == baseline_tree_counts["admin"] + 2
+        assert current_tree_counts["user"] == baseline_tree_counts["user"] + 1
+        for term, expected_id in (
+            ("Q3HttpDocxBodyOnlyTerm", main["id"]),
+            ("Q3HttpPdfBodyOnlyTerm", pdf["id"]),
+        ):
+            search = requests.get(
+                f"{base_url}/documents",
+                params={"keyword": term},
+                headers=_headers(admin_token),
+                timeout=10,
+            )
+            search.raise_for_status()
+            assert [item["id"] for item in search.json()["data"]["items"]] == [
+                expected_id
+            ]
+        with app.app_context():
+            audit_after_queries = db.session.scalar(
+                select(func.count()).select_from(AuditLog)
+            )
+            download_before = {
+                row.id: row.download_count
+                for row in db.session.scalars(
+                    select(Document).where(
+                        Document.id.in_([int(main["id"]), int(pdf["id"])])
+                    )
+                )
+            }
+        assert audit_after_queries == audit_before_queries
+
+        docx_preview = requests.get(
+            f"{base_url}/documents/{main['id']}/preview",
+            headers=_headers(admin_token),
+            timeout=10,
+        )
+        docx_preview.raise_for_status()
+        assert docx_preview.content == docx_content
+        assert docx_preview.headers["Content-Type"].startswith(
+            "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+        )
+        assert docx_preview.headers["Content-Disposition"].startswith("inline")
+        assert "filename*=" in docx_preview.headers["Content-Disposition"]
+        pdf_preview = requests.get(
+            f"{base_url}/documents/{pdf['id']}/preview",
+            headers=_headers(admin_token),
+            timeout=10,
+        )
+        pdf_preview.raise_for_status()
+        assert pdf_preview.content == pdf_content
+        assert pdf_preview.headers["Content-Type"].startswith("application/pdf")
+        legacy_preview = requests.get(
+            f"{base_url}/documents/{legacy['id']}/preview",
+            headers=_headers(admin_token),
+            timeout=10,
+        )
+        assert legacy_preview.status_code == 415
+        assert legacy_preview.json()["code"] == "PREVIEW_UNAVAILABLE"
+        with app.app_context():
+            download_after = {
+                row.id: row.download_count
+                for row in db.session.scalars(
+                    select(Document).where(
+                        Document.id.in_([int(main["id"]), int(pdf["id"])])
+                    )
+                )
+            }
+        assert download_after == download_before
+
+        result.update(
+            {
+                "adminLogin": 200,
+                "userLogin": 200,
+                "auditorLogin": auditor.status_code,
+                "adminModules": me.json()["data"]["allowedModules"],
+                "adminAudit": audit_api.status_code,
+                "userAttachmentLibrary": user_library.status_code,
+                "userMountedAttachment": mounted.status_code,
+                "recursiveDocumentDelta": 2,
+                "allDocumentDelta": 2,
+                "adminTreeCountDelta": 2,
+                "userTreeCountDelta": 1,
+                "docxPreview": docx_preview.status_code,
+                "pdfPreview": pdf_preview.status_code,
+                "docPreview": legacy_preview.status_code,
+                "bodySearch": True,
+                "queryAuditUnchanged": True,
+                "previewDownloadCountUnchanged": True,
+            }
+        )
+    finally:
+        if "admin_token" in locals():
+            if "main" in locals() and "attachment" in locals() and main_version:
+                response = requests.delete(
+                    f"{base_url}/main-plans/{main['id']}/attachments/"
+                    f"{attachment['id']}",
+                    params={"mainPlanRowVersion": main_version},
+                    headers=_headers(admin_token),
+                    timeout=10,
+                )
+                if response.ok:
+                    main_version = response.json()["data"]["mainPlanRowVersion"]
+            for kind, document_id, version in reversed(created):
+                if "main" in locals() and document_id == main["id"]:
+                    current = requests.get(
+                        f"{base_url}/documents/{document_id}",
+                        headers=_headers(admin_token),
+                        timeout=10,
+                    )
+                    if current.ok:
+                        version = current.json()["data"]["rowVersion"]
+                path = "attachments" if kind == "attachment" else "documents"
+                requests.delete(
+                    f"{base_url}/{path}/{document_id}",
+                    params={"rowVersion": version},
+                    headers=_headers(admin_token),
+                    timeout=10,
+                )
+        server.shutdown()
+        server.server_close()
+        thread.join(timeout=5)
+
+    with app.app_context():
+        active_left = db.session.scalar(
+            select(func.count()).select_from(Document).where(
+                Document.document_name.like("Q3_HTTP_%"),
+                Document.is_deleted.is_(False),
+            )
+        )
+    assert active_left == 0
+    result["activeTemporaryDocumentsAfterCleanup"] = active_left
+    print(json.dumps(result, ensure_ascii=False, sort_keys=True))
+
+
+if __name__ == "__main__":
+    main()

+ 14 - 8
backend/tests/dms/test_b2_auth_and_directory.py

@@ -58,7 +58,11 @@ def test_login_success_and_safe_user_summary(b2_client, b2_password):
     assert payload["data"]["expiresIn"] == 7200
     user = payload["data"]["user"]
     assert isinstance(user["id"], str)
-    assert user["allowedModules"] == ["DOCUMENT_BROWSER", "BACKEND_MANAGEMENT"]
+    assert user["allowedModules"] == [
+        "DOCUMENT_BROWSER",
+        "BACKEND_MANAGEMENT",
+        "AUDIT_LOG",
+    ]
     assert "passwordHash" not in user
     assert "authVersion" not in user
 
@@ -281,8 +285,8 @@ def test_user_pagination_and_keyword(b2_client, login_token):
     page = response.get_json()["data"]
     assert response.status_code == 200
     assert page["pageSize"] == 2
-    assert page["total"] == 2
-    assert len(page["items"]) == 2
+    assert page["total"] == 1
+    assert len(page["items"]) == 1
 
 
 def test_include_descendants(b2_app, b2_client, login_token):
@@ -298,8 +302,8 @@ def test_include_descendants(b2_app, b2_client, login_token):
         f"/api/v1/users?organizationId={root_id}&includeDescendants=true",
         headers=_auth(login_token),
     ).get_json()["data"]
-    assert direct["total"] == 2
-    assert recursive["total"] == 4
+    assert direct["total"] == 1
+    assert recursive["total"] == 3
 
 
 def test_user_status_filter(b2_client, login_token):
@@ -318,7 +322,7 @@ def test_page_size_limit(b2_client, login_token):
     assert response.get_json()["code"] == "INVALID_ARGUMENT"
 
 
-@pytest.mark.parametrize("username", ["user", "auditor"])
+@pytest.mark.parametrize("username", ["user"])
 def test_admin_role_required_for_directory(
     b2_client, b2_password, username
 ):
@@ -339,8 +343,10 @@ def test_admin_role_required_for_directory(
 @pytest.mark.parametrize(
     "username,modules",
     [
-        ("admin", ["DOCUMENT_BROWSER", "BACKEND_MANAGEMENT"]),
-        ("auditor", ["AUDIT_LOG"]),
+        (
+            "admin",
+            ["DOCUMENT_BROWSER", "BACKEND_MANAGEMENT", "AUDIT_LOG"],
+        ),
         ("user", ["DOCUMENT_BROWSER"]),
     ],
 )

+ 2 - 2
backend/tests/dms/test_b3_categories.py

@@ -81,7 +81,7 @@ def test_category_tree_requires_authentication(b3_client):
     assert response.get_json()["code"] == "TOKEN_INVALID"
 
 
-@pytest.mark.parametrize("username", ["user", "admin", "auditor"])
+@pytest.mark.parametrize("username", ["user", "admin"])
 def test_all_enabled_roles_can_query_tree(b3_client, token_for, username):
     response = b3_client.get(
         "/api/v1/categories/tree",
@@ -236,7 +236,7 @@ def test_category_code_is_permanently_unique(
     assert response.status_code == 409
 
 
-@pytest.mark.parametrize("username", ["user", "auditor"])
+@pytest.mark.parametrize("username", ["user"])
 def test_non_admin_cannot_create(b3_client, token_for, username):
     response = b3_client.post(
         "/api/v1/categories",

+ 50 - 23
backend/tests/dms/test_b4_documents.py

@@ -46,18 +46,23 @@ def test_user_list_filters_status_security_and_acl(b4_client, token_for):
     assert "B4_TEST_全部主案" in names
     assert "B4_TEST_组织主案" in names
     assert "B4_TEST_自定义主案" in names
-    assert "B4_TEST_动态继承子案" in names
+    assert "B4_TEST_动态继承子案" not in names
     assert "B4_TEST_草稿主案" not in names
     assert "B4_TEST_绝密主案" not in names
     assert "B4_TEST_无授权主案" not in names
 
 
-def test_auditor_plan_list_is_forbidden(b4_client, token_for):
-    response = b4_client.get(
-        "/api/v1/documents", headers=auth(token_for("auditor"))
+def test_historical_auditor_cannot_login(b4_client, b2_password):
+    response = b4_client.post(
+        "/api/v1/auth/login",
+        json={
+            "username": "auditor",
+            "password": b2_password,
+            "keepSignedIn": False,
+        },
     )
-    assert response.status_code == 403
-    assert response.get_json()["code"] == "DOCUMENT_VIEW_FORBIDDEN"
+    assert response.status_code == 401
+    assert response.get_json()["code"] == "INVALID_CREDENTIALS"
 
 
 @pytest.mark.parametrize(
@@ -200,21 +205,27 @@ def test_sub_plan_page_and_main_type_validation(b4_app, b4_client, token_for):
     assert invalid.get_json()["code"] == "MAIN_PLAN_NOT_FOUND"
 
 
-def test_all_roles_can_list_attachments(b4_client, token_for):
-    for username in ("user", "admin", "auditor"):
-        response = b4_client.get(
-            "/api/v1/attachments?pageSize=100",
-            headers=auth(token_for(username)),
-        )
-        assert response.status_code == 200
-        assert data(response)["total"] == 2
-        assert all(item["securityLevel"] == "PUBLIC" for item in data(response)["items"])
+def test_only_admin_can_list_attachment_library(b4_client, token_for):
+    response = b4_client.get(
+        "/api/v1/attachments?pageSize=100",
+        headers=auth(token_for("admin")),
+    )
+    assert response.status_code == 200
+    assert data(response)["total"] == 2
+    assert all(
+        item["securityLevel"] == "PUBLIC" for item in data(response)["items"]
+    )
+    denied = b4_client.get(
+        "/api/v1/attachments?pageSize=100",
+        headers=auth(token_for("user")),
+    )
+    assert denied.status_code == 403
 
 
 def test_attachment_filters_and_real_mount_count(b4_client, token_for):
     response = b4_client.get(
         "/api/v1/attachments?attachmentType=WORK_STANDARD&fileExtension=docx",
-        headers=auth(token_for("user")),
+        headers=auth(token_for("admin")),
     )
     assert response.status_code == 200
     assert data(response)["total"] == 1
@@ -230,7 +241,7 @@ def test_attachment_detail_increments_view_and_attachment_audit(
     attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
     response = b4_client.get(
         f"/api/v1/attachments/{attachment_id}",
-        headers=auth(token_for("auditor")),
+        headers=auth(token_for("user")),
     )
     assert response.status_code == 200
     item = data(response)
@@ -266,11 +277,11 @@ def test_attachment_main_plans_respect_plan_access(
         headers=auth(token_for("user")),
     )
     assert data(user_response)["total"] == 2
-    auditor_response = b4_client.get(
+    admin_response = b4_client.get(
         f"/api/v1/attachments/{attachment_id}/main-plans",
-        headers=auth(token_for("auditor")),
+        headers=auth(token_for("admin")),
     )
-    assert data(auditor_response) == {"items": [], "total": 0}
+    assert data(admin_response)["total"] == 2
 
 
 def test_main_plan_attachments_stable_binding_order(
@@ -302,11 +313,11 @@ def test_allowed_actions_are_stable(b4_app, b4_client, token_for):
         "UNBIND_ATTACHMENT",
     ]
     attachment_id = b4_app.config["B4_IDS"]["attachment_one"]
-    auditor = b4_client.get(
+    user = b4_client.get(
         f"/api/v1/attachments/{attachment_id}",
-        headers=auth(token_for("auditor")),
+        headers=auth(token_for("user")),
     )
-    assert data(auditor)["allowedActions"] == ["VIEW", "DOWNLOAD"]
+    assert data(user)["allowedActions"] == ["VIEW", "DOWNLOAD"]
 
 
 def test_seed_documents_is_idempotent_and_creates_valid_files(
@@ -335,6 +346,22 @@ def test_seed_documents_is_idempotent_and_creates_valid_files(
                 )
             )
         db.session.commit()
+        scene_a = db.session.scalar(
+            db.select(Category).where(Category.category_code == "SCENE_A")
+        )
+        db.session.add(
+            Category(
+                category_code="STYLE_A1",
+                category_name="B4_TEST_样式A1",
+                category_type="STYLE",
+                parent_id=scene_a.id,
+                category_path=f"{scene_a.category_path}/B4_TEST_样式A1",
+                sort_no=10,
+                document_count=0,
+                status="ENABLED",
+            )
+        )
+        db.session.commit()
         first = seed_documents(tmp_path / "storage")
         second = seed_documents(tmp_path / "storage")
         assert first == (9, 0, 0)

+ 121 - 11
backend/tests/dms/test_b5_file_mutations.py

@@ -15,10 +15,10 @@ def auth(token: str) -> dict[str, str]:
     return {"Authorization": f"Bearer {token}"}
 
 
-def docx_bytes() -> bytes:
+def docx_bytes(text: str = "B5测试文档") -> bytes:
     stream = io.BytesIO()
     document = WordDocument()
-    document.add_paragraph("B5测试文档")
+    document.add_paragraph(text)
     document.save(stream)
     return stream.getvalue()
 
@@ -80,7 +80,7 @@ def category_id(app) -> str:
 
     with app.app_context():
         category = db.session.scalar(
-            db.select(Category).where(Category.category_code == "ROOT_A")
+            db.select(Category).where(Category.category_code == "CHILD_A1")
         )
         return str(category.id)
 
@@ -92,8 +92,6 @@ def main_metadata(app, name: str = "B5_TEST_主案") -> dict:
         "summary": "B5上传测试",
         "categoryId": category_id(app),
         "securityLevel": "INTERNAL",
-        "visibilityType": "ALL_AUTHENTICATED",
-        "status": "PUBLISHED",
         "tags": ["B5", "上传"],
     }
 
@@ -164,6 +162,7 @@ def test_sub_plan_upload_updates_parent_count(b5_app, b5_client, token_for):
     metadata = main_metadata(b5_app, "B5_TEST_子案")
     metadata["documentType"] = "SUB_PLAN"
     metadata["parentDocumentId"] = main["id"]
+    metadata.pop("categoryId")
     child = upload(
         b5_client, token, "/api/v1/documents", docx_bytes(), metadata
     )
@@ -173,6 +172,85 @@ def test_sub_plan_upload_updates_parent_count(b5_app, b5_client, token_for):
         assert db.session.get(Document, int(main["id"])).child_count == 1
 
 
+def test_duplicate_main_requires_confirmation_and_overwrites_same_record(
+    b5_app, b5_client, token_for
+):
+    from dms.extensions import db
+    from dms.models import Document
+
+    token = token_for("admin")
+    metadata = main_metadata(b5_app, "B5_TEST_同名覆盖主案")
+    first_content = docx_bytes()
+    first = upload(
+        b5_client, token, "/api/v1/documents", first_content, metadata
+    )
+    assert first.status_code == 201
+    first_item = first.get_json()["data"]
+    with b5_app.app_context():
+        first_document = db.session.get(Document, int(first_item["id"]))
+        old_path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / first_document.file_relative_path
+        old_version = first_document.row_version
+
+    second_content = docx_bytes("B5测试覆盖后的新内容")
+    conflict = upload(
+        b5_client, token, "/api/v1/documents", second_content, metadata
+    )
+    assert conflict.status_code == 409
+    payload = conflict.get_json()
+    assert payload["code"] == "DOCUMENT_NAME_CONFLICT"
+    assert payload["details"]["existingDocumentId"] == first_item["id"]
+    assert payload["details"]["existingRowVersion"] == old_version
+
+    confirmed = dict(
+        metadata,
+        overwriteDocumentId=first_item["id"],
+        rowVersion=old_version,
+    )
+    replaced = upload(
+        b5_client, token, "/api/v1/documents", second_content, confirmed
+    )
+    assert replaced.status_code == 201
+    replaced_item = replaced.get_json()["data"]
+    assert replaced_item["id"] == first_item["id"]
+    assert replaced_item["rowVersion"] == old_version + 1
+    with b5_app.app_context():
+        document = db.session.get(Document, int(first_item["id"]))
+        new_path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / document.file_relative_path
+        assert new_path.read_bytes() == second_content
+        assert not old_path.exists()
+
+
+def test_duplicate_sub_plan_is_scoped_to_parent(b5_app, b5_client, token_for):
+    token = token_for("admin")
+    parent_one = upload(
+        b5_client, token, "/api/v1/documents", docx_bytes(),
+        main_metadata(b5_app, "B5_TEST_父案一"),
+    ).get_json()["data"]
+    parent_two = upload(
+        b5_client, token, "/api/v1/documents", docx_bytes(),
+        main_metadata(b5_app, "B5_TEST_父案二"),
+    ).get_json()["data"]
+
+    def child_metadata(parent_id):
+        value = main_metadata(b5_app, "B5_TEST_同名子案")
+        value.update(documentType="SUB_PLAN", parentDocumentId=parent_id)
+        value.pop("categoryId")
+        return value
+
+    assert upload(
+        b5_client, token, "/api/v1/documents", docx_bytes(),
+        child_metadata(parent_one["id"]),
+    ).status_code == 201
+    assert upload(
+        b5_client, token, "/api/v1/documents", docx_bytes(),
+        child_metadata(parent_one["id"]),
+    ).status_code == 409
+    assert upload(
+        b5_client, token, "/api/v1/documents", docx_bytes(),
+        child_metadata(parent_two["id"]),
+    ).status_code == 201
+
+
 def test_attachment_upload_forces_fixed_security(b5_client, token_for):
     response = upload(
         b5_client,
@@ -386,8 +464,6 @@ def test_edit_version_conflict_and_forbidden_identity_fields(
         "summary": "更新摘要",
         "categoryId": category_id(b5_app),
         "securityLevel": "SECRET",
-        "visibilityType": "CUSTOM",
-        "status": "DRAFT",
         "tags": ["更新"],
         "rowVersion": created["rowVersion"],
     }
@@ -420,6 +496,7 @@ def test_delete_sub_updates_parent_and_keeps_file(
     ).get_json()["data"]
     metadata = main_metadata(b5_app, "B5_TEST_删除子案")
     metadata.update(documentType="SUB_PLAN", parentDocumentId=main["id"])
+    metadata.pop("categoryId")
     child = upload(
         b5_client, token, "/api/v1/documents", docx_bytes(), metadata
     ).get_json()["data"]
@@ -446,6 +523,7 @@ def test_main_with_child_and_mounted_attachment_deletion_rules(
     ).get_json()["data"]
     metadata = main_metadata(b5_app, "B5_TEST_保留子案")
     metadata.update(documentType="SUB_PLAN", parentDocumentId=main["id"])
+    metadata.pop("categoryId")
     upload(b5_client, token, "/api/v1/documents", docx_bytes(), metadata)
     response = b5_client.delete(
         f"/api/v1/documents/{main['id']}?rowVersion={main['rowVersion'] + 1}",
@@ -496,6 +574,7 @@ def test_pdf_preview_and_download_count_audit(
     assert preview.status_code == 200
     assert preview.mimetype == "application/pdf"
     assert preview.headers["Content-Disposition"].startswith("inline")
+    preview.close()
     download = b5_client.get(
         f"/api/v1/documents/{created['id']}/download", headers=auth(token)
     )
@@ -503,6 +582,7 @@ def test_pdf_preview_and_download_count_audit(
     assert download.data == b"%PDF-1.4\\nB5-CONTENT"
     assert download.headers["X-Request-Id"]
     assert "\r" not in download.headers["Content-Disposition"]
+    download.close()
     with b5_app.app_context():
         document = db.session.get(Document, int(created["id"]))
         assert document.download_count == 1
@@ -524,18 +604,24 @@ def test_office_preview_file_missing_and_no_restore_route(
     from dms.models import Document
 
     token = token_for("admin")
+    content = docx_bytes()
     created = upload(
         b5_client,
         token,
         "/api/v1/attachments",
-        docx_bytes(),
+        content,
         attachment_metadata("B5_TEST_Office预览"),
     ).get_json()["data"]
     preview = b5_client.get(
         f"/api/v1/documents/{created['id']}/preview", headers=auth(token)
     )
-    assert preview.status_code == 415
-    assert preview.get_json()["code"] == "PREVIEW_UNAVAILABLE"
+    assert preview.status_code == 200
+    assert preview.mimetype == (
+        "application/vnd.openxmlformats-officedocument."
+        "wordprocessingml.document"
+    )
+    assert preview.data == content
+    preview.close()
     with b5_app.app_context():
         document = db.session.get(Document, int(created["id"]))
         path = Path(b5_app.config["DMS_STORAGE_ROOT"]) / document.file_relative_path
@@ -551,6 +637,31 @@ def test_office_preview_file_missing_and_no_restore_route(
     assert restore.status_code == 404
 
 
+def test_legacy_doc_preview_is_explicitly_unsupported(
+    b5_client, token_for
+):
+    token = token_for("admin")
+    created_response = b5_client.post(
+        "/api/v1/attachments",
+        data={
+            "file": (io.BytesIO(cfb_bytes("WordDocument")), "legacy.doc"),
+            "metadata": json.dumps(
+                attachment_metadata("B5_TEST_旧版DOC"), ensure_ascii=False
+            ),
+        },
+        headers=auth(token),
+        content_type="multipart/form-data",
+    )
+    assert created_response.status_code == 201
+    created = created_response.get_json()["data"]
+
+    preview = b5_client.get(
+        f"/api/v1/documents/{created['id']}/preview", headers=auth(token)
+    )
+    assert preview.status_code == 415
+    assert preview.get_json()["code"] == "PREVIEW_UNAVAILABLE"
+
+
 def test_size_limit_and_temporary_cleanup(b5_app, b5_client, token_for):
     b5_app.config["DMS_MAX_FILE_SIZE"] = 8
     temporary = Path(b5_app.config["DMS_STORAGE_ROOT"]) / "temporary"
@@ -684,7 +795,6 @@ def test_plan_download_requires_acl_and_path_escape_is_rejected(
 
     admin_token = token_for("admin")
     metadata = main_metadata(b5_app, "B5_TEST_无下载权限")
-    metadata["visibilityType"] = "CUSTOM"
     created = upload(
         b5_client, admin_token, "/api/v1/documents", docx_bytes(), metadata
     ).get_json()["data"]

+ 4 - 11
backend/tests/dms/test_b6_bindings_permissions.py

@@ -154,7 +154,7 @@ def test_bind_rejects_invalid_payloads(
     assert response.get_json()["code"] == "INVALID_ARGUMENT"
 
 
-@pytest.mark.parametrize("username", ["user", "auditor"])
+@pytest.mark.parametrize("username", ["user"])
 def test_binding_management_requires_admin(
     username, b6_app, b6_client, token_for
 ):
@@ -384,7 +384,7 @@ def test_binding_audit_failure_rolls_back(
         assert db.session.get(Document, ids["main_custom"]).row_version == 0
 
 
-@pytest.mark.parametrize("username", ["user", "auditor"])
+@pytest.mark.parametrize("username", ["user"])
 def test_unbind_requires_admin(username, b6_app, b6_client, token_for):
     ids = _ids(b6_app)
     response = b6_client.delete(
@@ -498,7 +498,7 @@ def test_permission_get_main_sub_inheritance_and_attachment_error(
     assert attachment.get_json()["code"] == "ATTACHMENT_HAS_NO_ACL"
 
 
-@pytest.mark.parametrize("username", ["user", "auditor"])
+@pytest.mark.parametrize("username", ["user"])
 def test_permission_management_requires_admin(
     username, b6_app, b6_client, token_for
 ):
@@ -918,7 +918,7 @@ def test_permission_authorization_linkage_and_dynamic_sub_inheritance(
     assert download.status_code == 200
 
 
-def test_all_authenticated_and_attachment_ignore_acl_auditor_has_no_plan_privilege(
+def test_all_authenticated_plan_and_mounted_attachment_are_visible_to_user(
     b6_app, b6_client, login_token, token_for
 ):
     ids = _ids(b6_app)
@@ -938,13 +938,6 @@ def test_all_authenticated_and_attachment_ignore_acl_auditor_has_no_plan_privile
         ).status_code
         == 200
     )
-    assert (
-        b6_client.get(
-            f"/api/v1/documents/{ids['main_all']}",
-            headers=_headers(token_for("auditor")),
-        ).status_code
-        == 403
-    )
     assert (
         b6_client.get(
             f"/api/v1/attachments/{ids['attachment_one']}",

+ 32 - 27
backend/tests/dms/test_b7_audit_statistics.py

@@ -39,9 +39,8 @@ def test_all_b7_endpoints_require_authentication(b7_client, path):
         "/api/v1/audit/statistics/users",
     ],
 )
-def test_audit_endpoints_are_auditor_only(b7_client, token_for, path):
-    assert _get(b7_client, path, token_for("auditor")).status_code == 200
-    assert _get(b7_client, path, token_for("admin")).status_code == 403
+def test_audit_endpoints_are_admin_only(b7_client, token_for, path):
+    assert _get(b7_client, path, token_for("admin")).status_code == 200
     assert _get(b7_client, path, token_for("user")).status_code == 403
 
 
@@ -49,16 +48,13 @@ def test_document_statistics_is_admin_only(b7_client, token_for):
     assert _get(
         b7_client, "/api/v1/statistics/documents", token_for("admin")
     ).status_code == 200
-    assert _get(
-        b7_client, "/api/v1/statistics/documents", token_for("auditor")
-    ).status_code == 403
     assert _get(
         b7_client, "/api/v1/statistics/documents", token_for("user")
     ).status_code == 403
 
 
 def test_audit_list_dto_paging_and_stable_sort(b7_client, token_for):
-    token = token_for("auditor")
+    token = token_for("admin")
     response = _get(
         b7_client,
         f"/api/v1/audit/logs?{RANGE}&page=1&pageSize=2&sortDirection=asc",
@@ -123,7 +119,7 @@ def test_keyword_searches_only_contract_fields(
     response = _get(
         b7_client,
         f"/api/v1/audit/logs?{RANGE}&keyword={keyword}",
-        token_for("auditor"),
+        token_for("admin"),
     )
     request_ids = {
         item["requestId"] for item in response.get_json()["data"]["items"]
@@ -135,7 +131,7 @@ def test_keyword_escapes_sql_wildcards(b7_client, token_for):
     response = _get(
         b7_client,
         f"/api/v1/audit/logs?{RANGE}&keyword=%25",
-        token_for("auditor"),
+        token_for("admin"),
     )
     assert response.get_json()["data"]["total"] == 0
 
@@ -167,7 +163,7 @@ def test_audit_list_contract_filters(
     response = _get(
         b7_client,
         f"/api/v1/audit/logs?{RANGE}&{query}",
-        token_for("auditor"),
+        token_for("admin"),
     )
     assert request_id in {
         item["requestId"] for item in response.get_json()["data"]["items"]
@@ -177,7 +173,7 @@ def test_audit_list_contract_filters(
 def test_time_range_is_half_open_and_each_bound_is_optional(
     b7_client, token_for
 ):
-    token = token_for("auditor")
+    token = token_for("admin")
     inclusive = _get(
         b7_client,
         "/api/v1/audit/logs?"
@@ -222,7 +218,7 @@ def test_invalid_audit_list_parameters_return_400(
     b7_client, token_for, query
 ):
     response = _get(
-        b7_client, f"/api/v1/audit/logs?{query}", token_for("auditor")
+        b7_client, f"/api/v1/audit/logs?{query}", token_for("admin")
     )
     assert response.status_code == 400
     assert response.get_json()["code"] == "INVALID_ARGUMENT"
@@ -275,7 +271,7 @@ def test_trend_database_buckets(
     response = _get(
         b7_client,
         f"/api/v1/audit/statistics/trend?{RANGE}&granularity={granularity}",
-        token_for("auditor"),
+        token_for("admin"),
     )
     assert response.status_code == 200
     items = response.get_json()["data"]["items"]
@@ -286,7 +282,7 @@ def test_trend_database_buckets(
 def test_trend_requires_valid_granularity_and_supports_empty_result(
     b7_client, token_for
 ):
-    token = token_for("auditor")
+    token = token_for("admin")
     assert _get(
         b7_client, "/api/v1/audit/statistics/trend", token
     ).status_code == 400
@@ -309,7 +305,7 @@ def test_action_distribution_order_and_all_results(b7_client, token_for):
     items = _get(
         b7_client,
         f"/api/v1/audit/statistics/actions?{RANGE}",
-        token_for("auditor"),
+        token_for("admin"),
     ).get_json()["data"]["items"]
     assert items[0] == {"actionType": "VIEW_DOCUMENT", "count": 5}
     assert sum(item["count"] for item in items) == 8
@@ -321,7 +317,7 @@ def test_active_users_latest_audit_snapshot_and_order(b7_client, token_for):
     items = _get(
         b7_client,
         f"/api/v1/audit/statistics/users?{RANGE}&limit=2",
-        token_for("auditor"),
+        token_for("admin"),
     ).get_json()["data"]["items"]
     assert [item["operationCount"] for item in items] == [4, 2]
     assert items[0]["realName"] == "管理员新名"
@@ -334,7 +330,7 @@ def test_active_user_limit_validation(b7_client, token_for, limit):
     assert _get(
         b7_client,
         f"/api/v1/audit/statistics/users?limit={limit}",
-        token_for("auditor"),
+        token_for("admin"),
     ).status_code == 400
 
 
@@ -342,7 +338,7 @@ def test_audit_queries_do_not_write_audit_log(b7_app, b7_client, token_for):
     from dms.extensions import db
     from dms.models import AuditLog
 
-    token = token_for("auditor")
+    token = token_for("admin")
     with b7_app.app_context():
         before = db.session.scalar(db.select(db.func.count(AuditLog.id)))
     for path in (
@@ -409,7 +405,7 @@ def test_b7_real_http_on_8755_uses_only_formal_apis_for_audit_writes(
             "/api/v1/auth/login",
             body={
                 "username": username,
-                "password": password,
+                "password": b2_password,
                 "keepSignedIn": False,
             },
         )
@@ -439,7 +435,16 @@ def test_b7_real_http_on_8755_uses_only_formal_apis_for_audit_writes(
                 "keepSignedIn": False,
             },
         )[0] == 401
-        auditor_token = login("auditor")
+        assert call(
+            "POST",
+            "/api/v1/auth/login",
+                body={
+                    "username": "auditor",
+                    "password": b2_password,
+                    "keepSignedIn": False,
+                },
+        )[0] == 401
+        audit_token = admin_token
 
         assert call(
             "GET", f"/api/v1/documents/{ids['main_all']}", token=admin_token
@@ -509,7 +514,7 @@ def test_b7_real_http_on_8755_uses_only_formal_apis_for_audit_writes(
             "GET",
             f"/api/v1/audit/logs?{base_query}&page=1&pageSize=100&"
             "sortDirection=asc",
-            token=auditor_token,
+            token=audit_token,
         )
         assert status == 200
         assert headers["Access-Control-Allow-Origin"] == origin
@@ -533,7 +538,7 @@ def test_b7_real_http_on_8755_uses_only_formal_apis_for_audit_writes(
             f"/api/v1/audit/statistics/users?{base_query}&limit=10",
         ]
         for path in checks:
-            assert call("GET", path, token=auditor_token)[0] == 200
+            assert call("GET", path, token=audit_token)[0] == 200
 
         status, _, statistics = call(
             "GET", "/api/v1/statistics/documents", token=admin_token
@@ -542,18 +547,18 @@ def test_b7_real_http_on_8755_uses_only_formal_apis_for_audit_writes(
         assert statistics["data"]["todayViewerCount"] == 2
         assert call(
             "GET", "/api/v1/audit/logs", token=admin_token
-        )[0] == 403
+        )[0] == 200
         assert call(
-            "GET", "/api/v1/statistics/documents", token=auditor_token
-        )[0] == 403
+            "GET", "/api/v1/statistics/documents", token=audit_token
+        )[0] == 200
         assert call(
-            "GET", "/api/v1/audit/export", token=auditor_token
+            "GET", "/api/v1/audit/export", token=audit_token
         )[0] == 404
 
         after_total = call(
             "GET",
             f"/api/v1/audit/logs?{base_query}&pageSize=100",
-            token=auditor_token,
+            token=audit_token,
         )[2]["data"]["total"]
         assert after_total == before_total
     finally:

+ 30 - 23
backend/tests/dms/test_b8_recycle_restore.py

@@ -23,7 +23,7 @@ def _category_id(app) -> str:
 
     with app.app_context():
         category = db.session.scalar(
-            db.select(Category).where(Category.category_code == "ROOT_A")
+            db.select(Category).where(Category.category_code == "CHILD_A1")
         )
         assert category is not None
         return str(category.id)
@@ -43,13 +43,12 @@ def _upload_plan(
         "documentName": name,
         "documentType": document_type,
         "summary": f"{name}摘要",
-        "categoryId": category_id,
         "securityLevel": "INTERNAL",
-        "visibilityType": visibility,
-        "status": "PUBLISHED",
         "tags": ["B8"],
     }
-    if parent_id is not None:
+    if document_type == "MAIN":
+        metadata["categoryId"] = category_id
+    if document_type == "SUB_PLAN" and parent_id is not None:
         metadata["parentDocumentId"] = parent_id
     response = client.post(
         "/api/v1/documents",
@@ -61,7 +60,23 @@ def _upload_plan(
         content_type="multipart/form-data",
     )
     assert response.status_code == 201, response.get_json()
-    return response.get_json()["data"]
+    document = response.get_json()["data"]
+    if document_type == "MAIN" and visibility == "ALL_AUTHENTICATED":
+        permission = client.put(
+            f"/api/v1/documents/{document['id']}/permissions",
+            json={
+                "visibilityType": "ALL_AUTHENTICATED",
+                "documentRowVersion": document["rowVersion"],
+                "entries": [],
+            },
+            headers=_headers(token),
+        )
+        assert permission.status_code == 200, permission.get_json()
+        document["rowVersion"] = permission.get_json()["data"][
+            "documentRowVersion"
+        ]
+        document["visibilityType"] = "ALL_AUTHENTICATED"
+    return document
 
 
 def _upload_attachment(client, token: str, name: str):
@@ -132,7 +147,7 @@ def test_recycle_bin_auth_query_filters_and_deleted_by(
     _delete(b3_client, login_token, deleted)
 
     assert b3_client.get("/api/v1/recycle-bin/documents").status_code == 401
-    for username in ("user", "auditor"):
+    for username in ("user",):
         response = b3_client.get(
             "/api/v1/recycle-bin/documents",
             headers=_headers(token_for(username)),
@@ -839,22 +854,14 @@ def test_restore_relation_conflicts_and_all_authenticated_acl_roll_back(
         },
         headers=_headers(login_token),
     ).get_json()["data"]
-    edited = b3_client.put(
-        f"/api/v1/documents/{all_main['id']}",
-        json={
-            "documentName": all_main["documentName"],
-            "summary": "切换全员可见但保留历史ACL",
-            "categoryId": category_id,
-            "securityLevel": "INTERNAL",
-            "visibilityType": "ALL_AUTHENTICATED",
-            "status": "PUBLISHED",
-            "tags": ["B8"],
-            "rowVersion": saved["documentRowVersion"],
-        },
-        headers=_headers(login_token),
-    )
-    assert edited.status_code == 200
-    all_main = edited.get_json()["data"]
+    # 构造历史异常数据:全员可见文档仍残留有效ACL。
+    with b3_app.app_context():
+        document = db.session.get(Document, int(all_main["id"]))
+        document.visibility_type = "ALL_AUTHENTICATED"
+        document.row_version = saved["documentRowVersion"] + 1
+        db.session.commit()
+        all_main["rowVersion"] = document.row_version
+        all_main["visibilityType"] = document.visibility_type
     _delete(b3_client, login_token, all_main)
     all_deleted = _deleted_document(b3_app, all_main["id"])
     invalid = _restore(

+ 25 - 1
backend/tests/dms/test_migration.py

@@ -13,6 +13,11 @@ REVISION_PATH = MIGRATIONS_ROOT / "versions" / "0001_initial_schema.py"
 RESTORE_REVISION_PATH = (
     MIGRATIONS_ROOT / "versions" / "0002_add_restore_audit_action.py"
 )
+Q3_REVISION_PATH = (
+    MIGRATIONS_ROOT
+    / "versions"
+    / "0003_q3_business_alignment_and_content_search.py"
+)
 
 
 def _load_revision_module():
@@ -26,7 +31,9 @@ def _load_revision_module():
 def test_alembic_config_and_head_are_valid():
     config = Config(str(MIGRATIONS_ROOT / "alembic.ini"))
     script = ScriptDirectory.from_config(config)
-    assert script.get_current_head() == "0002_add_restore_audit_action"
+    assert script.get_current_head() == (
+        "0003_q3_business_alignment_and_content_search"
+    )
 
 
 def test_initial_migration_exports_upgrade_and_downgrade():
@@ -85,3 +92,20 @@ def test_restore_migration_is_scoped_and_reversible():
     assert source.count("create_check_constraint") == 2
     assert "create_table" not in source
     assert "add_column" not in source
+
+
+def test_q3_migration_is_single_scoped_revision_with_safe_downgrade():
+    source = Q3_REVISION_PATH.read_text(encoding="utf-8")
+    assert (
+        'down_revision: str | None = "0002_add_restore_audit_action"'
+        in source
+    )
+    assert "content_text" in source
+    assert "content_extract_status" in source
+    assert "content_extracted_at" in source
+    assert "LONGTEXT" in source
+    assert "auth_version = auth_version + 1" in source
+    assert "role_code = 'USER'" in source
+    assert "role_code IN ('USER', 'ADMIN')" in source
+    assert "RuntimeError" in source
+    assert "create_table" not in source

+ 9 - 0
backend/tests/dms/test_models.py

@@ -8,6 +8,8 @@ from dms.common.enums import (
     AuditAction,
     AuditResult,
     AuditTarget,
+    ContentExtractStatus,
+    RoleCode,
     SecurityLevel,
     SECURITY_LEVEL_VALUES,
 )
@@ -150,6 +152,13 @@ def test_permission_model_has_no_inherited_source_column():
 
 
 def test_contract_enums_are_exact():
+    assert {item.value for item in RoleCode} == {"USER", "ADMIN"}
+    assert {item.value for item in ContentExtractStatus} == {
+        "SUCCESS",
+        "EMPTY",
+        "UNSUPPORTED",
+        "FAILED",
+    }
     assert {item.value for item in AllowedModule} == {
         "DOCUMENT_BROWSER",
         "BACKEND_MANAGEMENT",

+ 11 - 2
backend/tests/dms/test_mysql_integration.py

@@ -10,7 +10,7 @@ from sqlalchemy import create_engine, text
 
 
 @pytest.mark.integration
-def test_mysql_upgrade_and_downgrade_when_test_database_is_configured(
+def test_mysql_q3_upgrade_downgrade_upgrade_when_test_database_is_configured(
     monkeypatch,
 ):
     database_url = os.environ.get("DMS_TEST_DATABASE_URL", "").strip()
@@ -35,6 +35,15 @@ def test_mysql_upgrade_and_downgrade_when_test_database_is_configured(
                 "WHERE action_type = 'RESTORE_DOCUMENT'"
             )
         )
+        # 0003明确阻止正文结果的有损降级;测试库先回到可安全降级状态。
+        connection.execute(
+            text(
+                "UPDATE doc_document SET content_text = NULL, "
+                "content_extract_status = 'UNSUPPORTED', "
+                "content_extracted_at = NULL, "
+                "search_text = CONCAT_WS(' ', document_name, summary)"
+            )
+        )
     engine.dispose()
-    command.downgrade(config, "base")
+    command.downgrade(config, "0002_add_restore_audit_action")
     command.upgrade(config, "head")

+ 1 - 1
backend/tests/dms/test_openapi_storage.py

@@ -115,7 +115,7 @@ def test_openapi_contains_only_q2b_paths():
         "/api/v1/recycle-bin/documents",
         "/api/v1/recycle-bin/documents/{id}/restore",
     }
-    assert document["info"]["version"] == "1.3.0"
+    assert document["info"]["version"] == "1.5.0"
     assert "/api/v1/documents/{id}/restore" not in document["paths"]
     assert document["paths"]["/api/v1/health"]["get"]["security"] == []
     login_path = yaml.safe_load(

+ 6 - 7
backend/tests/dms/test_q1b_ui_dictionaries.py

@@ -206,7 +206,7 @@ def test_all_roles_receive_stable_dictionary_without_audit_or_business_query(
 
     tokens = {
         username: token_for(username)
-        for username in ("admin", "auditor", "user")
+        for username in ("admin", "user")
     }
     with b2_app.app_context():
         audit_count_before = db.session.scalar(
@@ -246,10 +246,9 @@ def test_all_roles_receive_stable_dictionary_without_audit_or_business_query(
 
     assert all(response.status_code == 200 for response in responses)
     payloads = [response.get_json() for response in responses]
-    assert [payload["data"] for payload in payloads[1:]] == [
-        payloads[0]["data"],
-        payloads[0]["data"],
-    ]
+    assert all(
+        payload["data"] == payloads[0]["data"] for payload in payloads[1:]
+    )
     for response, payload in zip(responses, payloads):
         assert payload["requestId"] == response.headers["X-Request-Id"]
         assert "X-Request-Id" in response.headers[
@@ -259,7 +258,7 @@ def test_all_roles_receive_stable_dictionary_without_audit_or_business_query(
         assert "configPath" not in payload["data"]
         assert "loadedAt" not in payload["data"]
     assert audit_count_after == audit_count_before
-    assert len(statements) == 3
+    assert len(statements) == len(tokens)
     assert all(
         statement.lstrip().startswith("select") and "sys_user" in statement
         for statement in statements
@@ -285,7 +284,7 @@ def test_openapi_ui_dictionary_contract_and_no_write_operation():
         )
     )
 
-    assert document["info"]["version"] == "1.3.0"
+    assert document["info"]["version"] == "1.5.0"
     assert set(path_item) == {"get"}
     assert path_item["get"]["operationId"] == "getUiDictionaries"
     assert {"200", "401", "500"} <= set(path_item["get"]["responses"])

+ 2 - 17
backend/tests/dms/test_q2b_updated_range.py

@@ -257,7 +257,7 @@ def test_role_boundaries_and_queries_do_not_increment_counts_or_audit(
     from dms.models import AuditLog, Document
 
     ids = q2b_app.config["B4_IDS"]
-    tokens = {name: token_for(name) for name in ("admin", "user", "auditor")}
+    tokens = {name: token_for(name) for name in ("admin", "user")}
     with q2b_app.app_context():
         before_counts = {
             item.id: (item.view_count, item.download_count)
@@ -277,23 +277,8 @@ def test_role_boundaries_and_queries_do_not_increment_counts_or_audit(
         tokens["user"],
         updatedTo="2026-12-31T23:59:59Z",
     )
-    auditor = query(
-        q2b_client,
-        "/api/v1/documents",
-        tokens["auditor"],
-        updatedFrom="2026-01-01T00:00:00Z",
-    )
-    auditor_mounted = query(
-        q2b_client,
-        f"/api/v1/main-plans/{ids['main_all']}/attachments",
-        tokens["auditor"],
-        updatedTo="2026-12-31T23:59:59Z",
-    )
-
     assert admin.status_code == 200
     assert user.status_code == 200
-    assert auditor.status_code == 403
-    assert auditor_mounted.status_code == 403
     with q2b_app.app_context():
         after_counts = {
             item.id: (item.view_count, item.download_count)
@@ -359,7 +344,7 @@ def test_openapi_declares_q2b_parameters_without_new_paths():
         (root / "paths" / "documents.yaml").read_text(encoding="utf-8")
     )
 
-    assert document["info"]["version"] == "1.3.0"
+    assert document["info"]["version"] == "1.5.0"
     for operation in ("Documents", "SubPlans", "MainPlanAttachments"):
         references = {
             parameter.get("$ref")

+ 466 - 0
backend/tests/dms/test_q3b_business_content_preview.py

@@ -0,0 +1,466 @@
+from __future__ import annotations
+
+import io
+import json
+from pathlib import Path
+
+import fitz
+import pytest
+from docx import Document as WordDocument
+
+
+def _auth(token: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {token}"}
+
+
+def _category_id(app, code: str) -> str:
+    from dms.extensions import db
+    from dms.models import Category
+
+    with app.app_context():
+        category = db.session.scalar(
+            db.select(Category).where(Category.category_code == code)
+        )
+        assert category is not None
+        return str(category.id)
+
+
+def _docx_bytes(term: str = "Q3正文唯一词") -> bytes:
+    stream = io.BytesIO()
+    document = WordDocument()
+    document.add_paragraph(f"段落 {term}")
+    table = document.add_table(rows=1, cols=1)
+    table.cell(0, 0).text = "表格唯一词"
+    section = document.sections[0]
+    section.header.paragraphs[0].text = "页眉唯一词"
+    section.footer.paragraphs[0].text = "页脚唯一词"
+    document.save(stream)
+    return stream.getvalue()
+
+
+def _pdf_bytes(term: str = "Q3PdfUniqueTerm") -> bytes:
+    document = fitz.open()
+    page = document.new_page()
+    page.insert_text((72, 72), term)
+    content = document.tobytes()
+    document.close()
+    return content
+
+
+def _main_metadata(category_id: str, name: str = "Q3主案") -> dict:
+    return {
+        "documentName": name,
+        "documentType": "MAIN",
+        "summary": "Q3摘要",
+        "categoryId": category_id,
+        "securityLevel": "INTERNAL",
+        "tags": ["Q3"],
+    }
+
+
+def _sub_metadata(parent_id: str, name: str = "Q3子方案") -> dict:
+    return {
+        "documentName": name,
+        "documentType": "SUB_PLAN",
+        "summary": "Q3子方案摘要",
+        "parentDocumentId": parent_id,
+        "securityLevel": "INTERNAL",
+        "tags": ["Q3"],
+    }
+
+
+def _upload(client, token: str, metadata: dict, content: bytes, suffix: str):
+    return client.post(
+        "/api/v1/documents",
+        data={
+            "file": (io.BytesIO(content), f"{metadata['documentName']}.{suffix}"),
+            "metadata": json.dumps(metadata, ensure_ascii=False),
+        },
+        headers=_auth(token),
+        content_type="multipart/form-data",
+    )
+
+
+def _tree_node(nodes: list[dict], category_id: str) -> dict:
+    pending = list(nodes)
+    while pending:
+        node = pending.pop()
+        if node["id"] == category_id:
+            return node
+        pending.extend(node["children"])
+    raise AssertionError(f"category {category_id} not found")
+
+
+def test_business_timezone_default_and_invalid_fail_fast(monkeypatch):
+    from dms.config import load_dms_config
+
+    monkeypatch.delenv("DMS_BUSINESS_TIMEZONE", raising=False)
+    assert load_dms_config()["DMS_BUSINESS_TIMEZONE"] == "Asia/Shanghai"
+    monkeypatch.setenv("DMS_BUSINESS_TIMEZONE", "Not/A_Real_Zone")
+    with pytest.raises(ValueError, match="DMS_BUSINESS_TIMEZONE"):
+        load_dms_config()
+
+
+def test_admin_modules_and_historical_auditor_are_invalid(
+    b2_app, b2_client, b2_password, token_for
+):
+    from dms.extensions import db
+    from dms.models import User
+    from dms.security.jwt_tokens import issue_access_token
+
+    admin = b2_client.get(
+        "/api/v1/users/me", headers=_auth(token_for("admin"))
+    )
+    assert admin.get_json()["data"]["allowedModules"] == [
+        "DOCUMENT_BROWSER",
+        "BACKEND_MANAGEMENT",
+        "AUDIT_LOG",
+    ]
+    failed = b2_client.post(
+        "/api/v1/auth/login",
+        json={
+            "username": "auditor",
+            "password": b2_password,
+            "keepSignedIn": False,
+        },
+    )
+    assert failed.status_code == 401
+    with b2_app.app_context():
+        historical = db.session.scalar(
+            db.select(User).where(User.username == "auditor")
+        )
+        assert historical.role_code == "USER"
+        assert historical.status == "DISABLED"
+        assert historical.is_deleted
+        old_token, _ = issue_access_token(historical, keep_signed_in=False)
+    assert b2_client.get(
+        "/api/v1/users/me", headers=_auth(old_token)
+    ).status_code == 401
+
+
+def test_leaf_rules_create_defaults_and_sub_plan_category_sync(
+    b3_app, b3_client, token_for
+):
+    from dms.extensions import db
+    from dms.models import Document
+
+    token = token_for("admin")
+    root_id = _category_id(b3_app, "ROOT_A")
+    leaf_one = _category_id(b3_app, "CHILD_A1")
+    leaf_two = _category_id(b3_app, "CHILD_A2")
+    rejected = _upload(
+        b3_client, token, _main_metadata(root_id), _docx_bytes(), "docx"
+    )
+    assert rejected.status_code == 409
+    assert rejected.get_json()["code"] == "CATEGORY_NOT_LEAF"
+    assert rejected.get_json()["details"] == {
+        "categoryId": root_id,
+        "childCategoryCount": 2,
+    }
+
+    created_response = _upload(
+        b3_client,
+        token,
+        _main_metadata(leaf_one, "Q3叶子主案"),
+        _docx_bytes(),
+        "docx",
+    )
+    assert created_response.status_code == 201
+    main = created_response.get_json()["data"]
+    assert (main["status"], main["visibilityType"]) == (
+        "PUBLISHED",
+        "CUSTOM",
+    )
+    child_category = b3_client.post(
+        "/api/v1/categories",
+        json={
+            "categoryCode": "Q3_CHILD_FORBIDDEN",
+            "categoryName": "禁止子分类",
+            "categoryType": "OTHER",
+            "parentId": leaf_one,
+            "sortNo": 99,
+        },
+        headers=_auth(token),
+    )
+    assert child_category.status_code == 409
+    assert child_category.get_json()["code"] == "CATEGORY_NOT_LEAF"
+
+    sub_response = _upload(
+        b3_client,
+        token,
+        _sub_metadata(main["id"]),
+        _docx_bytes("子方案正文"),
+        "docx",
+    )
+    assert sub_response.status_code == 201
+    sub = sub_response.get_json()["data"]
+    assert sub["categoryId"] == leaf_one
+    with b3_app.app_context():
+        current = db.session.get(Document, int(main["id"]))
+        current_version = current.row_version
+    moved = b3_client.put(
+        f"/api/v1/documents/{main['id']}",
+        json={
+            "documentName": main["documentName"],
+            "summary": main["summary"],
+            "categoryId": leaf_two,
+            "securityLevel": main["securityLevel"],
+            "tags": main["tags"],
+            "rowVersion": current_version,
+        },
+        headers=_auth(token),
+    )
+    assert moved.status_code == 200
+    with b3_app.app_context():
+        persisted_sub = db.session.get(Document, int(sub["id"]))
+        assert str(persisted_sub.category_id) == leaf_two
+        assert persisted_sub.category_name == moved.get_json()["data"]["categoryName"]
+
+
+def test_include_descendants_and_permission_aware_subtree_count(
+    b3_app, b3_client, token_for
+):
+    from dms.extensions import db
+    from dms.models import AuditLog, User
+
+    admin_token = token_for("admin")
+    user_token = token_for("user")
+    root_id = _category_id(b3_app, "ROOT_A")
+    leaf_id = _category_id(b3_app, "CHILD_A1")
+    created = _upload(
+        b3_client,
+        admin_token,
+        _main_metadata(leaf_id, "Q3递归主案"),
+        _docx_bytes(),
+        "docx",
+    ).get_json()["data"]
+
+    exact = b3_client.get(
+        f"/api/v1/documents?categoryId={root_id}", headers=_auth(admin_token)
+    ).get_json()["data"]
+    recursive = b3_client.get(
+        f"/api/v1/documents?categoryId={root_id}&includeDescendants=true",
+        headers=_auth(admin_token),
+    ).get_json()["data"]
+    all_categories = b3_client.get(
+        "/api/v1/documents", headers=_auth(admin_token)
+    ).get_json()["data"]
+    assert exact["total"] == 0
+    assert recursive["total"] == 1
+    assert all_categories["total"] == 1
+    invalid = b3_client.get(
+        f"/api/v1/documents?categoryId={root_id}&includeDescendants=yes",
+        headers=_auth(admin_token),
+    )
+    assert invalid.status_code == 400
+
+    admin_tree = b3_client.get(
+        "/api/v1/categories/tree", headers=_auth(admin_token)
+    ).get_json()["data"]
+    user_tree = b3_client.get(
+        "/api/v1/categories/tree", headers=_auth(user_token)
+    ).get_json()["data"]
+    assert _tree_node(admin_tree, root_id)["documentCount"] == 1
+    assert _tree_node(user_tree, root_id)["documentCount"] == 0
+    with b3_app.app_context():
+        user = db.session.scalar(db.select(User).where(User.username == "user"))
+        audit_before = db.session.scalar(
+            db.select(db.func.count()).select_from(AuditLog)
+        )
+    saved = b3_client.put(
+        f"/api/v1/documents/{created['id']}/permissions",
+        json={
+            "visibilityType": "CUSTOM",
+            "documentRowVersion": created["rowVersion"],
+            "entries": [
+                {
+                    "subjectType": "USER",
+                    "subjectId": str(user.id),
+                    "canView": True,
+                    "canDownload": True,
+                    "canEdit": False,
+                    "canManagePermission": False,
+                    "canDelete": False,
+                }
+            ],
+        },
+        headers=_auth(admin_token),
+    )
+    assert saved.status_code == 200
+    keyword_tree = b3_client.get(
+        "/api/v1/categories/tree?keyword=CHILD_A1",
+        headers=_auth(user_token),
+    ).get_json()["data"]
+    assert _tree_node(keyword_tree, root_id)["documentCount"] == 1
+    with b3_app.app_context():
+        audit_after = db.session.scalar(
+            db.select(db.func.count()).select_from(AuditLog)
+        )
+    assert audit_after == audit_before + 1  # 仅权限配置产生审计,树查询不产生。
+
+
+def test_docx_pdf_content_search_and_original_preview(
+    b3_app, b3_client, token_for
+):
+    from dms.extensions import db
+    from dms.models import AuditLog, Document
+
+    token = token_for("admin")
+    leaf_id = _category_id(b3_app, "CHILD_A1")
+    docx_content = _docx_bytes("只在DOCX正文中的检索词")
+    docx_item = _upload(
+        b3_client,
+        token,
+        _main_metadata(leaf_id, "Q3-DOCX"),
+        docx_content,
+        "docx",
+    ).get_json()["data"]
+    pdf_content = _pdf_bytes("OnlyInsidePdfBody")
+    pdf_item = _upload(
+        b3_client,
+        token,
+        _main_metadata(leaf_id, "Q3-PDF"),
+        pdf_content,
+        "pdf",
+    ).get_json()["data"]
+
+    for keyword, expected_id in (
+        ("只在DOCX正文中的检索词", docx_item["id"]),
+        ("表格唯一词", docx_item["id"]),
+        ("页眉唯一词", docx_item["id"]),
+        ("页脚唯一词", docx_item["id"]),
+        ("OnlyInsidePdfBody", pdf_item["id"]),
+    ):
+        result = b3_client.get(
+            f"/api/v1/documents?keyword={keyword}", headers=_auth(token)
+        ).get_json()["data"]
+        assert [item["id"] for item in result["items"]] == [expected_id]
+
+    with b3_app.app_context():
+        before_download = {
+            item.id: item.download_count
+            for item in db.session.scalars(
+                db.select(Document).where(
+                    Document.id.in_([int(docx_item["id"]), int(pdf_item["id"])])
+                )
+            )
+        }
+        before_audit = db.session.scalar(
+            db.select(db.func.count(AuditLog.id)).where(
+                AuditLog.action_type == "DOWNLOAD_DOCUMENT"
+            )
+        )
+    for item, content, mime in (
+        (
+            docx_item,
+            docx_content,
+            "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+        ),
+        (pdf_item, pdf_content, "application/pdf"),
+    ):
+        response = b3_client.get(
+            f"/api/v1/documents/{item['id']}/preview",
+            headers={**_auth(token), "Origin": "http://127.0.0.1:9346"},
+        )
+        assert response.status_code == 200
+        assert response.mimetype == mime
+        assert response.data == content
+        assert response.headers["Content-Disposition"].startswith("inline")
+        assert "filename*=" in response.headers["Content-Disposition"]
+        assert response.headers["X-Content-Type-Options"] == "nosniff"
+        assert response.headers["Cache-Control"] == "private, no-store"
+        assert "Content-Disposition" in response.headers[
+            "Access-Control-Expose-Headers"
+        ]
+        response.close()
+    with b3_app.app_context():
+        after_download = {
+            item.id: item.download_count
+            for item in db.session.scalars(
+                db.select(Document).where(
+                    Document.id.in_([int(docx_item["id"]), int(pdf_item["id"])])
+                )
+            )
+        }
+        after_audit = db.session.scalar(
+            db.select(db.func.count(AuditLog.id)).where(
+                AuditLog.action_type == "DOWNLOAD_DOCUMENT"
+            )
+        )
+    assert after_download == before_download
+    assert after_audit == before_audit
+
+
+def test_attachment_library_is_admin_only_and_user_cannot_guess_unmounted_id(
+    b3_app, b3_client, token_for
+):
+    admin_token = token_for("admin")
+    user_token = token_for("user")
+    response = b3_client.post(
+        "/api/v1/attachments",
+        data={
+            "file": (io.BytesIO(_docx_bytes()), "Q3附件.docx"),
+            "metadata": json.dumps(
+                {
+                    "documentName": "Q3未挂载附件",
+                    "attachmentType": "OTHER",
+                    "summary": "未挂载",
+                    "tags": ["Q3"],
+                },
+                ensure_ascii=False,
+            ),
+        },
+        headers=_auth(admin_token),
+        content_type="multipart/form-data",
+    )
+    attachment = response.get_json()["data"]
+    assert b3_client.get(
+        "/api/v1/attachments", headers=_auth(user_token)
+    ).status_code == 403
+    assert b3_client.get(
+        f"/api/v1/attachments/{attachment['id']}", headers=_auth(user_token)
+    ).status_code == 403
+    assert b3_client.get(
+        f"/api/v1/documents/{attachment['id']}/preview",
+        headers=_auth(user_token),
+    ).status_code == 403
+
+
+def test_backfill_gate_requires_explicit_dms_test_url(monkeypatch, b2_app):
+    from dms.backfill_document_content import _database_gate
+
+    with b2_app.app_context():
+        monkeypatch.delenv("DMS_DATABASE_URL", raising=False)
+        with pytest.raises(RuntimeError, match="DMS_DATABASE_URL"):
+            _database_gate()
+        monkeypatch.setenv(
+            "DMS_DATABASE_URL",
+            "mysql+pymysql://hidden:hidden@localhost:3306/dms",
+        )
+        with pytest.raises(RuntimeError, match="dms_test"):
+            _database_gate()
+
+
+def test_q3_openapi_contract_has_no_new_preview_path():
+    import yaml
+
+    root = Path(__file__).resolve().parents[2] / "openapi"
+    document = yaml.safe_load((root / "openapi.yaml").read_text("utf-8"))
+    schemas = yaml.safe_load(
+        (root / "components" / "schemas.yaml").read_text("utf-8")
+    )
+    paths = yaml.safe_load(
+        (root / "paths" / "documents.yaml").read_text("utf-8")
+    )
+    assert document["info"]["version"] == "1.5.0"
+    assert schemas["RoleCode"]["enum"] == ["USER", "ADMIN"]
+    parameters = paths["Documents"]["get"]["parameters"]
+    assert any(item.get("name") == "includeDescendants" for item in parameters)
+    preview_content = paths["DocumentPreview"]["get"]["responses"]["200"][
+        "content"
+    ]
+    assert set(preview_content) == {
+        "application/pdf",
+        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+    }
+    assert all("content-preview" not in path for path in document["paths"])

+ 2 - 0
frontend/ATTRIBUTIONS.md

@@ -1,3 +1,5 @@
 This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
 
 This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).
+
+This application uses [docx-preview 0.3.7](https://www.npmjs.com/package/docx-preview), licensed under the Apache License 2.0, to render DOCX files in the browser.

+ 137 - 14
frontend/package-lock.json

@@ -44,6 +44,7 @@
         "clsx": "2.1.1",
         "cmdk": "1.1.1",
         "date-fns": "3.6.0",
+        "docx-preview": "0.3.7",
         "embla-carousel-react": "8.6.0",
         "input-otp": "1.4.2",
         "lucide-react": "0.487.0",
@@ -108,6 +109,7 @@
       "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@babel/code-frame": "^7.29.7",
         "@babel/generator": "^7.29.7",
@@ -424,6 +426,7 @@
       "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
       "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@babel/runtime": "^7.18.3",
         "@emotion/babel-plugin": "^11.13.5",
@@ -467,6 +470,7 @@
       "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
       "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@babel/runtime": "^7.18.3",
         "@emotion/babel-plugin": "^11.13.5",
@@ -1092,6 +1096,7 @@
       "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
       "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@babel/runtime": "^7.28.4",
         "@mui/core-downloads-tracker": "^7.3.5",
@@ -1289,6 +1294,7 @@
       "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
       "license": "MIT",
+      "peer": true,
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/popperjs"
@@ -3425,8 +3431,9 @@
       "version": "22.15.3",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz",
       "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==",
-      "dev": true,
+      "devOptional": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "undici-types": "~6.21.0"
       }
@@ -3447,8 +3454,8 @@
       "version": "18.3.20",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz",
       "integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==",
-      "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@types/prop-types": "*",
         "csstype": "^3.0.2"
@@ -3458,8 +3465,9 @@
       "version": "18.3.6",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.6.tgz",
       "integrity": "sha512-nf22//wEbKXusP6E9pfOCDwFdHAX4u172eaJI4YkDRQEZiorm6KfYnSC2SWLDMVWUOWPERmJnN0ujeAfTBLvrw==",
-      "dev": true,
+      "devOptional": true,
       "license": "MIT",
+      "peer": true,
       "peerDependencies": {
         "@types/react": "^18.0.0"
       }
@@ -3679,6 +3687,7 @@
         }
       ],
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "baseline-browser-mapping": "^2.10.44",
         "caniuse-lite": "^1.0.30001806",
@@ -3842,6 +3851,12 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "license": "MIT"
+    },
     "node_modules/cosmiconfig": {
       "version": "7.1.0",
       "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -3858,6 +3873,15 @@
         "node": ">=10"
       }
     },
+    "node_modules/cosmiconfig/node_modules/yaml": {
+      "version": "1.10.3",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
+      "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/csstype": {
       "version": "3.2.3",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -3990,6 +4014,7 @@
       "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
       "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
       "license": "MIT",
+      "peer": true,
       "funding": {
         "type": "github",
         "url": "https://github.com/sponsors/kossnocorp"
@@ -4055,6 +4080,15 @@
         "redux": "^4.2.0"
       }
     },
+    "node_modules/docx-preview": {
+      "version": "0.3.7",
+      "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.7.tgz",
+      "integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "jszip": ">=3.0.0"
+      }
+    },
     "node_modules/dom-helpers": {
       "version": "5.2.1",
       "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -4076,7 +4110,8 @@
       "version": "8.6.0",
       "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
       "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
-      "license": "MIT"
+      "license": "MIT",
+      "peer": true
     },
     "node_modules/embla-carousel-react": {
       "version": "8.6.0",
@@ -4372,6 +4407,12 @@
       "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
       "license": "MIT"
     },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+      "license": "MIT"
+    },
     "node_modules/import-fresh": {
       "version": "3.3.1",
       "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4388,6 +4429,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
     "node_modules/input-otp": {
       "version": "1.4.2",
       "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
@@ -4428,6 +4475,12 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
     "node_modules/jiti": {
       "version": "2.7.0",
       "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -4484,6 +4537,27 @@
         "node": ">=6"
       }
     },
+    "node_modules/jszip": {
+      "version": "3.10.1",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+      "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+      "license": "(MIT OR GPL-3.0-or-later)",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
     "node_modules/lightningcss": {
       "version": "1.30.1",
       "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
@@ -4907,6 +4981,12 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+      "license": "(MIT AND Zlib)"
+    },
     "node_modules/parent-module": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4981,6 +5061,7 @@
       "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -5017,6 +5098,12 @@
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
     "node_modules/prop-types": {
       "version": "15.8.1",
       "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -5039,6 +5126,7 @@
       "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
       "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "loose-envify": "^1.1.0"
       },
@@ -5104,6 +5192,7 @@
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
       "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "loose-envify": "^1.1.0",
         "scheduler": "^0.23.2"
@@ -5319,6 +5408,21 @@
         "react-dom": ">=16.6.0"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
     "node_modules/recharts": {
       "version": "2.15.2",
       "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.2.tgz",
@@ -5448,6 +5552,12 @@
         "fsevents": "~2.3.2"
       }
     },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
     "node_modules/scheduler": {
       "version": "0.23.2",
       "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -5473,6 +5583,12 @@
       "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
       "license": "MIT"
     },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+      "license": "MIT"
+    },
     "node_modules/siginfo": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -5523,6 +5639,15 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/string-convert": {
       "version": "0.2.1",
       "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
@@ -5725,7 +5850,7 @@
       "version": "6.21.0",
       "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
       "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
-      "dev": true,
+      "devOptional": true,
       "license": "MIT"
     },
     "node_modules/update-browserslist-db": {
@@ -5802,6 +5927,12 @@
         }
       }
     },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
     "node_modules/vaul": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
@@ -5843,6 +5974,7 @@
       "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "esbuild": "^0.25.0",
         "fdir": "^6.4.4",
@@ -6040,15 +6172,6 @@
       "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
       "dev": true,
       "license": "ISC"
-    },
-    "node_modules/yaml": {
-      "version": "1.10.3",
-      "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
-      "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
-      "license": "ISC",
-      "engines": {
-        "node": ">= 6"
-      }
     }
   }
 }

+ 1 - 0
frontend/package.json

@@ -46,6 +46,7 @@
     "clsx": "2.1.1",
     "cmdk": "1.1.1",
     "date-fns": "3.6.0",
+    "docx-preview": "0.3.7",
     "embla-carousel-react": "8.6.0",
     "input-otp": "1.4.2",
     "lucide-react": "0.487.0",

+ 77 - 67
frontend/src/app/App.tsx

@@ -28,7 +28,6 @@ import { categoryApi } from "../features/categories/categoryApi";
 import { useCategoryTree } from "../features/categories/categoryState";
 import {
   findCategoryNode,
-  getCategoryBranchMainPlanCount,
   getCategoryParentOptions,
   retainExistingCategoryIds,
 } from "../features/categories/categoryTree";
@@ -345,7 +344,7 @@ function CategoryTreeNode({ node, depth, expanded, onToggle, selectedId, onSelec
         <Layers size={12} style={{ color: B }} className="flex-shrink-0" />
         <span className="text-sm font-bold truncate flex-1" title={`${node.categoryCode} · ${node.categoryPath}`}>
           {node.categoryName}
-          <span className="ml-1 text-[10px] opacity-45">({getCategoryBranchMainPlanCount(node)})</span>
+          <span className="ml-1 text-[10px] opacity-45">({node.documentCount})</span>
         </span>
         {isAdmin && (
           <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
@@ -376,10 +375,11 @@ function CategoryTreeNode({ node, depth, expanded, onToggle, selectedId, onSelec
   );
 }
 
-function CategoryPanel({ admin, selectedCategoryId, onSelectCategory }: {
+function CategoryPanel({ admin, selectedCategoryId, onSelectCategory, allCount }: {
   admin?: boolean;
   selectedCategoryId?: string | null;
   onSelectCategory?: (id: string | null) => void;
+  allCount?: number | null;
 }) {
   const { keyword, setKeyword, tree, status, error, refresh } = useCategoryTree();
   const [expanded, setExpanded] = useState<Set<string>>(new Set());
@@ -507,6 +507,7 @@ function CategoryPanel({ admin, selectedCategoryId, onSelectCategory }: {
         </div>
       )}
       <div className="flex-1 overflow-y-auto">
+        {!keyword && <button type="button" onClick={() => setSelectedId(null)} className="w-full flex items-center gap-2 py-2 px-2 mb-1 text-sm font-black rounded-sm" style={{ color: selectedId === null ? B : N, background: selectedId === null ? "rgba(21,101,192,.1)" : undefined }}><Layers size={13} /><span>全部分类</span><span className="ml-auto text-[10px] opacity-50">{allCount === null || allCount === undefined ? "加载中" : `(${allCount})`}</span></button>}
         {status === "loading" && <CategoryPanelState icon={<RefreshCw size={16} className="animate-spin" />} text="正在加载分类树" />}
         {status === "error" && (
           <CategoryPanelState
@@ -650,11 +651,12 @@ function CategoryFormField({ label, children }: { label: string; children: React
 }
 
 // ─── Shared left panel ────────────────────────────────────────────────────────
-function LeftPanel({ children, admin, selectedCategoryId, onSelectCategory }: {
+function LeftPanel({ children, admin, selectedCategoryId, onSelectCategory, allCount }: {
   children?: React.ReactNode;
   admin?: boolean;
   selectedCategoryId?: string | null;
   onSelectCategory?: (id: string | null) => void;
+  allCount?: number | null;
 }) {
   return (
     <div className="w-[272px] flex-shrink-0 m-3 flex flex-col" style={{ background: W, border: `1px solid rgba(40,118,193,0.2)` }}>
@@ -667,6 +669,7 @@ function LeftPanel({ children, admin, selectedCategoryId, onSelectCategory }: {
           admin={admin}
           selectedCategoryId={selectedCategoryId}
           onSelectCategory={onSelectCategory}
+          allCount={allCount}
         />
         {children}
       </div>
@@ -1071,13 +1074,14 @@ function PermissionModal({ doc, onClose }: { doc: Doc; onClose: () => void }) {
 }
 
 // ─── Page frame (shared header + background) ──────────────────────────────────
-function PageFrame({ title, current, navigationItems, onNav, onLogout, logoutPending, children }: {
+function PageFrame({ title, current, navigationItems, onNav, onLogout, logoutPending, currentUser, children }: {
   title: string;
   current: AuthenticatedPage;
   navigationItems: NavigationItem[];
   onNav: (p: AuthenticatedPage) => void;
   onLogout: () => void;
   logoutPending: boolean;
+  currentUser: UserSummary;
   children: React.ReactNode;
 }) {
   return (
@@ -1085,11 +1089,9 @@ function PageFrame({ title, current, navigationItems, onNav, onLogout, logoutPen
       {/* Header — auto height from padding, fits menu naturally */}
       <div className="relative z-30 flex-shrink-0 flex items-center justify-between px-8 py-3" style={{ background: B }}>
         {/* Left spacer — mirrors nav width for true centering */}
-        <div className="flex items-center gap-1 opacity-0 pointer-events-none" aria-hidden>
-          <span className="px-3 py-1.5 text-sm font-black whitespace-nowrap">文档浏览</span>
-          <span className="px-3 py-1.5 text-sm font-black whitespace-nowrap">后台管理</span>
-          <span className="px-3 py-1.5 text-sm font-black whitespace-nowrap">日志审计</span>
-          <span className="px-2 py-1.5 text-sm font-black whitespace-nowrap">退出</span>
+        <div className="min-w-[250px] leading-tight" style={{ color: W }}>
+          <div className="text-xs font-bold opacity-75 truncate">{currentUser.organizationName || "未设置单位"}</div>
+          <div className="text-sm font-black truncate mt-1">{currentUser.realName}</div>
         </div>
         {/* Center: title */}
         <h1 className="absolute left-1/2 -translate-x-1/2 font-black tracking-[0.18em] whitespace-nowrap"
@@ -1120,7 +1122,7 @@ function PageFrame({ title, current, navigationItems, onNav, onLogout, logoutPen
         style={{ height: 20, background: `linear-gradient(to bottom, ${B} 0%, ${PBG} 100%)` }} />
 
       {/* Main content — sits below header */}
-      <div className="relative z-10 flex flex-1 min-h-0">
+      <div className="relative z-40 flex flex-1 min-h-0">
         {children}
       </div>
     </div>
@@ -1229,9 +1231,10 @@ interface AuthenticatedPageProps {
   navigationItems: NavigationItem[];
   onLogout: () => void;
   logoutPending: boolean;
+  currentUser: UserSummary;
 }
 
-function MainPage({ onNav, navigationItems, onLogout, logoutPending }: AuthenticatedPageProps) {
+function MainPage({ onNav, navigationItems, onLogout, logoutPending, currentUser }: AuthenticatedPageProps) {
   const [tab, setTab] = useState<MainTab>("主案列表");
   const [searchMode, setSearchMode] = useState(false);
   const [keyword, setKeyword] = useState("");
@@ -1278,7 +1281,7 @@ function MainPage({ onNav, navigationItems, onLogout, logoutPending }: Authentic
     : [];
 
   return (
-    <PageFrame title="方案管理系统" current="main" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending}>
+    <PageFrame title="方案管理系统" current="main" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending} currentUser={currentUser}>
       <LeftPanel />
       <div className="flex-1 flex flex-col m-3 ml-0 min-h-0">
         {/* 顶部 Tab 栏 */}
@@ -1690,7 +1693,7 @@ function MountAttachmentModal({ plan, attachments, mountedIds, onConfirm, onClos
   );
 }
 
-function AdminPage({ onNav, navigationItems, onLogout, logoutPending }: AuthenticatedPageProps) {
+function AdminPage({ onNav, navigationItems, onLogout, logoutPending, currentUser }: AuthenticatedPageProps) {
   const [viewDoc, setViewDoc] = useState<Doc | null>(null);
   const [permDoc, setPermDoc] = useState<Doc | null>(null);
   const [showUpload, setShowUpload] = useState(false);
@@ -1739,7 +1742,7 @@ function AdminPage({ onNav, navigationItems, onLogout, logoutPending }: Authenti
   };
 
   return (
-    <PageFrame title="方案管理系统后台" current="admin" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending}>
+    <PageFrame title="方案管理系统后台" current="admin" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending} currentUser={currentUser}>
       {adminMode === "plans"
         ? <LeftPanel admin />
         : <AttachmentTypePanel selected={attachmentType} onSelect={setAttachmentType} />}
@@ -1857,7 +1860,7 @@ function AdminPage({ onNav, navigationItems, onLogout, logoutPending }: Authenti
 }
 
 // ─── P4: Audit page ───────────────────────────────────────────────────────────
-function AuditPage({ onNav, navigationItems, onLogout, logoutPending }: AuthenticatedPageProps) {
+function AuditPage({ onNav, navigationItems, onLogout, logoutPending, currentUser }: AuthenticatedPageProps) {
   const [keyword, setKeyword] = useState("");
   const [filterAction, setFilterAction] = useState("全部");
 
@@ -1876,7 +1879,7 @@ function AuditPage({ onNav, navigationItems, onLogout, logoutPending }: Authenti
   const ACTIONS = ["全部", "查看文档", "下载文档", "上传文档", "编辑文档", "权限配置", "删除文档", "登录系统"];
 
   return (
-    <PageFrame title="操作日志审计" current="audit" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending}>
+    <PageFrame title="操作日志审计" current="audit" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending} currentUser={currentUser}>
       {/* Left panel with filter */}
       <div className="w-[272px] flex-shrink-0 m-3 flex flex-col" style={{ background: W, border: `1px solid rgba(40,118,193,0.2)` }}>
         <div className="px-4 py-3 flex items-center gap-2 border-b" style={{ borderColor: "rgba(40,118,193,0.14)", background: `rgba(40,118,193,0.05)` }}>
@@ -2099,12 +2102,11 @@ function LoginPage({ onLogin, notice }: {
                     </div>
                   </div>
                 ))}
-                <div className="flex items-center justify-between">
+                <div className="flex items-center">
                   <label className="flex items-center gap-2 cursor-pointer">
                     <input type="checkbox" disabled={submitting} checked={keepSignedIn} onChange={e => setKeepSignedIn(e.target.checked)} className="accent-[#2876C1]" />
                     <span className="text-sm font-bold" style={{ color: `rgba(26,82,153,0.55)` }}>保持登录</span>
                   </label>
-                  <button className="text-sm font-black transition-opacity hover:opacity-60" style={{ color: B }}>忘记密码?</button>
                 </div>
                 <button onClick={() => void handleLogin()} disabled={submitting} aria-busy={submitting}
                   className="w-full h-11 font-black text-base rounded-sm transition-opacity hover:opacity-80 active:opacity-60 disabled:opacity-60 disabled:cursor-not-allowed tracking-widest mt-2"
@@ -2117,7 +2119,7 @@ function LoginPage({ onLogin, notice }: {
           </div>
       </div>
       <div className="relative z-10 pb-6 text-center text-xs font-bold" style={{ color: `rgba(26,82,153,0.3)` }}>
-        © 2024 方案管理系统 · 版权所有 · 严禁扩散
+        © 方案管理系统 · 版权所有 · 严禁扩散
       </div>
     </div>
   );
@@ -2214,14 +2216,14 @@ function F4Pager({ page, totalPages, onPage }: {
   );
 }
 
-function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode = false, onReverse, onPreview, onEdit, onDelete, onPermission, onUnbind, onFileError }: {
+function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode = false, onReverse, onView, onEdit, onDelete, onPermission, onUnbind, onFileError }: {
   items: (DocumentSummary | AttachmentBindingSummary)[];
   selectedId?: string | null;
   onSelect?: (item: DocumentSummary) => void;
   onDetail: (item: DocumentSummary, attachment: boolean) => void;
   bindingMode?: boolean;
   onReverse?: (item: DocumentSummary) => void;
-  onPreview?: (item: DocumentSummary) => void;
+  onView?: (item: DocumentSummary) => void;
   onEdit?: (item: DocumentSummary) => void;
   onDelete?: (item: DocumentSummary) => void;
   onPermission?: (item: DocumentSummary) => void;
@@ -2278,7 +2280,7 @@ function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode =
               </td>
               <td className="px-4 py-3 whitespace-nowrap">
                 {canView && <button onClick={(event) => { event.stopPropagation(); onDetail(item, attachment); }} className="mr-2 text-xs font-black" style={{ color: B }}>详情</button>}
-            {canView && onPreview && <button onClick={(event) => { event.stopPropagation(); onPreview(item); }} className="mr-2 text-xs font-black" style={{ color: B }}>查看</button>}
+                {canView && onView && <button onClick={(event) => { event.stopPropagation(); onView(item); }} className="mr-2 text-xs font-black" style={{ color: B }}>查看</button>}
                 {canDownload && onFileError && <DownloadButton item={item} onError={onFileError} onSuccess={() => { invalidateDetail(attachment ? "attachment" : "document", item.id); notifyContentChanged(attachment ? "attachments" : "documents"); }} />}
                 {canEdit && onEdit && <button onClick={(event) => { event.stopPropagation(); onEdit(item); }} className="mr-2 text-xs font-black" style={{ color: B }}>编辑</button>}
                 {canDelete && onDelete && <button onClick={(event) => { event.stopPropagation(); onDelete(item); }} className="mr-2 text-xs font-black" style={{ color: N }}>删除</button>}
@@ -2372,14 +2374,16 @@ function F4ReverseModal({ attachment, onClose }: { attachment: DocumentSummary;
   );
 }
 
-function F4AttachmentLibrary({ compact = false, management = false }: { compact?: boolean; management?: boolean }) {
+function F4AttachmentLibrary({ compact = false, management = false, onPreview }: { compact?: boolean; management?: boolean; onPreview?: (item: DocumentSummary) => void }) {
   const dictionaries = useDictionaries();
   const [keyword, setKeyword] = useState("");
   const [attachmentType, setAttachmentType] = useState("");
   const [page, setPage] = useState(1);
   const [detail, setDetail] = useState<DocumentSummary | null>(null);
   const [reverse, setReverse] = useState<DocumentSummary | null>(null);
-  const [preview, setPreview] = useState<DocumentSummary | null>(null);
+  const [localPreview, setLocalPreview] = useState<DocumentSummary | null>(null);
+  const [fullView, setFullView] = useState<DocumentSummary | null>(null);
+  const openPreview = onPreview ?? setLocalPreview;
   const [edit, setEdit] = useState<DocumentSummary | null>(null);
   const [remove, setRemove] = useState<DocumentSummary | null>(null);
   const [upload, setUpload] = useState(false);
@@ -2413,9 +2417,10 @@ function F4AttachmentLibrary({ compact = false, management = false }: { compact?
         {state.data && state.data.items.length > 0 && (
           <F4DocumentTable
             items={state.data.items}
+            onSelect={openPreview}
             onDetail={(item) => setDetail(item)}
             onReverse={setReverse}
-            onPreview={setPreview}
+            onView={setFullView}
             onEdit={management ? setEdit : undefined}
             onDelete={management ? setRemove : undefined}
             onFileError={setFileError}
@@ -2431,7 +2436,8 @@ function F4AttachmentLibrary({ compact = false, management = false }: { compact?
       )}
       {detail && <F4DetailModal item={detail} attachment onClose={() => setDetail(null)} />}
       {reverse && <F4ReverseModal attachment={reverse} onClose={() => setReverse(null)} />}
-      {preview && <FilePreviewModal item={preview} onClose={() => setPreview(null)} />}
+      {!onPreview && localPreview && <FilePreviewModal item={localPreview} onClose={() => setLocalPreview(null)} />}
+      {fullView && <FilePreviewModal item={fullView} mode="modal" onClose={() => setFullView(null)} />}
       {edit && <EditMetadataModal item={edit} onClose={() => setEdit(null)} />}
       {remove && <DeleteDocumentModal item={remove} onClose={() => setRemove(null)} onDeleted={() => { setDetail(null); setReverse(null); }} />}
       {upload && <SingleUploadModal kind="attachment" onClose={() => setUpload(false)} />}
@@ -2446,15 +2452,15 @@ function F4AttachmentLibrary({ compact = false, management = false }: { compact?
   );
 }
 
-function RealMainPage({ onNav, navigationItems, onLogout, logoutPending }: AuthenticatedPageProps) {
-  const [tab, setTab] = useState<"main" | "all" | "recent">("main");
+function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, currentUser }: AuthenticatedPageProps) {
   const [keyword, setKeyword] = useState("");
   const [updatedFrom, setUpdatedFrom] = useState("");
   const [updatedTo, setUpdatedTo] = useState("");
   const [categoryId, setCategoryId] = useState<string | null>(null);
+  const [allCategoryCount, setAllCategoryCount] = useState<number | null>(null);
   const [page, setPage] = useState(1);
   const [selected, setSelected] = useState<DocumentSummary | null>(null);
-  const [lowerTab, setLowerTab] = useState<"sub" | "mounted" | "library">("sub");
+  const [lowerTab, setLowerTab] = useState<"sub" | "mounted">("sub");
   const [subKeyword, setSubKeyword] = useState("");
   const [subStatus, setSubStatus] = useState<DocumentStatusCode | "">("");
   const [subUpdatedFrom, setSubUpdatedFrom] = useState("");
@@ -2467,20 +2473,19 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending }: Authe
   const [mountedPage, setMountedPage] = useState(1);
   const [detail, setDetail] = useState<{ item: DocumentSummary; attachment: boolean } | null>(null);
   const [preview, setPreview] = useState<DocumentSummary | null>(null);
-  const [edit, setEdit] = useState<DocumentSummary | null>(null);
-  const [remove, setRemove] = useState<DocumentSummary | null>(null);
+  const [fullView, setFullView] = useState<DocumentSummary | null>(null);
   const [fileError, setFileError] = useState<HttpError | null>(null);
-  const [permission, setPermission] = useState<DocumentSummary | null>(null);
   const mainRange = useMemo(() => toUpdatedTimeRange(updatedFrom, updatedTo), [updatedFrom, updatedTo]);
   const subRange = useMemo(() => toUpdatedTimeRange(subUpdatedFrom, subUpdatedTo), [subUpdatedFrom, subUpdatedTo]);
   const mountedRange = useMemo(() => toUpdatedTimeRange(mountedUpdatedFrom, mountedUpdatedTo), [mountedUpdatedFrom, mountedUpdatedTo]);
   const documents = useDocumentList({
-    documentType: tab === "main" ? DocumentType.MAIN : `${DocumentType.MAIN},${DocumentType.SUB_PLAN}`,
+    documentType: DocumentType.MAIN,
     categoryId: categoryId ?? undefined,
+    includeDescendants: categoryId ? true : undefined,
     keyword: keyword.trim() || undefined,
     ...mainRange.query,
-    sortBy: tab === "recent" ? "updatedAt" : "documentName",
-    sortDirection: tab === "recent" ? "desc" : "asc",
+    sortBy: "documentName",
+    sortDirection: "asc",
     page,
     pageSize: 20,
   }, !mainRange.error);
@@ -2502,6 +2507,10 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending }: Authe
     pageSize: 20,
   }, !mountedRange.error);
 
+  useEffect(() => {
+    if (categoryId === null && documents.data) setAllCategoryCount(documents.data.total);
+  }, [categoryId, documents.data]);
+
   useEffect(() => {
     setSubKeyword("");
     setSubStatus("");
@@ -2516,19 +2525,13 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending }: Authe
   }, [selectedMainId]);
 
   return (
-    <PageFrame title="方案管理系统" current="main" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending}>
-      <LeftPanel selectedCategoryId={categoryId} onSelectCategory={(id) => { setCategoryId(id); setPage(1); setSelected(null); }} />
+    <PageFrame title="方案管理系统" current="main" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending} currentUser={currentUser}>
+      <LeftPanel allCount={allCategoryCount} selectedCategoryId={categoryId} onSelectCategory={(id) => { setCategoryId(id); setPage(1); setSelected(null); setPreview(null); }} />
       <div className="flex-1 flex flex-col m-3 ml-0 min-h-0">
-        <div className="flex items-center mb-2" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
-          {([{ id: "main", label: "主案列表" }, { id: "all", label: "全部方案" }, { id: "recent", label: "最近更新" }] as const).map((item) => (
-            <button key={item.id} onClick={() => { setTab(item.id); setPage(1); setSelected(null); }} className="px-7 py-3 text-sm font-black"
-              style={{ color: tab === item.id ? W : N, background: tab === item.id ? N : W }}>{item.label}</button>
-          ))}
-        </div>
         <DocumentSearchToolbar
           ariaLabel="方案检索"
           keyword={keyword}
-          keywordPlaceholder="搜索方案名称或概述"
+          keywordPlaceholder="搜索方案名称、概述、标签或文档内容"
           updatedFrom={updatedFrom}
           updatedTo={updatedTo}
           error={mainRange.error}
@@ -2539,27 +2542,27 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending }: Authe
         />
         <div className="flex-1 overflow-auto min-h-0 mb-2" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
           <F4State loading={documents.loading} error={documents.error} empty={documents.data?.items.length === 0} onRetry={documents.retry} />
-          {documents.data && documents.data.items.length > 0 && <F4DocumentTable items={documents.data.items} selectedId={selected?.id} onSelect={setSelected} onDetail={(item, attachment) => setDetail({ item, attachment })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onPermission={setPermission} onFileError={setFileError} />}
+          {documents.data && documents.data.items.length > 0 && <F4DocumentTable items={documents.data.items} selectedId={selected?.id} onSelect={(item) => { setSelected(item); setPreview(item); }} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onFileError={setFileError} />}
           {documents.data && <F4Pager page={page} totalPages={documents.data.totalPages} onPage={(value) => { setPage(value); setSelected(null); }} />}
         </div>
         <div className="h-[390px] flex-shrink-0 flex flex-col" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
           <div className="flex border-b" style={{ borderColor: "rgba(40,118,193,.14)" }}>
-            {([{ id: "sub", label: "直属子方案" }, { id: "mounted", label: "已挂载共享附件" }, { id: "library", label: "共享附件库" }] as const).map((item) => (
+            {([{ id: "sub", label: "直属子方案" }, { id: "mounted", label: "已挂载共享附件" }] as const).map((item) => (
               <button key={item.id} onClick={() => setLowerTab(item.id)} className="px-6 py-2.5 text-sm font-black" style={{ color: lowerTab === item.id ? W : N, background: lowerTab === item.id ? N : W }}>{item.label}</button>
             ))}
             <span className="ml-auto px-4 py-3 text-xs font-bold" style={{ color: N }}>{selected ? `当前:${selected.documentName}` : "请选择一个主案"}</span>
           </div>
           <div className="flex-1 overflow-auto min-h-0">
-            {lowerTab === "library" ? <F4AttachmentLibrary compact /> : !selected || selected.documentType !== DocumentType.MAIN ? (
+            {!selected || selected.documentType !== DocumentType.MAIN ? (
               <div className="p-12 text-center text-sm font-bold opacity-50" style={{ color: N }}>请选择主案查看关联数据</div>
             ) : lowerTab === "sub" ? (
               <>
-                <DocumentSearchToolbar ariaLabel="直属子方案检索" compact keyword={subKeyword} keywordPlaceholder="搜索子方案名称或概述" updatedFrom={subUpdatedFrom} updatedTo={subUpdatedTo} error={subRange.error}
+                <DocumentSearchToolbar ariaLabel="直属子方案检索" compact keyword={subKeyword} keywordPlaceholder="搜索方案名称、概述、标签或文档内容" updatedFrom={subUpdatedFrom} updatedTo={subUpdatedTo} error={subRange.error}
                   selectFilter={{ dictionary: "documentStatuses", value: subStatus, emptyLabel: "全部状态", ariaLabel: "直属子方案状态", onChange: (value) => { setSubStatus(value as DocumentStatusCode | ""); setSubPage(1); } }}
                   onKeywordChange={(value) => { setSubKeyword(value); setSubPage(1); }} onUpdatedFromChange={(value) => { setSubUpdatedFrom(value); setSubPage(1); }} onUpdatedToChange={(value) => { setSubUpdatedTo(value); setSubPage(1); }}
                   onClear={() => { setSubKeyword(""); setSubStatus(""); setSubUpdatedFrom(""); setSubUpdatedTo(""); setSubPage(1); }} />
                 <F4State loading={subPlans.loading} error={subPlans.error} empty={subPlans.data?.items.length === 0} onRetry={subPlans.retry} />
-                {subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} onDetail={(item, attachment) => setDetail({ item, attachment })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onPermission={setPermission} onFileError={setFileError} /> : null}
+                {subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} onSelect={setPreview} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onFileError={setFileError} /> : null}
                 {subPlans.data && <F4Pager page={subPage} totalPages={subPlans.data.totalPages} onPage={setSubPage} />}
               </>
             ) : (
@@ -2569,7 +2572,7 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending }: Authe
                   onKeywordChange={(value) => { setMountedKeyword(value); setMountedPage(1); }} onUpdatedFromChange={(value) => { setMountedUpdatedFrom(value); setMountedPage(1); }} onUpdatedToChange={(value) => { setMountedUpdatedTo(value); setMountedPage(1); }}
                   onClear={() => { setMountedKeyword(""); setMountedType(""); setMountedUpdatedFrom(""); setMountedUpdatedTo(""); setMountedPage(1); }} />
                 <F4State loading={mounted.loading} error={mounted.error} empty={mounted.data?.items.length === 0} onRetry={mounted.retry} />
-                {mounted.data?.items.length ? <F4DocumentTable items={mounted.data.items} bindingMode onDetail={(item) => setDetail({ item, attachment: true })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onFileError={setFileError} /> : null}
+                {mounted.data?.items.length ? <F4DocumentTable items={mounted.data.items} bindingMode onSelect={setPreview} onDetail={(item) => setDetail({ item, attachment: true })} onView={setFullView} onFileError={setFileError} /> : null}
                 {mounted.data && <F4Pager page={mountedPage} totalPages={mounted.data.totalPages} onPage={setMountedPage} />}
               </>
             )}
@@ -2578,17 +2581,16 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending }: Authe
       </div>
       {detail && <F4DetailModal {...detail} onClose={() => setDetail(null)} />}
       {preview && <FilePreviewModal item={preview} onClose={() => setPreview(null)} />}
-      {edit && <EditMetadataModal item={edit} onClose={() => setEdit(null)} />}
-      {remove && <DeleteDocumentModal item={remove} onClose={() => setRemove(null)} onDeleted={() => { if (selected?.id === remove.id) setSelected(null); setDetail(null); }} />}
-      {permission && <F6PermissionModal document={permission} onClose={() => setPermission(null)} />}
+      {fullView && <FilePreviewModal item={fullView} mode="modal" onClose={() => setFullView(null)} />}
       {fileError && <div className="fixed bottom-6 right-6 z-[70] max-w-md p-4 text-sm font-bold text-white shadow-xl" style={{ background: N }}><button onClick={() => setFileError(null)} className="float-right ml-3"><X size={14} /></button>{fileError.message}{fileError.requestId ? `(错误编号:${fileError.requestId})` : ""}</div>}
     </PageFrame>
   );
 }
 
-function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending }: AuthenticatedPageProps) {
+function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, currentUser }: AuthenticatedPageProps) {
   const [mode, setMode] = useState<"plans" | "attachments">("plans");
   const [categoryId, setCategoryId] = useState<string | null>(null);
+  const [allCategoryCount, setAllCategoryCount] = useState<number | null>(null);
   const [keyword, setKeyword] = useState("");
   const [updatedFrom, setUpdatedFrom] = useState("");
   const [updatedTo, setUpdatedTo] = useState("");
@@ -2607,6 +2609,7 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending }: Auth
   const [mountedPage, setMountedPage] = useState(1);
   const [detail, setDetail] = useState<{ item: DocumentSummary; attachment: boolean } | null>(null);
   const [preview, setPreview] = useState<DocumentSummary | null>(null);
+  const [fullView, setFullView] = useState<DocumentSummary | null>(null);
   const [edit, setEdit] = useState<DocumentSummary | null>(null);
   const [remove, setRemove] = useState<DocumentSummary | null>(null);
   const [uploadDocument, setUploadDocument] = useState(false);
@@ -2619,10 +2622,14 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending }: Auth
   const mainRange = useMemo(() => toUpdatedTimeRange(updatedFrom, updatedTo), [updatedFrom, updatedTo]);
   const subRange = useMemo(() => toUpdatedTimeRange(subUpdatedFrom, subUpdatedTo), [subUpdatedFrom, subUpdatedTo]);
   const mountedRange = useMemo(() => toUpdatedTimeRange(mountedUpdatedFrom, mountedUpdatedTo), [mountedUpdatedFrom, mountedUpdatedTo]);
-  const plans = useDocumentList({ documentType: DocumentType.MAIN, categoryId: categoryId ?? undefined, keyword: keyword.trim() || undefined, ...mainRange.query, sortBy: "updatedAt", sortDirection: "desc", page: planPage, pageSize: 20 }, !mainRange.error);
+  const plans = useDocumentList({ documentType: DocumentType.MAIN, categoryId: categoryId ?? undefined, includeDescendants: categoryId ? true : undefined, keyword: keyword.trim() || undefined, ...mainRange.query, sortBy: "updatedAt", sortDirection: "desc", page: planPage, pageSize: 20 }, !mainRange.error);
   const subPlans = useSubPlanList(selected?.id ?? null, { keyword: subKeyword.trim() || undefined, status: subStatus || undefined, ...subRange.query, page: subPage, pageSize: 20, sortBy: "documentName", sortDirection: "asc" }, !subRange.error);
   const mounted = useMountedAttachments(selected?.id ?? null, { keyword: mountedKeyword.trim() || undefined, attachmentType: mountedType || undefined, ...mountedRange.query, page: mountedPage, pageSize: 20 }, !mountedRange.error);
 
+  useEffect(() => {
+    if (categoryId === null && plans.data) setAllCategoryCount(plans.data.total);
+  }, [categoryId, plans.data]);
+
   useEffect(() => {
     setSubKeyword("");
     setSubStatus("");
@@ -2643,8 +2650,8 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending }: Auth
   }, [plans.data, selected]);
 
   return (
-    <PageFrame title="方案管理系统后台" current="admin" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending}>
-      {mode === "plans" ? <LeftPanel admin selectedCategoryId={categoryId} onSelectCategory={(id) => { setCategoryId(id); setPlanPage(1); setSelected(null); }} /> : <div className="w-[272px] m-3 p-5 flex-shrink-0" style={{ background: W, color: N }}><Library size={22} className="mb-3" /><div className="font-black">共享附件</div></div>}
+    <PageFrame title="方案管理系统后台" current="admin" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending} currentUser={currentUser}>
+      {mode === "plans" ? <LeftPanel allCount={allCategoryCount} admin selectedCategoryId={categoryId} onSelectCategory={(id) => { setCategoryId(id); setPlanPage(1); setSelected(null); setPreview(null); }} /> : <div className="w-[272px] m-3 p-5 flex-shrink-0" style={{ background: W, color: N }}><Library size={22} className="mb-3" /><div className="font-black">共享附件</div></div>}
       <div className="flex-1 flex flex-col m-3 ml-0 min-h-0">
         <DocumentStatisticsCards />
         <div className="flex items-center mb-2" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
@@ -2652,17 +2659,17 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending }: Auth
           <button onClick={() => setMode("attachments")} className="px-7 py-3 text-sm font-black" style={{ color: mode === "attachments" ? W : N, background: mode === "attachments" ? N : W }}>共享附件库</button>
           <div className="ml-auto flex gap-2 mr-3">
             <button onClick={() => setRecycleBinOpen(true)} className="px-3 py-1.5 text-xs font-black border" style={{ color: N, borderColor: B }}><Trash2 size={12} className="inline mr-1" />回收站</button>
-            {mode === "plans" && <><button onClick={() => setUploadDocument(true)} className="px-3 py-1.5 text-xs font-black text-white" style={{ background: B }}>上传主案/子案</button><button onClick={() => setBatchDocuments(true)} className="px-3 py-1.5 text-xs font-black border" style={{ color: N, borderColor: B }}>批量导入方案</button></>}
+            {mode === "plans" && <><button onClick={() => setUploadDocument(true)} className="px-3 py-1.5 text-xs font-black text-white" style={{ background: B }}>上传主案/子案</button><button onClick={() => setBatchDocuments(true)} className="px-3 py-1.5 text-xs font-black border" style={{ color: N, borderColor: B }}>批量导入方案</button></>}
           </div>
         </div>
-        {mode === "attachments" ? <div className="flex-1 min-h-0"><F4AttachmentLibrary management /></div> : (
+        {mode === "attachments" ? <div className="flex-1 min-h-0"><F4AttachmentLibrary management onPreview={setPreview} /></div> : (
           <>
-            <DocumentSearchToolbar ariaLabel="后台主案检索" keyword={keyword} keywordPlaceholder="搜索主案名称或概述" updatedFrom={updatedFrom} updatedTo={updatedTo} error={mainRange.error}
+            <DocumentSearchToolbar ariaLabel="后台主案检索" keyword={keyword} keywordPlaceholder="搜索方案名称、概述、标签或文档内容" updatedFrom={updatedFrom} updatedTo={updatedTo} error={mainRange.error}
               onKeywordChange={(value) => { setKeyword(value); setPlanPage(1); setSelected(null); }} onUpdatedFromChange={(value) => { setUpdatedFrom(value); setPlanPage(1); setSelected(null); }} onUpdatedToChange={(value) => { setUpdatedTo(value); setPlanPage(1); setSelected(null); }}
               onClear={() => { setKeyword(""); setUpdatedFrom(""); setUpdatedTo(""); setPlanPage(1); setSelected(null); }} />
             <div className="flex-1 overflow-auto min-h-0 mb-2" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
               <F4State loading={plans.loading} error={plans.error} empty={plans.data?.items.length === 0} onRetry={plans.retry} />
-              {plans.data?.items.length ? <F4DocumentTable items={plans.data.items} selectedId={selected?.id} onSelect={setSelected} onDetail={(item, attachment) => setDetail({ item, attachment })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onPermission={setPermission} onFileError={setFileError} /> : null}
+              {plans.data?.items.length ? <F4DocumentTable items={plans.data.items} selectedId={selected?.id} onSelect={(item) => { setSelected(item); setPreview(item); }} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onEdit={setEdit} onDelete={setRemove} onPermission={setPermission} onFileError={setFileError} /> : null}
               {plans.data && <F4Pager page={planPage} totalPages={plans.data.totalPages} onPage={(value) => { setPlanPage(value); setSelected(null); }} />}
             </div>
             <div className="h-[400px] flex-shrink-0 flex flex-col" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
@@ -2676,12 +2683,13 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending }: Auth
               <div className="flex-1 overflow-auto min-h-0">
                 {!selected ? <div className="p-12 text-center text-sm font-bold opacity-50" style={{ color: N }}>请选择主案</div> : relationTab === "sub" ? (
                   <>
-                    <DocumentSearchToolbar ariaLabel="后台直属子方案检索" compact keyword={subKeyword} keywordPlaceholder="搜索子方案名称或概述" updatedFrom={subUpdatedFrom} updatedTo={subUpdatedTo} error={subRange.error}
+                    <DocumentSearchToolbar ariaLabel="后台直属子方案检索" compact keyword={subKeyword} keywordPlaceholder="搜索方案名称、概述、标签或文档内容" updatedFrom={subUpdatedFrom} updatedTo={subUpdatedTo} error={subRange.error}
                       selectFilter={{ dictionary: "documentStatuses", value: subStatus, emptyLabel: "全部状态", ariaLabel: "后台直属子方案状态", onChange: (value) => { setSubStatus(value as DocumentStatusCode | ""); setSubPage(1); } }}
                       onKeywordChange={(value) => { setSubKeyword(value); setSubPage(1); }} onUpdatedFromChange={(value) => { setSubUpdatedFrom(value); setSubPage(1); }} onUpdatedToChange={(value) => { setSubUpdatedTo(value); setSubPage(1); }}
                       onClear={() => { setSubKeyword(""); setSubStatus(""); setSubUpdatedFrom(""); setSubUpdatedTo(""); setSubPage(1); }} />
                     <F4State loading={subPlans.loading} error={subPlans.error} empty={subPlans.data?.items.length === 0} onRetry={subPlans.retry} />
-                    {subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} onDetail={(item, attachment) => setDetail({ item, attachment })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onPermission={setPermission} onFileError={setFileError} /> : null}
+                    <div className="px-4 py-2 text-xs font-bold" style={{ color: N }}>权限继承自主案:{selected.documentName}</div>
+                    {subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} onSelect={setPreview} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onEdit={setEdit} onDelete={setRemove} onFileError={setFileError} /> : null}
                     {subPlans.data && <F4Pager page={subPage} totalPages={subPlans.data.totalPages} onPage={setSubPage} />}
                   </>
                 ) : (
@@ -2691,7 +2699,7 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending }: Auth
                       onKeywordChange={(value) => { setMountedKeyword(value); setMountedPage(1); }} onUpdatedFromChange={(value) => { setMountedUpdatedFrom(value); setMountedPage(1); }} onUpdatedToChange={(value) => { setMountedUpdatedTo(value); setMountedPage(1); }}
                       onClear={() => { setMountedKeyword(""); setMountedType(""); setMountedUpdatedFrom(""); setMountedUpdatedTo(""); setMountedPage(1); }} />
                     <F4State loading={mounted.loading} error={mounted.error} empty={mounted.data?.items.length === 0} onRetry={mounted.retry} />
-                    {mounted.data?.items.length ? <F4DocumentTable items={mounted.data.items} bindingMode onDetail={(item) => setDetail({ item, attachment: true })} onPreview={setPreview} onEdit={setEdit} onDelete={setRemove} onUnbind={selected.allowedActions.includes(AllowedAction.UNBIND_ATTACHMENT) ? setUnbind : undefined} onFileError={setFileError} /> : null}
+                    {mounted.data?.items.length ? <F4DocumentTable items={mounted.data.items} bindingMode onSelect={setPreview} onDetail={(item) => setDetail({ item, attachment: true })} onView={setFullView} onEdit={setEdit} onDelete={setRemove} onUnbind={selected.allowedActions.includes(AllowedAction.UNBIND_ATTACHMENT) ? setUnbind : undefined} onFileError={setFileError} /> : null}
                     {mounted.data && <F4Pager page={mountedPage} totalPages={mounted.data.totalPages} onPage={setMountedPage} />}
                   </>
                 )}
@@ -2702,6 +2710,7 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending }: Auth
       </div>
       {detail && <F4DetailModal {...detail} onClose={() => setDetail(null)} />}
       {preview && <FilePreviewModal item={preview} onClose={() => setPreview(null)} />}
+      {fullView && <FilePreviewModal item={fullView} mode="modal" onClose={() => setFullView(null)} />}
       {edit && <EditMetadataModal item={edit} onClose={() => setEdit(null)} />}
       {remove && <DeleteDocumentModal item={remove} onClose={() => setRemove(null)} onDeleted={() => { if (selected?.id === remove.id) setSelected(null); setDetail(null); }} />}
       {uploadDocument && <SingleUploadModal kind="document" onClose={() => setUploadDocument(false)} />}
@@ -2882,6 +2891,7 @@ export default function App() {
     navigationItems,
     onLogout: () => void handleLogout(),
     logoutPending,
+    currentUser,
   };
 
   return (
@@ -2890,13 +2900,13 @@ export default function App() {
       {page === "admin" && currentUser.roleCode === RoleCode.ADMIN && (
         <RealAdminPage {...pageProps} />
       )}
-      {page === "audit" && currentUser.roleCode === RoleCode.AUDITOR && (
+      {page === "audit" && currentUser.roleCode === RoleCode.ADMIN && (
         <PageFrame title="操作日志审计" current="audit" {...pageProps}>
           <AuditDashboard />
         </PageFrame>
       )}
       {((page === "admin" && currentUser.roleCode !== RoleCode.ADMIN) ||
-        (page === "audit" && currentUser.roleCode !== RoleCode.AUDITOR)) && (
+        (page === "audit" && currentUser.roleCode !== RoleCode.ADMIN)) && (
         <PageFrame
           title="模块权限异常"
           current={page}

+ 2 - 2
frontend/src/features/audit/__tests__/f7Scope.test.ts

@@ -28,11 +28,11 @@ describe("F7 App and request scope", () => {
     expect(appSource).toContain("currentUser.allowedModules,");
     expect(appSource).toContain('dictionaries.label("allowedModules", module)');
     expect(appSource).toContain(
-      'page === "audit" && currentUser.roleCode === RoleCode.AUDITOR',
+      'page === "audit" && currentUser.roleCode === RoleCode.ADMIN',
     );
   });
 
-  it("ADMIN后台挂载真实统计,AUDITOR页面不挂载文档统计", () => {
+  it("ADMIN后台挂载真实统计,审计页面不重复挂载文档统计", () => {
     const adminStart = appSource.indexOf("function RealAdminPage");
     const rootStart = appSource.indexOf("export default function App");
     const adminSource = appSource.slice(adminStart, rootStart);

+ 3 - 3
frontend/src/features/auth/__tests__/authSession.test.ts

@@ -136,17 +136,17 @@ describe("authSession", () => {
     expect(tokenStore.getAccessToken()).toBe("jwt");
   });
 
-  it("allowedModules 按后端顺序生成导航,不根据角色补全", () => {
+  it("allowedModules 按正式界面顺序生成导航,不补全后端未授权模块", () => {
     const modules = ["AUDIT_LOG", "DOCUMENT_BROWSER"] as const;
     const labels: Record<string, string> = {
       AUDIT_LOG: "日志审计",
       DOCUMENT_BROWSER: "文档浏览",
     };
     expect(getNavigationItems(modules, (module) => labels[module])).toEqual([
-      { module: "AUDIT_LOG", page: "audit", label: "日志审计" },
       { module: "DOCUMENT_BROWSER", page: "main", label: "文档浏览" },
+      { module: "AUDIT_LOG", page: "audit", label: "日志审计" },
     ]);
-    expect(getFirstAllowedPage(modules)).toBe("audit");
+    expect(getFirstAllowedPage(modules)).toBe("main");
     expect(isPageAllowed("admin", modules)).toBe(false);
     expect(isPageAllowed("main", modules)).toBe(true);
   });

+ 11 - 5
frontend/src/features/auth/authSession.ts

@@ -20,14 +20,19 @@ const MODULE_PAGES: Record<AllowedModuleCode, AuthenticatedPage> = {
   [AllowedModule.AUDIT_LOG]: "audit",
 };
 
+const MODULE_ORDER: readonly AllowedModuleCode[] = [
+  AllowedModule.DOCUMENT_BROWSER,
+  AllowedModule.BACKEND_MANAGEMENT,
+  AllowedModule.AUDIT_LOG,
+];
+
 export function getNavigationItems(
   allowedModules: readonly AllowedModuleCode[],
   getLabel: (module: AllowedModuleCode) => string,
 ): NavigationItem[] {
-  const seen = new Set<AllowedModuleCode>();
-  return allowedModules.flatMap((module) => {
-    if (seen.has(module)) return [];
-    seen.add(module);
+  const allowed = new Set(allowedModules);
+  return MODULE_ORDER.flatMap((module) => {
+    if (!allowed.has(module)) return [];
     return [{ module, page: MODULE_PAGES[module], label: getLabel(module) }];
   });
 }
@@ -35,7 +40,8 @@ export function getNavigationItems(
 export function getFirstAllowedPage(
   allowedModules: readonly AllowedModuleCode[],
 ): AuthenticatedPage | null {
-  const first = allowedModules[0];
+  const allowed = new Set(allowedModules);
+  const first = MODULE_ORDER.find((module) => allowed.has(module));
   return first ? MODULE_PAGES[first] : null;
 }
 

+ 2 - 2
frontend/src/features/auth/authTypes.ts

@@ -23,7 +23,7 @@ export interface UserSummary {
   username: string;
   realName: string;
   organizationId: Id | null;
-  organizationName: string;
+  organizationName: string | null;
   roleCode: RoleCode;
   securityLevel: SecurityLevel;
   status: EnabledStatus;
@@ -62,7 +62,7 @@ export function isUserSummary(value: unknown): value is UserSummary {
     typeof value.username === "string" &&
     typeof value.realName === "string" &&
     (value.organizationId === null || isId(value.organizationId)) &&
-    typeof value.organizationName === "string" &&
+    (value.organizationName === null || typeof value.organizationName === "string") &&
     isValueOf(RoleCodeValues, value.roleCode) &&
     isValueOf(SecurityLevelValues, value.securityLevel) &&
     isValueOf(EnabledStatusValues, value.status) &&

+ 1 - 1
frontend/src/features/categories/__tests__/f3Scope.test.ts

@@ -20,7 +20,7 @@ describe("F3 capabilities retained during F4", () => {
 
   it("ADMIN 保留分类管理,文档浏览分类选择驱动真实查询", () => {
     expect(appSource).toContain("<LeftPanel admin");
-    expect(appSource).toContain("<LeftPanel selectedCategoryId=");
+    expect(appSource).toContain("selectedCategoryId={categoryId}");
     expect(appSource).toContain("categoryId: categoryId ?? undefined");
   });
 

+ 2 - 0
frontend/src/features/categories/categoryTree.ts

@@ -7,6 +7,7 @@ export interface CategoryOption {
   depth: number;
   parentId: string | null;
   sortNo: number;
+  isLeaf: boolean;
   displayLabel: string;
 }
 
@@ -47,6 +48,7 @@ export function flattenCategoryOptions(
       depth,
       parentId: node.parentId,
       sortNo: node.sortNo,
+      isLeaf: node.children.length === 0,
       displayLabel: `${CATEGORY_INDENT.repeat(depth)}${node.categoryName}`,
     },
     ...flattenCategoryOptions(node.children, depth + 1),

+ 4 - 1
frontend/src/features/dictionaries/__tests__/fixtures.ts

@@ -14,7 +14,10 @@ export function dictionaryFixture(): UiDictionaries {
   const dictionaries = Object.fromEntries(
     DICTIONARY_NAMES.map((name, index) => [
       name,
-      [{
+      name === "roleCodes" ? [
+        { code: "ADMIN", label: "管理员", sortNo: 1 },
+        { code: "USER", label: "普通用户", sortNo: 2 },
+      ] : [{
         code: name === "securityLevels"
           ? "INTERNAL"
           : name === "visibilityTypes"

+ 2 - 2
frontend/src/features/dictionaries/__tests__/q1fScope.test.ts

@@ -15,12 +15,12 @@ describe("Q1-F page coverage", () => {
       "documentTypes",
       "attachmentTypes",
       "securityLevels",
-      "visibilityTypes",
-      "documentStatuses",
     ]) {
       expect(modal).toContain(`dictionaries.options("${name}")`);
     }
     expect(modal).toContain("categoryId,");
+    expect(modal).not.toContain('dictionaries.options("visibilityTypes")');
+    expect(modal).not.toContain('dictionaries.options("documentStatuses")');
     expect(modal).not.toContain("categoryPath:");
   });
 

+ 4 - 1
frontend/src/features/dictionaries/dictionaryTypes.ts

@@ -70,7 +70,7 @@ export function isDictionaryOption(
 
 function isDictionaryGroups(value: unknown): value is DictionaryGroups {
   if (!isRecord(value) || !hasExactKeys(value, DICTIONARY_NAMES)) return false;
-  return DICTIONARY_NAMES.every((name) => {
+  const valid = DICTIONARY_NAMES.every((name) => {
     const options = value[name];
     if (!Array.isArray(options) || !options.every(isDictionaryOption)) {
       return false;
@@ -79,6 +79,9 @@ function isDictionaryGroups(value: unknown): value is DictionaryGroups {
     if (new Set(codes).size !== codes.length) return false;
     return true;
   });
+  if (!valid) return false;
+  const roleCodes = (value.roleCodes as DictionaryOption[]).map((option) => option.code).sort();
+  return roleCodes.length === 2 && roleCodes[0] === "ADMIN" && roleCodes[1] === "USER";
 }
 
 export function isUiDictionaries(value: unknown): value is UiDictionaries {

+ 1 - 1
frontend/src/features/documents/DocumentSearchToolbar.tsx

@@ -55,7 +55,7 @@ export function DocumentSearchToolbar({
           value={keyword}
           onChange={(event) => onKeywordChange(event.target.value)}
           placeholder={keywordPlaceholder}
-          className={`${inputClass} ${compact ? "w-48" : "w-60"}`}
+          className={`${inputClass} ${compact ? "w-72" : "w-80"}`}
           style={{ borderColor: "rgba(40,118,193,.25)" }}
         />
       </label>

+ 1 - 1
frontend/src/features/documents/__tests__/documentApi.test.ts

@@ -9,7 +9,7 @@ describe("documentApi", () => {
     const client = { get: vi.fn().mockResolvedValue(result(page)) };
     const api = createDocumentApi(client);
     const signal = new AbortController().signal;
-    const query = { documentType: "MAIN,SUB_PLAN", categoryId: "2", keyword: "应急", updatedFrom: "2026-08-04T01:00:00.000Z", updatedTo: "2026-08-04T10:00:00.000Z", sortBy: "updatedAt" as const, sortDirection: "desc" as const, page: 2, pageSize: 20 };
+    const query = { documentType: "MAIN", categoryId: "2", includeDescendants: true, keyword: "应急", updatedFrom: "2026-08-04T01:00:00.000Z", updatedTo: "2026-08-04T10:00:00.000Z", sortBy: "updatedAt" as const, sortDirection: "desc" as const, page: 2, pageSize: 20 };
     await api.list(query, signal);
     expect(client.get).toHaveBeenCalledWith("documents", { query, signal });
   });

+ 1 - 0
frontend/src/features/documents/documentTypes.ts

@@ -70,6 +70,7 @@ export interface MainPlanBriefResult {
 export interface DocumentListQuery {
   documentType?: string;
   categoryId?: Id;
+  includeDescendants?: boolean;
   keyword?: string;
   securityLevel?: SecurityLevelCode;
   visibilityType?: VisibilityTypeCode;

+ 126 - 76
frontend/src/features/files/FilePreviewModal.tsx

@@ -1,117 +1,167 @@
 import { useEffect, useRef, useState } from "react";
 import { Download, FileWarning, RefreshCw, X } from "lucide-react";
+import { renderAsync } from "docx-preview";
 import { HttpError } from "../../shared/api/errors";
 import { AllowedAction } from "../../shared/api/enums";
 import type { DocumentSummary } from "../documents/documentTypes";
-import {
-  createPreviewUrl,
-  downloadFile,
-  acquirePreview,
-  revokeObjectUrl,
-} from "./fileState";
+import { acquirePreview, createPreviewUrl, downloadFile, revokeObjectUrl } from "./fileState";
 
-function errorText(error: HttpError): string {
-  if (error.code === "PREVIEW_UNAVAILABLE") return "当前文件类型暂不支持在线查看";
-  if (error.code === "FILE_NOT_FOUND") return "文件不存在";
-  if (error.code === "DOCUMENT_VIEW_FORBIDDEN" || error.httpStatus === 403) {
-    return "当前账号无权查看该文件";
+const PDF_MIME = "application/pdf";
+const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
+
+function previewError(value: unknown, requestId = "unknown"): HttpError {
+  if (value instanceof HttpError) return value;
+  return new HttpError({
+    httpStatus: 0,
+    code: "INTERNAL_ERROR",
+    message: value instanceof Error ? value.message : "文件预览失败",
+    details: null,
+    requestId,
+    cause: value,
+  });
+}
+
+function errorText(error: HttpError, extension: string): string {
+  if (error.code === "PREVIEW_UNAVAILABLE" || error.httpStatus === 415) {
+    return extension.toLowerCase() === "doc"
+      ? "当前DOC格式暂不支持在线预览,请下载原始文件。"
+      : "当前文件类型暂不支持在线预览";
   }
+  if (error.httpStatus === 403 || error.code === "DOCUMENT_VIEW_FORBIDDEN") return "当前账号无权查看该文件";
+  if (error.httpStatus === 404 || error.code === "FILE_NOT_FOUND") return "文件不存在或已被删除";
+  if (error.httpStatus === 500) return "预览服务暂时不可用";
+  if (error.httpStatus === 0) return "网络连接失败或文档渲染失败";
   return error.message;
 }
 
-export function FilePreviewModal({ item, onClose }: {
+function sanitizeRenderedContent(container: HTMLElement): void {
+  container.querySelectorAll("a").forEach((anchor) => {
+    const href = anchor.getAttribute("href") ?? "";
+    if (href.trim().toLowerCase().startsWith("javascript:")) anchor.removeAttribute("href");
+    if (!/^(https?:|mailto:|#)/i.test(href)) anchor.removeAttribute("href");
+    anchor.setAttribute("rel", "noopener noreferrer");
+    if (/^https?:/i.test(href)) anchor.setAttribute("target", "_blank");
+  });
+  container.querySelectorAll("img").forEach((image) => {
+    const src = image.getAttribute("src") ?? "";
+    if (src && !/^(blob:|data:)/i.test(src)) image.removeAttribute("src");
+  });
+}
+
+export function FilePreviewModal({ item, onClose, mode = "side" }: {
   item: DocumentSummary;
   onClose: () => void;
+  mode?: "side" | "modal";
 }) {
   const [url, setUrl] = useState<string | null>(null);
   const [error, setError] = useState<HttpError | null>(null);
   const [loading, setLoading] = useState(true);
   const [downloading, setDownloading] = useState(false);
-  const mounted = useRef(true);
+  const [retryVersion, setRetryVersion] = useState(0);
+  const docxContainer = useRef<HTMLDivElement>(null);
+  const requestVersion = useRef(0);
   const canDownload = item.allowedActions.includes(AllowedAction.DOWNLOAD);
 
   useEffect(() => {
-    mounted.current = true;
-    const preview = acquirePreview(item.id);
-    let ownedUrl: string | null = null;
-    setLoading(true);
+    const version = ++requestVersion.current;
+    const preview = acquirePreview(item.id, `${item.rowVersion}:${item.updatedAt}:${retryVersion}`);
+    let objectUrl: string | null = null;
+    let responseRequestId = "unknown";
+    setUrl(null);
     setError(null);
-    void preview.promise
-      .then((result) => {
-        if (!mounted.current) return;
-        if (!result.contentType?.toLowerCase().includes("application/pdf")) {
-          throw new HttpError({
-            httpStatus: result.httpStatus,
-            code: "INTERNAL_ERROR",
-            message: "文件内容无法查看",
-            details: null,
-            requestId: result.requestId,
-          });
+    setLoading(true);
+    if (docxContainer.current) docxContainer.current.replaceChildren();
+
+    void preview.promise.then(async (result) => {
+      responseRequestId = result.requestId;
+      if (version !== requestVersion.current) return;
+      const mime = (result.contentType || result.blob.type || "").split(";", 1)[0].trim().toLowerCase();
+      const extension = item.fileExtension.trim().toLowerCase();
+      const expectedMime = extension === "pdf" ? PDF_MIME : extension === "docx" ? DOCX_MIME : null;
+      if (!expectedMime || mime !== expectedMime) {
+        throw new HttpError({ httpStatus: result.httpStatus, code: "INTERNAL_ERROR", message: "预览文件类型与响应内容不一致", details: null, requestId: result.requestId });
+      }
+      if (mime === PDF_MIME) {
+        objectUrl = createPreviewUrl(result.blob);
+        if (version !== requestVersion.current) {
+          revokeObjectUrl(objectUrl);
+          return;
         }
-        ownedUrl = createPreviewUrl(result.blob);
-        setUrl(ownedUrl);
-      })
-      .catch((value: unknown) => {
-        if (!mounted.current) return;
-        setError(
-          value instanceof HttpError
-            ? value
-            : new HttpError({
-                httpStatus: 0,
-                code: "INTERNAL_ERROR",
-                message: "文件查看失败",
-                details: null,
-                requestId: "unknown",
-                cause: value,
-              }),
-        );
-      })
-      .finally(() => {
-        if (mounted.current) setLoading(false);
+        setUrl(objectUrl);
+        return;
+      }
+      if (mime !== DOCX_MIME) {
+        throw new HttpError({ httpStatus: result.httpStatus, code: "INTERNAL_ERROR", message: "预览文件类型与响应内容不一致", details: null, requestId: result.requestId });
+      }
+      const buffer = await result.blob.arrayBuffer();
+      if (version !== requestVersion.current || !docxContainer.current) return;
+      docxContainer.current.replaceChildren();
+      await renderAsync(buffer, docxContainer.current, undefined, {
+        inWrapper: true,
+        ignoreWidth: mode === "side",
+        ignoreHeight: true,
+        breakPages: true,
+        useBase64URL: true,
       });
+      if (version !== requestVersion.current || !docxContainer.current) return;
+      sanitizeRenderedContent(docxContainer.current);
+    }).catch((value: unknown) => {
+      if (version === requestVersion.current) setError(previewError(value, responseRequestId));
+    }).finally(() => {
+      if (version === requestVersion.current) setLoading(false);
+    });
+
     return () => {
-      mounted.current = false;
+      requestVersion.current += 1;
       preview.release();
-      revokeObjectUrl(ownedUrl);
+      revokeObjectUrl(objectUrl);
+      if (docxContainer.current) docxContainer.current.replaceChildren();
     };
-  }, [item.id]);
+  }, [item.id, item.rowVersion, item.updatedAt, retryVersion, mode]);
 
   const download = async () => {
-    if (downloading || !canDownload) return;
+    if (!canDownload || downloading) return;
     setDownloading(true);
     try {
       await downloadFile(item.id, `${item.documentName}.${item.fileExtension || "bin"}`);
     } catch (value) {
-      setError(value instanceof HttpError ? value : null);
+      setError(previewError(value));
     } finally {
       setDownloading(false);
     }
   };
 
-  return (
-    <div className="fixed inset-0 z-[60] flex items-center justify-center" style={{ background: "rgba(26,82,153,.55)" }} onClick={onClose}>
-      <div className="w-[900px] h-[86vh] flex flex-col bg-white shadow-2xl" onClick={(event) => event.stopPropagation()}>
-        <div className="h-11 px-5 flex items-center justify-between text-white" style={{ background: "#1A5299" }}>
-          <span className="font-black">文件查看 · {item.documentName}</span>
-          <button onClick={onClose}><X size={17} /></button>
-        </div>
-        <div className="flex-1 min-h-0 flex items-center justify-center" style={{ background: "#EBF4FD" }}>
-          {loading && <div className="font-bold" style={{ color: "#1A5299" }}><RefreshCw className="inline animate-spin mr-2" size={16} />正在加载文件…</div>}
-          {url && <iframe title={`${item.documentName} PDF查看`} src={url} className="w-full h-full border-0" />}
-          {error && (
-            <div className="text-center p-8" style={{ color: "#1A5299" }}>
-              <FileWarning size={40} className="mx-auto mb-3 opacity-60" />
-              <div className="font-black">{errorText(error)}</div>
-              {error.requestId && <div className="text-xs font-bold opacity-55 mt-2">错误编号:{error.requestId}</div>}
-              {canDownload && (
-                <button disabled={downloading} onClick={() => void download()} className="mt-5 px-4 py-2 text-sm font-black text-white disabled:opacity-60" style={{ background: "#3191eb" }}>
-                  <Download size={14} className="inline mr-2" />{downloading ? "正在下载…" : "下载原始文件"}
-                </button>
-              )}
-            </div>
-          )}
+  const panel = (
+    <aside
+      aria-label={mode === "modal" ? "完整文档查看" : "文档右侧预览"}
+      className={mode === "modal"
+        ? "w-[min(1100px,92vw)] h-[90vh] flex flex-col bg-white shadow-2xl min-h-0"
+        : "w-[400px] max-w-[28vw] min-w-[360px] flex-shrink-0 flex flex-col border-l bg-white min-h-0"}
+      style={{ borderColor: "rgba(40,118,193,.2)" }}
+    >
+      <div className="px-4 py-3 flex items-start gap-3 text-white" style={{ background: "#1A5299" }}>
+        <div className="min-w-0 flex-1">
+          <div className="text-sm font-black truncate" title={item.documentName}>{mode === "modal" ? `文件查看 · ${item.documentName}` : item.documentName}</div>
+          <div className="text-[11px] font-bold opacity-70 mt-1">{item.fileExtension?.toUpperCase() || "无扩展名"} · {new Date(item.updatedAt).toLocaleString("zh-CN")}</div>
         </div>
+        <button aria-label="关闭预览" onClick={onClose}><X size={17} /></button>
+      </div>
+      <div className="flex-1 min-h-0 overflow-auto relative" style={{ background: "#EBF4FD" }}>
+        {loading && <div className="absolute inset-0 z-10 flex items-center justify-center text-sm font-black" style={{ color: "#1A5299", background: "rgba(235,244,253,.9)" }}><RefreshCw size={15} className="animate-spin mr-2" />正在加载预览…</div>}
+        {url && <iframe title={`${item.documentName} PDF预览`} src={url} className="w-full h-full min-h-[560px] border-0" />}
+        <div ref={docxContainer} className={`q3-docx-preview min-h-full overflow-auto [&_.docx-wrapper]:!bg-[#d8e8f5] [&_section.docx]:!shadow-sm ${mode === "side" ? "[&_.docx-wrapper]:!p-3 [&_section.docx]:!max-w-full" : "[&_.docx-wrapper]:!p-8"}`} />
+        {error && <div className="p-8 text-center" style={{ color: "#1A5299" }}>
+          <FileWarning size={38} className="mx-auto mb-3 opacity-60" />
+          <div className="text-sm font-black leading-6">{errorText(error, item.fileExtension)}</div>
+          {error.requestId && <div className="text-xs font-bold opacity-60 mt-2">错误编号:{error.requestId}</div>}
+          {error.httpStatus !== 401 && <button onClick={() => setRetryVersion((value) => value + 1)} className="mt-4 px-3 py-1.5 text-xs font-black border" style={{ borderColor: "#3191eb" }}>重试预览</button>}
+        </div>}
       </div>
-    </div>
+      {canDownload && <button disabled={downloading} onClick={() => void download()} className="m-3 px-3 py-2 text-sm font-black text-white disabled:opacity-60" style={{ background: "#3191eb" }}><Download size={14} className="inline mr-2" />{downloading ? "正在下载…" : "下载原文件"}</button>}
+    </aside>
   );
+  if (mode === "modal") {
+    return <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(26,82,153,.58)" }} onClick={onClose}><div onClick={(event) => event.stopPropagation()}>{panel}</div></div>;
+  }
+  return panel;
 }

+ 155 - 0
frontend/src/features/files/__tests__/q3fScope.test.ts

@@ -0,0 +1,155 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { RoleCode } from "../../../shared/api/enums";
+import { fileApi } from "../fileApi";
+import { clearPreviewRequestsForTests, loadPreviewOnce } from "../fileState";
+
+const read = (path: string) => readFileSync(resolve(process.cwd(), "src", path), "utf8");
+const app = read("app/App.tsx");
+const preview = read("features/files/FilePreviewModal.tsx");
+const mutations = read("features/mutations/MutationModals.tsx");
+const mutationTypes = read("features/mutations/mutationTypes.ts");
+const mainPage = app.slice(app.indexOf("function RealMainPage"), app.indexOf("function RealAdminPage"));
+
+describe("Q3-F 1.4 business alignment", () => {
+  it("正式角色仅ADMIN和USER", () => {
+    expect(Object.values(RoleCode).sort()).toEqual(["ADMIN", "USER"]);
+  });
+
+  it("ADMIN审计与导航由allowedModules驱动", () => {
+    expect(app).toContain('page === "audit" && currentUser.roleCode === RoleCode.ADMIN');
+    expect(app).toContain("getNavigationItems(");
+    expect(app).toContain("currentUser.allowedModules,");
+  });
+
+  it("所有登录页框显示单位和真实姓名并处理空单位", () => {
+    expect(app).toContain('currentUser.organizationName || "未设置单位"');
+    expect(app).toContain("currentUser.realName");
+    expect(app).not.toContain("currentUser.username}</div>");
+  });
+
+  it("用户主列表无三标签且只查询MAIN", () => {
+    expect(mainPage).not.toContain('label: "全部方案"');
+    expect(mainPage).not.toContain('label: "最近更新"');
+    expect(mainPage).toContain("documentType: DocumentType.MAIN");
+    expect(mainPage).not.toContain("MAIN},${DocumentType.SUB_PLAN");
+  });
+
+  it("全部分类不伪造ID,真实分类启用后代查询", () => {
+    expect(app).toContain("全部分类");
+    expect(app).toContain("includeDescendants: categoryId ? true : undefined");
+    expect(app).toContain("categoryId: categoryId ?? undefined");
+  });
+
+  it("分类数字直接使用后端documentCount", () => {
+    expect(app).toContain("node.documentCount");
+    expect(app).not.toContain("getCategoryBranchMainPlanCount(node)");
+  });
+
+  it("主案分类只允许叶子,分类管理父级选择不复用该限制", () => {
+    expect(mutations).toContain("category.isLeaf");
+    expect(mutations).toContain("disabled={!category.isLeaf}");
+    expect(app).toContain("getCategoryParentOptions");
+  });
+
+  it("单文件上传统一选择主案或子案且子案继承主案分类", () => {
+    expect(app).toContain("上传主案/子案</button>");
+    expect(mutations).toContain('label="方案类型 *"');
+    expect(mutations).toContain("方案分类继承自主案");
+    expect(mutations).toContain("DOCUMENT_NAME_CONFLICT");
+    expect(mutations).toContain("确认覆盖");
+  });
+
+  it("1.4 DTO不含status、visibilityType或子方案categoryId", () => {
+    expect(mutationTypes).not.toContain("visibilityType:");
+    expect(mutationTypes).not.toContain("status:");
+    const sub = mutationTypes.slice(mutationTypes.indexOf("export interface SubPlanCreateMetadata"), mutationTypes.indexOf("export type DocumentCreateMetadata"));
+    expect(sub).not.toContain("categoryId");
+  });
+
+  it("USER页面不挂独立共享附件库且仍挂载已关联附件", () => {
+    expect(mainPage).not.toContain("<F4AttachmentLibrary");
+    expect(mainPage).toContain("useMountedAttachments");
+    expect(mainPage).toContain("已挂载共享附件");
+  });
+
+  it("USER页面无继承权限按钮,管理员显示只读继承说明", () => {
+    expect(mainPage).not.toContain("onPermission={setPermission}");
+    expect(app).toContain("权限继承自主案:{selected.documentName}");
+  });
+
+  it("方案关键词提示覆盖主案和子方案", () => {
+    expect(app.match(/搜索方案名称、概述、标签或文档内容/g)?.length).toBeGreaterThanOrEqual(4);
+  });
+});
+
+describe("Q3-F right preview", () => {
+  beforeEach(() => {
+    clearPreviewRequestsForTests();
+    vi.restoreAllMocks();
+  });
+
+  it("预览只调用正式preview路径而不调用download", () => {
+    expect(read("features/files/fileApi.ts")).toContain("`documents/${id}/preview`");
+    expect(preview).not.toContain("fileApi.download");
+  });
+
+  it("点击行保留右侧预览,查看按钮打开完整文档窗口", () => {
+    expect(app).toContain("onSelect={(item) => { setSelected(item); setPreview(item); }}");
+    expect(app).toContain("onView={setFullView}");
+    expect(app).toContain('mode="modal"');
+    expect(preview).toContain('mode?: "side" | "modal"');
+    expect(preview).toContain('aria-label={mode === "modal" ? "完整文档查看" : "文档右侧预览"}');
+  });
+
+  it("业务弹窗所在主体层级高于顶部导航,关闭按钮不会被遮挡", () => {
+    expect(app).toContain('className="relative z-30 flex-shrink-0 flex items-center');
+    expect(app).toContain('className="relative z-40 flex flex-1 min-h-0"');
+    expect(preview).toContain('className="fixed inset-0 z-[80] flex items-center justify-center"');
+  });
+
+  it("PDF使用iframe和Object URL并在清理时撤销", () => {
+    expect(preview).toContain("mime === PDF_MIME");
+    expect(preview).toContain("<iframe");
+    expect(preview).toContain("revokeObjectUrl(objectUrl)");
+  });
+
+  it("DOCX严格校验MIME、arrayBuffer并调用renderAsync", () => {
+    expect(preview).toContain("mime !== DOCX_MIME");
+    expect(preview).toContain("result.blob.arrayBuffer()");
+    expect(preview).toContain("await renderAsync");
+  });
+
+  it("DOC不会进入renderAsync并显示415友好提示和requestId", () => {
+    expect(preview).toContain("当前DOC格式暂不支持在线预览,请下载原始文件。");
+    expect(preview).toContain("error.httpStatus === 415");
+    expect(preview).toContain("错误编号:{error.requestId}");
+  });
+
+  it("下载入口由DOWNLOAD动作控制", () => {
+    expect(preview).toContain("item.allowedActions.includes(AllowedAction.DOWNLOAD)");
+    expect(preview).toContain("{canDownload &&");
+  });
+
+  it("版本键相同合并请求,文档版本变化产生新请求", async () => {
+    const result = { blob: new Blob(["pdf"]), contentType: "application/pdf", contentDisposition: null, requestId: "r", httpStatus: 200, headers: new Headers() };
+    const call = vi.spyOn(fileApi, "preview").mockResolvedValue(result);
+    await Promise.all([loadPreviewOnce("1", "v1"), loadPreviewOnce("1", "v1")]);
+    await loadPreviewOnce("1", "v2");
+    expect(call).toHaveBeenCalledTimes(2);
+  });
+
+  it("快速切换、DOCX容器和危险链接均有清理保护", () => {
+    expect(preview).toContain("requestVersion.current");
+    expect(preview).toContain("replaceChildren()");
+    expect(preview).toContain("noopener noreferrer");
+    expect(preview).toContain("javascript:");
+  });
+
+  it("401之外可重试且错误不会保留上一文档内容", () => {
+    expect(preview).toContain("setUrl(null)");
+    expect(preview).toContain("error.httpStatus !== 401");
+    expect(preview).toContain("重试预览");
+  });
+});

+ 32 - 11
frontend/src/features/files/fileState.ts

@@ -7,9 +7,17 @@ interface PreviewRequest {
   consumers: number;
 }
 const previewRequests = new Map<string, PreviewRequest>();
+const previewResults = new Map<string, Awaited<ReturnType<typeof fileApi.preview>>>();
 
-export function loadPreviewOnce(id: string) {
-  const existing = previewRequests.get(id);
+function previewKey(id: string, version: string | number = ""): string {
+  return `${id}:${version}`;
+}
+
+export function loadPreviewOnce(id: string, version: string | number = "") {
+  const key = previewKey(id, version);
+  const cached = previewResults.get(key);
+  if (cached) return Promise.resolve(cached);
+  const existing = previewRequests.get(key);
   if (existing) return existing.promise;
   const controller = new AbortController();
   const request: PreviewRequest = {
@@ -17,19 +25,25 @@ export function loadPreviewOnce(id: string) {
     consumers: 0,
     promise: Promise.resolve(undefined as never),
   };
-  request.promise = fileApi.preview(id, controller.signal).finally(() => {
-    if (previewRequests.get(id) === request) previewRequests.delete(id);
-  });
-  previewRequests.set(id, request);
+  request.promise = fileApi.preview(id, controller.signal)
+    .then((result) => {
+      previewResults.set(key, result);
+      return result;
+    })
+    .finally(() => {
+      if (previewRequests.get(key) === request) previewRequests.delete(key);
+    });
+  previewRequests.set(key, request);
   return request.promise;
 }
 
-export function acquirePreview(id: string): {
+export function acquirePreview(id: string, version: string | number = ""): {
   promise: Promise<Awaited<ReturnType<typeof fileApi.preview>>>;
   release: () => void;
 } {
-  const promise = loadPreviewOnce(id);
-  const request = previewRequests.get(id);
+  const key = previewKey(id, version);
+  const promise = loadPreviewOnce(id, version);
+  const request = previewRequests.get(key);
   if (!request) return { promise, release: () => undefined };
   request.consumers += 1;
   let released = false;
@@ -42,10 +56,10 @@ export function acquirePreview(id: string): {
       globalThis.setTimeout(() => {
         if (
           request.consumers === 0 &&
-          previewRequests.get(id) === request
+          previewRequests.get(key) === request
         ) {
           request.controller.abort();
-          previewRequests.delete(id);
+          previewRequests.delete(key);
         }
       }, 0);
     },
@@ -87,4 +101,11 @@ export async function downloadFile(
 
 export function clearPreviewRequestsForTests(): void {
   previewRequests.clear();
+  previewResults.clear();
+}
+
+export function invalidatePreview(id: string): void {
+  for (const key of previewResults.keys()) {
+    if (key.startsWith(`${id}:`)) previewResults.delete(key);
+  }
 }

+ 63 - 47
frontend/src/features/mutations/MutationModals.tsx

@@ -3,10 +3,8 @@ import { Check, FilePlus2, RefreshCw, Trash2, Upload, X } from "lucide-react";
 import { HttpError } from "../../shared/api/errors";
 import {
   AttachmentType,
-  DocumentStatus,
   DocumentType,
   SecurityLevel,
-  VisibilityType,
 } from "../../shared/api/enums";
 import { notifyContentChanged } from "../../shared/state/contentRefresh";
 import { attachmentApi } from "../attachments/attachmentApi";
@@ -25,6 +23,7 @@ import {
   suggestedNameFromFile,
   validateSelectedFile,
 } from "../files/filename";
+import { invalidatePreview } from "../files/fileState";
 import { attachmentMutationApi } from "./attachmentMutationApi";
 import { documentMutationApi } from "./documentMutationApi";
 import {
@@ -37,6 +36,7 @@ import {
   type AttachmentCreateMetadata,
   type BatchDraft,
   type DocumentCreateMetadata,
+  type DocumentUploadMetadata,
 } from "./mutationTypes";
 
 const BLUE = "#3191eb";
@@ -55,6 +55,7 @@ function mutationMessage(error: HttpError): string {
     FORBIDDEN: "当前账号无权执行该操作",
     DOCUMENT_VIEW_FORBIDDEN: "当前账号无权查看该文档",
     DOCUMENT_DOWNLOAD_FORBIDDEN: "当前账号无权下载该文档",
+    CATEGORY_NOT_LEAF: "所选分类不是叶子分类,请选择最末级分类",
   };
   return messages[error.code] ?? error.message;
 }
@@ -82,6 +83,7 @@ function ErrorBlock({ error }: { error: HttpError | null }) {
     <div className="p-3 text-xs font-bold leading-5" style={{ background: "rgba(26,82,153,.08)", color: NAVY }}>
       <div>{mutationMessage(error)}</div>
       {typeof details?.mountedPlanCount === "number" && <div>当前挂载主案数:{details.mountedPlanCount}</div>}
+      {typeof details?.childCategoryCount === "number" && <div>下级分类数:{details.childCategoryCount}</div>}
       {plans.map((plan, index) => {
         const value = plan && typeof plan === "object" ? plan as Record<string, unknown> : {};
         return <div key={String(value.id ?? index)}>· {String(value.documentName ?? value.name ?? "未命名主案")}</div>;
@@ -129,6 +131,7 @@ function useReferenceData() {
   return {
     categories: flattenCategoryOptions(categories.tree),
     categoryLoading: categories.status === "loading",
+    refreshCategories: categories.refresh,
     mainPlans: mainPlans.data?.items ?? [],
     mainPlanLoading: mainPlans.loading,
   };
@@ -144,14 +147,13 @@ function defaultDocumentMetadata(
     summary: null,
     categoryId,
     securityLevel: SecurityLevel.INTERNAL,
-    visibilityType: VisibilityType.ALL_AUTHENTICATED,
-    status: DocumentStatus.DRAFT,
     tags: [],
   };
 }
 
-export function SingleUploadModal({ kind, initialParentId, onClose }: {
+export function SingleUploadModal({ kind, documentType: fixedDocumentType = "MAIN", initialParentId, onClose }: {
   kind: "document" | "attachment";
+  documentType?: "MAIN" | "SUB_PLAN";
   initialParentId?: string;
   onClose: () => void;
 }) {
@@ -159,31 +161,37 @@ export function SingleUploadModal({ kind, initialParentId, onClose }: {
   const dictionaries = useDictionaries();
   const [file, setFile] = useState<File | null>(null);
   const [documentName, setDocumentName] = useState("");
-  const [documentType, setDocumentType] = useState<"MAIN" | "SUB_PLAN">(initialParentId ? "SUB_PLAN" : "MAIN");
+  const [documentType, setDocumentType] = useState<"MAIN" | "SUB_PLAN">(
+    initialParentId ? "SUB_PLAN" : fixedDocumentType,
+  );
   const [parentDocumentId, setParentDocumentId] = useState(initialParentId ?? "");
   const [categoryId, setCategoryId] = useState("");
   const [summary, setSummary] = useState("");
   const [securityLevel, setSecurityLevel] = useState(SecurityLevel.INTERNAL);
-  const [visibilityType, setVisibilityType] = useState(VisibilityType.ALL_AUTHENTICATED);
-  const [status, setStatus] = useState(DocumentStatus.DRAFT);
   const [attachmentType, setAttachmentType] = useState(AttachmentType.OTHER);
   const [tags, setTags] = useState("");
   const [submitting, setSubmitting] = useState(false);
   const [error, setError] = useState<HttpError | null>(null);
   const [formError, setFormError] = useState("");
+  const [overwrite, setOverwrite] = useState<{
+    existingDocumentId: string;
+    existingDocumentName: string;
+    existingRowVersion: number;
+  } | null>(null);
   const controller = useRef<AbortController | null>(null);
 
   useEffect(() => () => controller.current?.abort(), []);
 
-  const submit = async () => {
+  const submit = async (confirmedOverwrite = false) => {
     if (submitting) return;
     const fileError = file ? validateSelectedFile(file) : "请选择文件";
     if (fileError || !documentName.trim()) {
       setFormError(fileError || "名称不能为空");
       return;
     }
-    if (kind === "document" && !categoryId) {
-      setFormError("请选择有效分类");
+    const selectedCategory = references.categories.find((category) => category.id === categoryId);
+    if (kind === "document" && documentType === "MAIN" && (!selectedCategory || !selectedCategory.isLeaf)) {
+      setFormError("请选择有效的最末级分类");
       return;
     }
     if (kind === "document" && documentType === "SUB_PLAN" && !parentDocumentId) {
@@ -209,22 +217,37 @@ export function SingleUploadModal({ kind, initialParentId, onClose }: {
         const base = {
           documentName: documentName.trim(),
           summary: summary.trim() || null,
-          categoryId,
           securityLevel,
-          visibilityType,
-          status,
           tags: parseTags(tags),
         };
-        const metadata: DocumentCreateMetadata = documentType === "MAIN"
-          ? { ...base, documentType: "MAIN" }
+        const metadata: DocumentUploadMetadata = documentType === "MAIN"
+          ? { ...base, documentType: "MAIN", categoryId }
           : { ...base, documentType: "SUB_PLAN", parentDocumentId };
+        if (confirmedOverwrite && overwrite) {
+          metadata.overwriteDocumentId = overwrite.existingDocumentId;
+          metadata.rowVersion = overwrite.existingRowVersion;
+        }
         const detail = await documentMutationApi.create(file!, metadata, controller.current.signal);
         cacheDetail("document", detail);
         notifyContentChanged("documents");
       }
       onClose();
     } catch (value) {
-      setError(asMutationError(value));
+      const nextError = asMutationError(value);
+      if (nextError.code === "DOCUMENT_NAME_CONFLICT" && nextError.details && typeof nextError.details === "object") {
+        const details = nextError.details as Record<string, unknown>;
+        if (typeof details.existingDocumentId === "string" && typeof details.existingDocumentName === "string" && typeof details.existingRowVersion === "number") {
+          setOverwrite({
+            existingDocumentId: details.existingDocumentId,
+            existingDocumentName: details.existingDocumentName,
+            existingRowVersion: details.existingRowVersion,
+          });
+          setError(null);
+          return;
+        }
+      }
+      setError(nextError);
+      if (nextError.code === "CATEGORY_NOT_LEAF") references.refreshCategories();
     } finally {
       setSubmitting(false);
     }
@@ -236,25 +259,20 @@ export function SingleUploadModal({ kind, initialParentId, onClose }: {
         <Field label="文件 *"><input type="file" accept={ACCEPT} disabled={submitting} onChange={(event) => {
           const selected = event.target.files?.[0] ?? null;
           setFile(selected);
+          setOverwrite(null);
           if (selected && !documentName) setDocumentName(suggestedNameFromFile(selected));
           setFormError(selected ? validateSelectedFile(selected) ?? "" : "");
         }} className={inputClass} style={selectStyle} /></Field>
-        {kind === "document" && (
-          <div className="grid grid-cols-2 gap-3">
-            <Field label="方案类型 *"><select value={documentType} disabled={submitting} onChange={(event) => setDocumentType(event.target.value as "MAIN" | "SUB_PLAN")} className={inputClass} style={selectStyle}>{dictionaries.options("documentTypes").filter((option) => option.code !== "ATTACHMENT").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
-            {documentType === "SUB_PLAN" && <Field label="所属主案 *"><select value={parentDocumentId} disabled={submitting || references.mainPlanLoading} onChange={(event) => setParentDocumentId(event.target.value)} className={inputClass} style={selectStyle}><option value="">请选择主案</option>{references.mainPlans.map((plan) => <option key={plan.id} value={plan.id}>{plan.documentName}</option>)}</select></Field>}
-          </div>
-        )}
-        <Field label={`${kind === "attachment" ? "附件" : "文档"}名称 *`}><input value={documentName} disabled={submitting} onChange={(event) => setDocumentName(event.target.value)} className={inputClass} style={selectStyle} /></Field>
+        {kind === "document" && <Field label="方案类型 *"><select value={documentType} disabled={submitting || !!initialParentId} onChange={(event) => { const next = event.target.value as "MAIN" | "SUB_PLAN"; setDocumentType(next); setOverwrite(null); if (next === "SUB_PLAN" && !parentDocumentId) setParentDocumentId(references.mainPlans[0]?.id ?? ""); }} className={inputClass} style={selectStyle}><option value="MAIN">主案</option><option value="SUB_PLAN">子案</option></select></Field>}
+        {kind === "document" && documentType === "SUB_PLAN" && <Field label="所属主案 *"><select value={parentDocumentId} disabled={submitting || references.mainPlanLoading} onChange={(event) => { setParentDocumentId(event.target.value); setOverwrite(null); }} className={inputClass} style={selectStyle}><option value="">请选择主案</option>{references.mainPlans.map((plan) => <option key={plan.id} value={plan.id}>{plan.documentName}</option>)}</select></Field>}
+        <Field label={`${kind === "attachment" ? "附件" : "文档"}名称 *`}><input value={documentName} disabled={submitting} onChange={(event) => { setDocumentName(event.target.value); setOverwrite(null); }} className={inputClass} style={selectStyle} /></Field>
         {kind === "attachment" ? (
           <Field label="附件类型 *"><select value={attachmentType} disabled={submitting} onChange={(event) => setAttachmentType(event.target.value as typeof attachmentType)} className={inputClass} style={selectStyle}>{dictionaries.options("attachmentTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
         ) : (
           <>
-            <Field label="方案分类 *"><select value={categoryId} disabled={submitting || references.categoryLoading} onChange={(event) => setCategoryId(event.target.value)} className={inputClass} style={selectStyle}><option value="">请选择分类</option>{references.categories.map((category) => <option key={category.id} value={category.id} title={category.categoryPath}>{category.displayLabel}</option>)}</select></Field>
-            <div className="grid grid-cols-3 gap-3">
+            {documentType === "MAIN" ? <Field label="方案分类 *"><select value={categoryId} disabled={submitting || references.categoryLoading} onChange={(event) => { setCategoryId(event.target.value); setOverwrite(null); }} className={inputClass} style={selectStyle}><option value="">请选择最末级分类</option>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}{category.isLeaf ? "" : "(请选择下级分类)"}</option>)}</select></Field> : <div className="px-3 py-2 text-xs font-bold" style={{ color: NAVY, background: "#EBF4FD" }}>方案分类继承自主案:{references.mainPlans.find((plan) => plan.id === parentDocumentId)?.categoryPath ?? references.mainPlans.find((plan) => plan.id === parentDocumentId)?.categoryName ?? "请选择所属主案"}</div>}
+            <div className="grid grid-cols-1 gap-3">
               <Field label="密级 *"><select value={securityLevel} onChange={(event) => setSecurityLevel(event.target.value as typeof securityLevel)} className={inputClass} style={selectStyle}>{dictionaries.options("securityLevels").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
-              <Field label="可见范围 *"><select value={visibilityType} onChange={(event) => setVisibilityType(event.target.value as typeof visibilityType)} className={inputClass} style={selectStyle}>{dictionaries.options("visibilityTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
-              <Field label="状态 *"><select value={status} onChange={(event) => setStatus(event.target.value as typeof status)} className={inputClass} style={selectStyle}>{dictionaries.options("documentStatuses").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
             </div>
           </>
         )}
@@ -262,6 +280,7 @@ export function SingleUploadModal({ kind, initialParentId, onClose }: {
         <Field label="标签(逗号分隔)"><input value={tags} disabled={submitting} onChange={(event) => setTags(event.target.value)} className={inputClass} style={selectStyle} /></Field>
         {formError && <div className="text-xs font-bold" style={{ color: NAVY }}>{formError}</div>}
         <ErrorBlock error={error} />
+        {overwrite && <div role="alertdialog" className="p-4 text-sm font-bold border" style={{ color: NAVY, borderColor: BLUE, background: "#EBF4FD" }}><div>已存在同名{documentType === "MAIN" ? "主案" : "子案"}“{overwrite.existingDocumentName}”,是否覆盖?</div><div className="mt-1 text-xs opacity-70">覆盖将保留原记录、权限及关联关系,仅替换文件和本次填写的元数据。</div><div className="flex justify-end gap-2 mt-3"><button type="button" onClick={() => setOverwrite(null)} className="px-3 py-1.5 border" style={selectStyle}>取消覆盖</button><button type="button" disabled={submitting} onClick={() => void submit(true)} className="px-3 py-1.5 text-white" style={{ background: BLUE }}>确认覆盖</button></div></div>}
         <div className="flex justify-end gap-2"><button onClick={onClose} disabled={submitting} className="px-4 py-2 text-sm font-black border" style={selectStyle}>取消</button><button onClick={() => void submit()} disabled={submitting || dictionaries.status !== "success"} className="px-4 py-2 text-sm font-black text-white disabled:opacity-60" style={{ background: BLUE }}>{submitting ? <><RefreshCw size={13} className="inline animate-spin mr-1" />正在上传/处理中</> : <><Upload size={13} className="inline mr-1" />确认上传</>}</button></div>
       </div>
     </Modal>
@@ -296,7 +315,7 @@ export function BatchImportModal({ kind, onClose }: { kind: BatchKind; onClose:
             } satisfies AttachmentCreateMetadata
           : defaultDocumentMetadata(
               suggestedNameFromFile(file),
-              references.categories[0]?.id ?? "",
+              references.categories.find((category) => category.isLeaf)?.id ?? "",
             ),
       )),
     ]);
@@ -316,16 +335,13 @@ export function BatchImportModal({ kind, onClose }: { kind: BatchKind; onClose:
       const common = {
         documentName: metadata.documentName,
         summary: metadata.summary,
-        categoryId: metadata.categoryId,
         securityLevel: metadata.securityLevel,
-        visibilityType: metadata.visibilityType,
-        status: metadata.status,
         tags: metadata.tags,
       };
       return {
         ...draft,
         metadata: type === "MAIN"
-          ? { ...common, documentType: "MAIN" }
+          ? { ...common, documentType: "MAIN", categoryId: references.categories.find((category) => category.isLeaf)?.id ?? "" }
           : {
               ...common,
               documentType: "SUB_PLAN",
@@ -383,7 +399,7 @@ export function BatchImportModal({ kind, onClose }: { kind: BatchKind; onClose:
               {kind === "document" ? (
                 <>
                   <div className="col-span-1"><select value={documentMetadata!.documentType} onChange={(event) => changeDocumentType(draft.key, event.target.value as "MAIN" | "SUB_PLAN")} className={inputClass} style={selectStyle}>{dictionaries.options("documentTypes").filter((option) => option.code !== "ATTACHMENT").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></div>
-                  <div className="col-span-2"><select value={documentMetadata!.categoryId} onChange={(event) => updateMetadata(draft.key, { categoryId: event.target.value } as Partial<BatchMetadata>)} className={inputClass} style={selectStyle}>{references.categories.map((category) => <option key={category.id} value={category.id} title={category.categoryPath}>{category.displayLabel}</option>)}</select></div>
+                  <div className="col-span-2">{documentMetadata!.documentType === "MAIN" ? <select value={documentMetadata!.categoryId} onChange={(event) => updateMetadata(draft.key, { categoryId: event.target.value } as Partial<BatchMetadata>)} className={inputClass} style={selectStyle}>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}</option>)}</select> : <div className="h-9 px-2 flex items-center text-xs font-bold" style={{ background: "#EBF4FD", color: NAVY }}>分类继承自主案</div>}</div>
                   <div className="col-span-2"><select disabled={documentMetadata!.documentType === "MAIN"} value={documentMetadata!.documentType === "SUB_PLAN" ? documentMetadata!.parentDocumentId : ""} onChange={(event) => updateMetadata(draft.key, { parentDocumentId: event.target.value } as Partial<BatchMetadata>)} className={inputClass} style={selectStyle}><option value="">父主案</option>{references.mainPlans.map((plan) => <option key={plan.id} value={plan.id}>{plan.documentName}</option>)}</select></div>
                 </>
               ) : (
@@ -394,8 +410,6 @@ export function BatchImportModal({ kind, onClose }: { kind: BatchKind; onClose:
               <div className="col-span-4"><input value={metadata.summary ?? ""} onChange={(event) => updateMetadata(draft.key, { summary: event.target.value || null })} placeholder="概述" className={inputClass} style={selectStyle} /></div>
               {documentMetadata && <>
                 <div className="col-span-2"><select value={documentMetadata.securityLevel} onChange={(event) => updateMetadata(draft.key, { securityLevel: event.target.value as DocumentCreateMetadata["securityLevel"] } as Partial<BatchMetadata>)} className={inputClass} style={selectStyle}>{dictionaries.options("securityLevels").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></div>
-                <div className="col-span-3"><select value={documentMetadata.visibilityType} onChange={(event) => updateMetadata(draft.key, { visibilityType: event.target.value as DocumentCreateMetadata["visibilityType"] } as Partial<BatchMetadata>)} className={inputClass} style={selectStyle}>{dictionaries.options("visibilityTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></div>
-                <div className="col-span-2"><select value={documentMetadata.status} onChange={(event) => updateMetadata(draft.key, { status: event.target.value as DocumentCreateMetadata["status"] } as Partial<BatchMetadata>)} className={inputClass} style={selectStyle}>{dictionaries.options("documentStatuses").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></div>
               </>}
               {draft.result && <div className="col-span-12 text-xs font-bold" style={{ color: draft.result.success ? BLUE : NAVY }}>{draft.result.success ? `成功:${draft.result.document.documentName}` : `失败:${draft.result.errorCode} · ${draft.result.errorMessage}`}</div>}
             </div>
@@ -425,8 +439,6 @@ export function EditMetadataModal({ item, onClose }: {
   const [summary, setSummary] = useState(item.summary ?? "");
   const [categoryId, setCategoryId] = useState(item.categoryId ?? "");
   const [securityLevel, setSecurityLevel] = useState(item.securityLevel);
-  const [visibilityType, setVisibilityType] = useState(item.visibilityType);
-  const [status, setStatus] = useState(item.status);
   const [attachmentType, setAttachmentType] = useState(item.attachmentType ?? AttachmentType.OTHER);
   const [tags, setTags] = useState(item.tags.join(","));
   const [loading, setLoading] = useState(true);
@@ -445,11 +457,10 @@ export function EditMetadataModal({ item, onClose }: {
       setSummary(latest.summary ?? "");
       setCategoryId(latest.categoryId ?? "");
       setSecurityLevel(latest.securityLevel);
-      setVisibilityType(latest.visibilityType);
-      setStatus(latest.status);
       setAttachmentType(latest.attachmentType ?? AttachmentType.OTHER);
       setTags(latest.tags.join(","));
       cacheDetail(attachment ? "attachment" : "document", latest);
+      invalidatePreview(item.id);
     } catch (value) {
       if (value instanceof HttpError) setError(value);
     } finally {
@@ -472,17 +483,22 @@ export function EditMetadataModal({ item, onClose }: {
             tags: parseTags(tags),
             rowVersion: detail.rowVersion,
           })
-        : await documentMutationApi.update(item.id, {
+        : await documentMutationApi.update(item.id, item.documentType === DocumentType.MAIN ? {
             documentName: documentName.trim(),
             summary: summary.trim() || null,
             categoryId,
             securityLevel,
-            visibilityType,
-            status,
+            tags: parseTags(tags),
+            rowVersion: detail.rowVersion,
+          } : {
+            documentName: documentName.trim(),
+            summary: summary.trim() || null,
+            securityLevel,
             tags: parseTags(tags),
             rowVersion: detail.rowVersion,
           });
       cacheDetail(attachment ? "attachment" : "document", latest);
+      invalidatePreview(item.id);
       notifyContentChanged(attachment ? "attachments" : "documents", ...(attachment ? ["documents" as const] : []));
       onClose();
     } catch (value) {
@@ -493,6 +509,7 @@ export function EditMetadataModal({ item, onClose }: {
           notifyContentChanged(attachment ? "attachments" : "documents");
           await loadLatest(true);
       }
+      if (nextError.code === "CATEGORY_NOT_LEAF") references.refreshCategories();
     } finally {
       setSubmitting(false);
     }
@@ -505,11 +522,9 @@ export function EditMetadataModal({ item, onClose }: {
         {detail && <>
           <Field label="名称 *"><input value={documentName} onChange={(event) => setDocumentName(event.target.value)} className={inputClass} style={selectStyle} /></Field>
           {attachment ? <Field label="附件类型"><select value={attachmentType} onChange={(event) => setAttachmentType(event.target.value as typeof attachmentType)} className={inputClass} style={selectStyle}>{dictionaries.options("attachmentTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field> : <>
-            <Field label="分类"><select value={categoryId} onChange={(event) => setCategoryId(event.target.value)} className={inputClass} style={selectStyle}>{references.categories.map((category) => <option key={category.id} value={category.id} title={category.categoryPath}>{category.displayLabel}</option>)}</select></Field>
-            <div className="grid grid-cols-3 gap-3">
+            {item.documentType === DocumentType.MAIN ? <Field label="分类"><select value={categoryId} onChange={(event) => setCategoryId(event.target.value)} className={inputClass} style={selectStyle}>{references.categories.map((category) => <option key={category.id} value={category.id} disabled={!category.isLeaf} title={category.categoryPath}>{category.displayLabel}{category.isLeaf ? "" : "(请选择下级分类)"}</option>)}</select></Field> : <div className="px-3 py-2 text-xs font-bold" style={{ background: "#EBF4FD", color: NAVY }}>方案分类继承自主案:{detail.categoryPath ?? detail.categoryName ?? "未分类"}</div>}
+            <div className="grid grid-cols-1 gap-3">
               <Field label="密级"><select value={securityLevel} onChange={(event) => setSecurityLevel(event.target.value as typeof securityLevel)} className={inputClass} style={selectStyle}>{dictionaries.options("securityLevels").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
-              <Field label="可见范围"><select value={visibilityType} onChange={(event) => setVisibilityType(event.target.value as typeof visibilityType)} className={inputClass} style={selectStyle}>{dictionaries.options("visibilityTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
-              <Field label="状态"><select value={status} onChange={(event) => setStatus(event.target.value as typeof status)} className={inputClass} style={selectStyle}>{dictionaries.options("documentStatuses").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}</select></Field>
             </div>
           </>}
           <Field label="概述"><textarea value={summary} onChange={(event) => setSummary(event.target.value)} className="w-full min-h-20 p-3 text-sm font-bold border outline-none" style={selectStyle} /></Field>
@@ -553,6 +568,7 @@ export function DeleteDocumentModal({ item, onClose, onDeleted }: {
       if (attachment) await attachmentMutationApi.remove(item.id, detail.rowVersion);
       else await documentMutationApi.remove(item.id, detail.rowVersion);
       invalidateDetail(attachment ? "attachment" : "document", item.id);
+      invalidatePreview(item.id);
       notifyContentChanged("documents", "attachments");
       onDeleted?.();
       onClose();

+ 21 - 5
frontend/src/features/mutations/__tests__/mutationApi.test.ts

@@ -26,12 +26,13 @@ const mainMetadata: DocumentCreateMetadata = {
   summary: null,
   categoryId: "1",
   securityLevel: "INTERNAL",
-  visibilityType: "ALL_AUTHENTICATED",
-  status: "DRAFT",
   tags: ["标签"],
 };
 const subMetadata: DocumentCreateMetadata = {
-  ...mainMetadata,
+  documentName: mainMetadata.documentName,
+  summary: mainMetadata.summary,
+  securityLevel: mainMetadata.securityLevel,
+  tags: mainMetadata.tags,
   documentType: "SUB_PLAN",
   parentDocumentId: "9",
 };
@@ -73,6 +74,12 @@ describe("F5 mutation APIs", () => {
     expect(JSON.parse(String(documentUploadForm(file, mainMetadata).get("metadata")))).not.toHaveProperty("parentDocumentId");
   });
 
+  it("确认覆盖时同时提交字符串文档ID和版本", () => {
+    const metadata = { ...mainMetadata, overwriteDocumentId: "12", rowVersion: 4 };
+    expect(JSON.parse(String(documentUploadForm(file, metadata).get("metadata")))).toEqual(metadata);
+    expect(() => documentUploadForm(file, { ...mainMetadata, overwriteDocumentId: "12" })).toThrow();
+  });
+
   it("方案批量使用重复files和稳定items顺序", async () => {
     const second = new File(["two"], "two.docx");
     const form = batchImportForm([file, second], [mainMetadata, subMetadata]);
@@ -90,8 +97,6 @@ describe("F5 mutation APIs", () => {
       summary: null,
       categoryId: "1",
       securityLevel: "SECRET" as const,
-      visibilityType: "CUSTOM" as const,
-      status: "PUBLISHED" as const,
       tags: [],
       rowVersion: 3,
     };
@@ -102,6 +107,17 @@ describe("F5 mutation APIs", () => {
     expect(mock.delete).toHaveBeenCalledWith("documents/10", { query: { rowVersion: 3 }, signal: undefined });
   });
 
+  it("1.4主案和子方案请求严格排除废止字段", () => {
+    const main = JSON.parse(String(documentUploadForm(file, mainMetadata).get("metadata")));
+    const sub = JSON.parse(String(documentUploadForm(file, subMetadata).get("metadata")));
+    for (const value of [main, sub]) {
+      expect(value).not.toHaveProperty("status");
+      expect(value).not.toHaveProperty("visibilityType");
+    }
+    expect(sub).not.toHaveProperty("categoryId");
+    expect(main).toHaveProperty("categoryId", "1");
+  });
+
   it("单附件上传只包含附件metadata,不提交固定后端字段", async () => {
     const mock = client({ ...detail, id: "7", documentType: "ATTACHMENT", attachmentType: "POLICY", categoryId: null, categoryName: null, categoryPath: null, parentDocumentId: null, rootDocumentId: null });
     await createAttachmentMutationApi(mock).create(file, attachmentMetadata);

+ 3 - 2
frontend/src/features/mutations/documentMutationApi.ts

@@ -10,6 +10,7 @@ import {
   type BatchImportResult,
   type DeleteDocumentResult,
   type DocumentCreateMetadata,
+  type DocumentUploadMetadata,
   type UpdateDocumentRequest,
 } from "./mutationTypes";
 
@@ -29,7 +30,7 @@ function contractError(result: JsonResult<unknown>, target: string): HttpError {
   });
 }
 
-export function documentUploadForm(file: File, metadata: DocumentCreateMetadata): FormData {
+export function documentUploadForm(file: File, metadata: DocumentUploadMetadata): FormData {
   if (!isDocumentCreateMetadata(metadata)) throw new Error("文档metadata不符合DMS接口契约");
   const form = new FormData();
   form.append("file", file);
@@ -55,7 +56,7 @@ export function batchImportForm(
 
 export function createDocumentMutationApi(client: MutationClient = httpClient) {
   return {
-    async create(file: File, metadata: DocumentCreateMetadata, signal?: AbortSignal): Promise<DocumentDetail> {
+    async create(file: File, metadata: DocumentUploadMetadata, signal?: AbortSignal): Promise<DocumentDetail> {
       const result = await client.post<unknown>(
         "documents",
         documentUploadForm(file, metadata),

+ 37 - 31
frontend/src/features/mutations/mutationTypes.ts

@@ -1,12 +1,8 @@
 import {
   AttachmentType,
-  DocumentStatus,
   SecurityLevel,
-  VisibilityType,
   type AttachmentType as AttachmentTypeCode,
-  type DocumentStatus as DocumentStatusCode,
   type SecurityLevel as SecurityLevelCode,
-  type VisibilityType as VisibilityTypeCode,
 } from "../../shared/api/enums";
 import type { Id } from "../../shared/api/types";
 import {
@@ -17,15 +13,13 @@ import {
 interface DocumentMetadataBase {
   documentName: string;
   summary: string | null;
-  categoryId: Id;
   securityLevel: SecurityLevelCode;
-  visibilityType: VisibilityTypeCode;
-  status: DocumentStatusCode;
   tags: string[];
 }
 
 export interface MainDocumentCreateMetadata extends DocumentMetadataBase {
   documentType: "MAIN";
+  categoryId: Id;
 }
 
 export interface SubPlanCreateMetadata extends DocumentMetadataBase {
@@ -37,6 +31,11 @@ export type DocumentCreateMetadata =
   | MainDocumentCreateMetadata
   | SubPlanCreateMetadata;
 
+export type DocumentUploadMetadata = DocumentCreateMetadata & {
+  overwriteDocumentId?: Id;
+  rowVersion?: number;
+};
+
 export interface AttachmentCreateMetadata {
   documentName: string;
   attachmentType: AttachmentTypeCode;
@@ -44,17 +43,25 @@ export interface AttachmentCreateMetadata {
   tags: string[];
 }
 
-export interface UpdateDocumentRequest {
+export interface MainDocumentUpdateRequest {
   documentName: string;
   summary: string | null;
   categoryId: Id;
   securityLevel: SecurityLevelCode;
-  visibilityType: VisibilityTypeCode;
-  status: DocumentStatusCode;
   tags: string[];
   rowVersion: number;
 }
 
+export interface SubPlanUpdateRequest {
+  documentName: string;
+  summary: string | null;
+  securityLevel: SecurityLevelCode;
+  tags: string[];
+  rowVersion: number;
+}
+
+export type UpdateDocumentRequest = MainDocumentUpdateRequest | SubPlanUpdateRequest;
+
 export interface UpdateAttachmentRequest {
   documentName: string;
   attachmentType: AttachmentTypeCode;
@@ -130,34 +137,34 @@ function hasOnlyKeys(value: Record<string, unknown>, keys: readonly string[]): b
     keys.every((key) => key in value);
 }
 
-export function isDocumentCreateMetadata(value: unknown): value is DocumentCreateMetadata {
+export function isDocumentCreateMetadata(value: unknown): value is DocumentUploadMetadata {
   if (!isRecord(value)) return false;
   const commonKeys = [
     "documentName",
     "documentType",
     "summary",
-    "categoryId",
     "securityLevel",
-    "visibilityType",
-    "status",
     "tags",
   ];
   const common =
     typeof value.documentName === "string" &&
     value.documentName.length > 0 &&
     isNullableString(value.summary) &&
-    isId(value.categoryId) &&
     isEnum(SecurityLevel, value.securityLevel) &&
-    isEnum(VisibilityType, value.visibilityType) &&
-    isEnum(DocumentStatus, value.status) &&
     isTags(value.tags);
   if (!common) return false;
-  if (value.documentType === "MAIN") return hasOnlyKeys(value, commonKeys);
-  return (
-    value.documentType === "SUB_PLAN" &&
-    hasOnlyKeys(value, [...commonKeys, "parentDocumentId"]) &&
-    isId(value.parentDocumentId)
-  );
+  const overwriteKeys = "overwriteDocumentId" in value || "rowVersion" in value
+    ? ["overwriteDocumentId", "rowVersion"]
+    : [];
+  if (overwriteKeys.length && (!isId(value.overwriteDocumentId) || !isNonNegativeInteger(value.rowVersion))) {
+    return false;
+  }
+  if (value.documentType === "MAIN") {
+    return hasOnlyKeys(value, [...commonKeys, "categoryId", ...overwriteKeys]) && isId(value.categoryId);
+  }
+  return value.documentType === "SUB_PLAN" &&
+    hasOnlyKeys(value, [...commonKeys, "parentDocumentId", ...overwriteKeys]) &&
+    isId(value.parentDocumentId);
 }
 
 export function isAttachmentCreateMetadata(value: unknown): value is AttachmentCreateMetadata {
@@ -173,19 +180,18 @@ export function isAttachmentCreateMetadata(value: unknown): value is AttachmentC
 }
 
 export function isUpdateDocumentRequest(value: unknown): value is UpdateDocumentRequest {
-  return (
-    isRecord(value) &&
-    hasOnlyKeys(value, ["documentName", "summary", "categoryId", "securityLevel", "visibilityType", "status", "tags", "rowVersion"]) &&
+  if (!isRecord(value)) return false;
+  const common =
     typeof value.documentName === "string" &&
     value.documentName.length > 0 &&
     isNullableString(value.summary) &&
-    isId(value.categoryId) &&
     isEnum(SecurityLevel, value.securityLevel) &&
-    isEnum(VisibilityType, value.visibilityType) &&
-    isEnum(DocumentStatus, value.status) &&
     isTags(value.tags) &&
-    isNonNegativeInteger(value.rowVersion)
-  );
+    isNonNegativeInteger(value.rowVersion);
+  if (!common) return false;
+  const mainKeys = ["documentName", "summary", "categoryId", "securityLevel", "tags", "rowVersion"];
+  const subKeys = ["documentName", "summary", "securityLevel", "tags", "rowVersion"];
+  return (hasOnlyKeys(value, mainKeys) && isId(value.categoryId)) || hasOnlyKeys(value, subKeys);
 }
 
 export function isUpdateAttachmentRequest(value: unknown): value is UpdateAttachmentRequest {

+ 2 - 0
frontend/src/features/recycle-bin/RestoreDocumentModal.tsx

@@ -5,6 +5,7 @@ import { DocumentType } from "../../shared/api/enums";
 import { notifyContentChanged } from "../../shared/state/contentRefresh";
 import { useDictionaries } from "../dictionaries/dictionaryContext";
 import { invalidateDetail } from "../documents/documentState";
+import { invalidatePreview } from "../files/fileState";
 import { recycleBinApi } from "./recycleBinApi";
 import {
   RESTORE_DESCRIPTIONS,
@@ -67,6 +68,7 @@ export function RestoreDocumentModal({
         invalidateDetail("document", item.parentDocumentId);
       }
       notifyContentChanged("documents", "attachments");
+      invalidatePreview(item.id);
       setResult(response.data);
       onRestored(response.data);
     } catch (value) {

+ 1 - 3
frontend/src/features/statistics/DocumentStatisticsCards.tsx

@@ -26,9 +26,7 @@ export function DocumentStatisticsCards() {
         style={{ background: "#fff", color: NAVY }}
       >
         <span>
-          {state.error.httpStatus === 403
-            ? "当前账号无权读取文档统计"
-            : state.error.message}
+          统计数据加载失败
           {state.error.requestId
             ? `(错误编号:${state.error.requestId})`
             : ""}

+ 0 - 1
frontend/src/shared/api/enums.ts

@@ -1,7 +1,6 @@
 export const RoleCode = {
   USER: "USER",
   ADMIN: "ADMIN",
-  AUDITOR: "AUDITOR",
 } as const;
 export type RoleCode = (typeof RoleCode)[keyof typeof RoleCode];