3 コミット c07e1bfb77 ... c7749418de

作者 SHA1 メッセージ 日付
  weisijie c7749418de 1. 实现了多角色挂载架构(契约 1.46),用户可对应多个角色(sys_user_role 多对多挂载表),登录支持角色选择,用户新增席位输入框与首长用户类型,席位随用户名展示于右上角信息面板,标题横幅后加「系统」二字; 2 週間 前
  weisijie 015af00ed9 1. 实现了附件权限细分(契约 1.41),权限矩阵新增附件上传/编辑/查看/下载四权限位(仅本部门行可设置,其他部门行服务端拒收),附件查看下载不再跟随主案权限——配置附件位即使主案无权限也可查看下载;方案锁「挂载/解除挂载」合并为单一「挂载」位(protectedActions 收敛 6 项,解挂仍按挂载位判定),迁移 0039/V25 同批并入; 3 週間 前
  weisijie 07862084d2 1. 实现了禁用用户名密码登录开关(契约 1.39,安全管控),新增 DMS_PASSWORD_LOGIN_DISABLED 配置(缺省 false),开启后登录接口对全部用户(含内置 admin)最前置返回 403「用户名密码登录已被禁用,请使用单点登录」,守卫先于验证码校验与用户查询——不消耗验证码、不泄露账号存在,门户验签与本地 SSO 代理登录成为仅存登录入口; 4 週間 前
100 ファイル変更、5939 行追加、440 行削除
  1. 90 36
      DMS_API_CONTRACT.md
  2. 41 4
      backend-java/.env.example
  3. 3 2
      backend-java/src/main/java/com/dms/api/v1/AttachmentsController.java
  4. 58 5
      backend-java/src/main/java/com/dms/api/v1/AuthController.java
  5. 83 0
      backend-java/src/main/java/com/dms/api/v1/BorrowSettingsController.java
  6. 50 0
      backend-java/src/main/java/com/dms/api/v1/LoginBrandingController.java
  7. 22 2
      backend-java/src/main/java/com/dms/api/v1/OrganizationsController.java
  8. 6 3
      backend-java/src/main/java/com/dms/api/v1/PortalAuthController.java
  9. 2 0
      backend-java/src/main/java/com/dms/api/v1/SystemController.java
  10. 7 1
      backend-java/src/main/java/com/dms/api/v1/UsersController.java
  11. 2 1
      backend-java/src/main/java/com/dms/common/DmsEnums.java
  12. 1 1
      backend-java/src/main/java/com/dms/common/Errors.java
  13. 47 2
      backend-java/src/main/java/com/dms/config/DmsProperties.java
  14. 6 0
      backend-java/src/main/java/com/dms/mapper/AttachmentBindingMapper.java
  15. 17 7
      backend-java/src/main/java/com/dms/mapper/BorrowRequestMapper.java
  16. 9 1
      backend-java/src/main/java/com/dms/mapper/DocumentMapper.java
  17. 4 2
      backend-java/src/main/java/com/dms/mapper/NotificationMapper.java
  18. 45 0
      backend-java/src/main/java/com/dms/mapper/PasswordResetRequestMapper.java
  19. 5 0
      backend-java/src/main/java/com/dms/mapper/RoleMapper.java
  20. 40 6
      backend-java/src/main/java/com/dms/mapper/RolePermissionMapper.java
  21. 51 0
      backend-java/src/main/java/com/dms/mapper/SettingMapper.java
  22. 38 41
      backend-java/src/main/java/com/dms/mapper/UserMapper.java
  23. 86 0
      backend-java/src/main/java/com/dms/mapper/UserRoleMapper.java
  24. 2 1
      backend-java/src/main/java/com/dms/model/CategoryEntity.java
  25. 29 0
      backend-java/src/main/java/com/dms/model/PasswordResetRequestEntity.java
  26. 16 0
      backend-java/src/main/java/com/dms/model/RoleMountEntity.java
  27. 12 1
      backend-java/src/main/java/com/dms/model/RolePermissionEntity.java
  28. 21 0
      backend-java/src/main/java/com/dms/model/SettingEntity.java
  29. 5 1
      backend-java/src/main/java/com/dms/model/UserEntity.java
  30. 3 1
      backend-java/src/main/java/com/dms/security/AuthContext.java
  31. 1 1
      backend-java/src/main/java/com/dms/security/AuthInterceptor.java
  32. 18 5
      backend-java/src/main/java/com/dms/security/JwtTokens.java
  33. 6 1
      backend-java/src/main/java/com/dms/security/TokenRotationInterceptor.java
  34. 5 1
      backend-java/src/main/java/com/dms/service/AccessMemo.java
  35. 29 8
      backend-java/src/main/java/com/dms/service/AdminPermission.java
  36. 54 16
      backend-java/src/main/java/com/dms/service/AttachmentQueryService.java
  37. 10 0
      backend-java/src/main/java/com/dms/service/AuditService.java
  38. 133 10
      backend-java/src/main/java/com/dms/service/AuthService.java
  39. 209 31
      backend-java/src/main/java/com/dms/service/AuthorizationService.java
  40. 26 27
      backend-java/src/main/java/com/dms/service/BorrowRequestService.java
  41. 239 0
      backend-java/src/main/java/com/dms/service/BorrowSettingsService.java
  42. 3 3
      backend-java/src/main/java/com/dms/service/CategoryService.java
  43. 23 10
      backend-java/src/main/java/com/dms/service/DocumentMutationService.java
  44. 14 11
      backend-java/src/main/java/com/dms/service/DocumentPasswordService.java
  45. 82 6
      backend-java/src/main/java/com/dms/service/DocumentQueryService.java
  46. 10 1
      backend-java/src/main/java/com/dms/service/DocumentRelationService.java
  47. 97 0
      backend-java/src/main/java/com/dms/service/LoginBrandingService.java
  48. 10 1
      backend-java/src/main/java/com/dms/service/NotificationService.java
  49. 90 1
      backend-java/src/main/java/com/dms/service/OrganizationService.java
  50. 248 0
      backend-java/src/main/java/com/dms/service/PasswordResetRequestService.java
  51. 2 2
      backend-java/src/main/java/com/dms/service/PermissionService.java
  52. 102 18
      backend-java/src/main/java/com/dms/service/PortalAuthService.java
  53. 103 0
      backend-java/src/main/java/com/dms/service/RolePasswords.java
  54. 276 68
      backend-java/src/main/java/com/dms/service/RoleService.java
  55. 240 42
      backend-java/src/main/java/com/dms/service/UserService.java
  56. 47 6
      backend-java/src/main/java/com/dms/service/UserSummaryService.java
  57. 36 10
      backend-java/src/main/java/com/dms/service/VoucherService.java
  58. 37 0
      backend-java/src/main/resources/db/migration-dm/V25__attachment_permissions_lock_merge.sql
  59. 24 0
      backend-java/src/main/resources/db/migration-dm/V26__attachment_delete_permission.sql
  60. 63 0
      backend-java/src/main/resources/db/migration-dm/V27__multi_role_seat_user_type.sql
  61. 5 0
      backend-java/src/main/resources/db/migration-dm/V28__admin_real_name.sql
  62. 80 0
      backend-java/src/main/resources/db/migration-dm/V29__matrix_top_category_kind.sql
  63. 36 0
      backend-java/src/main/resources/db/migration-dm/V30__password_reset_request.sql
  64. 18 0
      backend-java/src/main/resources/db/migration-dm/V31__role_password.sql
  65. 25 0
      backend-java/src/main/resources/db/migration-dm/V32__borrow_settings.sql
  66. 13 0
      backend-java/src/main/resources/db/migration-dm/V33__attachment_matrix_simplify.sql
  67. 33 0
      backend-java/src/main/resources/db/migration-dm/V34__notification_notify_type_check.sql
  68. 76 0
      backend-java/src/main/resources/db/migration-dm/V35__drop_enum_check_constraints.sql
  69. 19 0
      backend-java/src/main/resources/db/migration-dm/V36__user_password_hash_nullable.sql
  70. 30 0
      backend-java/src/main/resources/db/migration/V25__attachment_permissions_lock_merge.sql
  71. 18 0
      backend-java/src/main/resources/db/migration/V26__attachment_delete_permission.sql
  72. 48 0
      backend-java/src/main/resources/db/migration/V27__multi_role_seat_user_type.sql
  73. 4 0
      backend-java/src/main/resources/db/migration/V28__admin_real_name.sql
  74. 73 0
      backend-java/src/main/resources/db/migration/V29__matrix_top_category_kind.sql
  75. 28 0
      backend-java/src/main/resources/db/migration/V30__password_reset_request.sql
  76. 17 0
      backend-java/src/main/resources/db/migration/V31__role_password.sql
  77. 17 0
      backend-java/src/main/resources/db/migration/V32__borrow_settings.sql
  78. 16 0
      backend-java/src/main/resources/db/migration/V33__attachment_matrix_simplify.sql
  79. 22 0
      backend-java/src/main/resources/db/migration/V34__notification_notify_type_check.sql
  80. 153 0
      backend-java/src/main/resources/db/migration/V35__drop_enum_check_constraints.sql
  81. 7 0
      backend-java/src/main/resources/db/migration/V36__user_password_hash_nullable.sql
  82. 3 3
      backend-java/src/test/java/com/dms/api/v1/PortalAuthControllerTest.java
  83. 5 5
      backend-java/src/test/java/com/dms/security/TokenRotationInterceptorTest.java
  84. 26 15
      backend-java/src/test/java/com/dms/service/AdminLoginSwitchTest.java
  85. 17 5
      backend-java/src/test/java/com/dms/service/AdminPermissionUserScopeTest.java
  86. 336 0
      backend-java/src/test/java/com/dms/service/AdvancedSearchScopeTest.java
  87. 369 0
      backend-java/src/test/java/com/dms/service/AttachmentPermissionSplitTest.java
  88. 171 0
      backend-java/src/test/java/com/dms/service/AttachmentSecurityLevelTest.java
  89. 97 0
      backend-java/src/test/java/com/dms/service/AuthRealNameLoginTest.java
  90. 16 7
      backend-java/src/test/java/com/dms/service/AuthorizationServicePlanSplitTest.java
  91. 79 0
      backend-java/src/test/java/com/dms/service/BorrowRequestCreateGuardTest.java
  92. 170 0
      backend-java/src/test/java/com/dms/service/BorrowSettingsServiceTest.java
  93. 12 4
      backend-java/src/test/java/com/dms/service/CaptchaServiceTest.java
  94. 89 0
      backend-java/src/test/java/com/dms/service/DocumentPasswordMergeTest.java
  95. 236 0
      backend-java/src/test/java/com/dms/service/EditAccessIsolationTest.java
  96. 141 0
      backend-java/src/test/java/com/dms/service/LoginBrandingServiceTest.java
  97. 269 0
      backend-java/src/test/java/com/dms/service/MultiRoleLoginSelectionTest.java
  98. 3 2
      backend-java/src/test/java/com/dms/service/NotificationServiceTest.java
  99. 45 2
      backend-java/src/test/java/com/dms/service/OrganizationTreeScopeTest.java
  100. 79 0
      backend-java/src/test/java/com/dms/service/PasswordLoginSwitchTest.java

ファイルの差分が大きいため隠しています
+ 90 - 36
DMS_API_CONTRACT.md


+ 41 - 4
backend-java/.env.example

@@ -8,6 +8,9 @@ DMS_DATABASE_URL=dm+dmPython://DMS:change-me@127.0.0.1:5236
 # 与 Python 版并行验证共用存储时,显式指向 backend/dms-storage
 # DMS_STORAGE_ROOT=
 
+# 留空时使用内置中文UI字典;部署时可指向外部UTF-8 JSON,修改后需重启
+DMS_UI_DICTIONARY_CONFIG_PATH=
+
 # 单文件、请求总大小与批量文件数
 DMS_MAX_FILE_SIZE=52428800
 DMS_MAX_UPLOAD_SIZE=104857600
@@ -22,19 +25,46 @@ DMS_LIBREOFFICE_MAX_CONCURRENCY=2
 # JWT 密钥(与 Python 版一致时两套后端令牌互通)
 DMS_JWT_SECRET=replace-with-a-random-secret
 
+# 访问令牌有效期(秒),缺省 3600(1 小时);保持登录(7 天)不受此项影响
+DMS_JWT_EXPIRES_SECONDS=3600
+
 # 页面空闲自动登出(分钟),缺省 5:用户在页面上无鼠标/滚动/键盘等操作持续
 # 达到该时长后前端自动退出登录;设为 0 表示关闭该功能
 DMS_IDLE_LOGOUT_MINUTES=5
 
-# 登录图片验证码(缺省 true 开启,键与 Python 版一致):开启时登录页显示图片
-# 验证码输入,登录请求需携带 captchaId/captchaCode;false 关闭后回归三键请求
-DMS_CAPTCHA_ENABLED=true
+# 高级检索·提供单位机构级别,缺省 2:提供单位下拉展示机构树第 N 级机构
+# (1=一级机构,2=二级机构,依此类推),选中后按该机构及其下级过滤
+DMS_SEARCH_ORG_FILTER_LEVEL=2
+
+# 登录图片验证码(缺省 false 关闭,键与 Python 版一致):开启时登录页显示图片
+# 验证码输入,登录请求需携带 captchaId/captchaCode;关闭后回归三键请求体
+DMS_CAPTCHA_ENABLED=false
 
 # 超级管理员登录开关(缺省 true 允许,键与 Python 版一致):设为 false 后内置
 # admin 禁止登录(报错与用户名或密码错误完全一致)、用户列表隐藏 admin、创建
 # 同名用户报"不能创建该名称的用户",外部无法感知系统中存在该账号
 DMS_ALLOW_ADMIN_LOGIN=true
 
+# ─────────────────────────────────────────────────────────────
+# 禁用用户名密码登录(安全管控)
+# ─────────────────────────────────────────────────────────────
+# 缺省 false。设为 true 后仅允许单点登录(门户跳转验签、本地 SSO 代理)进入系统:
+# POST /auth/login 一律 403「用户名密码登录已被禁用」(先于验证码校验与用户查询,
+# 不消耗验证码、不泄露账号是否存在),登录页隐藏账号密码表单仅显示单点登录提示。
+# 注意:开启前请确认门户(DMS_PORTAL_*)或本地 SSO(DMS_SSO_PROXY_ENABLED=true)
+# 至少一项可用,否则将无人能登录系统(需改回 .env 后重启方可恢复)
+DMS_PASSWORD_LOGIN_DISABLED=false
+
+# 登录界面重置密码审批流(契约1.49)
+# ─────────────────────────────────────────────────────────────
+# 登录页(含单点登录的独立密码界面)可自助提交重置密码申请,按用户类型路由
+# 审批角色;审批人在通知页待办处理(先到先得),通过后该用户密码清空、
+# 回首次登录态(经单点登录进入时重新设置独立密码)。
+# 两个键的值均为「角色名」:该角色须存在且启用、且至少有一名活跃挂载用户,
+# 否则提交申请时报错;首长密码不可再由系统管理员在用户管理中直接重置。
+# DMS_RESET_PASSWORD_APPROVER_ROLE_COMMANDER=安全管理员
+# DMS_RESET_PASSWORD_APPROVER_ROLE_NORMAL=系统管理员
+
 # 一次一码轮换(安全需求:写操作防重放,缺省 true 开启,当前仅 Java 版实现):
 # 开启后 POST/PUT/DELETE 写操作成功响应经 X-DMS-Access-Token 头返回轮换出的新
 # 授权码,旧码对写操作立即失效(401 SESSION_ROTATED);GET 读请求沿用原码不轮换。
@@ -49,6 +79,13 @@ DMS_SYSTEM_MONITOR_ENABLED=false
 # rsync,再发行目录 tools/rsync/ 内置版(Windows 发行包已随包携带 cwRsync)
 # DMS_RSYNC_EXECUTABLE=
 
+# 应用层信封加密 KEK(契约1.21):base64 编码的 32 字节密钥。
+# 配置后 original/ 新落盘文件以 AES-256-GCM(KEK/DEK)加密存储,读路径自动解密;
+# 留空=明文落盘(存量文件始终可读,不回迁加密)。
+# 生成示例:python -c "import secrets,base64;print(base64.b64encode(secrets.token_bytes(32)).decode())"
+# 注意:密钥丢失将导致已加密文件无法读取,请妥善备份;变更密钥前需先用旧密钥回迁解密。
+DMS_STORAGE_KEK=
+
 # CORS 跨域白名单(逗号分隔 Origin);默认留空=仅同源访问(推荐,前端由本服务托管)。
 # 仅开发分域联调时配置,例如 DMS_CORS_ORIGINS=http://localhost:5173
 DMS_CORS_ORIGINS=
@@ -76,7 +113,7 @@ DMS_PORTAL_HTTP_TIMEOUT_SECONDS=10
 # 本地单点登录代理(需求2,仅Java版,契约1.25;详见 docs/门户对接指南.md 场景D)
 # 开关缺省 false;开启后前端未登录时自动探测 proxy-verify,成功进入门户双阶段流程
 # ─────────────────────────────────────────────────────────────
-DMS_SSO_PROXY_ENABLED=true
+DMS_SSO_PROXY_ENABLED=false
 DMS_SSO_PROXY_HOST=127.0.0.1
 DMS_SSO_PROXY_PORT=8080
 DMS_SSO_USER_SERVICE_HOST=127.0.0.1

+ 3 - 2
backend-java/src/main/java/com/dms/api/v1/AttachmentsController.java

@@ -43,7 +43,8 @@ public class AttachmentsController {
                 Payloads.queryParams(request)));
     }
 
-    @AuthRequired(requireAdmin = true)
+    /** 契约1.41:上传鉴权收敛到服务内(ADMIN 恒可 + USER 矩阵附件上传位)。 */
+    @AuthRequired
     @PostMapping
     public ResponseEntity<ApiResponse<Object>> create(HttpServletRequest request) {
         DocumentUploads.UploadParts parts = documentUploads.metadata(request);
@@ -52,7 +53,7 @@ public class AttachmentsController {
                         parts.file(), parts.metadata(), false, request)));
     }
 
-    @AuthRequired(requireAdmin = true)
+    @AuthRequired
     @PostMapping("/batch-import")
     public ApiResponse<Object> batchImport(HttpServletRequest request) {
         return ApiResponse.ok(documentUploads.batch(request, true));

+ 58 - 5
backend-java/src/main/java/com/dms/api/v1/AuthController.java

@@ -2,9 +2,11 @@ package com.dms.api.v1;
 
 import com.dms.common.ApiResponse;
 import com.dms.common.Errors;
+import com.dms.config.DmsProperties;
 import com.dms.security.AuthRequired;
 import com.dms.service.AuthService;
 import com.dms.service.CaptchaService;
+import com.dms.service.PasswordResetRequestService;
 import jakarta.servlet.http.HttpServletRequest;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -18,6 +20,8 @@ import org.springframework.web.bind.annotation.RestController;
 /**
  * 登录与注销接口,对齐 backend/dms/api/v1/auth.py(严格请求体键集校验;
  * 验证码开启时登录扩为五键,安全需求4)。
+ * 多角色选择(契约1.46):可选 roleId 键——挂载多个启用角色的首轮登录不带,
+ * 返回角色列表后第二轮携带选定 roleId 重新提交。
  */
 @RestController
 @RequestMapping("/api/v1/auth")
@@ -26,19 +30,28 @@ public class AuthController {
     private static final Set<String> LOGIN_FIELDS = Set.of("username", "password", "keepSignedIn");
     private static final Set<String> LOGIN_FIELDS_CAPTCHA = Set.of(
             "username", "password", "keepSignedIn", "captchaId", "captchaCode");
+    /** 登录请求可选键(契约1.46 多角色第二阶段提交)。 */
+    private static final Set<String> LOGIN_OPTIONAL_FIELDS = Set.of("roleId");
 
     private final AuthService authService;
     private final CaptchaService captchaService;
+    private final DmsProperties dmsProperties;
+    private final PasswordResetRequestService passwordResetRequestService;
 
-    public AuthController(AuthService authService, CaptchaService captchaService) {
+    public AuthController(AuthService authService, CaptchaService captchaService,
+                          DmsProperties dmsProperties,
+                          PasswordResetRequestService passwordResetRequestService) {
         this.authService = authService;
         this.captchaService = captchaService;
+        this.dmsProperties = dmsProperties;
+        this.passwordResetRequestService = passwordResetRequestService;
     }
 
     /** 登录图片验证码(安全需求4):开启时返回 data URL 图片,关闭时仅返回 enabled=false。 */
     @GetMapping("/captcha")
     public ApiResponse<Object> captcha() {
         Map<String, Object> data = new LinkedHashMap<>();
+        data.put("passwordLoginDisabled", dmsProperties.passwordLoginDisabled());
         if (!captchaService.enabled()) {
             data.put("enabled", false);
             return ApiResponse.ok(data);
@@ -48,6 +61,30 @@ public class AuthController {
         return ApiResponse.ok(data);
     }
 
+    /**
+     * 重置密码申请(契约1.49,登录前公开接口):验证码开启时需 captchaId/captchaCode;
+     * 按申请用户类型路由审批角色(首长→安全管理员、其他→系统管理员,可配置),
+     * 审批人在通知页待办处理,通过后该用户回首次登录态重新设置密码。
+     */
+    @PostMapping("/reset-password-requests")
+    public ApiResponse<Object> createResetPasswordRequest(
+            @RequestBody(required = false) Map<String, Object> body,
+            HttpServletRequest request) {
+        boolean captchaEnabled = captchaService.enabled();
+        Set<String> fields = captchaEnabled
+                ? Set.of("username", "captchaId", "captchaCode")
+                : Set.of("username");
+        if (body == null || !body.keySet().equals(fields)) {
+            throw Errors.invalidArgument("请求体必须且只能包含username"
+                    + (captchaEnabled ? "、captchaId和captchaCode" : ""));
+        }
+        captchaService.verify(
+                body.get("captchaId") instanceof String id ? id : null,
+                body.get("captchaCode") instanceof String code ? code : null);
+        return ApiResponse.ok(
+                passwordResetRequestService.createRequest(body, request));
+    }
+
     @PostMapping("/login")
     public ApiResponse<Object> login(@RequestBody(required = false) Map<String, Object> body,
                                      HttpServletRequest request) {
@@ -55,10 +92,16 @@ public class AuthController {
         if (body == null) {
             throw Errors.invalidArgument("请求体必须是JSON对象");
         }
-        if (!captchaEnabled ? !LOGIN_FIELDS.equals(body.keySet())
-                : !LOGIN_FIELDS_CAPTCHA.equals(body.keySet())) {
+        Set<String> requiredFields = captchaEnabled ? LOGIN_FIELDS_CAPTCHA : LOGIN_FIELDS;
+        if (!body.keySet().containsAll(requiredFields)) {
             throw Errors.invalidArgument("请求体必须且只能包含username、password和keepSignedIn"
-                    + (captchaEnabled ? "、captchaId和captchaCode" : ""));
+                    + (captchaEnabled ? "、captchaId和captchaCode" : "") + "(可选roleId)");
+        }
+        for (String key : body.keySet()) {
+            if (!requiredFields.contains(key) && !LOGIN_OPTIONAL_FIELDS.contains(key)) {
+                throw Errors.invalidArgument("请求体必须且只能包含username、password和keepSignedIn"
+                        + (captchaEnabled ? "、captchaId和captchaCode" : "") + "(可选roleId)");
+            }
         }
         if (!(body.get("username") instanceof String username) || username.strip().isEmpty()) {
             throw Errors.invalidArgument("username不能为空");
@@ -81,8 +124,18 @@ public class AuthController {
             captchaId = id.strip();
             captchaCode = code;
         }
+        // 多角色选定角色(契约1.46):null/空串=未选定;须为数字字符串
+        Object rawRoleId = body.get("roleId");
+        String roleIdText = null;
+        if (rawRoleId != null) {
+            if (!(rawRoleId instanceof String text) || !text.matches("\\d+")) {
+                throw Errors.invalidArgument("roleId必须是数字字符串");
+            }
+            roleIdText = text;
+        }
         return ApiResponse.ok(authService.login(
-                username.strip(), password, keepSignedIn, captchaId, captchaCode, request));
+                username.strip(), password, keepSignedIn, captchaId, captchaCode,
+                roleIdText, request));
     }
 
     @AuthRequired

+ 83 - 0
backend-java/src/main/java/com/dms/api/v1/BorrowSettingsController.java

@@ -0,0 +1,83 @@
+package com.dms.api.v1;
+
+import com.dms.common.ApiResponse;
+import com.dms.common.DmsException;
+import com.dms.common.Errors;
+import com.dms.service.BorrowSettingsService;
+import jakarta.servlet.http.HttpServletRequest;
+import java.util.Map;
+import java.util.Set;
+import org.springframework.core.io.Resource;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.multipart.MultipartHttpServletRequest;
+import org.springframework.web.util.WebUtils;
+
+/**
+ * 审批相关设置接口(契约1.54):调阅说明与调阅审批单模板。
+ *
+ * <p>读取与模板下载放开给全部登录用户(调阅对话框展示说明、下载模板
+ * 不限管理位);说明保存与模板上传/清除要求系统管理权限位
+ * (manage_system),维护操作记审计(BORROW_SETTINGS_UPDATE)。</p>
+ */
+@RestController
+@RequestMapping("/api/v1/borrow-settings")
+public class BorrowSettingsController {
+
+    private final BorrowSettingsService service;
+
+    public BorrowSettingsController(BorrowSettingsService service) {
+        this.service = service;
+    }
+
+    /** 设置读取:调阅说明(null=未设置)与模板文件名(null=未上传)。 */
+    @GetMapping
+    public ApiResponse<Object> settings() {
+        return ApiResponse.ok(service.settings());
+    }
+
+    /** 保存调阅说明:请求体 {notice},notice 为 null/空白即清空。 */
+    @com.dms.security.AuthRequired(manageSystem = true)
+    @PutMapping
+    public ApiResponse<Object> updateNotice(@RequestBody(required = false) Map<String, Object> body,
+                                            HttpServletRequest request) {
+        if (body == null || !Set.of("notice").equals(body.keySet())) {
+            throw Errors.invalidArgument("请求体必须且只能包含notice");
+        }
+        return ApiResponse.ok(service.updateNotice(body.get("notice"), request));
+    }
+
+    /** 上传调阅审批单模板(multipart file=PDF,覆盖替换)。 */
+    @com.dms.security.AuthRequired(manageSystem = true)
+    @PostMapping("/template")
+    public ApiResponse<Object> uploadTemplate(HttpServletRequest request) {
+        MultipartHttpServletRequest multipart =
+                WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class);
+        if (multipart == null || multipart.getFile("file") == null) {
+            throw Errors.invalidArgument("请求必须包含file");
+        }
+        MultipartFile file = multipart.getFile("file");
+        return ApiResponse.ok(service.uploadTemplate(file, request));
+    }
+
+    /** 清除调阅审批单模板(删文件与元数据)。 */
+    @com.dms.security.AuthRequired(manageSystem = true)
+    @DeleteMapping("/template")
+    public ApiResponse<Object> deleteTemplate(HttpServletRequest request) {
+        return ApiResponse.ok(service.deleteTemplate(request));
+    }
+
+    /** 模板下载(PDF attachment 直出,未上传 404)。 */
+    @com.dms.security.AuthRequired
+    @GetMapping("/template")
+    public ResponseEntity<Resource> downloadTemplate() {
+        return service.downloadTemplate();
+    }
+}

+ 50 - 0
backend-java/src/main/java/com/dms/api/v1/LoginBrandingController.java

@@ -0,0 +1,50 @@
+package com.dms.api.v1;
+
+import com.dms.common.ApiResponse;
+import com.dms.common.Errors;
+import com.dms.service.LoginBrandingService;
+import jakarta.servlet.http.HttpServletRequest;
+import java.util.Map;
+import java.util.Set;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 登录页文案设置接口(契约1.62):大标题与底部小字。
+ *
+ * <p>读取公开(登录前无凭证访问,登录页与登录背景图同模式的运行时
+ * 配置);保存要求系统管理权限位(manage_system),记审计
+ * (LOGIN_BRANDING_UPDATE)。未设置时响应值为 null,前端回落内置
+ * 默认文案。</p>
+ */
+@RestController
+@RequestMapping("/api/v1/config/login-branding")
+public class LoginBrandingController {
+
+    private final LoginBrandingService service;
+
+    public LoginBrandingController(LoginBrandingService service) {
+        this.service = service;
+    }
+
+    /** 文案读取(公开):{title, footerText},null=未设置。 */
+    @GetMapping
+    public ApiResponse<Object> branding() {
+        return ApiResponse.ok(service.branding());
+    }
+
+    /** 保存文案:请求体 {title, footerText},null/空白即恢复默认。 */
+    @com.dms.security.AuthRequired(manageSystem = true)
+    @PutMapping
+    public ApiResponse<Object> updateBranding(@RequestBody(required = false) Map<String, Object> body,
+                                              HttpServletRequest request) {
+        if (body == null || !Set.of("title", "footerText").equals(body.keySet())) {
+            throw Errors.invalidArgument("请求体必须且只能包含title与footerText");
+        }
+        return ApiResponse.ok(service.updateBranding(body.get("title"),
+                body.get("footerText"), request));
+    }
+}

+ 22 - 2
backend-java/src/main/java/com/dms/api/v1/OrganizationsController.java

@@ -25,14 +25,34 @@ public class OrganizationsController {
 
     private final OrganizationService organizationService;
     private final IpEntryService ipEntryService;
+    private final com.dms.config.DmsProperties properties;
 
     public OrganizationsController(OrganizationService organizationService,
-                                   IpEntryService ipEntryService) {
+                                   IpEntryService ipEntryService,
+                                   com.dms.config.DmsProperties properties) {
         this.organizationService = organizationService;
         this.ipEntryService = ipEntryService;
+        this.properties = properties;
     }
 
-    @AuthRequired(requireAdmin = true)
+    /**
+     * 高级检索·提供单位选项(契约1.55):机构树第 N 级启用机构清单,
+     * N 由 DMS_SEARCH_ORG_FILTER_LEVEL 配置(缺省 2,二级机构);
+     * 任意登录用户可调。
+     */
+    @AuthRequired
+    @GetMapping("/filter-options")
+    public ApiResponse<Object> filterOptions() {
+        return ApiResponse.ok(organizationService.filterOptions(
+                properties.searchOrgFilterLevel()));
+    }
+
+    /**
+     * 机构树(契约1.45):只读接口不再要求ADMIN类别,任意登录用户可调——
+     * 普通用户(USER)返回本人部门为根的子树(无部门空树),ADMIN类别
+     * 维持既有管理范围裁剪。
+     */
+    @AuthRequired
     @GetMapping("/tree")
     public ApiResponse<Object> tree(@RequestParam(required = false) String keyword,
                                     @RequestParam(required = false) String status) {

+ 6 - 3
backend-java/src/main/java/com/dms/api/v1/PortalAuthController.java

@@ -59,10 +59,12 @@ public class PortalAuthController {
             throw Errors.invalidArgument("请求体必须是JSON对象");
         }
         Set<String> required = Set.of("portalTicket", "newPassword", "keepSignedIn");
-        Set<String> allowed = Set.of("portalTicket", "newPassword", "keepSignedIn", "organizationId");
+        Set<String> allowed = Set.of("portalTicket", "newPassword", "keepSignedIn",
+                "organizationId", "rolePasswords");
         if (!body.keySet().containsAll(required) || !allowed.containsAll(body.keySet())) {
             throw Errors.invalidArgument(
-                    "请求体必须包含portalTicket、newPassword、keepSignedIn(organizationId可选)");
+                    "请求体必须包含portalTicket、newPassword、keepSignedIn"
+                    + "(organizationId、rolePasswords可选)");
         }
         if (!(body.get("portalTicket") instanceof String ticket) || ticket.strip().isEmpty()) {
             throw Errors.invalidArgument("portalTicket不能为空");
@@ -83,7 +85,8 @@ public class PortalAuthController {
         try {
             return ApiResponse.ok(
                     portalAuthService.setupPortalPassword(ticket.strip(), password,
-                            keepSignedIn, organizationId, request));
+                            keepSignedIn, organizationId,
+                            body.get("rolePasswords"), request));
         } catch (DmsException ex) {
             if ("LOGIN_IP_FORBIDDEN".equals(ex.code())) {
                 auditService.recordLoginFailure(request, null, "(portal-setup)",

+ 2 - 0
backend-java/src/main/java/com/dms/api/v1/SystemController.java

@@ -155,6 +155,8 @@ public class SystemController {
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("userId", String.valueOf(numericId));
         result.put("username", user.getUsername());
+        // 用户名与姓名同语义(契约1.47):提示语展示姓名
+        result.put("realName", user.getRealName());
         result.put("authVersion", authVersion);
         auditService.insert(auditService.businessAudit(request,
                 AuditService.Action.FORCE_OFFLINE, "USER", numericId,

+ 7 - 1
backend-java/src/main/java/com/dms/api/v1/UsersController.java

@@ -58,7 +58,13 @@ public class UsersController {
         return ApiResponse.ok(userService.updateUiTheme(body));
     }
 
-    @AuthRequired(requireAdmin = true)
+    /**
+     * 分页查询人员(契约1.45):只读接口不再要求ADMIN类别,任意登录用户
+     * 可调——普通用户(USER)不传机构时静默收敛到本人部门子树(无部门
+     * 空页),显式请求机构不在本人部门子树返回403「无权限查看该机构的
+     * 用户」(与锚点管理员同口径);ADMIN类别维持既有管理范围收敛。
+     */
+    @AuthRequired
     @GetMapping
     public ApiResponse<Object> list(@RequestParam(required = false) String organizationId,
                                     @RequestParam(required = false) String includeDescendants,

+ 2 - 1
backend-java/src/main/java/com/dms/common/DmsEnums.java

@@ -60,7 +60,8 @@ public final class DmsEnums {
             "UPLOAD_PERMISSION_VOUCHER", "DELETE_PERMISSION_VOUCHER", "BACKUP_CREATE",
             "PREVIEW_CACHE_CLEAR", "FORCE_OFFLINE", "BACKUP_TEST_TARGET",
             "REORDER_ORGANIZATION", "REORDER_USER", "REORDER_ROLE",
-            "BORROW_REQUEST_CREATE", "BORROW_REQUEST_APPROVE", "BORROW_REQUEST_REJECT");
+            "BORROW_REQUEST_CREATE", "BORROW_REQUEST_APPROVE", "BORROW_REQUEST_REJECT",
+            "BORROW_SETTINGS_UPDATE");
 
     public static final Set<String> AUDIT_TARGETS = Set.of(
             "AUTH", "CATEGORY", "DOCUMENT", "ATTACHMENT", "PERMISSION",

+ 1 - 1
backend-java/src/main/java/com/dms/common/Errors.java

@@ -132,7 +132,7 @@ public final class Errors {
     /** Python: CategoryFlatModeConflictError (409)。 */
     public static DmsException categoryFlatModeConflict() {
         return new DmsException("CATEGORY_FLAT_MODE_CONFLICT", 409,
-                "该一级分类下已存在子方案或配套资料挂载,请先解绑后再切换为扁平模式");
+                "该一级分类下已存在共享资料挂载,请先解绑后再开启仅主案模式");
     }
 
     /** Python: MainPlanNotFoundError (404)。 */

+ 47 - 2
backend-java/src/main/java/com/dms/config/DmsProperties.java

@@ -28,6 +28,8 @@ public class DmsProperties {
     private final long jwtExpiresSeconds;
     /** 页面空闲自动登出(契约1.29):分钟数,0 表示关闭。 */
     private final int idleLogoutMinutes;
+    /** 高级检索·提供单位机构级别(契约1.55):选项取机构树第 N 级。 */
+    private final int searchOrgFilterLevel;
     /** 契约1.21:信封加密 KEK(base64 编码 32 字节),未配置为 null(明文落盘)。 */
     private final byte[] storageKek;
     private final long maxUploadSize;
@@ -50,6 +52,8 @@ public class DmsProperties {
     private final boolean captchaEnabled;
     /** 超级管理员登录开关(安全需求7):false 时内置admin禁止登录、列表隐藏、创建伪报错。 */
     private final boolean adminLoginAllowed;
+    /** 禁用用户名密码登录(安全管控):true 时仅允许单点登录(门户验签/本地SSO代理)。 */
+    private final boolean passwordLoginDisabled;
     /** 一次一码轮换(安全需求):写操作成功后轮换授权码并经响应头返回,旧码写操作即拒;
      *  关闭时登录不落会话代(存量会话自然过渡),缺省启用。 */
     private final boolean tokenRotationEnabled;
@@ -60,6 +64,10 @@ public class DmsProperties {
     private final String ssoUserServiceHost;
     private final int ssoUserServicePort;
     private final int ssoTimeoutSeconds;
+    /** 重置密码审批角色(契约1.49):首长用户审批角色名,缺省「安全管理员」。 */
+    private final String resetPasswordApproverRoleCommander;
+    /** 重置密码审批角色(契约1.49):其他用户审批角色名,缺省「系统管理员」。 */
+    private final String resetPasswordApproverRoleNormal;
 
     public DmsProperties(Environment environment) {
         this.databaseUrl = environment.getProperty("DMS_DATABASE_URL",
@@ -75,6 +83,9 @@ public class DmsProperties {
         // 对齐 config.py(契约1.29):DMS_IDLE_LOGOUT_MINUTES 缺省 5,0 表示关闭。
         this.idleLogoutMinutes =
                 (int) nonNegativeLong(environment, "DMS_IDLE_LOGOUT_MINUTES", 5L);
+        // 契约1.55:高级检索·提供单位下拉取机构树第 N 级,缺省 2(二级机构)。
+        this.searchOrgFilterLevel =
+                (int) positiveLong(environment, "DMS_SEARCH_ORG_FILTER_LEVEL", 2L);
         // 对齐 config.py load_kek:DMS_STORAGE_KEK 为 base64 编码的 32 字节密钥,
         // 留空=不明文加密;非法值启动即失败。
         this.storageKek = decodeStorageKek(
@@ -144,9 +155,9 @@ public class DmsProperties {
                 "DMS_SYSTEM_MONITOR_ENABLED", "false").trim().toLowerCase(Locale.ROOT);
         this.systemMonitorEnabled =
                 Set.of("1", "true", "yes", "on").contains(monitorValue);
-        // 对齐 config.py(安全需求4):登录图片验证码开关,缺省 true。
+        // 对齐 config.py(安全需求4):登录图片验证码开关,缺省 false(1.40 起默认关闭)。
         String captchaValue = environment.getProperty(
-                "DMS_CAPTCHA_ENABLED", "true").trim().toLowerCase(Locale.ROOT);
+                "DMS_CAPTCHA_ENABLED", "false").trim().toLowerCase(Locale.ROOT);
         this.captchaEnabled =
                 Set.of("1", "true", "yes", "on").contains(captchaValue);
         // 对齐 config.py(安全需求7):超级管理员登录开关,缺省 true。
@@ -154,6 +165,11 @@ public class DmsProperties {
                 "DMS_ALLOW_ADMIN_LOGIN", "true").trim().toLowerCase(Locale.ROOT);
         this.adminLoginAllowed =
                 Set.of("1", "true", "yes", "on").contains(adminLoginValue);
+        // 安全管控需求:禁用用户名密码登录开关,缺省 false;开启后仅单点登录可入。
+        String passwordLoginValue = environment.getProperty(
+                "DMS_PASSWORD_LOGIN_DISABLED", "false").trim().toLowerCase(Locale.ROOT);
+        this.passwordLoginDisabled =
+                Set.of("1", "true", "yes", "on").contains(passwordLoginValue);
         // 安全需求:一次一码轮换开关,缺省 true;关闭时写操作不比对、不轮换、不落会话代。
         String rotationValue = environment.getProperty(
                 "DMS_TOKEN_ROTATION_ENABLED", "true").trim().toLowerCase(Locale.ROOT);
@@ -177,6 +193,16 @@ public class DmsProperties {
                 (int) positiveLong(environment, "DMS_SSO_USER_SERVICE_PORT", 8800L);
         this.ssoTimeoutSeconds =
                 (int) positiveLong(environment, "DMS_SSO_TIMEOUT_SECONDS", 10L);
+        // 契约1.49:登录界面重置密码审批流,按用户类型路由审批角色;
+        // 值为角色名,该角色须存在且有人挂载,否则提交申请时报错。
+        String commanderRole = environment.getProperty(
+                "DMS_RESET_PASSWORD_APPROVER_ROLE_COMMANDER", "").trim();
+        this.resetPasswordApproverRoleCommander =
+                commanderRole.isEmpty() ? "安全管理员" : commanderRole;
+        String normalRole = environment.getProperty(
+                "DMS_RESET_PASSWORD_APPROVER_ROLE_NORMAL", "").trim();
+        this.resetPasswordApproverRoleNormal =
+                normalRole.isEmpty() ? "系统管理员" : normalRole;
     }
 
     /**
@@ -301,6 +327,10 @@ public class DmsProperties {
         return idleLogoutMinutes;
     }
 
+    public int searchOrgFilterLevel() {
+        return searchOrgFilterLevel;
+    }
+
     /** 信封加密 KEK;未配置返回 null(明文落盘)。 */
     public byte[] storageKek() {
         return storageKek;
@@ -366,6 +396,11 @@ public class DmsProperties {
         return adminLoginAllowed;
     }
 
+    /** 禁用用户名密码登录开关(安全管控);未配置默认不禁用。 */
+    public boolean passwordLoginDisabled() {
+        return passwordLoginDisabled;
+    }
+
     /** 一次一码轮换开关(安全需求);未配置默认启用。 */
     public boolean tokenRotationEnabled() {
         return tokenRotationEnabled;
@@ -396,6 +431,16 @@ public class DmsProperties {
         return ssoTimeoutSeconds;
     }
 
+    /** 首长用户重置密码审批角色名(契约1.49)。 */
+    public String resetPasswordApproverRoleCommander() {
+        return resetPasswordApproverRoleCommander;
+    }
+
+    /** 其他用户重置密码审批角色名(契约1.49)。 */
+    public String resetPasswordApproverRoleNormal() {
+        return resetPasswordApproverRoleNormal;
+    }
+
     /**
      * 将 Python 版 SQLAlchemy 连接串转换为 JDBC URL,保证同一份 .env 两版后端可用。
      *

+ 6 - 0
backend-java/src/main/java/com/dms/mapper/AttachmentBindingMapper.java

@@ -129,6 +129,12 @@ public interface AttachmentBindingMapper {
     long countActiveConflicts(@Param("mainDocumentId") long mainDocumentId,
                               @Param("attachmentIds") List<Long> attachmentIds);
 
+    /** 附件的活跃挂载主案 id 列表(无锁,契约1.48 附件矩阵按挂载主案一级分类判定)。 */
+    @Select("SELECT main_document_id FROM doc_attachment_binding "
+            + "WHERE attachment_document_id = #{attachmentDocumentId} AND is_deleted = 0")
+    List<Long> listActiveMainDocumentIds(
+            @Param("attachmentDocumentId") long attachmentDocumentId);
+
     /** 附件恢复:活跃挂载锁定(存在即冲突)。 */
     @Select("SELECT * FROM doc_attachment_binding WHERE attachment_document_id = #{attachmentDocumentId} "
             + "AND is_deleted = 0 ORDER BY id ASC FOR UPDATE")

+ 17 - 7
backend-java/src/main/java/com/dms/mapper/BorrowRequestMapper.java

@@ -65,17 +65,27 @@ public interface BorrowRequestMapper {
                       @Param("handledAt") LocalDateTime handledAt);
 
     /**
-     * 审批人反查:所有对该文档有授权权限的用户(契约1.37)。
+     * 审批人反查:所有对该文档有授权权限的用户(契约1.37;1.61 修复多角色反查)。
      * 口径对齐 AuthorizationService.matrixActions:生效角色 × 文档密级 × OWN/OTHER
-     * 矩阵行 can_config_permission=1;OWN=文档机构在用户机构子树内(org_path 前缀,
-     * 恒以 / 结尾),机构缺失按 OTHER;密级秩不足的用户排除(与 requireSecurityClearance
-     * 前置一致);剔除申请人本人。内置超管由服务层补充(恒有授权权限)。
+     * 矩阵行 can_config_permission=1(契约1.48 起该位仅 MAIN 行有效);
+     * 生效角色按挂载真源 sys_user_role 解析——修复:原 SQL join 已停用的
+     * sys_user.role_id 列(契约1.46 起读写全部改走关联表),导致关联表挂载
+     * 的用户(如新配的管理员)收不到待办;无任何挂载时回退默认角色
+     * (对齐 effectiveRole);OWN=文档机构在用户机构子树内(org_path 前缀,
+     * 恒以 / 结尾),机构缺失按 OTHER;密级秩不足的用户排除(与
+     * requireSecurityClearance 前置一致);剔除申请人本人。通知面按密级×
+     * 部门范围取(不含一级分类维度,偏宽),审批处理时运行时鉴权按文档
+     * 精确判定,越面待办自然拒绝;内置超管由服务层补充(恒有授权权限)。
      */
     @Select("SELECT DISTINCT u.id, u.username, u.real_name FROM sys_user u "
-            + "JOIN sys_role r ON r.id = u.role_id AND r.is_deleted = 0 "
-            + "AND r.status = 'ENABLED' "
-            + "JOIN sys_role_permission rp ON rp.role_id = u.role_id AND rp.is_deleted = 0 "
+            + "JOIN sys_role r ON r.is_deleted = 0 AND r.status = 'ENABLED' "
+            + "AND (r.id IN (SELECT ur.role_id FROM sys_user_role ur "
+            + "WHERE ur.user_id = u.id) "
+            + "OR (r.is_default = 1 AND NOT EXISTS (SELECT 1 FROM sys_user_role ur2 "
+            + "WHERE ur2.user_id = u.id))) "
+            + "JOIN sys_role_permission rp ON rp.role_id = r.id AND rp.is_deleted = 0 "
             + "AND rp.security_level = #{securityLevel} AND rp.can_config_permission = 1 "
+            + "AND rp.doc_kind = 'MAIN' "
             + "LEFT JOIN sys_organization uo ON uo.id = u.organization_id "
             + "AND uo.is_deleted = 0 "
             + "WHERE u.is_deleted = 0 AND u.status = 'ENABLED' AND u.role_id IS NOT NULL "

+ 9 - 1
backend-java/src/main/java/com/dms/mapper/DocumentMapper.java

@@ -100,6 +100,13 @@ public interface DocumentMapper {
             // 正文关键词严格匹配 content_text:search_text 含名称/概述/标签兜底词,
             // OR 匹配会把正文解析失败但名称撞词的行错误召回(对齐 Python _advanced_filters)。
             + "<if test='contentPattern != null'>AND content_text LIKE #{contentPattern} ESCAPE '\\'</if>"
+            // 高级检索·数据内容=全部(契约1.55):标题或正文任一命中
+            + "<if test='titleOrContentPattern != null'>AND (document_name LIKE #{titleOrContentPattern} ESCAPE '\\' "
+            + "OR content_text LIKE #{titleOrContentPattern} ESCAPE '\\')</if>"
+            // 高级检索·提供单位(契约1.55):按机构 ID 精确过滤(服务层展开子树)
+            + "<if test='organizationIds != null'>AND organization_id IN "
+            + "<foreach collection='organizationIds' item='o' open='(' separator=',' close=')'>"
+            + "#{o}</foreach></if>"
             + "<if test='securityLevels != null'>AND security_level IN "
             + "<foreach collection='securityLevels' item='l' open='(' separator=',' close=')'>"
             + "#{l}</foreach></if>"
@@ -258,9 +265,10 @@ public interface DocumentMapper {
     int updateFileRelativePath(@Param("id") long id,
                                @Param("fileRelativePath") String fileRelativePath);
 
-    /** 附件编辑:元数据字段 + search_text + rv+1(对齐 update_attachment)。 */
+    /** 附件编辑:元数据字段(含密级) + search_text + rv+1(对齐 update_attachment)。 */
     @Update("UPDATE doc_document SET document_name = #{documentName}, "
             + "attachment_type = #{attachmentType}, summary = #{summary}, tags = #{tags}, "
+            + "security_level = #{securityLevel}, "
             + "organization_id = #{organizationId}, organization_name = #{organizationName}, "
             + "search_text = #{searchText}, updated_by = #{updatedBy}, "
             + "updated_by_name = #{updatedByName}, updated_at = #{updatedAt}, "

+ 4 - 2
backend-java/src/main/java/com/dms/mapper/NotificationMapper.java

@@ -47,9 +47,11 @@ public interface NotificationMapper {
      * 并置已读(排除本次处理的通知行),前端原位显示「已同意/已拒绝」徽标。
      */
     @Update("UPDATE sys_notification SET action = #{action}, status = 'READ', "
-            + "handled_at = #{handledAt} WHERE biz_id = #{bizId} AND notify_type = 'APPROVAL' "
+            + "handled_at = #{handledAt} WHERE biz_id = #{bizId} AND notify_type = #{notifyType} "
             + "AND action = 'PENDING' AND id != #{excludeId}")
-    int settlePendingByBiz(@Param("bizId") long bizId, @Param("excludeId") long excludeId,
+    int settlePendingByBiz(@Param("bizId") long bizId,
+                           @Param("notifyType") String notifyType,
+                           @Param("excludeId") long excludeId,
                            @Param("action") String action,
                            @Param("handledAt") LocalDateTime handledAt);
 }

+ 45 - 0
backend-java/src/main/java/com/dms/mapper/PasswordResetRequestMapper.java

@@ -0,0 +1,45 @@
+package com.dms.mapper;
+
+import com.dms.model.PasswordResetRequestEntity;
+import java.time.LocalDateTime;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Options;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+/**
+ * 重置密码申请单表访问(V30迁移,契约1.49):申请落库、待审批防重、
+ * 先到先得状态流转(PENDING 行锁守卫 + CAS 置位)。
+ */
+public interface PasswordResetRequestMapper {
+
+    String COLUMNS = "id, user_id, username, real_name, user_type, approver_role_name, "
+            + "status, request_ip, requested_at, handled_by, handled_by_name, "
+            + "handled_at, created_at";
+
+    @Insert("INSERT INTO sys_password_reset_request (user_id, username, real_name, "
+            + "user_type, approver_role_name, status, request_ip, requested_at) "
+            + "VALUES (#{r.userId}, #{r.username}, #{r.realName}, #{r.userType}, "
+            + "#{r.approverRoleName}, #{r.status}, #{r.requestIp}, #{r.requestedAt})")
+    @Options(useGeneratedKeys = true, keyProperty = "r.id")
+    int insertRequest(@Param("r") PasswordResetRequestEntity request);
+
+    @Select("SELECT " + COLUMNS + " FROM sys_password_reset_request "
+            + "WHERE id = #{id} FOR UPDATE")
+    PasswordResetRequestEntity findForUpdate(@Param("id") long id);
+
+    @Select("SELECT COUNT(*) FROM sys_password_reset_request "
+            + "WHERE user_id = #{userId} AND status = 'PENDING'")
+    int countPendingByUser(@Param("userId") long userId);
+
+    /** 先到先得:仅当仍为 PENDING 时置位(CAS),后到者 0 行即冲突。 */
+    @Update("UPDATE sys_password_reset_request SET status = #{status}, "
+            + "handled_by = #{handledBy}, handled_by_name = #{handledByName}, "
+            + "handled_at = #{handledAt} "
+            + "WHERE id = #{id} AND status = 'PENDING'")
+    int updateHandled(@Param("id") long id, @Param("status") String status,
+                      @Param("handledBy") long handledBy,
+                      @Param("handledByName") String handledByName,
+                      @Param("handledAt") LocalDateTime handledAt);
+}

+ 5 - 0
backend-java/src/main/java/com/dms/mapper/RoleMapper.java

@@ -30,6 +30,11 @@ public interface RoleMapper {
             + "AND status = 'ENABLED' LIMIT 1")
     RoleEntity findActiveEnabledById(@Param("id") long id);
 
+    /** 按角色名查生效角色(契约1.49:重置密码审批角色路由)。 */
+    @Select("SELECT * FROM sys_role WHERE role_name = #{roleName} AND is_deleted = 0 "
+            + "AND status = 'ENABLED' LIMIT 1")
+    RoleEntity findActiveEnabledByRoleName(@Param("roleName") String roleName);
+
     /** 未挂角色用户回退默认角色(对齐 _effective_role 默认角色分支)。 */
     @Select("SELECT * FROM sys_role WHERE is_default = 1 AND is_deleted = 0 "
             + "AND status = 'ENABLED' LIMIT 1")

+ 40 - 6
backend-java/src/main/java/com/dms/mapper/RolePermissionMapper.java

@@ -14,22 +14,51 @@ public interface RolePermissionMapper {
             + "ORDER BY id ASC")
     List<RolePermissionEntity> listActiveByRole(@Param("roleId") long roleId);
 
-    /** 矩阵命中行:密级 × 部门范围(对齐 _matrix_actions)。 */
+    /** 矩阵命中行:一级分类 × 种类 × 密级 × 部门范围(契约1.48,对齐 _matrix_actions)。
+     *  topCategoryId 为一级分类 id(主体方案与分类下附件行);共享资料库行组用
+     *  {@link #findLibraryMatrixRow}。 */
     @Select("SELECT * FROM sys_role_permission WHERE role_id = #{roleId} "
+            + "AND top_category_id = #{topCategoryId} AND doc_kind = #{docKind} "
             + "AND security_level = #{securityLevel} AND dept_scope = #{deptScope} "
             + "AND is_deleted = 0 LIMIT 1")
     RolePermissionEntity findMatrixRow(@Param("roleId") long roleId,
+                                       @Param("topCategoryId") long topCategoryId,
+                                       @Param("docKind") String docKind,
                                        @Param("securityLevel") String securityLevel,
                                        @Param("deptScope") String deptScope);
 
-    /** 会话 permUpload 位:生效角色任一 OWN 行 can_upload=1(契约1.31)。 */
+    /** 共享资料库行组(top_category_id IS NULL,仅 OWN):未挂载附件判定用。 */
+    @Select("SELECT * FROM sys_role_permission WHERE role_id = #{roleId} "
+            + "AND top_category_id IS NULL AND doc_kind = #{docKind} "
+            + "AND security_level = #{securityLevel} AND dept_scope = 'OWN' "
+            + "AND is_deleted = 0 LIMIT 1")
+    RolePermissionEntity findLibraryMatrixRow(@Param("roleId") long roleId,
+                                              @Param("docKind") String docKind,
+                                              @Param("securityLevel") String securityLevel);
+
+    /** 会话 permUpload 位:生效角色任一主体方案 OWN 行 can_upload=1(契约1.31)。 */
     @Select("SELECT COUNT(*) FROM sys_role_permission WHERE role_id = #{roleId} "
-            + "AND dept_scope = 'OWN' AND can_upload = 1 AND is_deleted = 0")
+            + "AND doc_kind = 'MAIN' AND dept_scope = 'OWN' AND can_upload = 1 "
+            + "AND is_deleted = 0")
     int countOwnUpload(@Param("roleId") long roleId);
 
-    /** 新建角色种子 15 行(can_* 走 DB 默认值 0)。 */
-    @Insert("INSERT INTO sys_role_permission (role_id, security_level, dept_scope, "
-            + "created_by, updated_by) VALUES (#{roleId}, #{securityLevel}, #{deptScope}, "
+    /** 会话 permAttachmentLibrary 位(契约1.41/1.44):任一共享资料行任一附件位=1。 */
+    @Select("SELECT COUNT(*) FROM sys_role_permission WHERE role_id = #{roleId} "
+            + "AND doc_kind != 'MAIN' AND (can_attachment_view = 1 "
+            + "OR can_attachment_download = 1 OR can_attachment_edit = 1 "
+            + "OR can_attachment_upload = 1 OR can_attachment_delete = 1) "
+            + "AND is_deleted = 0")
+    int countOwnAnyAttachment(@Param("roleId") long roleId);
+
+    /** 会话 permAttachmentUpload 位(契约1.41):任一共享资料行附件上传位=1。 */
+    @Select("SELECT COUNT(*) FROM sys_role_permission WHERE role_id = #{roleId} "
+            + "AND doc_kind != 'MAIN' AND can_attachment_upload = 1 AND is_deleted = 0")
+    int countOwnAttachmentUpload(@Param("roleId") long roleId);
+
+    /** 新建角色种子行(can_* 走 DB 默认值 0;top_category_id/doc_kind 见实体)。 */
+    @Insert("INSERT INTO sys_role_permission (role_id, top_category_id, doc_kind, "
+            + "security_level, dept_scope, created_by, updated_by) VALUES "
+            + "(#{roleId}, #{topCategoryId}, #{docKind}, #{securityLevel}, #{deptScope}, "
             + "#{createdBy}, #{updatedBy})")
     int insert(RolePermissionEntity row);
 
@@ -46,6 +75,11 @@ public interface RolePermissionMapper {
             + "can_delete = #{canDelete}, can_config_permission = #{canConfigPermission}, "
             + "can_bind_attachment = #{canBindAttachment}, "
             + "can_unbind_attachment = #{canUnbindAttachment}, "
+            + "can_attachment_upload = #{canAttachmentUpload}, "
+            + "can_attachment_edit = #{canAttachmentEdit}, "
+            + "can_attachment_view = #{canAttachmentView}, "
+            + "can_attachment_download = #{canAttachmentDownload}, "
+            + "can_attachment_delete = #{canAttachmentDelete}, "
             + "updated_by = #{updatedBy}, updated_at = #{updatedAt} WHERE id = #{id}")
     int updateFlags(RolePermissionEntity row);
 }

+ 51 - 0
backend-java/src/main/java/com/dms/mapper/SettingMapper.java

@@ -0,0 +1,51 @@
+package com.dms.mapper;
+
+import com.dms.model.SettingEntity;
+import java.time.LocalDateTime;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+/**
+ * sys_setting 通用键值设置(契约1.54 审批相关设置):
+ * 行不存在即未设置;upsert 以「先 UPDATE、影响 0 行再 INSERT」实现
+ * (MySQL ON DUPLICATE KEY 与达梦 MERGE 语法互不兼容,顺序写法两端通用)。
+ */
+public interface SettingMapper {
+
+    @Select("SELECT setting_key, setting_value, file_name, file_size, "
+            + "updated_at, updated_by FROM sys_setting WHERE setting_key = #{settingKey}")
+    SettingEntity findByKey(@Param("settingKey") String settingKey);
+
+    @Update("UPDATE sys_setting SET setting_value = #{value}, file_name = NULL, "
+            + "file_size = NULL, updated_at = #{now}, updated_by = #{actorId} "
+            + "WHERE setting_key = #{settingKey}")
+    int updateValue(@Param("settingKey") String settingKey,
+                    @Param("value") String value,
+                    @Param("now") LocalDateTime now,
+                    @Param("actorId") long actorId);
+
+    @Update("UPDATE sys_setting SET file_name = #{fileName}, file_size = #{fileSize}, "
+            + "updated_at = #{now}, updated_by = #{actorId} "
+            + "WHERE setting_key = #{settingKey}")
+    int updateFileMeta(@Param("settingKey") String settingKey,
+                       @Param("fileName") String fileName,
+                       @Param("fileSize") long fileSize,
+                       @Param("now") LocalDateTime now,
+                       @Param("actorId") long actorId);
+
+    @Insert("INSERT INTO sys_setting (setting_key, setting_value, file_name, "
+            + "file_size, updated_at, updated_by) VALUES "
+            + "(#{settingKey}, #{value}, #{fileName}, #{fileSize}, #{now}, #{actorId})")
+    int insert(@Param("settingKey") String settingKey,
+               @Param("value") String value,
+               @Param("fileName") String fileName,
+               @Param("fileSize") Long fileSize,
+               @Param("now") LocalDateTime now,
+               @Param("actorId") long actorId);
+
+    @Delete("DELETE FROM sys_setting WHERE setting_key = #{settingKey}")
+    int deleteByKey(@Param("settingKey") String settingKey);
+}

+ 38 - 41
backend-java/src/main/java/com/dms/mapper/UserMapper.java

@@ -4,7 +4,6 @@ import com.dms.model.UserEntity;
 import java.time.LocalDateTime;
 import java.util.Collection;
 import java.util.List;
-import java.util.Map;
 import org.apache.ibatis.annotations.Insert;
 import org.apache.ibatis.annotations.Options;
 import org.apache.ibatis.annotations.Param;
@@ -16,6 +15,18 @@ public interface UserMapper {
     @Select("SELECT * FROM sys_user WHERE username = #{username} AND is_deleted = 0")
     UserEntity findActiveByUsername(@Param("username") String username);
 
+    /** 登录姓名回退(契约1.47):用户名与姓名同语义,支持姓名登录;
+     * 活跃同名多行时调用方按歧义处理(不锁定任何一人)。 */
+    @Select("SELECT * FROM sys_user WHERE real_name = #{realName} AND is_deleted = 0")
+    List<UserEntity> listActiveByRealName(@Param("realName") String realName);
+
+    /** 姓名唯一性校验(契约1.47,应用层判断不加库约束):
+     * excludeUserId 为 null 时用于新建,否则排除自身用于编辑。 */
+    @Select("SELECT COUNT(*) FROM sys_user WHERE real_name = #{realName} AND is_deleted = 0 "
+            + "AND (#{excludeUserId} IS NULL OR id != #{excludeUserId})")
+    long countActiveByRealName(@Param("realName") String realName,
+                               @Param("excludeUserId") Long excludeUserId);
+
     /** seed 工具:不过滤软删(对齐 Python select(User).where(username == ...) 含已删行)。 */
     @Select("SELECT * FROM sys_user WHERE username = #{username} LIMIT 1")
     UserEntity findByUsername(@Param("username") String username);
@@ -43,12 +54,14 @@ public interface UserMapper {
     @Options(useGeneratedKeys = true, keyProperty = "id")
     int insert(UserEntity user);
 
-    /** 用户维护:完整落库(auth/row_version 走 DB 默认值 0)。 */
+    /** 用户维护:完整落库(auth/row_version 走 DB 默认值 0;角色挂载走 sys_user_role)。 */
     @Insert("INSERT INTO sys_user (username, password_hash, real_name, organization_id, "
-            + "organization_name, role_code, role_id, security_level, status, "
-            + "managed_org_id, created_by, updated_by) VALUES (#{username}, #{passwordHash}, "
-            + "#{realName}, #{organizationId}, #{organizationName}, #{roleCode}, #{roleId}, "
-            + "#{securityLevel}, #{status}, #{managedOrgId}, #{createdBy}, #{updatedBy})")
+            + "organization_name, role_code, security_level, status, "
+            + "seat_name, user_type, managed_org_id, created_by, updated_by) "
+            + "VALUES (#{username}, #{passwordHash}, "
+            + "#{realName}, #{organizationId}, #{organizationName}, #{roleCode}, "
+            + "#{securityLevel}, #{status}, #{seatName}, #{userType}, "
+            + "#{managedOrgId}, #{createdBy}, #{updatedBy})")
     @Options(useGeneratedKeys = true, keyProperty = "id")
     int insertFull(UserEntity user);
 
@@ -56,7 +69,8 @@ public interface UserMapper {
      * managedOrgProvided=0/1 控制管理范围是否覆盖(契约1.30 可选字段,未提交保持原值)。 */
     @Update("UPDATE sys_user SET real_name = #{user.realName}, organization_id = #{user.organizationId}, "
             + "organization_name = #{user.organizationName}, role_code = #{user.roleCode}, "
-            + "role_id = #{user.roleId}, security_level = #{user.securityLevel}, status = #{user.status}, "
+            + "security_level = #{user.securityLevel}, status = #{user.status}, "
+            + "seat_name = #{user.seatName}, user_type = #{user.userType}, "
             + "managed_org_id = CASE WHEN #{managedOrgProvided} = 1 "
             + "THEN #{user.managedOrgId} ELSE managed_org_id END, "
             + "updated_by = #{user.updatedBy}, updated_at = #{user.updatedAt}, "
@@ -78,6 +92,18 @@ public interface UserMapper {
     int resetPassword(@Param("id") long id, @Param("passwordHash") String passwordHash,
                       @Param("now") LocalDateTime now, @Param("actorId") Long actorId);
 
+    /** 重置密码审批通过(契约1.49):清密码回首次登录态——password_hash/last_login_at
+     *  置 NULL(门户验签据此进入 SET_PASSWORD 阶段重新设密)、auth_version+1 旧会话失效、
+     *  会话代/锁定痕迹一并清零。 */
+    @Update("UPDATE sys_user SET password_hash = NULL, last_login_at = NULL, "
+            + "last_login_ip = NULL, auth_version = auth_version + 1, "
+            + "session_jti = NULL, failed_login_count = 0, locked_until = NULL, "
+            + "updated_by = #{actorId}, updated_at = #{now}, "
+            + "row_version = row_version + 1 WHERE id = #{id}")
+    int clearPasswordForReset(@Param("id") long id,
+                              @Param("now") LocalDateTime now,
+                              @Param("actorId") Long actorId);
+
     /** seed 工具:仅覆盖密码三要素,不动 updated_by/updated_at(对齐 seed_dev.py 的字段级效果)。 */
     @Update("UPDATE sys_user SET password_hash = #{passwordHash}, "
             + "auth_version = auth_version + 1, row_version = row_version + 1 WHERE id = #{id}")
@@ -120,44 +146,19 @@ public interface UserMapper {
                         @Param("keyword") String keyword,
                         @Param("status") String status);
 
-    // ------------------------------------------------------------ 角色关联
-
-    @Select("SELECT COUNT(*) FROM sys_user WHERE role_id = #{roleId} AND is_deleted = 0")
-    long countActiveByRoleId(@Param("roleId") long roleId);
-
-    @Select("SELECT id FROM sys_user WHERE role_id = #{roleId} AND is_deleted = 0 "
-            + "ORDER BY id ASC")
-    List<Long> listActiveIdsByRoleId(@Param("roleId") long roleId);
-
-    /** 角色关联维护:锁定当前挂载该角色的用户行(对齐 update_role_users)。 */
-    @Select("SELECT * FROM sys_user WHERE role_id = #{roleId} AND is_deleted = 0 "
-            + "ORDER BY id ASC FOR UPDATE")
-    List<UserEntity> listActiveByRoleIdForUpdate(@Param("roleId") long roleId);
+    // ------------------------------------------------------------ 角色关联(契约1.46:挂载改走 sys_user_role)
 
     @Select("<script>SELECT * FROM sys_user WHERE is_deleted = 0 AND id IN "
             + "<foreach item='item' collection='ids' open='(' separator=',' close=')'>#{item}"
             + "</foreach> FOR UPDATE</script>")
     List<UserEntity> listActiveByIdsForUpdate(@Param("ids") Collection<Long> ids);
 
-    /** 取消角色关联(auth_version+1 使旧 Token 失效)。 */
-    @Update("UPDATE sys_user SET role_id = NULL, auth_version = auth_version + 1, "
-            + "updated_by = #{actorId}, updated_at = #{now}, "
-            + "row_version = row_version + 1 WHERE id = #{id}")
-    int clearRole(@Param("id") long id, @Param("now") LocalDateTime now,
-                  @Param("actorId") Long actorId);
-
-    @Update("UPDATE sys_user SET role_id = #{roleId}, auth_version = auth_version + 1, "
+    /** 角色挂载变更联动:auth_version+1 使旧 Token 失效(会话角色随挂载失效)。 */
+    @Update("UPDATE sys_user SET auth_version = auth_version + 1, "
             + "updated_by = #{actorId}, updated_at = #{now}, "
             + "row_version = row_version + 1 WHERE id = #{id}")
-    int assignRole(@Param("id") long id, @Param("roleId") long roleId,
-                   @Param("now") LocalDateTime now, @Param("actorId") Long actorId);
-
-    /** 角色成员数(角色列表 memberCount)。 */
-    @Select("<script>SELECT role_id, COUNT(*) AS cnt FROM sys_user "
-            + "WHERE is_deleted = 0 AND role_id IN "
-            + "<foreach item='item' collection='roleIds' open='(' separator=',' close=')'>#{item}"
-            + "</foreach> GROUP BY role_id</script>")
-    List<Map<String, Object>> countMembersByRoleIds(@Param("roleIds") Collection<Long> roleIds);
+    int bumpForRoleChange(@Param("id") long id, @Param("now") LocalDateTime now,
+                          @Param("actorId") Long actorId);
 
     // ------------------------------------------------------------ 门户登录
 
@@ -219,10 +220,6 @@ public interface UserMapper {
     @Update("UPDATE sys_user SET ui_theme = #{uiTheme} WHERE id = #{id}")
     int updateUiTheme(@Param("id") long id, @Param("uiTheme") String uiTheme);
 
-    /** 实时解析生效角色:取未删用户的显式角色(对齐 _effective_role)。 */
-    @Select("SELECT role_id FROM sys_user WHERE id = #{id} AND is_deleted = 0 LIMIT 1")
-    Long findRoleIdActive(@Param("id") long id);
-
     // ------------------------------------------------------------ 拖动重排(契约1.27)
 
     /** 重排:全体未删用户按 sort_no,id 锁定(对齐 reorder_users 的 with_for_update)。 */

+ 86 - 0
backend-java/src/main/java/com/dms/mapper/UserRoleMapper.java

@@ -0,0 +1,86 @@
+package com.dms.mapper;
+
+import com.dms.model.RoleEntity;
+import com.dms.model.RoleMountEntity;
+import java.time.LocalDateTime;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+/**
+ * sys_user_role 用户-角色多对多关联(契约1.46):
+ * 一个用户可挂多个角色,会话锁定单角色;契约1.53 起挂载行可带独立密码
+ * (N 个密码对应 N 个角色,输入哪个密码即以该角色进入),NULL=使用
+ * 用户级密码;存量 sys_user.role_id 单值已由 V27 回填为关联记录,停用。
+ */
+public interface UserRoleMapper {
+
+    /** 用户挂载的活跃启用角色(登录选择、摘要展示、防提权),按 sort_no 排序。 */
+    @Select("SELECT r.* FROM sys_user_role ur JOIN sys_role r "
+            + "ON r.id = ur.role_id AND r.is_deleted = 0 AND r.status = 'ENABLED' "
+            + "WHERE ur.user_id = #{userId} ORDER BY r.sort_no ASC, r.id ASC")
+    List<RoleEntity> listActiveRolesByUserId(@Param("userId") long userId);
+
+    /** 登录挂载视图(契约1.53):角色与该角色密码哈希,NULL=使用用户级密码。 */
+    @Select("SELECT r.id AS role_id, r.role_name, ur.password_hash "
+            + "FROM sys_user_role ur JOIN sys_role r "
+            + "ON r.id = ur.role_id AND r.is_deleted = 0 AND r.status = 'ENABLED' "
+            + "WHERE ur.user_id = #{userId} ORDER BY r.sort_no ASC, r.id ASC")
+    List<RoleMountEntity> listLoginMounts(@Param("userId") long userId);
+
+    /** 设置/清除挂载行独立密码(契约1.53):passwordHash 传 null 即清除。 */
+    @Update("UPDATE sys_user_role SET password_hash = #{passwordHash} "
+            + "WHERE user_id = #{userId} AND role_id = #{roleId}")
+    int updatePassword(@Param("userId") long userId, @Param("roleId") long roleId,
+                       @Param("passwordHash") String passwordHash);
+
+    /** 清空用户全部挂载行独立密码(管理员重置/1.49自助重置:回过渡态)。 */
+    @Update("UPDATE sys_user_role SET password_hash = NULL WHERE user_id = #{userId}")
+    int clearAllPasswords(@Param("userId") long userId);
+
+    /** 用户挂载的活跃启用角色ID集合(编辑用户 diff 基线;停用角色关联保留不参与替换)。 */
+    @Select("SELECT ur.role_id FROM sys_user_role ur JOIN sys_role r "
+            + "ON r.id = ur.role_id AND r.is_deleted = 0 AND r.status = 'ENABLED' "
+            + "WHERE ur.user_id = #{userId} ORDER BY r.sort_no ASC, r.id ASC")
+    List<Long> listActiveRoleIdsByUserId(@Param("userId") long userId);
+
+    /** 挂载关联(角色端成员维护):已存在时唯一约束兜底为 0 行,幂等。 */
+    @Insert("INSERT INTO sys_user_role (user_id, role_id, created_by) "
+            + "VALUES (#{userId}, #{roleId}, #{actorId})")
+    int insert(@Param("userId") long userId, @Param("roleId") long roleId,
+               @Param("actorId") Long actorId);
+
+    /** 解除单条挂载(编辑用户差集 / 角色端成员移除)。 */
+    @Delete("DELETE FROM sys_user_role WHERE user_id = #{userId} AND role_id = #{roleId}")
+    int deleteByUserAndRole(@Param("userId") long userId, @Param("roleId") long roleId);
+
+    /** 清空用户挂载(软删用户兜底清理,正常编辑走差集保留停用角色关联)。 */
+    @Delete("DELETE FROM sys_user_role WHERE user_id = #{userId}")
+    int deleteByUserId(@Param("userId") long userId);
+
+    /** 角色成员:活跃未删用户ID(角色成员维护/列表)。 */
+    @Select("SELECT ur.user_id FROM sys_user_role ur JOIN sys_user u "
+            + "ON u.id = ur.user_id AND u.is_deleted = 0 "
+            + "WHERE ur.role_id = #{roleId} ORDER BY ur.user_id ASC")
+    List<Long> listActiveUserIdsByRoleId(@Param("roleId") long roleId);
+
+    /** 角色绑定用户数(角色删除守卫:有活跃用户挂载不可删)。 */
+    @Select("SELECT COUNT(*) FROM sys_user_role ur JOIN sys_user u "
+            + "ON u.id = ur.user_id AND u.is_deleted = 0 "
+            + "WHERE ur.role_id = #{roleId}")
+    long countActiveUsersByRoleId(@Param("roleId") long roleId);
+
+    /** 角色成员数(角色列表 memberCount)。 */
+    @Select("<script>SELECT ur.role_id AS role_id, COUNT(*) AS cnt FROM sys_user_role ur "
+            + "JOIN sys_user u ON u.id = ur.user_id AND u.is_deleted = 0 "
+            + "WHERE ur.role_id IN "
+            + "<foreach item='item' collection='roleIds' open='(' separator=',' close=')'>#{item}"
+            + "</foreach> GROUP BY ur.role_id</script>")
+    List<Map<String, Object>> countActiveUsersByRoleIds(
+            @Param("roleIds") Collection<Long> roleIds);
+}

+ 2 - 1
backend-java/src/main/java/com/dms/model/CategoryEntity.java

@@ -22,7 +22,8 @@ public class CategoryEntity {
     private Integer sortNo;
     private Integer documentCount;
     private String status;
-    /** 扁平模式:仅一级分类有效,true 时前端隐藏子方案/配套资料挂载入口。 */
+    /** 仅主案模式(界面名,字段沿用 flat_mode):仅一级分类有效,true 时该分类主方案
+     *  不挂载配套方案、支撑成果、方案训考资料、典型案例等共享资料。 */
     private Boolean flatMode;
     /** 类型显示名称:仅一级分类有效,检索结果中该分类主案类型徽标显示名。 */
     private String typeDisplayName;

+ 29 - 0
backend-java/src/main/java/com/dms/model/PasswordResetRequestEntity.java

@@ -0,0 +1,29 @@
+package com.dms.model;
+
+import java.time.LocalDateTime;
+import lombok.Data;
+
+/**
+ * 重置密码申请单(V30迁移,契约1.49):登录前自助提交,按申请用户类型
+ * 路由审批角色,任一审批人处理即定(先到先得)。
+ */
+@Data
+public class PasswordResetRequestEntity {
+
+    private Long id;
+    private Long userId;
+    private String username;
+    private String realName;
+    /** 用户类型快照:NORMAL/COMMANDER(提交时点值,审批按此路由)。 */
+    private String userType;
+    /** 审批角色名快照:首长→安全管理员、其他→系统管理员(可配置)。 */
+    private String approverRoleName;
+    /** PENDING/APPROVED/REJECTED。 */
+    private String status;
+    private String requestIp;
+    private LocalDateTime requestedAt;
+    private Long handledBy;
+    private String handledByName;
+    private LocalDateTime handledAt;
+    private LocalDateTime createdAt;
+}

+ 16 - 0
backend-java/src/main/java/com/dms/model/RoleMountEntity.java

@@ -0,0 +1,16 @@
+package com.dms.model;
+
+import lombok.Data;
+
+/**
+ * 登录挂载视图(契约1.53 密码即角色):用户挂载的角色与该角色密码哈希,
+ * N 个密码对应 N 个角色一一对应,登录时输入哪个角色的密码即以该角色进入;
+ * passwordHash 为 null 表示该角色使用用户级密码(存量未分配时全部为 null)。
+ */
+@Data
+public class RoleMountEntity {
+
+    private Long roleId;
+    private String roleName;
+    private String passwordHash;
+}

+ 12 - 1
backend-java/src/main/java/com/dms/model/RolePermissionEntity.java

@@ -3,7 +3,7 @@ package com.dms.model;
 import java.time.LocalDateTime;
 import lombok.Data;
 
-/** sys_role_permission 表实体(密级 × 部门范围 × 8 操作布尔位)。 */
+/** sys_role_permission 表实体(一级分类 × 种类 × 密级 × 部门范围 × 操作布尔位,契约1.48)。 */
 @Data
 public class RolePermissionEntity {
     private Long id;
@@ -15,6 +15,10 @@ public class RolePermissionEntity {
     private Boolean isDeleted;
     private Integer rowVersion;
     private Long roleId;
+    /** 一级分类 id(契约1.48):null = 共享资料库行组(未挂载附件专用)。 */
+    private Long topCategoryId;
+    /** 种类(契约1.48):MAIN=主体方案;附件四类型之一。 */
+    private String docKind;
     private String securityLevel;
     private String deptScope;
     private Boolean canView;
@@ -26,4 +30,11 @@ public class RolePermissionEntity {
     private Boolean canConfigPermission;
     private Boolean canBindAttachment;
     private Boolean canUnbindAttachment;
+    /** 附件四权限(契约1.41):仅 OWN 行有意义(附件无部门归属),OTHER 行恒0;
+     *  查看下载不再跟随主案权限,编辑覆盖共享附件库与已挂载区域附件。 */
+    private Boolean canAttachmentUpload;
+    private Boolean canAttachmentEdit;
+    private Boolean canAttachmentView;
+    private Boolean canAttachmentDownload;
+    private Boolean canAttachmentDelete;
 }

+ 21 - 0
backend-java/src/main/java/com/dms/model/SettingEntity.java

@@ -0,0 +1,21 @@
+package com.dms.model;
+
+import java.time.LocalDateTime;
+import lombok.Data;
+
+/**
+ * sys_setting 通用键值设置(契约1.54 审批相关设置):
+ * 行不存在即未设置。调阅说明存 settingValue(borrow.notice 行);
+ * 调阅审批单模板元数据存 fileName/fileSize(borrow.template 行,
+ * 模板文件落盘存储根目录固定路径 settings/borrow-template.pdf)。
+ */
+@Data
+public class SettingEntity {
+
+    private String settingKey;
+    private String settingValue;
+    private String fileName;
+    private Long fileSize;
+    private LocalDateTime updatedAt;
+    private Long updatedBy;
+}

+ 5 - 1
backend-java/src/main/java/com/dms/model/UserEntity.java

@@ -28,8 +28,12 @@ public class UserEntity {
     /** 登录失败锁定:连续密码错误计数与锁定截止时间(UTC,null=未锁定)。 */
     private Integer failedLoginCount;
     private LocalDateTime lockedUntil;
-    private Long roleId;
     private String externalId;
+    /** 席位(契约1.46):右上角用户信息第二行「机构 + 空格 + 席位」展示;可空。 */
+    private String seatName;
+    /** 用户类型(契约1.46):NORMAL=普通用户、COMMANDER=首长(仍属 USER 类别,
+     * 仅重置密码方式后续差异化);DB 缺省 NORMAL。 */
+    private String userType;
     /** 界面主题偏好(账号级漫游,契约1.20):blue/red/green,存量与缺省为 blue。 */
     private String uiTheme;
     /** 拖动排序号(契约1.27):拖拽重排按步长10全量重编号,排序号输入已废止。 */

+ 3 - 1
backend-java/src/main/java/com/dms/security/AuthContext.java

@@ -13,7 +13,9 @@ public record AuthContext(
         String securityLevel,
         int authVersion,
         /** 请求所携授权码的 JWT jti(一次一码轮换比对用)。 */
-        String tokenJti) {
+        String tokenJti,
+        /** 会话角色ID(契约1.46 多角色登录时选定);null=未挂角色(按默认角色解析)。 */
+        Long roleId) {
 
     public boolean isAdmin() {
         return "ADMIN".equals(roleCode);

+ 1 - 1
backend-java/src/main/java/com/dms/security/AuthInterceptor.java

@@ -107,7 +107,7 @@ public class AuthInterceptor implements HandlerInterceptor {
                 user.getId(), user.getUsername(), user.getRealName(),
                 user.getOrganizationId(), user.getOrganizationName(),
                 user.getRoleCode(), user.getSecurityLevel(), user.getAuthVersion(),
-                claims.jti());
+                claims.jti(), claims.roleId());
         AuthContext.set(context);
         return context;
     }

+ 18 - 5
backend-java/src/main/java/com/dms/security/JwtTokens.java

@@ -34,30 +34,38 @@ public final class JwtTokens {
     public record Issued(String token, String jti, int expiresInSeconds) {
     }
 
-    /** decode 后的必备声明(对齐 Python REQUIRED_CLAIMS);jti 用于一次一码轮换比对。 */
+    /** decode 后的必备声明(对齐 Python REQUIRED_CLAIMS);jti 用于一次一码轮换比对;
+     * roleId 为会话角色(契约1.46,未挂角色/存量旧 token 为 null)。 */
     public record Payload(long userId, String username, String roleCode, int authVersion,
-                          String jti) {
+                          String jti, Long roleId) {
     }
 
     /** expiresSeconds:普通令牌有效期(秒),来自 DMS_JWT_EXPIRES_SECONDS,缺省 3600。 */
     public static Issued issue(UserEntity user, boolean keepSignedIn, String secret,
                                long expiresSeconds) {
+        return issue(user, null, keepSignedIn, secret, expiresSeconds);
+    }
+
+    /** 多角色版(契约1.46):会话角色随 token 下发,权限按选定角色计算。 */
+    public static Issued issue(UserEntity user, Long roleId, boolean keepSignedIn,
+                               String secret, long expiresSeconds) {
         Duration duration = keepSignedIn
                 ? EXPIRES_KEEP_SIGNED_IN
                 : Duration.ofSeconds(expiresSeconds);
         return issue(UUID.randomUUID().toString(), user.getId(), user.getUsername(),
-                user.getRoleCode(), user.getAuthVersion(), duration, secret);
+                user.getRoleCode(), user.getAuthVersion(), roleId, duration, secret);
     }
 
     /** 核心签发:jti 由调用方给(登录随机生成;轮换重签复用库中当前会话代)。 */
     public static Issued issue(String jti, long userId, String username, String roleCode,
-                               int authVersion, Duration duration, String secret) {
+                               int authVersion, Long roleId, Duration duration, String secret) {
         Instant now = Instant.now();
         String token = JWT.create()
                 .withSubject(String.valueOf(userId))
                 .withClaim("username", username)
                 .withClaim("roleCode", roleCode)
                 .withClaim("authVersion", authVersion)
+                .withClaim("roleId", roleId == null ? null : String.valueOf(roleId))
                 .withIssuedAt(Date.from(now))
                 .withExpiresAt(Date.from(now.plus(duration)))
                 .withJWTId(jti)
@@ -78,8 +86,13 @@ public final class JwtTokens {
             if (username == null || roleCode == null || authVersion.isNull() || jwt.getId() == null) {
                 throw Errors.tokenInvalid();
             }
+            // roleId(契约1.46):存量旧 token 无此 claim,按 null 解析(回退默认角色)
+            Claim roleId = jwt.getClaim("roleId");
+            Long roleIdValue = roleId == null || roleId.isNull() || roleId.asString() == null
+                    ? null
+                    : Long.parseLong(roleId.asString());
             return new Payload(Long.parseLong(jwt.getSubject()), username, roleCode,
-                    authVersion.asInt(), jwt.getId());
+                    authVersion.asInt(), jwt.getId(), roleIdValue);
         } catch (TokenExpiredException ex) {
             throw Errors.tokenExpired();
         } catch (JWTVerificationException | DmsException | NumberFormatException ex) {

+ 6 - 1
backend-java/src/main/java/com/dms/security/TokenRotationInterceptor.java

@@ -12,6 +12,8 @@ import java.time.Duration;
 import java.time.Instant;
 import java.util.Set;
 import java.util.UUID;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Component;
 import org.springframework.web.method.HandlerMethod;
 import org.springframework.web.servlet.HandlerInterceptor;
@@ -36,6 +38,8 @@ import org.springframework.web.servlet.HandlerInterceptor;
 @Component
 public class TokenRotationInterceptor implements HandlerInterceptor {
 
+    private static final Logger log = LoggerFactory.getLogger(TokenRotationInterceptor.class);
+
     /** 轮换新授权码的响应头;前端据此替换本地存储的授权码。 */
     public static final String HEADER = "X-DMS-Access-Token";
 
@@ -93,7 +97,7 @@ public class TokenRotationInterceptor implements HandlerInterceptor {
             rotate(request, response, context);
         } catch (Exception rotateFailure) {
             // 轮换失败不影响业务响应:旧码继续有效至自然过期,客户端下次写重试轮换
-            System.out.println("提示:授权码轮换未完成(不影响本次业务):" + rotateFailure);
+            log.warn("授权码轮换未完成(不影响本次业务):{}", rotateFailure.getMessage(), rotateFailure);
         }
     }
 
@@ -119,6 +123,7 @@ public class TokenRotationInterceptor implements HandlerInterceptor {
         }
         JwtTokens.Issued issued = JwtTokens.issue(effectiveJti, context.userId(),
                 context.username(), context.roleCode(), context.authVersion(),
+                context.roleId(),
                 Duration.between(Instant.now(), expiresAt), jwtHolder.secret());
         response.setHeader(HEADER, issued.token());
     }

+ 5 - 1
backend-java/src/main/java/com/dms/service/AccessMemo.java

@@ -24,8 +24,12 @@ public final class AccessMemo {
     RoleEntity role;
     /** 机构按 id 缓存(deptScope 逐行判定复用)。 */
     final Map<Long, OrganizationEntity> orgsById = new HashMap<>();
-    /** 矩阵操作位按 "securityLevel|deptScope" 缓存。 */
+    /** 矩阵操作位按 "topCategoryId|securityLevel|deptScope" 缓存(契约1.48)。 */
     final Map<String, Set<String>> matrixActionsByKey = new HashMap<>();
+    /** 分类 → 所属一级分类 id 缓存(契约1.48 主案/附件判定共用)。 */
+    final Map<Long, Long> topCategoriesById = new HashMap<>();
+    /** 附件 → 活跃挂载主案 id 列表缓存(契约1.48 附件判定共用)。 */
+    final Map<Long, List<Long>> mainIdsByAttachmentId = new HashMap<>();
     /** SUB_PLAN 权限源主案按 rootId 缓存(同根子案批量评估复用)。 */
     final Map<Long, DocumentEntity> rootsById = new HashMap<>();
     /** 授权列表按权限源文档 id 缓存(evaluatePlanAccess 与权限摘要共享)。 */

+ 29 - 8
backend-java/src/main/java/com/dms/service/AdminPermission.java

@@ -4,6 +4,7 @@ import com.dms.common.Errors;
 import com.dms.mapper.OrganizationMapper;
 import com.dms.mapper.RoleMapper;
 import com.dms.mapper.UserMapper;
+import com.dms.mapper.UserRoleMapper;
 import com.dms.model.OrganizationEntity;
 import com.dms.model.RoleEntity;
 import com.dms.model.UserEntity;
@@ -45,12 +46,14 @@ public class AdminPermission {
     private final UserMapper userMapper;
     private final OrganizationMapper organizationMapper;
     private final RoleMapper roleMapper;
+    private final UserRoleMapper userRoleMapper;
 
     public AdminPermission(UserMapper userMapper, OrganizationMapper organizationMapper,
-                           RoleMapper roleMapper) {
+                           RoleMapper roleMapper, UserRoleMapper userRoleMapper) {
         this.userMapper = userMapper;
         this.organizationMapper = organizationMapper;
         this.roleMapper = roleMapper;
+        this.userRoleMapper = userRoleMapper;
     }
 
     /** 内置 admin 账号恒为超管,不受三管理位与管理范围约束。 */
@@ -74,11 +77,20 @@ public class AdminPermission {
     }
 
     /**
-     * 生效角色:显式角色(活跃且启用)优先,否则回退默认角色(活跃且启用)。
-     * 对齐 effective_manage_role(findActiveEnabledById / findDefaultActive)。
+     * 生效角色(契约1.46 多角色):按会话选定角色(AuthContext.roleId,登录时
+     * 从挂载角色中选定并写入 JWT claim)解析——活跃且启用优先,否则回退默认
+     * 角色(活跃且启用);无会话角色(未挂角色用户/存量旧 token/测试上下文)
+     * 同样回退默认角色。判定对象非本人(用户列表行等)时取其挂载的
+     * 第一角色(活跃启用,按 sortNo)。对齐 effective_manage_role
+     * (findActiveEnabledById / findDefaultActive)。
      */
     public RoleEntity effectiveManageRole(UserEntity user) {
-        return effectiveRoleSnapshot(user.getRoleId());
+        AuthContext context = AuthContext.getOrNull();
+        if (context != null && context.userId() == user.getId()) {
+            return effectiveRoleSnapshot(context.roleId());
+        }
+        List<RoleEntity> roles = userRoleMapper.listActiveRolesByUserId(user.getId());
+        return effectiveRoleSnapshot(roles.isEmpty() ? null : roles.get(0).getId());
     }
 
     public boolean canEditCategories(UserEntity user) {
@@ -261,22 +273,31 @@ public class AdminPermission {
     }
 
     /**
-     * 防提权(契约1.30修订):目标用户最终生效管理范围不得超出操作者。
+     * 防提权(契约1.30修订;1.46 多角色遍历):目标用户挂载的任一角色
+     * 的最终生效管理范围均不得超出操作者。
      *
-     * <p>仅当目标为 ADMIN 且生效角色具备受锚点约束的管理位
+     * <p>仅当目标为 ADMIN 且角色具备受锚点约束的管理位
      * (机构/用户编辑位)时校验——无管理位的角色(含默认角色)不构成提权。
+     * 多角色下逐一校验目标挂载的每个新角色(防止挂载后再切换角色提权);
      * 目标生效锚点按「角色锚点优先,角色 SELF 回落用户级三态」解析:
      * 解析为全局(角色锚点或用户级为 NULL)或范围外机构即拒绝;
      * 用户级 SELF 收敛到目标所在机构子树(其机构已由既有校验限制在
      * 操作者范围内,树传递性恒安全)。</p>
      */
     public void ensureUserScopeInManagedScope(UserEntity actor, String roleCode,
-                                              RoleEntity effectiveRole,
+                                              List<RoleEntity> targetRoles,
                                               Long managedOrgId, Long organizationId) {
         Set<Long> scope = managedScopeIds(actor);
-        if (scope == null || !"ADMIN".equals(roleCode)) {
+        if (scope == null || !"ADMIN".equals(roleCode) || targetRoles == null) {
             return;
         }
+        for (RoleEntity targetRole : targetRoles) {
+            ensureSingleRoleInScope(scope, targetRole, managedOrgId, organizationId);
+        }
+    }
+
+    private static void ensureSingleRoleInScope(Set<Long> scope, RoleEntity effectiveRole,
+                                                Long managedOrgId, Long organizationId) {
         if (effectiveRole == null || !(Boolean.TRUE.equals(effectiveRole.getManageOrgEdit())
                 || Boolean.TRUE.equals(effectiveRole.getManageUserEdit()))) {
             return;

+ 54 - 16
backend-java/src/main/java/com/dms/service/AttachmentQueryService.java

@@ -91,7 +91,7 @@ public class AttachmentQueryService {
         result.put("updatedAt", DocumentQueryService.iso(attachment.getUpdatedAt()));
         result.put("rowVersion", attachment.getRowVersion());
         result.put("allowedActions",
-                authorizationService.attachmentAllowedActions(context, planAccess));
+                authorizationService.attachmentAllowedActions(context, attachment, planAccess));
         result.put("mountedPlanCount", mountedPlanCount(attachment.getId()));
         String snippet = DocumentQueryService.contentSnippet(attachment.getContentText(), keyword);
         if (snippet != null) {
@@ -138,12 +138,21 @@ public class AttachmentQueryService {
                 DocumentQueryService.positiveIntParam(params, "pageSize", 20));
         Map<String, Object> filters = Payloads.orderedMap();
         filters.put("types", List.of("ATTACHMENT"));
+        // 高级检索·数据内容/提供单位(契约1.55):附件列表与方案列表同口径
+        // (keywordScope 缺省 TITLE,与原 keyword→titlePattern 行为一致)
         String keyword = params.get("keyword");
-        if (keyword != null && !keyword.strip().isEmpty()) {
-            filters.put("titlePattern",
-                    "%" + DocumentQueryService.escapeLike(keyword) + "%");
-        }
+        DocumentQueryService.applyKeywordScope(filters, keyword,
+                params.get("keywordScope"), false);
         DocumentQueryService.applyAdvancedFilters(filters, params);
+        documentQueryService.applyOrganizationFilter(filters, params.get("organizationId"));
+        // 高级检索·数据密级(契约1.55):单选过滤(securityLevels 多选保留兼容),
+        // 修复漏接线导致的密级筛选不生效
+        String securityLevel = DocumentQueryService.enumParam(
+                params.get("securityLevel"), DocumentQueryService.SECURITY_LEVELS,
+                "securityLevel");
+        if (securityLevel != null) {
+            filters.put("securityLevel", securityLevel);
+        }
         String attachmentType = DocumentQueryService.enumParam(
                 params.get("attachmentType"), ATTACHMENT_TYPES, "attachmentType");
         if (attachmentType != null) {
@@ -173,17 +182,22 @@ public class AttachmentQueryService {
         int toIndex = Math.min(page.offset() + page.pageSize(), attachments.size());
         List<DocumentEntity> selected = attachments.subList(fromIndex, toIndex);
         // 列表主体查询不携带 content_text 大字段;仅管理员 contentKeyword
-        // 检索生成当前页摘要时批量补查。
-        if (isAdmin) {
-            String contentKeyword = params.get("contentKeyword");
-            if (contentKeyword != null && !contentKeyword.strip().isEmpty()) {
-                documentQueryService.fillContentText(selected);
-            }
+        // 检索生成当前页摘要时批量补查。契约1.55:数据内容为 CONTENT/ALL
+        // 时摘要关键词回落到检索关键词(正文命中需展示片段)。
+        String snippetKeyword = params.get("contentKeyword");
+        if ((snippetKeyword == null || snippetKeyword.strip().isEmpty())
+                && DocumentQueryService.keywordScopeContentLike(params)
+                && keyword != null && !keyword.strip().isEmpty()) {
+            snippetKeyword = keyword.strip();
+        }
+        if (isAdmin
+                && snippetKeyword != null && !snippetKeyword.strip().isEmpty()) {
+            documentQueryService.fillContentText(selected);
         }
         List<Map<String, Object>> items = new ArrayList<>();
         for (DocumentEntity item : selected) {
             Map<String, Object> summary = attachmentSummary(item, context, null,
-                    isAdmin ? params.get("contentKeyword") : null);
+                    isAdmin ? snippetKeyword : null);
             if (!isAdmin) {
                 summary.put("description", null);
             }
@@ -196,18 +210,42 @@ public class AttachmentQueryService {
     public Map<String, Object> getAttachment(long attachmentId, HttpServletRequest request) {
         AuthContext context = AuthContext.get();
         DocumentEntity attachment = activeAttachment(attachmentId);
-        AttachmentAccess access =
-                authorizationService.requireAttachmentAccess(attachment, context, false);
+        AttachmentAccess access;
+        try {
+            access = authorizationService.requireAttachmentAccess(attachment, context, false);
+        } catch (DmsException e) {
+            // 操作位隔离补全:查看位不足但具备附件操作位(编辑/删除等)时放行
+            // "编辑视图"——详情仅元数据(查看计数与审计不记录);密级不足维持拒绝
+            if (!"DOCUMENT_VIEW_FORBIDDEN".equals(e.code())
+                    || authorizationService.attachmentAllowedActions(context, attachment, null)
+                            .isEmpty()) {
+                throw e;
+            }
+            access = authorizationService.evaluateAttachmentAccess(attachment, context);
+        }
         Map<String, Object> result = attachmentDetail(attachment, context);
         if ("USER".equals(context.roleCode())) {
             List<String> actions = new ArrayList<>();
-            actions.add("VIEW");
+            // 编辑视图(无查看权)不下发 VIEW 位
+            if (access.canView()) {
+                actions.add("VIEW");
+            }
             if (access.canDownload()) {
                 actions.add("DOWNLOAD");
             }
+            // 契约1.41:矩阵附件编辑位独立下发(详情访问已含挂载聚合查看)
+            if (authorizationService.attachmentEditAllowed(attachment, context)) {
+                actions.add("EDIT");
+            }
             result.put("allowedActions", actions);
         }
-        result.put("viewCount", documentQueryService.recordView(attachment, request));
+        if (access.canView()) {
+            result.put("viewCount", documentQueryService.recordView(attachment, request));
+        } else {
+            // 编辑视图:回显库中当前计数(查询未携带时按 0),不增计不审计
+            result.put("viewCount",
+                    attachment.getViewCount() == null ? 0L : attachment.getViewCount());
+        }
         return result;
     }
 

+ 10 - 0
backend-java/src/main/java/com/dms/service/AuditService.java

@@ -80,6 +80,16 @@ public class AuditService {
         public static final String REORDER_ROLE = "REORDER_ROLE";
         // 借阅申请审批流(V22迁移/契约1.37)
         public static final String BORROW_REQUEST_CREATE = "BORROW_REQUEST_CREATE";
+        /** 审批相关设置(契约1.54):调阅说明保存与模板上传/清除。 */
+        public static final String BORROW_SETTINGS_UPDATE = "BORROW_SETTINGS_UPDATE";
+        /** 登录页文案设置(契约1.62):大标题与底部小字保存。 */
+        public static final String LOGIN_BRANDING_UPDATE = "LOGIN_BRANDING_UPDATE";
+    /** 重置密码申请提交(契约1.49,登录前公开接口)。 */
+    public static final String PASSWORD_RESET_REQUEST_CREATE = "PASSWORD_RESET_REQUEST_CREATE";
+    /** 重置密码申请通过(契约1.49):清密码回首次登录态。 */
+    public static final String PASSWORD_RESET_APPROVE = "PASSWORD_RESET_APPROVE";
+    /** 重置密码申请拒绝(契约1.49)。 */
+    public static final String PASSWORD_RESET_REJECT = "PASSWORD_RESET_REJECT";
         public static final String BORROW_REQUEST_APPROVE = "BORROW_REQUEST_APPROVE";
         public static final String BORROW_REQUEST_REJECT = "BORROW_REQUEST_REJECT";
     }

+ 133 - 10
backend-java/src/main/java/com/dms/service/AuthService.java

@@ -4,15 +4,19 @@ import com.dms.common.DmsException;
 import com.dms.common.Errors;
 import com.dms.config.DmsProperties;
 import com.dms.mapper.UserMapper;
+import com.dms.mapper.UserRoleMapper;
+import com.dms.model.RoleEntity;
+import com.dms.model.RoleMountEntity;
 import com.dms.model.UserEntity;
 import com.dms.security.AuthContext;
 import com.dms.security.JwtTokens;
 import com.dms.security.Passwords;
-import com.dms.service.AdminPermission;
 import jakarta.servlet.http.HttpServletRequest;
 import java.time.LocalDateTime;
 import java.time.ZoneOffset;
+import java.util.ArrayList;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -24,19 +28,24 @@ import org.springframework.transaction.annotation.Transactional;
 public class AuthService {
 
     private final UserMapper userMapper;
+    private final UserRoleMapper userRoleMapper;
     private final AuditService auditService;
     private final LoginIpService loginIpService;
     private final UserSummaryService userSummaryService;
     private final LoginLockoutService loginLockoutService;
     private final CaptchaService captchaService;
+    private final PortalAuthService portalAuthService;
     private final JwtTokens.Holder jwtHolder;
     private final DmsProperties dmsProperties;
 
-    public AuthService(UserMapper userMapper, AuditService auditService,
-                       LoginIpService loginIpService, UserSummaryService userSummaryService,
+    public AuthService(UserMapper userMapper, UserRoleMapper userRoleMapper,
+                       AuditService auditService, LoginIpService loginIpService,
+                       UserSummaryService userSummaryService,
                        LoginLockoutService loginLockoutService, CaptchaService captchaService,
-                       JwtTokens.Holder jwtHolder, DmsProperties dmsProperties) {
+                       JwtTokens.Holder jwtHolder, DmsProperties dmsProperties,
+                       PortalAuthService portalAuthService) {
         this.userMapper = userMapper;
+        this.userRoleMapper = userRoleMapper;
         this.auditService = auditService;
         this.loginIpService = loginIpService;
         this.userSummaryService = userSummaryService;
@@ -44,12 +53,18 @@ public class AuthService {
         this.captchaService = captchaService;
         this.jwtHolder = jwtHolder;
         this.dmsProperties = dmsProperties;
+        this.portalAuthService = portalAuthService;
     }
 
     @Transactional
     public Map<String, Object> login(String username, String password, boolean keepSignedIn,
-                                     String captchaId, String captchaCode,
+                                     String captchaId, String captchaCode, String roleIdText,
                                      HttpServletRequest request) {
+        // 安全管控:禁用用户名密码登录时仅允许单点登录(门户验签/本地SSO代理),
+        // 守卫置于最前——验证码不消耗、用户名不查询,不泄露任何账号信息
+        if (dmsProperties.passwordLoginDisabled()) {
+            throw Errors.forbidden("用户名密码登录已被禁用,请使用单点登录");
+        }
         // 验证码是防爆破第一道闸:先于用户查询/锁定检查,一次性消费不计锁定(安全需求4)
         captchaService.verify(captchaId, captchaCode);
         // 超级管理员登录开关(安全需求7)关闭时按用户不存在处理:锁定/状态/IP
@@ -58,12 +73,48 @@ public class AuthService {
                 && !dmsProperties.adminLoginAllowed();
         UserEntity user = adminLoginBlocked ? null
                 : userMapper.findActiveByUsername(username);
+        // 姓名回退(契约1.47):用户名与姓名同语义,账号未命中时按姓名匹配;
+        // 同名多行视为歧义一律按无此账号处理(统一401,不泄露重名情况)
+        if (user == null && !adminLoginBlocked) {
+            List<UserEntity> byRealName = userMapper.listActiveByRealName(username);
+            if (byRealName.size() == 1) {
+                user = byRealName.get(0);
+            }
+        }
         // 锁定检查先于密码校验:锁定期间一律拒绝,不延长锁定窗口
         if (LoginLockoutService.isLocked(user)) {
             auditService.recordLoginFailure(request, user, username, "ACCOUNT_LOCKED");
             throw Errors.accountLocked();
         }
-        boolean passwordValid = Passwords.verify(
+        // 密码已重置(审批通过清空,契约1.49→1.65):本地登录不再校验密码,
+        // 无论输入什么都返回首次设密载荷——复用门户 SET_PASSWORD 界面重新设密
+        // (审批流即身份核实环节,验证码已拦截自动化尝试);IP 限制与正常登录一致
+        if (user != null
+                && (user.getPasswordHash() == null || user.getPasswordHash().isEmpty())) {
+            try {
+                loginIpService.verifyLoginIp(user, request);
+            } catch (DmsException ex) {
+                if ("LOGIN_IP_FORBIDDEN".equals(ex.code())) {
+                    auditService.recordLoginFailure(request, user, username, "IP_NOT_ALLOWED");
+                }
+                throw ex;
+            }
+            auditService.recordLoginFailure(request, user, username, "PASSWORD_RESET");
+            return portalAuthService.buildSetupPasswordPayload(user);
+        }
+        // 密码即角色(契约1.53):挂载行独立密码优先匹配——命中即以该
+        // 角色进入;未命中回退用户级密码验证(挂载行 NULL=使用用户密码)
+        List<RoleMountEntity> mounts = user == null
+                ? List.of() : userRoleMapper.listLoginMounts(user.getId());
+        Long mountedHit = null;
+        for (RoleMountEntity mount : mounts) {
+            if (mount.getPasswordHash() != null
+                    && Passwords.verify(mount.getPasswordHash(), password)) {
+                mountedHit = mount.getRoleId();
+                break;
+            }
+        }
+        boolean passwordValid = mountedHit != null || Passwords.verify(
                 user != null ? user.getPasswordHash() : null, password);
         if (user == null || !passwordValid) {
             auditService.recordLoginFailure(request, user, username, "INVALID_CREDENTIALS");
@@ -86,16 +137,61 @@ public class AuthService {
             throw ex;
         }
 
-        JwtTokens.Issued issued = JwtTokens.issue(user, keepSignedIn,
+        // 密码即角色(契约1.53):命中挂载行独立密码直接以该角色进入;
+        // 用户级密码命中时优先「使用用户密码」的挂载行(恰一个),挂载行
+        // 全部未分配且多角色为存量过渡态,维持1.46角色选择步骤(不签发
+        // token、不记成功、不更新登录痕迹),前端选择后携 roleId 重提交
+        Long selectedRoleId;
+        if (roleIdText != null) {
+            List<RoleEntity> mountedRoles =
+                    userRoleMapper.listActiveRolesByUserId(user.getId());
+            selectedRoleId = parseRoleSelection(roleIdText, mountedRoles);
+        } else if (mountedHit != null) {
+            selectedRoleId = mountedHit;
+        } else {
+            List<RoleMountEntity> userPasswordMounts = new ArrayList<>();
+            for (RoleMountEntity mount : mounts) {
+                if (mount.getPasswordHash() == null) {
+                    userPasswordMounts.add(mount);
+                }
+            }
+            if (userPasswordMounts.size() == 1) {
+                selectedRoleId = userPasswordMounts.get(0).getRoleId();
+            } else if (mounts.size() > 1) {
+                Map<String, Object> data = new LinkedHashMap<>();
+                data.put("roleSelectionRequired", true);
+                data.put("username", user.getUsername());
+                data.put("realName", user.getRealName());
+                List<Map<String, Object>> options = new ArrayList<>();
+                for (RoleMountEntity mount : mounts) {
+                    Map<String, Object> option = new LinkedHashMap<>();
+                    option.put("id", String.valueOf(mount.getRoleId()));
+                    option.put("roleName", mount.getRoleName());
+                    options.add(option);
+                }
+                data.put("roles", options);
+                return data;
+            } else {
+                // 单角色自动选中;未挂角色走默认角色(roleId=null)
+                selectedRoleId = mounts.isEmpty() ? null : mounts.get(0).getRoleId();
+            }
+        }
+
+        JwtTokens.Issued issued = JwtTokens.issue(user, selectedRoleId, keepSignedIn,
                 jwtHolder.secret(), jwtHolder.expiresSeconds());
         LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
         String clientIp = AuditService.clientIp(request);
         // 一次一码(安全需求):登录即绑定会话代;开关关闭落 NULL(不纳管)
         userMapper.updateLoginSuccess(user.getId(), now, clientIp,
                 dmsProperties.tokenRotationEnabled() ? issued.jti() : null);
+        // 审计明细:会话角色(无角色=null,未挂角色用户按默认角色解析)
+        Map<String, Object> loginDetail = new LinkedHashMap<>();
+        loginDetail.put("keepSignedIn", keepSignedIn);
+        loginDetail.put("roleId", selectedRoleId == null
+                ? null : String.valueOf(selectedRoleId));
         auditService.insert(auditService.authAudit(request, AuditService.Action.LOGIN,
                 AuditService.RESULT_SUCCESS, user, null, null,
-                auditService.toJson(Map.of("keepSignedIn", keepSignedIn))));
+                auditService.toJson(loginDetail)));
 
         user.setLastLoginAt(now);
         user.setLastLoginIp(clientIp);
@@ -109,11 +205,38 @@ public class AuthService {
         data.put("tokenRotation", dmsProperties.tokenRotationEnabled());
         // 页面空闲自动登出(契约1.29):分钟数,0 表示关闭
         data.put("idleLogoutMinutes", dmsProperties.idleLogoutMinutes());
-        data.put("user", userSummaryService.userSummary(user,
-                userSummaryService.allowedModulesForUser(user)));
+        // 会话角色随 token 下发:摘要与菜单按选定角色计算(登录线程无请求级
+        // 认证上下文,临时建立后释放,复用 effectiveManageRole 的会话解析)
+        AuthContext.set(new AuthContext(
+                user.getId(), user.getUsername(), user.getRealName(),
+                user.getOrganizationId(), user.getOrganizationName(),
+                user.getRoleCode(), user.getSecurityLevel(), user.getAuthVersion(),
+                issued.jti(), selectedRoleId));
+        try {
+            data.put("user", userSummaryService.userSummary(user,
+                    userSummaryService.allowedModulesForUser(user)));
+        } finally {
+            AuthContext.clear();
+        }
         return data;
     }
 
+    /** roleId 选择校验(契约1.46):必须指向本人挂载的启用角色,防越权选定。 */
+    private static Long parseRoleSelection(String roleIdText, List<RoleEntity> mountedRoles) {
+        long roleId;
+        try {
+            roleId = Long.parseLong(roleIdText);
+        } catch (NumberFormatException ex) {
+            throw Errors.invalidArgument("roleId必须是数字字符串");
+        }
+        for (RoleEntity role : mountedRoles) {
+            if (role.getId() == roleId) {
+                return roleId;
+            }
+        }
+        throw Errors.invalidArgument("roleId必须指向已挂载的角色");
+    }
+
     @Transactional
     public void logout(HttpServletRequest request) {
         UserEntity user = userMapper.findActiveByIdForUpdate(AuthContext.get().userId());

+ 209 - 31
backend-java/src/main/java/com/dms/service/AuthorizationService.java

@@ -2,12 +2,14 @@ package com.dms.service;
 
 import com.dms.common.Errors;
 import com.dms.mapper.AttachmentBindingMapper;
+import com.dms.mapper.CategoryMapper;
 import com.dms.mapper.DocumentMapper;
 import com.dms.mapper.OrganizationMapper;
 import com.dms.mapper.PermissionMapper;
 import com.dms.mapper.RoleMapper;
 import com.dms.mapper.RolePermissionMapper;
 import com.dms.mapper.UserMapper;
+import com.dms.model.CategoryEntity;
 import com.dms.model.DocumentEntity;
 import com.dms.model.OrganizationEntity;
 import com.dms.model.PermissionEntity;
@@ -72,6 +74,7 @@ public class AuthorizationService {
     }
 
     private final DocumentMapper documentMapper;
+    private final CategoryMapper categoryMapper;
     private final PermissionMapper permissionMapper;
     private final AttachmentBindingMapper attachmentBindingMapper;
     private final OrganizationMapper organizationMapper;
@@ -80,6 +83,7 @@ public class AuthorizationService {
     private final RolePermissionMapper rolePermissionMapper;
 
     public AuthorizationService(DocumentMapper documentMapper,
+                                CategoryMapper categoryMapper,
                                 PermissionMapper permissionMapper,
                                 AttachmentBindingMapper attachmentBindingMapper,
                                 OrganizationMapper organizationMapper,
@@ -87,6 +91,7 @@ public class AuthorizationService {
                                 RoleMapper roleMapper,
                                 RolePermissionMapper rolePermissionMapper) {
         this.documentMapper = documentMapper;
+        this.categoryMapper = categoryMapper;
         this.permissionMapper = permissionMapper;
         this.attachmentBindingMapper = attachmentBindingMapper;
         this.organizationMapper = organizationMapper;
@@ -160,11 +165,21 @@ public class AuthorizationService {
         }
         String label = ACTION_LABELS.getOrDefault(action, "执行该操作");
         if ("ATTACHMENT".equals(document.getDocumentType())) {
-            if (!"ADMIN".equals(context.roleCode())) {
-                throw Errors.documentActionForbidden(
-                        "无权" + label + "该共享附件:仅管理员可维护共享附件");
+            // 契约1.43/1.44:仅内置超管恒过;受限ADMIN与USER同口径按矩阵附件位判定
+            //(删除为第五附件位,不再按ADMIN类别专属)
+            if (isSuperAdmin(context)) {
+                return null;
             }
-            return null;
+            // 契约1.41:矩阵附件编辑位放行 EDIT(共享附件库与已挂载区域附件的编辑)
+            if ("EDIT".equals(action) && attachmentEditAllowed(document, context)) {
+                return null;
+            }
+            // 契约1.44:矩阵附件删除位放行 DELETE
+            if ("DELETE".equals(action) && attachmentDeleteAllowed(document, context)) {
+                return null;
+            }
+            throw Errors.documentActionForbidden(
+                    "无权" + label + "该共享附件:角色权限未授予该操作");
         }
         PlanAccess access = evaluatePlanAccess(document, context);
         if (!planAllowedActions(document, context, access).contains(action)) {
@@ -173,6 +188,17 @@ public class AuthorizationService {
         return access;
     }
 
+    /**
+     * 详情级访问(操作位隔离补全):查看权,或具备任一操作位(编辑视图)。
+     * 编辑视图详情仅元数据——正文不下发、查看计数不增、查看审计不记,
+     * 供编辑弹窗读取表单初值与 rowVersion;判定与写路径 requirePlanAction
+     * 同源(planAllowedActions),密级不足与状态拒绝维持拒绝。
+     */
+    public boolean detailAccessAllowed(DocumentEntity document, AuthContext context,
+                                       PlanAccess access) {
+        return access.allowed() || !planAllowedActions(document, context, access).isEmpty();
+    }
+
     // ------------------------------------------------------------ 权限源与角色
 
     /** MAIN 即自身;SUB_PLAN 取 root 主案;ATTACHMENT 无 ACL 源。 */
@@ -204,10 +230,11 @@ public class AuthorizationService {
         return effectiveRole(context, new AccessMemo());
     }
 
-    /** memo 版:请求内用户角色恒定,单槽缓存。 */
+    /** memo 版:请求内用户角色恒定,单槽缓存;会话角色(契约1.46)优先,
+     * 角色停用不回退默认(与显式角色语义一致)。 */
     RoleEntity effectiveRole(AuthContext context, AccessMemo memo) {
         if (!memo.roleLoaded) {
-            Long roleId = userMapper.findRoleIdActive(context.userId());
+            Long roleId = context.roleId();
             memo.role = roleId != null
                     ? roleMapper.findActiveEnabledById(roleId)
                     : roleMapper.findDefaultActive();
@@ -238,23 +265,29 @@ public class AuthorizationService {
         return "OTHER";
     }
 
-    /** 生效角色矩阵中命中的操作位集合(密级×部门范围,实时查库)。 */
+    /** 生效角色矩阵中命中的操作位集合(一级分类×密级×部门范围,实时查库)。 */
     public Set<String> matrixActions(DocumentEntity source, AuthContext context) {
         return matrixActions(source, context, new AccessMemo());
     }
 
-    /** memo 版:角色、机构与矩阵行按请求缓存,批量评估时消除逐行查库。 */
+    /** memo 版:角色、机构、顶级分类与矩阵行按请求缓存,批量评估时消除逐行查库。
+     *  契约1.48:主案按其一级分类 × 密级 × 部门范围取 MAIN 行。 */
     Set<String> matrixActions(DocumentEntity source, AuthContext context, AccessMemo memo) {
         RoleEntity role = effectiveRole(context, memo);
         if (role == null) {
             return Set.of();
         }
         String scope = deptScope(source, context, memo);
+        Long topCategoryId = topCategoryIdOf(source.getCategoryId(), memo);
+        if (topCategoryId == null) {
+            return Set.of();
+        }
         return memo.matrixActionsByKey.computeIfAbsent(
-                source.getSecurityLevel() + "|" + scope,
+                topCategoryId + "|" + source.getSecurityLevel() + "|" + scope,
                 key -> {
                     RolePermissionEntity row = rolePermissionMapper.findMatrixRow(
-                            role.getId(), source.getSecurityLevel(), scope);
+                            role.getId(), topCategoryId, RoleService.DOC_KIND_MAIN,
+                            source.getSecurityLevel(), scope);
                     if (row == null) {
                         return Set.of();
                     }
@@ -268,6 +301,28 @@ public class AuthorizationService {
                 });
     }
 
+    /** 一级分类解析(契约1.48):沿父链上溯到顶级分类(memo 按分类 id 缓存)。 */
+    private Long topCategoryIdOf(Long categoryId, AccessMemo memo) {
+        if (categoryId == null) {
+            return null;
+        }
+        return memo.topCategoriesById.computeIfAbsent(categoryId, id -> {
+            Long current = id;
+            // 分类创建有环校验,此处限深仅为脏数据兜底
+            for (int depth = 0; depth < 32 && current != null; depth++) {
+                CategoryEntity category = categoryMapper.findByIdNotDeleted(current);
+                if (category == null) {
+                    return null;
+                }
+                if (category.getParentId() == null) {
+                    return category.getId();
+                }
+                current = category.getParentId();
+            }
+            return null;
+        });
+    }
+
     /** 权限源文档的授权列表(memo 内按 sourceId 去重,评估与权限摘要共享)。 */
     List<PermissionEntity> activePermissions(long sourceId, AccessMemo memo) {
         return memo.permissionsBySourceId.computeIfAbsent(
@@ -284,6 +339,10 @@ public class AuthorizationService {
             case "canBindAttachment" -> row.getCanBindAttachment();
             case "canUnbindAttachment" -> row.getCanUnbindAttachment();
             case "canUpload" -> row.getCanUpload();
+            case "canAttachmentUpload" -> row.getCanAttachmentUpload();
+            case "canAttachmentEdit" -> row.getCanAttachmentEdit();
+            case "canAttachmentView" -> row.getCanAttachmentView();
+            case "canAttachmentDownload" -> row.getCanAttachmentDownload();
             default -> null;
         };
         return Boolean.TRUE.equals(value);
@@ -386,29 +445,44 @@ public class AuthorizationService {
                 "BIND_ATTACHMENT", "UNBIND_ATTACHMENT");
     }
 
-    /** 共享附件可用操作位(对齐 attachment_allowed_actions)。 */
-    public List<String> attachmentAllowedActions(AuthContext context, PlanAccess access) {
-        if ("ADMIN".equals(context.roleCode())) {
+    /**
+     * 共享附件可用操作位(契约1.43/1.44:仅矩阵附件位——不跟随挂载主案权限,
+     * 受限ADMIN与USER同口径;超管恒全量)。
+     */
+    public List<String> attachmentAllowedActions(AuthContext context, DocumentEntity attachment,
+                                                 PlanAccess access) {
+        if (isSuperAdmin(context)) {
             return List.of("VIEW", "DOWNLOAD", "EDIT", "DELETE");
         }
+        List<RolePermissionEntity> matrixRows = attachmentMatrixRows(attachment, context);
         List<String> actions = new ArrayList<>();
-        if (access != null && access.allowed()) {
+        // 多挂载主案时任一分类行命中即放行(契约1.48)
+        if (matrixRows.stream().anyMatch(row -> Boolean.TRUE.equals(row.getCanAttachmentView()))) {
             actions.add("VIEW");
         }
         // 下载独立下发(契约1.36完全拆分):无查看命中也可下载
-        if (access != null && access.canDownload()) {
+        if (matrixRows.stream().anyMatch(
+                row -> Boolean.TRUE.equals(row.getCanAttachmentDownload()))) {
             actions.add("DOWNLOAD");
         }
+        if (matrixRows.stream().anyMatch(
+                row -> Boolean.TRUE.equals(row.getCanAttachmentEdit()))) {
+            actions.add("EDIT");
+        }
+        if (matrixRows.stream().anyMatch(
+                row -> Boolean.TRUE.equals(row.getCanAttachmentDelete()))) {
+            actions.add("DELETE");
+        }
         return actions;
     }
 
     // ------------------------------------------------------------ 附件访问
 
     /**
-     * USER 只能通过至少一个有权有效主案访问共享附件。
+     * 共享附件查看/下载访问评估(契约1.43:仅矩阵附件位,不再跟随挂载主案权限)。
      *
-     * <p>阶段11:附件允许携带任意密级(V5放宽shape约束,存量子案保留原密级),
-     * 读路径密级前置与主案/子方案同口径——任何角色(含ADMIN)密级不足即拒绝。</p>
+     * <p>密级前置与主案/子方案同口径——任何角色(含超管)密级不足即拒绝;
+     * 超管恒可查看下载;受限ADMIN与USER同口径按附件密级取OWN行附件位。</p>
      */
     @Transactional(readOnly = true)
     public AttachmentAccess evaluateAttachmentAccess(DocumentEntity attachment,
@@ -416,21 +490,107 @@ public class AuthorizationService {
         if (!securityAllowed(attachment, context)) {
             return new AttachmentAccess(false, false);
         }
-        if ("ADMIN".equals(context.roleCode())) {
+        if (isSuperAdmin(context)) {
             return new AttachmentAccess(true, true);
         }
-        boolean canView = false;
-        boolean canDownload = false;
-        for (DocumentEntity main : attachmentBindingMapper
-                .listActiveMainsByAttachment(attachment.getId())) {
-            PlanAccess access = evaluatePlanAccess(main, context);
-            canView = canView || access.allowed();
-            // 下载独立聚合(契约1.36完全拆分):任一有效主案命中下载即可
-            canDownload = canDownload || access.canDownload();
-        }
+        List<RolePermissionEntity> matrixRows = attachmentMatrixRows(attachment, context);
+        boolean canView = matrixRows.stream().anyMatch(
+                row -> Boolean.TRUE.equals(row.getCanAttachmentView()));
+        boolean canDownload = matrixRows.stream().anyMatch(
+                row -> Boolean.TRUE.equals(row.getCanAttachmentDownload()));
         return new AttachmentAccess(canView, canDownload);
     }
 
+    /**
+     * 契约1.41/1.48:附件矩阵命中行集合——附件无部门归属,恒 OWN 行,
+     * 按附件自身密级与种类取行(存量子案转附件可能保留非 PUBLIC 密级)。
+     *
+     * <p>契约1.48:已挂载附件按其挂载主案的一级分类定位行(多主案挂载逐一定位,
+     * 任一命中即放行);未挂载附件落共享资料库行组(top_category_id IS NULL)。</p>
+     *
+     * <p>契约1.59 库行组兜底:库行组行(类型页签)对全部该类型附件生效(含
+     * 已挂载),与分类行任一命中即放行——分类页签附件矩阵已隐藏(迁移 V33
+     * 存量清零),库行组成为统一配置入口,分类行保留日后恢复配置的加细能力。</p>
+     */
+    private List<RolePermissionEntity> attachmentMatrixRows(DocumentEntity attachment,
+                                                            AuthContext context) {
+        RoleEntity role = effectiveRole(context);
+        if (role == null || attachment.getAttachmentType() == null) {
+            return List.of();
+        }
+        AccessMemo memo = new AccessMemo();
+        // 库行组兜底(契约1.59):无论是否挂载,先取类型页签行参与命中判定
+        List<RolePermissionEntity> rows = new ArrayList<>();
+        RolePermissionEntity libraryRow = rolePermissionMapper.findLibraryMatrixRow(
+                role.getId(), attachment.getAttachmentType(),
+                attachment.getSecurityLevel());
+        if (libraryRow != null) {
+            rows.add(libraryRow);
+        }
+        List<Long> mainIds = memo.mainIdsByAttachmentId.computeIfAbsent(
+                attachment.getId(),
+                id -> attachmentBindingMapper.listActiveMainDocumentIds(id));
+        if (mainIds.isEmpty()) {
+            return rows;
+        }
+        Set<Long> visitedTops = new HashSet<>();
+        for (Long mainId : mainIds) {
+            DocumentEntity main = memo.rootsById.computeIfAbsent(
+                    mainId, documentMapper::findActiveMainById);
+            if (main == null) {
+                continue;
+            }
+            Long topCategoryId = topCategoryIdOf(main.getCategoryId(), memo);
+            if (topCategoryId == null || !visitedTops.add(topCategoryId)) {
+                continue;
+            }
+            RolePermissionEntity row = rolePermissionMapper.findMatrixRow(
+                    role.getId(), topCategoryId, attachment.getAttachmentType(),
+                    attachment.getSecurityLevel(), "OWN");
+            if (row != null) {
+                rows.add(row);
+            }
+        }
+        return rows;
+    }
+
+    /** 契约1.41:矩阵附件编辑位(共享附件库与已挂载区域附件的编辑,任一命中行)。 */
+    @Transactional(readOnly = true)
+    public boolean attachmentEditAllowed(DocumentEntity attachment, AuthContext context) {
+        return attachmentMatrixRows(attachment, context).stream().anyMatch(
+                row -> Boolean.TRUE.equals(row.getCanAttachmentEdit()));
+    }
+
+    /** 契约1.44:矩阵附件删除位(附件删除不再按ADMIN类别专属,任一命中行)。 */
+    @Transactional(readOnly = true)
+    public boolean attachmentDeleteAllowed(DocumentEntity attachment, AuthContext context) {
+        return attachmentMatrixRows(attachment, context).stream().anyMatch(
+                row -> Boolean.TRUE.equals(row.getCanAttachmentDelete()));
+    }
+
+    /**
+     * 契约1.43/1.48:共享附件库上传位——附件上传必入共享资料库(未挂载),
+     * 按共享资料库行组(附件类型 × 密级,OWN)取附件上传位;
+     * 仅内置超管恒可,受限ADMIN与USER同口径。
+     */
+    @Transactional(readOnly = true)
+    public boolean canUploadAttachment(AuthContext context, String securityLevel,
+                                       String attachmentType) {
+        if (isSuperAdmin(context)) {
+            return true;
+        }
+        if (attachmentType == null) {
+            return false;
+        }
+        RoleEntity role = effectiveRole(context);
+        if (role == null) {
+            return false;
+        }
+        RolePermissionEntity row = rolePermissionMapper.findLibraryMatrixRow(
+                role.getId(), attachmentType, securityLevel);
+        return row != null && Boolean.TRUE.equals(row.getCanAttachmentUpload());
+    }
+
     /** 附件访问前置(download=true 校验下载位,否则校验查看位)。 */
     public AttachmentAccess requireAttachmentAccess(DocumentEntity attachment,
                                                     AuthContext context, boolean download) {
@@ -514,7 +674,8 @@ public class AuthorizationService {
     }
 
     /**
-     * 对齐 can_upload_plan:主案上传权限(契约1.31),方案权限矩阵 can_upload 列。
+     * 对齐 can_upload_plan:主案上传权限(契约1.31/1.48),按目标一级分类的
+     * MAIN 行 can_upload 列。
      *
      * <p>超管恒可;文档归属本部门子树(或无归属)按 OWN 行判定,
      * 归属其他部门按 OTHER 行判定(矩阵校验 OTHER 行 can_upload 恒 0,
@@ -522,7 +683,7 @@ public class AuthorizationService {
      */
     @Transactional(readOnly = true)
     public boolean canUploadPlan(AuthContext context, String securityLevel,
-                                 Long organizationId) {
+                                 Long categoryId, Long organizationId) {
         if (isSuperAdmin(context)) {
             return true;
         }
@@ -530,6 +691,10 @@ public class AuthorizationService {
         if (role == null) {
             return false;
         }
+        Long topCategoryId = topCategoryIdOf(categoryId, new AccessMemo());
+        if (topCategoryId == null) {
+            return false;
+        }
         String scope = "OWN";
         if (organizationId != null
                 && !organizationSubtreeIds(context.organizationId())
@@ -537,7 +702,8 @@ public class AuthorizationService {
             scope = "OTHER";
         }
         RolePermissionEntity row = rolePermissionMapper.findMatrixRow(
-                role.getId(), securityLevel, scope);
+                role.getId(), topCategoryId, RoleService.DOC_KIND_MAIN,
+                securityLevel, scope);
         return row != null && Boolean.TRUE.equals(row.getCanUpload());
     }
 
@@ -547,6 +713,18 @@ public class AuthorizationService {
         return roleId != null && rolePermissionMapper.countOwnUpload(roleId) > 0;
     }
 
+    /** 契约1.41:任一 OWN 行任一附件位=1(共享资料库页签会话位)。 */
+    @Transactional(readOnly = true)
+    public boolean hasAnyAttachmentLibraryPermission(Long roleId) {
+        return roleId != null && rolePermissionMapper.countOwnAnyAttachment(roleId) > 0;
+    }
+
+    /** 契约1.41:任一 OWN 行附件上传位=1(共享附件上传按钮会话位)。 */
+    @Transactional(readOnly = true)
+    public boolean hasAnyAttachmentUploadPermission(Long roleId) {
+        return roleId != null && rolePermissionMapper.countOwnAttachmentUpload(roleId) > 0;
+    }
+
     /** 密级白名单(含自身及以下,对齐 category_tree 的 allowed_security_levels)。 */
     public static List<String> allowedSecurityLevels(String securityLevel) {
         int rank = order(securityLevel);

+ 26 - 27
backend-java/src/main/java/com/dms/service/BorrowRequestService.java

@@ -38,7 +38,6 @@ import java.util.Set;
 import org.springframework.core.io.FileSystemResource;
 import org.springframework.core.io.Resource;
 import org.springframework.http.HttpHeaders;
-import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -48,7 +47,7 @@ import org.springframework.web.multipart.MultipartFile;
  * 借阅申请审批流(V22迁移,契约1.37)。
  *
  * <p>借阅授权入口由手工配置改为申请-审批:无查看且无下载权限(密级足够)的用户
- * 发起借阅申请,一批用户共用一张审批单(可选 PDF 凭证);申请以 PENDING 待办
+ * 发起借阅申请,一批用户共用一张审批单(必传 PDF/图片,契约1.72);申请以 PENDING 待办
  * 通知所有对该文档有授权权限(CONFIG_PERMISSION,矩阵反查+内置超管)的用户,
  * 任一审批人同意/拒绝即定(先到先得),其余待办自动失效;通过后逐条目落地
  * doc_permission(复用借阅 ACL 模型,到期/回收站语义自动继承),凭证以引用
@@ -112,22 +111,22 @@ public class BorrowRequestService {
     // ------------------------------------------------------------ 提交申请
 
     /**
-     * 提交借阅申请:一批用户共用一张审批单(可选 PDF),落库后向所有
+     * 提交借阅申请:一批用户共用一张审批单(必传 PDF/图片,契约1.72),落库后向所有
      * 有授权权限的用户发 PENDING 待办通知。文件提升放事务成功之后。
      */
     public Map<String, Object> createRequest(long documentId, Map<String, Object> payload,
                                              MultipartFile file,
                                              HttpServletRequest request) {
-        reauth.require(request);
+        // 契约1.66:调阅申请不再要求二次认证,改为强制上传审批单(无凭证不受理)
+        if (file == null || file.isEmpty()) {
+            throw Errors.invalidArgument("调阅申请必须上传审批单(PDF 或图片)");
+        }
         List<EntryRequest> entries = parseEntries(payload);
         LocalDateTime expiresAt = parseExpires(payload.get("expiresAt"));
-        // 非锁定预读仅取密级定凭证存储域;完整校验在 doCreate 事务内
-        StagedUpload staged = null;
-        if (file != null) {
-            DocumentEntity preview = documentMapper.findByIdNotDeleted(documentId);
-            staged = voucherService.stageVoucher(file,
-                    preview == null ? null : preview.getSecurityLevel());
-        }
+        // 非锁定预读仅取密级定凭证存储域;完整校验在 doCreate 事务内(审批单已必传)
+        DocumentEntity preview = documentMapper.findByIdNotDeleted(documentId);
+        StagedUpload staged = voucherService.stageVoucher(file,
+                preview == null ? null : preview.getSecurityLevel());
         Map<String, Object> result;
         try {
             result = doCreate(documentId, entries, expiresAt, staged, request);
@@ -164,7 +163,7 @@ public class BorrowRequestService {
         List<String> allowed = authorizationService.planAllowedActions(document, context,
                 authorizationService.evaluatePlanAccess(document, context));
         if (allowed.contains("VIEW") || allowed.contains("DOWNLOAD")) {
-            throw Errors.invalidArgument("你已拥有该文档的查看或下载权限,无需借阅");
+            throw Errors.invalidArgument("你已拥有该文档的查看或下载权限,无需调阅");
         }
 
         Map<Long, UserEntity> subjects = lockedSubjects(entries);
@@ -179,7 +178,7 @@ public class BorrowRequestService {
             }
             if (borrowRequestMapper.countPendingBySubject(document.getId(), row.getKey()) > 0) {
                 throw new DmsException("BORROW_REQUEST_DUPLICATE", 409,
-                        "该用户对该文档已有待审批的借阅申请", Payloads.orderedMap());
+                        "该用户对该文档已有待审批的调阅申请", Payloads.orderedMap());
             }
         }
 
@@ -249,7 +248,7 @@ public class BorrowRequestService {
         AuthContext context = AuthContext.get();
         BorrowRequestEntity entity = borrowRequestMapper.findById(requestId);
         if (entity == null) {
-            throw Errors.resourceNotFound("借阅申请不存在");
+            throw Errors.resourceNotFound("调阅申请不存在");
         }
         DocumentEntity document = requireVisibleDocument(entity, context);
         List<Map<String, Object>> items = new ArrayList<>();
@@ -281,14 +280,14 @@ public class BorrowRequestService {
         return result;
     }
 
-    /** 申请审批单下载(PDF attachment 直出,权限同详情)。 */
+    /** 申请审批单下载(attachment 直出,PDF/图片按扩展名映射类型,权限同详情)。 */
     @Transactional(readOnly = true)
     public ResponseEntity<Resource> downloadRequestVoucher(long requestId,
                                                             HttpServletRequest request) {
         AuthContext context = AuthContext.get();
         BorrowRequestEntity entity = borrowRequestMapper.findById(requestId);
         if (entity == null) {
-            throw Errors.resourceNotFound("借阅申请不存在");
+            throw Errors.resourceNotFound("调阅申请不存在");
         }
         requireVisibleDocument(entity, context);
         if (entity.getVoucherStoragePath() == null
@@ -302,7 +301,7 @@ public class BorrowRequestService {
         String name = entity.getVoucherFileName() != null
                 ? entity.getVoucherFileName() : "voucher.pdf";
         return ResponseEntity.ok()
-                .contentType(MediaType.APPLICATION_PDF)
+                .contentType(VoucherService.voucherMediaType(name))
                 .header(HttpHeaders.CONTENT_DISPOSITION,
                         FileReadService.attachmentDisposition(name))
                 .header("X-Content-Type-Options", "nosniff")
@@ -323,10 +322,10 @@ public class BorrowRequestService {
         AuthContext context = AuthContext.get();
         BorrowRequestEntity entity = borrowRequestMapper.findForUpdate(requestId);
         if (entity == null) {
-            throw Errors.resourceNotFound("借阅申请不存在");
+            throw Errors.resourceNotFound("调阅申请不存在");
         }
         if (!"PENDING".equals(entity.getStatus())) {
-            throw Errors.dataVersionConflict("该借阅申请已被处理");
+            throw Errors.dataVersionConflict("该调阅申请已被处理");
         }
         DocumentEntity document = documentMapper.findByIdNotDeleted(entity.getDocumentId());
         if (document == null) {
@@ -341,7 +340,7 @@ public class BorrowRequestService {
         int updated = borrowRequestMapper.updateHandled(requestId, finalStatus,
                 context.userId(), context.realName(), now);
         if (updated == 0) {
-            throw Errors.dataVersionConflict("该借阅申请已被处理");
+            throw Errors.dataVersionConflict("该调阅申请已被处理");
         }
 
         List<BorrowRequestItemEntity> items =
@@ -349,12 +348,12 @@ public class BorrowRequestService {
         if (approved) {
             grantPermissions(document, entity, items, context, now);
         }
-        notificationMapper.settlePendingByBiz(requestId, notificationId, action, now);
+        notificationMapper.settlePendingByBiz(requestId, "APPROVAL", notificationId, action, now);
 
         NotificationEntity result = new NotificationEntity();
         result.setUserId(entity.getRequesterId());
-        result.setTitle(approved ? "借阅申请已通过" : "借阅申请已拒绝");
-        result.setContent("你申请借阅的《" + document.getDocumentName() + "》已被 "
+        result.setTitle(approved ? "调阅申请已通过" : "调阅申请已拒绝");
+        result.setContent("你申请调阅的《" + document.getDocumentName() + "》已被 "
                 + context.realName() + (approved ? " 通过" : " 拒绝"));
         result.setNotifyType("APPROVAL");
         result.setAction("NONE");
@@ -392,7 +391,7 @@ public class BorrowRequestService {
             }
         }
         if (!users.keySet().equals(userIds)) {
-            throw Errors.resourceNotFound("借阅用户不存在、已删除或已禁用");
+            throw Errors.resourceNotFound("调阅用户不存在、已删除或已禁用");
         }
         return users;
     }
@@ -477,8 +476,8 @@ public class BorrowRequestService {
                                                           String approverName) {
         NotificationEntity notification = new NotificationEntity();
         notification.setUserId(approverId);
-        notification.setTitle("借阅审批申请");
-        notification.setContent(entity.getRequesterName() + " 申请借阅《"
+        notification.setTitle("调阅审批申请");
+        notification.setContent(entity.getRequesterName() + " 申请调阅《"
                 + document.getDocumentName() + "》,请审批");
         notification.setNotifyType("APPROVAL");
         notification.setAction("PENDING");
@@ -506,7 +505,7 @@ public class BorrowRequestService {
             throw Errors.invalidArgument("expiresAt必须晚于当前时间");
         }
         if (expires.isAfter(now.plus(MAX_BORROW).plus(BORROW_TOLERANCE))) {
-            throw Errors.invalidArgument("expiresAt借阅时限最长为7天");
+            throw Errors.invalidArgument("expiresAt调阅时限最长为7天");
         }
         return expires;
     }

+ 239 - 0
backend-java/src/main/java/com/dms/service/BorrowSettingsService.java

@@ -0,0 +1,239 @@
+package com.dms.service;
+
+import com.dms.common.DeadlockRetry;
+import com.dms.common.DmsException;
+import com.dms.common.Errors;
+import com.dms.config.DmsProperties;
+import com.dms.mapper.SettingMapper;
+import com.dms.model.SettingEntity;
+import com.dms.security.AuthContext;
+import com.dms.storage.StoragePaths;
+import com.dms.storage.UploadStorage;
+import jakarta.servlet.http.HttpServletRequest;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.UUID;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 审批相关设置(契约1.54):系统管理页维护调阅说明与调阅审批单模板,
+ * 调阅(借阅申请)对话框展示说明文本并提供模板下载。说明存 sys_setting
+ * 键值表(borrow.notice 行),模板 PDF 落盘存储根目录固定路径
+ * settings/borrow-template.pdf(覆盖替换语义,元数据存 borrow.template 行)。
+ */
+@Service
+public class BorrowSettingsService {
+
+    static final String NOTICE_KEY = "borrow.notice";
+    static final String TEMPLATE_KEY = "borrow.template";
+    /** 模板固定落盘相对路径(无密级概念的管理配置,不入密级域)。 */
+    static final String TEMPLATE_PATH = "settings/borrow-template.pdf";
+    private static final int NOTICE_MAX_LENGTH = 4000;
+    private static final int CHUNK_SIZE = 1024 * 1024;
+
+    private final SettingMapper settingMapper;
+    private final StoragePaths storagePaths;
+    private final UploadStorage uploadStorage;
+    private final DmsProperties properties;
+    private final AuditService auditService;
+    private final DeadlockRetry deadlockRetry;
+
+    public BorrowSettingsService(SettingMapper settingMapper, StoragePaths storagePaths,
+                                 UploadStorage uploadStorage, DmsProperties properties,
+                                 AuditService auditService, DeadlockRetry deadlockRetry) {
+        this.settingMapper = settingMapper;
+        this.storagePaths = storagePaths;
+        this.uploadStorage = uploadStorage;
+        this.properties = properties;
+        this.auditService = auditService;
+        this.deadlockRetry = deadlockRetry;
+    }
+
+    /** 设置读取(登录用户即可):调阅说明与模板文件名,null=未设置。 */
+    @Transactional(readOnly = true)
+    public Map<String, Object> settings() {
+        SettingEntity notice = settingMapper.findByKey(NOTICE_KEY);
+        SettingEntity template = settingMapper.findByKey(TEMPLATE_KEY);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("notice", notice == null ? null : notice.getSettingValue());
+        data.put("templateFileName", template == null ? null : template.getFileName());
+        return data;
+    }
+
+    /** 保存调阅说明(可清空:null/空串按未设置落 NULL 行)。 */
+    @Transactional
+    public Map<String, Object> updateNotice(Object raw, HttpServletRequest request) {
+        final String notice;
+        if (raw instanceof String text) {
+            notice = text.isBlank() ? null : text.strip();
+        } else if (raw != null) {
+            throw Errors.invalidArgument("notice必须是字符串");
+        } else {
+            notice = null;
+        }
+        if (notice != null && notice.length() > NOTICE_MAX_LENGTH) {
+            throw Errors.invalidArgument("调阅说明不能超过" + NOTICE_MAX_LENGTH + "字");
+        }
+        LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+        long actorId = AuthContext.get().userId();
+        deadlockRetry.execute(() -> {
+            if (settingMapper.updateValue(NOTICE_KEY, notice, now, actorId) == 0) {
+                settingMapper.insert(NOTICE_KEY, notice, null, null, now, actorId);
+            }
+            return null;
+        });
+        auditService.insert(auditService.businessAudit(request,
+                AuditService.Action.BORROW_SETTINGS_UPDATE, "SYSTEM", 0L, "borrow",
+                auditService.toJson(Map.of("kind", "notice",
+                        "length", notice == null ? 0 : notice.length()))));
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("notice", notice);
+        return data;
+    }
+
+    /**
+     * 上传调阅审批单模板(覆盖替换):校验同借阅审批单口径(仅 PDF、
+     * 不超 DMS_MAX_FILE_SIZE、非空、魔数校验),先暂存校验再原子覆盖
+     * 固定路径,随后落库元数据(原始文件名与大小)。
+     */
+    @Transactional
+    public Map<String, Object> uploadTemplate(MultipartFile file,
+                                              HttpServletRequest request) {
+        if (file == null || file.isEmpty()) {
+            throw unsupported("不允许上传空文件");
+        }
+        String originalName = baseName(file.getOriginalFilename());
+        if (originalName == null || !originalName.toLowerCase(Locale.ROOT).endsWith(".pdf")) {
+            throw unsupported("调阅审批单模板只允许PDF文件");
+        }
+        Path target = storagePaths.resolveStoragePath(TEMPLATE_PATH);
+        Path temporary = target.getParent().resolve(
+                "borrow-template-" + UUID.randomUUID() + ".upload");
+        long size = 0;
+        try {
+            Files.createDirectories(target.getParent());
+            try (InputStream input = file.getInputStream();
+                    OutputStream output = Files.newOutputStream(temporary)) {
+                byte[] chunk = new byte[CHUNK_SIZE];
+                int read;
+                while ((read = input.read(chunk)) >= 0) {
+                    if (read == 0) {
+                        continue;
+                    }
+                    size += read;
+                    if (size > properties.maxFileSize()) {
+                        throw new DmsException("PAYLOAD_TOO_LARGE", 413, "模板超过允许大小");
+                    }
+                    output.write(chunk, 0, read);
+                }
+            }
+            if (size == 0) {
+                throw unsupported("不允许上传空文件");
+            }
+            uploadStorage.validate(temporary, "pdf");
+            Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING,
+                    StandardCopyOption.ATOMIC_MOVE);
+        } catch (IOException ex) {
+            deleteQuietly(temporary);
+            throw new IllegalStateException("调阅审批单模板写入失败", ex);
+        } catch (RuntimeException ex) {
+            deleteQuietly(temporary);
+            throw ex;
+        }
+        LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+        long actorId = AuthContext.get().userId();
+        final String fileName = originalName;
+        final long fileSize = size;
+        deadlockRetry.execute(() -> {
+            if (settingMapper.updateFileMeta(TEMPLATE_KEY, fileName, fileSize, now,
+                    actorId) == 0) {
+                settingMapper.insert(TEMPLATE_KEY, null, fileName, fileSize, now, actorId);
+            }
+            return null;
+        });
+        auditService.insert(auditService.businessAudit(request,
+                AuditService.Action.BORROW_SETTINGS_UPDATE, "SYSTEM", 0L, "borrow",
+                auditService.toJson(Map.of("kind", "template-upload",
+                        "fileName", originalName, "fileSize", size))));
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("templateFileName", originalName);
+        data.put("templateFileSize", size);
+        return data;
+    }
+
+    /** 清除模板:删固定路径文件 + 删元数据行。 */
+    @Transactional
+    public Map<String, Object> deleteTemplate(HttpServletRequest request) {
+        SettingEntity template = settingMapper.findByKey(TEMPLATE_KEY);
+        deadlockRetry.execute(() -> {
+            settingMapper.deleteByKey(TEMPLATE_KEY);
+            return null;
+        });
+        deleteQuietly(storagePaths.resolveStoragePath(TEMPLATE_PATH));
+        auditService.insert(auditService.businessAudit(request,
+                AuditService.Action.BORROW_SETTINGS_UPDATE, "SYSTEM", 0L, "borrow",
+                auditService.toJson(Map.of("kind", "template-delete",
+                        "fileName", template == null || template.getFileName() == null
+                                ? "" : template.getFileName()))));
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("templateFileName", null);
+        return data;
+    }
+
+    /** 模板下载(登录用户即可):PDF attachment 直出,未上传时 404。 */
+    @Transactional(readOnly = true)
+    public ResponseEntity<org.springframework.core.io.Resource> downloadTemplate() {
+        SettingEntity template = settingMapper.findByKey(TEMPLATE_KEY);
+        Path path = storagePaths.resolveStoragePath(TEMPLATE_PATH);
+        if (template == null || !Files.isRegularFile(path)) {
+            throw Errors.resourceNotFound("调阅审批单模板未上传");
+        }
+        String downloadName = template.getFileName() == null
+                ? "调阅审批单模板.pdf" : template.getFileName();
+        return ResponseEntity.ok()
+                .contentType(MediaType.APPLICATION_PDF)
+                .header(HttpHeaders.CONTENT_DISPOSITION,
+                        "attachment; filename=\"borrow-template.pdf\"; filename*=UTF-8''"
+                                + java.net.URLEncoder.encode(downloadName,
+                                        java.nio.charset.StandardCharsets.UTF_8)
+                                .replace("+", "%20"))
+                .body(new FileSystemResource(path));
+    }
+
+    private static DmsException unsupported(String message) {
+        return new DmsException("UNSUPPORTED_FILE_TYPE", 415, message);
+    }
+
+    private static void deleteQuietly(Path path) {
+        try {
+            Files.deleteIfExists(path);
+        } catch (Exception ignored) {
+            // 清理失败不影响业务结果(模板可覆盖重写)
+        }
+    }
+
+    /** 去掉文件名两侧路径分隔(含 Windows 与 POSIX 形式)。 */
+    private static String baseName(String filename) {
+        if (filename == null) {
+            return null;
+        }
+        String normalized = filename.replace("\\", "/");
+        int slash = normalized.lastIndexOf('/');
+        return slash >= 0 ? normalized.substring(slash + 1) : normalized;
+    }
+}

+ 3 - 3
backend-java/src/main/java/com/dms/service/CategoryService.java

@@ -332,7 +332,7 @@ public class CategoryService {
 
     // ------------------------------------------------------------ 更新
 
-    /** 对齐 update_category:全表锁 + 循环/占用/扁平模式守卫 + 路径级联。 */
+    /** 对齐 update_category:全表锁 + 循环/占用/仅主案模式守卫 + 路径级联。 */
     @Transactional
     public Map<String, Object> update(long categoryId, Object payload,
                                       HttpServletRequest request) {
@@ -393,7 +393,7 @@ public class CategoryService {
             }
         }
 
-        // flat_mode 切换保护:仅一级分类可启用;false→true 时拒绝已存在挂载数据
+        // 仅主案模式切换保护:仅一级分类可启用;false→true 时拒绝已存在挂载数据
         boolean newFlatMode = category.getParentId() != null ? false : flatModeInput;
         String newTypeDisplayName = category.getParentId() != null ? null : typeDisplayNameInput;
         if (newFlatMode && !Boolean.TRUE.equals(category.getFlatMode())) {
@@ -410,7 +410,7 @@ public class CategoryService {
                 details.put("hasAttachment", hasAttachment);
                 details.put("hasSubPlanMount", hasSubPlanMount);
                 throw new DmsException("CATEGORY_FLAT_MODE_CONFLICT", 409,
-                        "该一级分类下已存在子方案或配套资料挂载,请先解绑后再切换为扁平模式", details);
+                        "该一级分类下已存在共享资料挂载,请先解绑后再开启仅主案模式", details);
             }
         }
 

+ 23 - 10
backend-java/src/main/java/com/dms/service/DocumentMutationService.java

@@ -63,9 +63,9 @@ public class DocumentMutationService {
     private static final Set<String> SUB_PLAN_EDIT_FIELDS = Set.of(
             "documentName", "summary", "securityLevel", "tags", "rowVersion");
     private static final Set<String> ATTACHMENT_CREATE_FIELDS =
-            Set.of("documentName", "attachmentType", "summary", "tags");
+            Set.of("documentName", "attachmentType", "summary", "tags", "securityLevel");
     private static final Set<String> ATTACHMENT_EDIT_FIELDS =
-            Set.of("documentName", "attachmentType", "summary", "tags", "rowVersion");
+            Set.of("documentName", "attachmentType", "summary", "tags", "securityLevel", "rowVersion");
     /** 独立于元数据编辑的备注字段(主案/子案/附件通用),不参与 rowVersion 乐观锁。 */
     private static final Set<String> DESCRIPTION_EDIT_FIELDS = Set.of("description");
 
@@ -133,7 +133,7 @@ public class DocumentMutationService {
     public Map<String, Object> createDocument(MultipartFile file, Object payload, boolean batch,
                                               HttpServletRequest request) {
         UploadStorage.StagedUpload upload =
-                uploadStorage.stageUpload(file, earlySecurityLevel(payload, false));
+                uploadStorage.stageUpload(file, earlySecurityLevel(payload));
         return createRecord(upload, payload, false, batch, request);
     }
 
@@ -141,15 +141,12 @@ public class DocumentMutationService {
     public Map<String, Object> createAttachment(MultipartFile file, Object payload, boolean batch,
                                                 HttpServletRequest request) {
         UploadStorage.StagedUpload upload =
-                uploadStorage.stageUpload(file, earlySecurityLevel(payload, true));
+                uploadStorage.stageUpload(file, earlySecurityLevel(payload));
         return createRecord(upload, payload, true, batch, request);
     }
 
     /** 域路由提前解析:暂存即按密级定域(绝密隔离存储);完整枚举校验仍在 createRecord。 */
-    private static String earlySecurityLevel(Object payload, boolean attachment) {
-        if (attachment) {
-            return "PUBLIC";
-        }
+    private static String earlySecurityLevel(Object payload) {
         if (payload instanceof Map<?, ?> map && map.get("securityLevel") instanceof String level) {
             return level;
         }
@@ -189,7 +186,8 @@ public class DocumentMutationService {
             docType = "ATTACHMENT";
             attType = enumValue(body.get("attachmentType"),
                     AttachmentQueryService.ATTACHMENT_TYPES, "attachmentType");
-            security = "PUBLIC";
+            // 契约1.42:附件开放密级(必填,公开级为常态缺省由前端预置)
+            security = enumValue(body.get("securityLevel"), SECURITY_LEVELS, "securityLevel");
         } else {
             Object rawType = body.get("documentType");
             // 阶段11:配套方案并入共享资料库(0020),停止新建SUB_PLAN文档;
@@ -250,9 +248,17 @@ public class DocumentMutationService {
                 // 矩阵 can_upload 校验(超管恒可;归属他部门按 OTHER 行恒拒);
                 // 共享附件挂载走挂载权限位,不受此限
                 if (!attachment && !authorizationService.canUploadPlan(
-                        context, security, organization == null ? null : organization.id())) {
+                        context, security, category.getId(),
+                        organization == null ? null : organization.id())) {
                     throw Errors.forbidden("无该密级方案的上传权限");
                 }
+                // 契约1.41:共享附件库上传按矩阵附件上传位校验(ADMIN 恒可),
+                // 不含挂载操作(挂载仍走主案挂载位);契约1.48 按附件类型的
+                // 共享资料库行组判定
+                if (attachment && !authorizationService.canUploadAttachment(
+                        context, security, attType)) {
+                    throw Errors.forbidden("无共享附件的上传权限");
+                }
                 DocumentEntity duplicate = null;
                 if (!attachment) {
                     duplicate = documentMapper.findDuplicateForUpdate(name, docType,
@@ -1022,6 +1028,8 @@ public class DocumentMutationService {
             before.put("documentName", locked.getDocumentName());
             before.put("attachmentType", locked.getAttachmentType());
             before.put("organizationId", serializeId(locked.getOrganizationId()));
+            String previousSecurityLevel = locked.getSecurityLevel();
+            before.put("securityLevel", locked.getSecurityLevel());
             List<String> tags = tagsValue(payload.get("tags"));
             locked.setDocumentName(text(payload.get("documentName"), "documentName", 255, false));
             locked.setAttachmentType(enumValue(payload.get("attachmentType"),
@@ -1030,6 +1038,9 @@ public class DocumentMutationService {
             locked.setTags(encodeTags(tags));
             locked.setOrganizationId(organization.id());
             locked.setOrganizationName(organization.name());
+            // 契约1.42:附件编辑可调整密级(绝密隔离域随编辑迁移)
+            locked.setSecurityLevel(
+                    enumValue(payload.get("securityLevel"), SECURITY_LEVELS, "securityLevel"));
             // 锁查询不携带 LOB:重建 search_text 需要旧正文时按 id 补查(行锁已持有)。
             locked.setSearchText(DocumentContentService.buildSearchText(
                     locked.getDocumentName(), locked.getSummary(), tags,
@@ -1039,6 +1050,8 @@ public class DocumentMutationService {
             locked.setUpdatedAt(now());
             locked.setRowVersion(locked.getRowVersion() + 1);
             documentMapper.updateAttachmentMeta(locked);
+            // 密级跨域变更:原件/预览缓存随密级迁移(失败整体回滚),与主案编辑同路径
+            migrateSecurityDomain(locked, previousSecurityLevel);
             Map<String, Object> detail = Payloads.orderedMap();
             detail.put("before", before);
             detail.put("rowVersion", locked.getRowVersion());

+ 14 - 11
backend-java/src/main/java/com/dms/service/DocumentPasswordService.java

@@ -23,7 +23,8 @@ import org.springframework.transaction.annotation.Transactional;
  * 方案密码锁:判定、验证与设置/修改/清除,
  * 对齐 backend/dms/services/document_password_service.py。
  *
- * <p>核心语义:一个 Argon2id 哈希 + 7 操作勾选位,仅主案可设密;
+ * <p>核心语义:一个 Argon2id 哈希 + 6 操作勾选位(契约1.41:挂载与解除挂载
+ * 合并为单一「挂载」位,一位同保护两类操作),仅主案可设密;
  * 普通用户仅勾选操作需密码;管理员对设密主案除 DELETE 外全部操作均需密码;
  * 修改/清除时任何人必须提供原密码;密码经 X-DMS-Document-Password 头传递。</p>
  */
@@ -35,10 +36,14 @@ public class DocumentPasswordService {
 
     private static final Set<String> PASSWORD_FIELDS = Set.of("password", "protectedActions");
 
-    /** 操作码稳定顺序(对齐 DocumentPasswordAction 枚举定义序)。 */
+    /**
+     * 操作码稳定顺序(对齐 DocumentPasswordAction 枚举定义序)。
+     * 契约1.41:挂载/解除挂载合并——勾选位仅 BIND_ATTACHMENT(挂载),
+     * 该位同时保护挂载与解除挂载两类接口操作(操作码仍区分以保持审计粒度)。
+     */
     public static final List<String> ACTION_ORDER = List.of(
             "VIEW", "DOWNLOAD", "EDIT", "DELETE",
-            "CONFIG_PERMISSION", "BIND_ATTACHMENT", "UNBIND_ATTACHMENT");
+            "CONFIG_PERMISSION", "BIND_ATTACHMENT");
 
     private static final Set<String> ALLOWED_KEYS =
             Set.of("oldPassword", "password", "protectedActions", "rowVersion");
@@ -60,8 +65,8 @@ public class DocumentPasswordService {
     public static List<String> parseProtectedActions(Object value) {
         if (!(value instanceof List<?> list) || list.isEmpty()) {
             throw Errors.invalidArgument("protectedActions必须是非空数组,元素为"
-                    + "VIEW/DOWNLOAD/EDIT/DELETE/CONFIG_PERMISSION/"
-                    + "BIND_ATTACHMENT/UNBIND_ATTACHMENT");
+                    + "VIEW/DOWNLOAD/EDIT/DELETE/CONFIG_PERMISSION/BIND_ATTACHMENT"
+                    + "(挂载与解除挂载已合并为挂载位)");
         }
         Set<String> seen = new LinkedHashSet<>();
         for (Object item : list) {
@@ -104,9 +109,6 @@ public class DocumentPasswordService {
         if (truthy(document.getProtectBindAttachment())) {
             actions.add("BIND_ATTACHMENT");
         }
-        if (truthy(document.getProtectUnbindAttachment())) {
-            actions.add("UNBIND_ATTACHMENT");
-        }
         return actions;
     }
 
@@ -126,8 +128,9 @@ public class DocumentPasswordService {
             case "EDIT" -> truthy(document.getProtectEdit());
             case "DELETE" -> truthy(document.getProtectDelete());
             case "CONFIG_PERMISSION" -> truthy(document.getProtectConfigPermission());
-            case "BIND_ATTACHMENT" -> truthy(document.getProtectBindAttachment());
-            case "UNBIND_ATTACHMENT" -> truthy(document.getProtectUnbindAttachment());
+            // 契约1.41:挂载位同保护挂载与解除挂载(存量 unbind 位已由 V25 迁移并入)
+            case "BIND_ATTACHMENT", "UNBIND_ATTACHMENT" ->
+                    truthy(document.getProtectBindAttachment());
             default -> false;
         };
     }
@@ -192,7 +195,7 @@ public class DocumentPasswordService {
         document.setProtectDelete(selected.contains("DELETE"));
         document.setProtectConfigPermission(selected.contains("CONFIG_PERMISSION"));
         document.setProtectBindAttachment(selected.contains("BIND_ATTACHMENT"));
-        document.setProtectUnbindAttachment(selected.contains("UNBIND_ATTACHMENT"));
+        document.setProtectUnbindAttachment(false);
     }
 
     public static void clearPasswordLock(DocumentEntity document) {

+ 82 - 6
backend-java/src/main/java/com/dms/service/DocumentQueryService.java

@@ -60,7 +60,8 @@ public class DocumentQueryService {
             "downloadCount", "download_count",
             "sortNo", "sort_no");
 
-    private static final Set<String> SECURITY_LEVELS = Set.of(
+    /** 密级全集(附件列表过滤复用,契约1.55 单选)。 */
+    public static final Set<String> SECURITY_LEVELS = Set.of(
             "PUBLIC", "INTERNAL", "SECRET", "CONFIDENTIAL", "TOP_SECRET");
     private static final Set<String> DOCUMENT_STATUSES = Set.of("DRAFT", "PUBLISHED", "ARCHIVED");
 
@@ -443,10 +444,9 @@ public class DocumentQueryService {
                     new ArrayList<>(categoryScope(categoryId, includeDescendants)));
         }
         String keyword = params.get("keyword");
-        if (!aggregate && keyword != null && !keyword.strip().isEmpty()) {
-            filters.put("titlePattern", "%" + escapeLike(keyword) + "%");
-        }
+        applyKeywordScope(filters, keyword, params.get("keywordScope"), aggregate);
         applyAdvancedFilters(filters, params);
+        applyOrganizationFilter(filters, params.get("organizationId"));
         applyTagsFilter(filters, params.get("tags"), params.get("tagsMatch"));
         String securityLevel =
                 enumParam(params.get("securityLevel"), SECURITY_LEVELS, "securityLevel");
@@ -502,7 +502,13 @@ public class DocumentQueryService {
         List<DocAccess> selected = listed.subList(fromIndex, toIndex);
         // 正文补查仅覆盖有权限行(列表主体查询不携带 content_text 大字段,
         // 仅 contentKeyword 检索生成当前页摘要时按需补查)。
+        // 契约1.55:数据内容为 CONTENT/ALL 时正文参与匹配,摘要关键词
+        // 回落到检索关键词(正文命中需展示片段;标题高亮由 keyword 承担)。
         String contentKeyword = params.get("contentKeyword");
+        if ((contentKeyword == null || contentKeyword.strip().isEmpty())
+                && keywordScopeContentLike(params)) {
+            contentKeyword = keyword;
+        }
         if (contentKeyword != null && !contentKeyword.strip().isEmpty()) {
             List<DocumentEntity> needContent = new ArrayList<>();
             for (DocAccess item : selected) {
@@ -664,9 +670,28 @@ public class DocumentQueryService {
     public Map<String, Object> getDocument(long documentId, HttpServletRequest request) {
         AuthContext context = AuthContext.get();
         DocumentEntity document = activePlan(documentId);
-        PlanAccess access = requirePlanAccess(document, context);
+        PlanAccess access = authorizationService.evaluatePlanAccess(document, context);
+        boolean viewAllowed = access.allowed();
+        if (!viewAllowed) {
+            if ("SECURITY".equals(access.reason())) {
+                throw Errors.securityLevelForbidden();
+            }
+            // 操作位隔离补全:无查看权但具备任一操作位时放行"编辑视图",
+            // 详情仅元数据(正文不下发、查看计数与审计不记录)
+            if (!authorizationService.detailAccessAllowed(document, context, access)) {
+                throw Errors.documentViewForbidden();
+            }
+        }
         Map<String, Object> result = documentDetail(document, context, access, request);
-        result.put("viewCount", recordView(document, request));
+        if (viewAllowed) {
+            result.put("viewCount", recordView(document, request));
+        } else {
+            // 编辑视图:回显库中当前计数(实体查询未携带时按 0),不增计不审计;
+            // 正文属查看类内容,编辑视图强制不下发
+            result.put("viewCount",
+                    document.getViewCount() == null ? 0L : document.getViewCount());
+            result.put("contentText", null);
+        }
         return result;
     }
 
@@ -690,6 +715,57 @@ public class DocumentQueryService {
         return types;
     }
 
+    /** 高级检索·数据内容(契约1.55):关键词匹配范围 TITLE/CONTENT/ALL。 */
+    private static final Set<String> KEYWORD_SCOPES = Set.of("TITLE", "CONTENT", "ALL");
+
+    /**
+     * 高级检索·数据内容(契约1.55):keyword 的匹配范围——TITLE(缺省,标题)
+     * / CONTENT(正文)/ ALL(标题或正文任一命中);聚合检索仅支持标题口径
+     * (聚合命中统计按标题冒泡),非 TITLE 直接拒绝。
+     */
+    public static void applyKeywordScope(Map<String, Object> filters, String keyword,
+                                         String scopeRaw, boolean aggregate) {
+        if (keyword == null || keyword.strip().isEmpty()) {
+            return;
+        }
+        String scope = enumParam(scopeRaw, KEYWORD_SCOPES, "keywordScope");
+        if (scope == null) {
+            scope = "TITLE";
+        }
+        if (aggregate) {
+            if (!"TITLE".equals(scope)) {
+                throw Errors.invalidArgument("aggregateHits仅支持keywordScope为TITLE的标题范围检索");
+            }
+            return;
+        }
+        String pattern = "%" + escapeLike(keyword) + "%";
+        switch (scope) {
+            case "CONTENT" -> filters.put("contentPattern", pattern);
+            case "ALL" -> filters.put("titleOrContentPattern", pattern);
+            default -> filters.put("titlePattern", pattern);
+        }
+    }
+
+    /** 契约1.55:数据内容为 CONTENT/ALL(正文参与匹配)时返回 true。 */
+    public static boolean keywordScopeContentLike(Map<String, String> params) {
+        String scope = params.get("keywordScope");
+        return "CONTENT".equals(scope) || "ALL".equals(scope);
+    }
+
+    /**
+     * 高级检索·提供单位(契约1.55):按机构 ID 精确过滤,含其下级子树;
+     * 机构不存在或子树为空时以不存在的 ID 兜底(空结果而非语法错误)。
+     */
+    public void applyOrganizationFilter(Map<String, Object> filters, String raw) {
+        if (raw == null || raw.isEmpty()) {
+            return;
+        }
+        long organizationId = Payloads.queryOptionalId(raw, "organizationId");
+        Set<Long> subtree = authorizationService.organizationSubtreeIds(organizationId);
+        filters.put("organizationIds",
+                subtree.isEmpty() ? List.of(-1L) : new ArrayList<>(subtree));
+    }
+
     /** 对齐 _advanced_filters:正文关键词 / 密级多选 / 部门模糊。 */
     public static void applyAdvancedFilters(Map<String, Object> filters,
                                             Map<String, String> params) {

+ 10 - 1
backend-java/src/main/java/com/dms/service/DocumentRelationService.java

@@ -10,6 +10,7 @@ import com.dms.mapper.DocumentRelationMapper;
 import com.dms.model.DocumentEntity;
 import com.dms.model.DocumentRelationEntity;
 import com.dms.security.AuthContext;
+import com.dms.service.AuthorizationService.PlanAccess;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import jakarta.servlet.http.HttpServletRequest;
 import java.io.IOException;
@@ -182,7 +183,15 @@ public class DocumentRelationService {
         if (document == null) {
             throw new DmsException("RESOURCE_NOT_FOUND", 404, "方案文档不存在", null);
         }
-        documentQueryService.requirePlanAccess(document, context);
+        PlanAccess access = authorizationService.evaluatePlanAccess(document, context);
+        if ("SECURITY".equals(access.reason())) {
+            throw Errors.securityLevelForbidden();
+        }
+        // 操作位隔离补全:查看或任一操作位(编辑视图)均可读关联列表;
+        // 保存关联仍按 EDIT 位判定(saveDocumentRelations)
+        if (!authorizationService.detailAccessAllowed(document, context, access)) {
+            throw Errors.documentViewForbidden();
+        }
         Map<String, Object> result = Payloads.orderedMap();
         result.put("items", contractRelationItems(document.getId()));
         return result;

+ 97 - 0
backend-java/src/main/java/com/dms/service/LoginBrandingService.java

@@ -0,0 +1,97 @@
+package com.dms.service;
+
+import com.dms.common.DeadlockRetry;
+import com.dms.common.Errors;
+import com.dms.mapper.SettingMapper;
+import com.dms.model.SettingEntity;
+import com.dms.security.AuthContext;
+import jakarta.servlet.http.HttpServletRequest;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 登录页文案设置(契约1.62):大标题与底部小字在系统管理页维护,
+ * 登录页匿名读取展示。两文案分别存 sys_setting 键值表的
+ * login.title / login.footer 行,行不存在即未设置——登录页回落内置
+ * 默认文案(默认值由前端持有,后端只透传配置值)。
+ */
+@Service
+public class LoginBrandingService {
+
+    static final String TITLE_KEY = "login.title";
+    static final String FOOTER_KEY = "login.footer";
+    private static final int TITLE_MAX_LENGTH = 64;
+    private static final int FOOTER_MAX_LENGTH = 200;
+
+    private final SettingMapper settingMapper;
+    private final AuditService auditService;
+    private final DeadlockRetry deadlockRetry;
+
+    public LoginBrandingService(SettingMapper settingMapper, AuditService auditService,
+                                DeadlockRetry deadlockRetry) {
+        this.settingMapper = settingMapper;
+        this.auditService = auditService;
+        this.deadlockRetry = deadlockRetry;
+    }
+
+    /** 文案读取(公开接口,登录页与系统管理页共用):null=未设置。 */
+    @Transactional(readOnly = true)
+    public Map<String, Object> branding() {
+        SettingEntity title = settingMapper.findByKey(TITLE_KEY);
+        SettingEntity footer = settingMapper.findByKey(FOOTER_KEY);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("title", title == null ? null : title.getSettingValue());
+        data.put("footerText", footer == null ? null : footer.getSettingValue());
+        return data;
+    }
+
+    /** 保存两文案(可清空:null/空白按未设置落 NULL 行,登录页回落默认)。 */
+    @Transactional
+    public Map<String, Object> updateBranding(Object rawTitle, Object rawFooter,
+                                              HttpServletRequest request) {
+        String title = textOrNull(rawTitle, "title", TITLE_MAX_LENGTH, "登录页大标题");
+        String footerText = textOrNull(rawFooter, "footerText", FOOTER_MAX_LENGTH, "登录页底部小字");
+        LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+        long actorId = AuthContext.get().userId();
+        deadlockRetry.execute(() -> {
+            if (settingMapper.updateValue(TITLE_KEY, title, now, actorId) == 0) {
+                settingMapper.insert(TITLE_KEY, title, null, null, now, actorId);
+            }
+            return null;
+        });
+        deadlockRetry.execute(() -> {
+            if (settingMapper.updateValue(FOOTER_KEY, footerText, now, actorId) == 0) {
+                settingMapper.insert(FOOTER_KEY, footerText, null, null, now, actorId);
+            }
+            return null;
+        });
+        auditService.insert(auditService.businessAudit(request,
+                AuditService.Action.LOGIN_BRANDING_UPDATE, "SYSTEM", 0L, "login-branding",
+                auditService.toJson(Map.of(
+                        "titleLength", title == null ? 0 : title.length(),
+                        "footerLength", footerText == null ? 0 : footerText.length()))));
+        return branding();
+    }
+
+    /** 字符串规范化:null/空白清除为 null,超长报错(对齐调阅说明口径)。 */
+    private static String textOrNull(Object raw, String field, int maxLength, String label) {
+        if (raw instanceof String text) {
+            if (text.isBlank()) {
+                return null;
+            }
+            String stripped = text.strip();
+            if (stripped.length() > maxLength) {
+                throw Errors.invalidArgument(label + "不能超过" + maxLength + "字");
+            }
+            return stripped;
+        }
+        if (raw != null) {
+            throw Errors.invalidArgument(field + "必须是字符串");
+        }
+        return null;
+    }
+}

+ 10 - 1
backend-java/src/main/java/com/dms/service/NotificationService.java

@@ -32,11 +32,14 @@ public class NotificationService {
 
     private final NotificationMapper notificationMapper;
     private final BorrowRequestService borrowRequestService;
+    private final PasswordResetRequestService passwordResetRequestService;
 
     public NotificationService(NotificationMapper notificationMapper,
-                               BorrowRequestService borrowRequestService) {
+                               BorrowRequestService borrowRequestService,
+                               PasswordResetRequestService passwordResetRequestService) {
         this.notificationMapper = notificationMapper;
         this.borrowRequestService = borrowRequestService;
+        this.passwordResetRequestService = passwordResetRequestService;
     }
 
     @Transactional(readOnly = true)
@@ -81,6 +84,12 @@ public class NotificationService {
             borrowRequestService.handleApproval(item.getBizId(), action, notificationId,
                     request);
         }
+        // 重置密码审批(契约1.49):PASSWORD_RESET 待办联动申请单,同先到先得语义
+        if (PasswordResetRequestService.NOTIFY_TYPE.equals(item.getNotifyType())
+                && item.getBizId() != null) {
+            passwordResetRequestService.handleApproval(item.getBizId(), action,
+                    notificationId, request);
+        }
         // 对齐 Python 版:同一对象置位后序列化(无软删无乐观锁,无并发读放大)
         item.setAction(action);
         item.setStatus("READ");

+ 90 - 1
backend-java/src/main/java/com/dms/service/OrganizationService.java

@@ -64,15 +64,77 @@ public class OrganizationService {
      * 传值时按 ENABLED/DISABLED 过滤并补全祖先链;keyword 匹配名称/编码。
      */
     @Transactional(readOnly = true)
+    /**
+     * 高级检索·提供单位选项(契约1.55,契约1.66 扩两级):机构树第 level
+     * 级与其下一级(level+1)启用机构清单,供全局检索两行筛选;全量口径
+     * (不做 1.45 的部门子树裁剪——检索维度面向全机构,选中后按该机构含
+     * 下级子树过滤方案)。
+     */
+    public Map<String, Object> filterOptions(int level) {
+        List<Map<String, Object>> organizations = new ArrayList<>();
+        List<Map<String, Object>> childOrganizations = new ArrayList<>();
+        for (OrganizationEntity organization : sortedLevelOrganizations()) {
+            int depth = orgPathDepth(organization.getOrgPath());
+            if (depth != level && depth != level + 1) {
+                continue;
+            }
+            Map<String, Object> item = Payloads.orderedMap();
+            item.put("id", serializeId(organization.getId()));
+            item.put("orgName", organization.getOrgName());
+            (depth == level ? organizations : childOrganizations).add(item);
+        }
+        Map<String, Object> result = Payloads.orderedMap();
+        result.put("orgFilterLevel", level);
+        result.put("organizations", organizations);
+        // 契约1.66:第二行展示第 level+1 级机构(更细粒度筛选)
+        result.put("childOrganizations", childOrganizations);
+        return result;
+    }
+
+    /** 全部启用机构按 sortNo(null 最后)与 id 稳定排序。 */
+    private List<OrganizationEntity> sortedLevelOrganizations() {
+        List<OrganizationEntity> matched = new ArrayList<>();
+        for (OrganizationEntity organization : organizationMapper.listAllNotDeleted()) {
+            if ("ENABLED".equals(organization.getStatus())) {
+                matched.add(organization);
+            }
+        }
+        matched.sort(java.util.Comparator
+                .comparing(OrganizationEntity::getSortNo,
+                        java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
+                .thenComparing(OrganizationEntity::getId));
+        return matched;
+    }
+
+    /** orgPath 形如 /root/dept-a/:深度 = 斜杠分隔段数(根为 1,二级机构为 2)。 */
+    private static int orgPathDepth(String orgPath) {
+        if (orgPath == null || orgPath.isBlank()) {
+            return 0;
+        }
+        String trimmed = orgPath.strip();
+        if (trimmed.endsWith("/")) {
+            trimmed = trimmed.substring(0, trimmed.length() - 1);
+        }
+        if (trimmed.startsWith("/")) {
+            trimmed = trimmed.substring(1);
+        }
+        return trimmed.isEmpty() ? 0 : trimmed.split("/").length;
+    }
+
     public List<Map<String, Object>> organizationTree(String keyword, String status) {
         List<OrganizationEntity> organizations = organizationMapper.listAllNotDeleted();
         // 安全需求8:锚点管理员的机构树裁剪到管理范围子树(全局管理员不受限)。
         // 机构页/用户页/用户机构调整下拉均消费本接口,裁剪后各处只见可管理机构。
+        // 契约1.45:普通用户(USER)只读放开但恒见本人部门为根的子树
+        //(沿ENABLED边,无部门空树)——与用户列表收敛口径一致。
         Long currentUserId = AuthContext.get().userId();
         if (currentUserId != null) {
             UserEntity actor = userMapper.findActiveById(currentUserId);
             if (actor != null) {
-                Set<Long> scope = adminPermission.managedScopeIds(actor);
+                Set<Long> managedScope = adminPermission.managedScopeIds(actor);
+                Set<Long> scope = managedScope != null ? managedScope
+                        : AuthContext.get().isAdmin() ? null
+                        : subtreeOf(actor.getOrganizationId(), organizations);
                 if (scope != null) {
                     organizations = organizations.stream()
                             .filter(item -> scope.contains(item.getId())).toList();
@@ -141,6 +203,33 @@ public class OrganizationService {
         return roots;
     }
 
+    /** 普通用户可见子树(契约1.45):以本人部门为根、沿 ENABLED 边展开;无部门为空集。 */
+    private static Set<Long> subtreeOf(Long rootId, List<OrganizationEntity> organizations) {
+        Set<Long> scope = new HashSet<>();
+        if (rootId == null) {
+            return scope;
+        }
+        scope.add(rootId);
+        Map<Long, List<Long>> children = new HashMap<>();
+        for (OrganizationEntity item : organizations) {
+            if ("ENABLED".equals(item.getStatus())) {
+                children.computeIfAbsent(item.getParentId(), key -> new ArrayList<>())
+                        .add(item.getId());
+            }
+        }
+        List<Long> pending = new ArrayList<>();
+        pending.add(rootId);
+        while (!pending.isEmpty()) {
+            long current = pending.remove(pending.size() - 1);
+            for (Long childId : children.getOrDefault(current, List.of())) {
+                if (scope.add(childId)) {
+                    pending.add(childId);
+                }
+            }
+        }
+        return scope;
+    }
+
     // ------------------------------------------------------------ 写操作
 
     /** 对齐 create_organization。 */

+ 248 - 0
backend-java/src/main/java/com/dms/service/PasswordResetRequestService.java

@@ -0,0 +1,248 @@
+package com.dms.service;
+
+import com.dms.common.DmsException;
+import com.dms.common.Errors;
+import com.dms.config.DmsProperties;
+import com.dms.mapper.NotificationMapper;
+import com.dms.mapper.PasswordResetRequestMapper;
+import com.dms.mapper.UserRoleMapper;
+import com.dms.mapper.RoleMapper;
+import com.dms.mapper.UserMapper;
+import com.dms.mapper.UserRoleMapper;
+import com.dms.model.NotificationEntity;
+import com.dms.model.PasswordResetRequestEntity;
+import com.dms.model.RoleEntity;
+import com.dms.model.UserEntity;
+import com.dms.security.AuthContext;
+import com.dms.security.Reauth;
+import jakarta.servlet.http.HttpServletRequest;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 登录界面重置密码审批流(契约1.49):
+ * 登录前自助提交申请(公开接口,验证码防滥用),按申请用户类型路由审批
+ * 角色(首长→安全管理员、其他→系统管理员,均可配置),审批人在通知页
+ * 待办处理(任一审批人先到先得,对齐借阅审批 1.37 模式);通过即清密码
+ * 回首次登录态(门户重新设密)。
+ */
+@Service
+public class PasswordResetRequestService {
+
+    /** 通知类型:重置密码审批待办/结果(与借阅 APPROVAL 区分 bizId 语义)。 */
+    static final String NOTIFY_TYPE = "PASSWORD_RESET";
+
+    private final PasswordResetRequestMapper passwordResetRequestMapper;
+    private final UserMapper userMapper;
+    private final RoleMapper roleMapper;
+    private final UserRoleMapper userRoleMapper;
+    private final NotificationMapper notificationMapper;
+    private final AuditService auditService;
+    private final DmsProperties dmsProperties;
+    private final Reauth reauth;
+
+    public PasswordResetRequestService(PasswordResetRequestMapper passwordResetRequestMapper,
+                                       UserMapper userMapper, RoleMapper roleMapper,
+                                       UserRoleMapper userRoleMapper,
+                                       NotificationMapper notificationMapper,
+                                       AuditService auditService,
+                                       DmsProperties dmsProperties, Reauth reauth) {
+        this.passwordResetRequestMapper = passwordResetRequestMapper;
+        this.userMapper = userMapper;
+        this.roleMapper = roleMapper;
+        this.userRoleMapper = userRoleMapper;
+        this.notificationMapper = notificationMapper;
+        this.auditService = auditService;
+        this.dmsProperties = dmsProperties;
+        this.reauth = reauth;
+    }
+
+    /**
+     * 登录前提交重置申请(公开接口):用户名与姓名同语义(与登录口径一致,
+     * 重名歧义按无此账号处理);审批角色按用户类型路由,向该角色全部
+     * 活跃挂载用户发待办通知。
+     */
+    @Transactional
+    public Map<String, Object> createRequest(Map<String, Object> payload,
+                                             HttpServletRequest request) {
+        if (!(payload.get("username") instanceof String usernameText)
+                || usernameText.trim().isEmpty()) {
+            throw Errors.invalidArgument("username不能为空");
+        }
+        String username = usernameText.trim();
+        UserEntity user = userMapper.findActiveByUsername(username);
+        if (user == null) {
+            List<UserEntity> byRealName = userMapper.listActiveByRealName(username);
+            if (byRealName.size() == 1) {
+                user = byRealName.get(0);
+            }
+        }
+        if (user == null) {
+            throw Errors.invalidArgument("用户不存在");
+        }
+        // 内置超管无门户设密路径,密码清空即永久锁死,拒绝自助申请
+        if (AdminPermission.SUPER_ADMIN_USERNAME.equals(user.getUsername())) {
+            throw Errors.invalidArgument("内置超级管理员不支持自助重置密码");
+        }
+        if (!"ENABLED".equals(user.getStatus())) {
+            throw Errors.invalidArgument("用户已停用");
+        }
+        if (passwordResetRequestMapper.countPendingByUser(user.getId()) > 0) {
+            throw new DmsException("PASSWORD_RESET_DUPLICATE", 409,
+                    "已有待审批的重置密码申请,请等待审批", null);
+        }
+
+        boolean commander = "COMMANDER".equals(user.getUserType());
+        String roleName = commander
+                ? dmsProperties.resetPasswordApproverRoleCommander()
+                : dmsProperties.resetPasswordApproverRoleNormal();
+        List<UserEntity> approvers = resolveApprovers(roleName, user.getId());
+
+        LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+        PasswordResetRequestEntity entity = new PasswordResetRequestEntity();
+        entity.setUserId(user.getId());
+        entity.setUsername(user.getUsername());
+        entity.setRealName(user.getRealName());
+        entity.setUserType(commander ? "COMMANDER" : "NORMAL");
+        entity.setApproverRoleName(roleName);
+        entity.setStatus("PENDING");
+        entity.setRequestIp(AuditService.clientIp(request));
+        entity.setRequestedAt(now);
+        passwordResetRequestMapper.insertRequest(entity);
+
+        for (UserEntity approver : approvers) {
+            notificationMapper.insert(pendingNotification(entity, approver));
+        }
+
+        Map<String, Object> detail = new LinkedHashMap<>();
+        detail.put("requestId", String.valueOf(entity.getId()));
+        detail.put("userId", String.valueOf(user.getId()));
+        detail.put("username", user.getUsername());
+        detail.put("userType", entity.getUserType());
+        detail.put("approverRoleName", roleName);
+        detail.put("notified", approvers.size());
+        auditService.insert(auditService.authAudit(request,
+                AuditService.Action.PASSWORD_RESET_REQUEST_CREATE,
+                AuditService.RESULT_SUCCESS, user, null, null,
+                auditService.toJson(detail)));
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("requestId", String.valueOf(entity.getId()));
+        result.put("notified", approvers.size());
+        return result;
+    }
+
+    /**
+     * 任一审批人处理即定(先到先得,通知 handle 联动):PENDING 行锁守卫 +
+     * 审批人实时鉴权(当前用户挂载审批角色或内置超管,防通知发出后角色变化);
+     * 通过则清密码回首次登录态;其余审批人待办同步失效;申请人收结果通知。
+     */
+    @Transactional
+    public void handleApproval(long requestId, String action, long notificationId,
+                               HttpServletRequest request) {
+        reauth.require(request);
+        AuthContext context = AuthContext.get();
+        PasswordResetRequestEntity entity = passwordResetRequestMapper.findForUpdate(requestId);
+        if (entity == null) {
+            throw Errors.resourceNotFound("重置密码申请不存在");
+        }
+        if (!"PENDING".equals(entity.getStatus())) {
+            throw Errors.dataVersionConflict("该重置密码申请已被处理");
+        }
+        // 审批人实时鉴权:内置超管或当前挂载申请单所路由的审批角色
+        if (!AdminPermission.SUPER_ADMIN_USERNAME.equals(context.username())
+                && !hasRoleName(context.userId(), entity.getApproverRoleName())) {
+            throw Errors.forbidden("无权审批该重置密码申请");
+        }
+
+        LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+        boolean approved = "AGREE".equals(action);
+        String finalStatus = approved ? "APPROVED" : "REJECTED";
+        int updated = passwordResetRequestMapper.updateHandled(requestId, finalStatus,
+                context.userId(), context.realName(), now);
+        if (updated == 0) {
+            throw Errors.dataVersionConflict("该重置密码申请已被处理");
+        }
+
+        if (approved) {
+            // 清密码回首次登录态:门户验签进入 SET_PASSWORD 阶段重新设密,
+            // auth_version+1 使该用户当前全部会话立即失效;挂载行独立密码
+            // 一并清空(契约1.53),多角色用户重设密时逐角色重新分配
+            userMapper.clearPasswordForReset(entity.getUserId(), now, context.userId());
+            userRoleMapper.clearAllPasswords(entity.getUserId());
+        }
+        notificationMapper.settlePendingByBiz(requestId, NOTIFY_TYPE, notificationId,
+                action, now);
+
+        NotificationEntity result = new NotificationEntity();
+        result.setUserId(entity.getUserId());
+        result.setTitle(approved ? "重置密码申请已通过" : "重置密码申请已拒绝");
+        result.setContent("你的重置密码申请已被 " + context.realName()
+                + (approved ? " 通过,下次登录时请重新设置密码" : " 拒绝"));
+        result.setNotifyType(NOTIFY_TYPE);
+        result.setAction("NONE");
+        result.setStatus("UNREAD");
+        result.setBizId(requestId);
+        notificationMapper.insert(result);
+
+        Map<String, Object> detail = new LinkedHashMap<>();
+        detail.put("requestId", String.valueOf(requestId));
+        detail.put("userId", String.valueOf(entity.getUserId()));
+        detail.put("username", entity.getUsername());
+        detail.put("approved", approved);
+        auditService.insert(auditService.businessAudit(request,
+                approved ? AuditService.Action.PASSWORD_RESET_APPROVE
+                        : AuditService.Action.PASSWORD_RESET_REJECT,
+                "PASSWORD_RESET_REQUEST", requestId, entity.getUsername(),
+                auditService.toJson(detail)));
+    }
+
+    /** 审批角色路由:角色须存在且启用,其活跃挂载用户即审批人(至少一人)。 */
+    private List<UserEntity> resolveApprovers(String roleName, long requesterId) {
+        RoleEntity role = roleMapper.findActiveEnabledByRoleName(roleName);
+        if (role == null) {
+            throw Errors.invalidArgument("审批角色「" + roleName + "」不存在或未启用");
+        }
+        List<UserEntity> approvers = new ArrayList<>();
+        for (Long userId : userRoleMapper.listActiveUserIdsByRoleId(role.getId())) {
+            UserEntity candidate = userMapper.findActiveById(userId == null ? 0 : userId);
+            if (candidate != null && "ENABLED".equals(candidate.getStatus())
+                    && candidate.getId() != requesterId) {
+                approvers.add(candidate);
+            }
+        }
+        if (approvers.isEmpty()) {
+            throw Errors.invalidArgument("暂无可审批该申请的用户,请联系管理员");
+        }
+        return approvers;
+    }
+
+    /** 当前用户挂载角色中是否含指定角色名(活跃启用挂载)。 */
+    private boolean hasRoleName(long userId, String roleName) {
+        for (RoleEntity role : userRoleMapper.listActiveRolesByUserId(userId)) {
+            if (roleName.equals(role.getRoleName())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static NotificationEntity pendingNotification(PasswordResetRequestEntity entity,
+                                                          UserEntity approver) {
+        NotificationEntity notification = new NotificationEntity();
+        notification.setUserId(approver.getId());
+        notification.setTitle("重置密码申请");
+        notification.setContent(entity.getRealName() + " 申请重置密码,请审批");
+        notification.setNotifyType(NOTIFY_TYPE);
+        notification.setAction("PENDING");
+        notification.setStatus("UNREAD");
+        notification.setBizId(entity.getId());
+        return notification;
+    }
+}

+ 2 - 2
backend-java/src/main/java/com/dms/service/PermissionService.java

@@ -271,7 +271,7 @@ public class PermissionService {
             }
             if (!valid.keySet().equals(userIds)) {
                 throw new DmsException("RESOURCE_NOT_FOUND", 404,
-                        "借阅用户不存在、已删除或已禁用", null);
+                        "调阅用户不存在、已删除或已禁用", null);
             }
             for (UserEntity user : valid.values()) {
                 names.put(user.getId(), user.getRealName());
@@ -352,7 +352,7 @@ public class PermissionService {
         }
         if (expires.isAfter(now.plus(MAX_BORROW).plus(BORROW_TOLERANCE))) {
             throw Errors.invalidArgument(
-                    "entries[" + index + "].expiresAt借阅时限最长为7天");
+                    "entries[" + index + "].expiresAt调阅时限最长为7天");
         }
         return expires;
     }

+ 102 - 18
backend-java/src/main/java/com/dms/service/PortalAuthService.java

@@ -12,7 +12,9 @@ import com.dms.common.Payloads;
 import com.dms.config.DmsProperties;
 import com.dms.mapper.OrganizationMapper;
 import com.dms.mapper.UserMapper;
+import com.dms.mapper.UserRoleMapper;
 import com.dms.model.OrganizationEntity;
+import com.dms.model.RoleMountEntity;
 import com.dms.model.UserEntity;
 import com.dms.security.JwtTokens;
 import com.dms.security.Passwords;
@@ -29,9 +31,11 @@ import java.time.ZoneOffset;
 import java.util.ArrayList;
 import java.util.Base64;
 import java.util.Date;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -52,6 +56,7 @@ public class PortalAuthService {
     private final DmsProperties dmsProperties;
     private final UserMapper userMapper;
     private final OrganizationMapper organizationMapper;
+    private final UserRoleMapper userRoleMapper;
     private final AuditService auditService;
     private final LoginIpService loginIpService;
     private final UserSummaryService userSummaryService;
@@ -60,7 +65,8 @@ public class PortalAuthService {
     public PortalAuthService(PortalConfigService configService, PortalProvider portalProvider,
                              SsoProxyGateway ssoProxyGateway, DmsProperties dmsProperties,
                              UserMapper userMapper, OrganizationMapper organizationMapper,
-                             AuditService auditService, LoginIpService loginIpService,
+                             UserRoleMapper userRoleMapper, AuditService auditService,
+                             LoginIpService loginIpService,
                              UserSummaryService userSummaryService, JwtTokens.Holder jwtHolder) {
         this.configService = configService;
         this.portalProvider = portalProvider;
@@ -68,6 +74,7 @@ public class PortalAuthService {
         this.dmsProperties = dmsProperties;
         this.userMapper = userMapper;
         this.organizationMapper = organizationMapper;
+        this.userRoleMapper = userRoleMapper;
         this.auditService = auditService;
         this.loginIpService = loginIpService;
         this.userSummaryService = userSummaryService;
@@ -197,15 +204,7 @@ public class PortalAuthService {
 
         Map<String, Object> result = new LinkedHashMap<>();
         if (user.getLastLoginAt() == null) {
-            result.put("phase", "SET_PASSWORD");
-            result.put("username", user.getUsername());
-            result.put("realName", user.getRealName());
-            result.put("portalTicket", issueSetupTicket(user));
-            // 机构树与大系统可能不一致,设密时让用户自选所在机构(预选推断结果)
-            result.put("organizations", portalOrgOptions());
-            result.put("suggestedOrganizationId",
-                    user.getOrganizationId() != null
-                            ? String.valueOf(user.getOrganizationId()) : null);
+            result.putAll(buildSetupPasswordPayload(user));
         } else {
             result.put("phase", "LOGIN");
             result.put("username", user.getUsername());
@@ -222,12 +221,26 @@ public class PortalAuthService {
     @Transactional
     public Map<String, Object> setupPortalPassword(String ticket, String newPassword,
                                                    boolean keepSignedIn, String organizationId,
+                                                   Object rolePasswordsRaw,
                                                    HttpServletRequest request) {
         validatePortalPassword(newPassword);
         UserEntity user = consumeSetupTicket(ticket);
         if (!"ENABLED".equals(user.getStatus())) {
             throw Errors.userDisabled();
         }
+        // 多角色首登设密(契约1.53 密码即角色):逐角色分配密码——恰一个
+        // 使用用户密码,其余独立密码互异且不等于用户密码;设密后以「使用
+        // 用户密码」的角色签发进入,此后登录输入哪个密码即以该角色进入
+        List<RoleMountEntity> mounts = userRoleMapper.listLoginMounts(user.getId());
+        Map<Long, String> rolePasswords = null;
+        if (mounts.size() >= 2) {
+            Set<Long> targetRoleIds = new HashSet<>();
+            for (RoleMountEntity mount : mounts) {
+                targetRoleIds.add(mount.getRoleId());
+            }
+            rolePasswords = RolePasswords.parse(rolePasswordsRaw, targetRoleIds,
+                    newPassword, null);
+        }
         // IP登录限制与普通登录一致(ADMIN豁免、无部门/无分配放行);禁止时由控制器层记审计
         loginIpService.verifyLoginIp(user, request);
         Long targetOrgId = user.getOrganizationId();
@@ -235,6 +248,15 @@ public class PortalAuthService {
         Map<String, Object> detail = new LinkedHashMap<>();
         detail.put("portal", true);
         detail.put("setupPassword", true);
+        if (rolePasswords != null) {
+            List<String> rolePasswordRoleIds = new ArrayList<>();
+            for (Map.Entry<Long, String> entry : rolePasswords.entrySet()) {
+                if (entry.getValue() != null) {
+                    rolePasswordRoleIds.add(String.valueOf(entry.getKey()));
+                }
+            }
+            detail.put("rolePasswordRoleIds", rolePasswordRoleIds);
+        }
         if (organizationId != null) {
             long parsedOrgId = Payloads.pathId(organizationId, "organizationId");
             OrganizationEntity organization = organizationMapper.findEnabledById(parsedOrgId);
@@ -251,12 +273,10 @@ public class PortalAuthService {
         LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
         String clientIp = AuditService.clientIp(request);
         String passwordHash = Passwords.hash(newPassword);
-        // 先签发取会话代再落库(一次一码,安全需求);开关关闭落 NULL(不纳管)
-        JwtTokens.Issued issued = JwtTokens.issue(user, keepSignedIn,
-                jwtHolder.secret(), jwtHolder.expiresSeconds());
-        userMapper.updatePortalPassword(user.getId(), passwordHash, now, clientIp,
-                targetOrgId, targetOrgName,
-                dmsProperties.tokenRotationEnabled() ? issued.jti() : null);
+        // 先同步内存对象再签发(契约1.71 修复):authVersion+1 必须先于签发——
+        // token 声明的 auth_version 须与 updatePortalPassword 落库后的库值一致,
+        // 否则设密成功的响应 token 一落地即 AUTH_VERSION_MISMATCH,用户进不了
+        // 系统(设密页重试又报「凭证已使用」,形成死锁)。
         user.setPasswordHash(passwordHash);
         user.setOrganizationId(targetOrgId);
         user.setOrganizationName(targetOrgName);
@@ -264,6 +284,35 @@ public class PortalAuthService {
         user.setLastLoginAt(now);
         user.setLastLoginIp(clientIp);
         user.setRowVersion(user.getRowVersion() + 1);
+        // 再签发取会话代落库(一次一码,安全需求);开关关闭落 NULL(不纳管);
+        // 多角色以「使用用户密码」的角色签发(契约1.53)
+        JwtTokens.Issued issued;
+        if (rolePasswords != null) {
+            Long entryRoleId = null;
+            for (Map.Entry<Long, String> entry : rolePasswords.entrySet()) {
+                if (entry.getValue() == null) {
+                    entryRoleId = entry.getKey();
+                    break;
+                }
+            }
+            issued = JwtTokens.issue(user, entryRoleId, keepSignedIn,
+                    jwtHolder.secret(), jwtHolder.expiresSeconds());
+        } else {
+            issued = JwtTokens.issue(user, keepSignedIn,
+                    jwtHolder.secret(), jwtHolder.expiresSeconds());
+        }
+        userMapper.updatePortalPassword(user.getId(), passwordHash, now, clientIp,
+                targetOrgId, targetOrgName,
+                dmsProperties.tokenRotationEnabled() ? issued.jti() : null);
+        // 挂载行独立密码写入(契约1.53):使用用户密码的角色保持 NULL
+        if (rolePasswords != null) {
+            for (Map.Entry<Long, String> entry : rolePasswords.entrySet()) {
+                if (entry.getValue() != null) {
+                    userRoleMapper.updatePassword(user.getId(), entry.getKey(),
+                            Passwords.hash(entry.getValue()));
+                }
+            }
+        }
         auditService.insert(auditService.authAudit(request, AuditService.Action.LOGIN,
                 AuditService.RESULT_SUCCESS, user, null, null, auditService.toJson(detail)));
 
@@ -339,6 +388,39 @@ public class PortalAuthService {
         return secret;
     }
 
+    /**
+     * 首次设密载荷(契约1.65 公共化):门户验签(lastLoginAt 为空)与本地登录
+     * (密码已被重置审批清空,契约1.49→1.65)共用——phase=SET_PASSWORD 加
+     * 一次性票据与设密选项;设密提交统一走 /portal/setup-password。
+     */
+    public Map<String, Object> buildSetupPasswordPayload(UserEntity user) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("phase", "SET_PASSWORD");
+        result.put("username", user.getUsername());
+        result.put("realName", user.getRealName());
+        result.put("portalTicket", issueSetupTicket(user));
+        // 机构树与大系统可能不一致,设密时让用户自选所在机构(预选推断结果)
+        result.put("organizations", portalOrgOptions());
+        result.put("suggestedOrganizationId",
+                user.getOrganizationId() != null
+                        ? String.valueOf(user.getOrganizationId()) : null);
+        // 多角色首登设密(契约1.53 密码即角色):为每个角色分配密码
+        // (恰一个使用用户密码),设密后以该角色进入,此后登录输入
+        // 哪个角色的密码即以该角色进入
+        List<RoleMountEntity> setupMounts = userRoleMapper.listLoginMounts(user.getId());
+        if (setupMounts.size() >= 2) {
+            List<Map<String, Object>> roleOptions = new ArrayList<>();
+            for (RoleMountEntity mount : setupMounts) {
+                Map<String, Object> option = new LinkedHashMap<>();
+                option.put("id", String.valueOf(mount.getRoleId()));
+                option.put("roleName", mount.getRoleName());
+                roleOptions.add(option);
+            }
+            result.put("roles", roleOptions);
+        }
+        return result;
+    }
+
     private String issueSetupTicket(UserEntity user) {
         Instant now = Instant.now();
         return JWT.create()
@@ -379,8 +461,10 @@ public class PortalAuthService {
             throw Errors.portalTicketInvalid();
         }
         if (user.getAuthVersion() == null || user.getAuthVersion() != authVersion.asInt()) {
-            // 票据已使用过(设密后auth_version+1)或用户状态已变更
-            throw new DmsException("PORTAL_TICKET_INVALID", 401, "设置密码凭证已使用,请从门户重新跳转");
+            // 票据已使用过(设密后auth_version+1)或用户状态已变更。
+            // 契约1.71:凭证已用=设密大概率已在服务端成功(弱网下响应丢失/前端
+            // 处理失败时用户被困设密页),文案给出自恢复出路而非「重新跳转」
+            throw new DmsException("PORTAL_TICKET_INVALID", 401, "设置密码凭证已使用——密码可能已设置成功,请返回登录页使用新密码重新登录");
         }
         return user;
     }

+ 103 - 0
backend-java/src/main/java/com/dms/service/RolePasswords.java

@@ -0,0 +1,103 @@
+package com.dms.service;
+
+import com.dms.common.DmsException;
+import com.dms.common.Errors;
+import com.dms.security.Passwords;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * 角色密码分配解析与校验(契约1.53 密码即角色):创建/编辑用户与门户
+ * 设密三入口共用。请求形态 [{roleId, password}]——password 为 null/缺省
+ * 表示该角色使用用户级密码。硬约束(消除登录匹配歧义):
+ * ①必须为一个角色选择使用用户密码(恰一个 null);
+ * ②其余独立密码两两互异;
+ * ③独立密码不等于用户级密码(创建/设密比对明文,编辑用旧哈希 verify)。
+ */
+public final class RolePasswords {
+
+    private RolePasswords() {
+    }
+
+    /**
+     * 解析并校验角色密码分配。
+     *
+     * @param raw                  请求体 rolePasswords 数组
+     * @param targetRoleIds        本次挂载的角色ID全集(调用方保证 ≥2)
+     * @param userPasswordPlaintext 用户级密码明文(创建/设密场景提供)
+     * @param userPasswordHash      用户级密码哈希(编辑未改用户密码场景提供)
+     * @return roleId → 独立密码明文;使用用户密码的角色映射值为 null
+     */
+    public static Map<Long, String> parse(Object raw, Set<Long> targetRoleIds,
+                                          String userPasswordPlaintext,
+                                          String userPasswordHash) {
+        if (!(raw instanceof List<?> list)) {
+            throw Errors.invalidArgument("rolePasswords必须是数组");
+        }
+        Map<Long, String> result = new LinkedHashMap<>();
+        for (Object item : list) {
+            if (!(item instanceof Map<?, ?> entry)
+                    || !(entry.get("roleId") instanceof String roleIdText)) {
+                throw Errors.invalidArgument("rolePasswords每项须为含roleId的对象");
+            }
+            long roleId;
+            try {
+                roleId = Long.parseLong(roleIdText);
+            } catch (NumberFormatException ex) {
+                throw Errors.invalidArgument("roleId必须是数字字符串");
+            }
+            if (!targetRoleIds.contains(roleId)) {
+                throw Errors.invalidArgument("roleId必须指向本次挂载的角色");
+            }
+            if (result.containsKey(roleId)) {
+                throw Errors.invalidArgument("rolePasswords存在重复角色");
+            }
+            Object password = entry.get("password");
+            if (password instanceof String text && !text.isEmpty()) {
+                try {
+                    Passwords.validatePolicy(text);
+                } catch (IllegalArgumentException ex) {
+                    throw Errors.invalidArgument(ex.getMessage());
+                }
+                result.put(roleId, text);
+            } else {
+                result.put(roleId, null);
+            }
+        }
+        if (!result.keySet().equals(targetRoleIds)) {
+            throw Errors.invalidArgument("rolePasswords必须覆盖全部挂载角色");
+        }
+        long nullCount = result.values().stream().filter(Objects::isNull).count();
+        if (nullCount != 1) {
+            throw Errors.invalidArgument("必须为一个角色选择使用用户密码");
+        }
+        List<String> independents = new ArrayList<>();
+        for (String password : result.values()) {
+            if (password == null) {
+                continue;
+            }
+            if ((userPasswordPlaintext != null && password.equals(userPasswordPlaintext))
+                    || (userPasswordHash != null
+                        && Passwords.verify(userPasswordHash, password))) {
+                throw conflict();
+            }
+            for (String prev : independents) {
+                if (prev.equals(password)) {
+                    throw conflict();
+                }
+            }
+            independents.add(password);
+        }
+        return result;
+    }
+
+    /** 互异约束冲突:登录按密码匹配角色,同密码会产生进入歧义。 */
+    public static DmsException conflict() {
+        return new DmsException("PASSWORD_CONFLICT", 400,
+                "各角色密码不能相同,且不能与用户密码相同", null);
+    }
+}

+ 276 - 68
backend-java/src/main/java/com/dms/service/RoleService.java

@@ -5,9 +5,12 @@ import com.dms.common.Errors;
 import com.dms.common.Page;
 import com.dms.common.Payloads;
 import com.dms.common.Reorders;
+import com.dms.mapper.CategoryMapper;
 import com.dms.mapper.RoleMapper;
 import com.dms.mapper.RolePermissionMapper;
 import com.dms.mapper.UserMapper;
+import com.dms.mapper.UserRoleMapper;
+import com.dms.model.CategoryEntity;
 import com.dms.model.RoleEntity;
 import com.dms.model.RolePermissionEntity;
 import com.dms.model.UserEntity;
@@ -22,7 +25,6 @@ import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Set;
 import java.util.TreeSet;
 import org.springframework.dao.DuplicateKeyException;
@@ -49,32 +51,50 @@ public class RoleService {
     private static final Set<String> ROLE_USER_FIELDS = Set.of("userIds", "rowVersion");
     private static final Set<String> ROLE_REORDER_FIELDS = Set.of("orderedIds");
 
-    /** 矩阵 8 个操作布尔位(camel → 实体字段一一对应;契约1.31 新增 canUpload,
-     *  canBind/canUnbind 界面合并为单一「挂载」开关,两列恒同值)。 */
+    /** 矩阵 12 个操作布尔位(camel → 实体字段一一对应;契约1.31 新增 canUpload,
+     *  canBind/canUnbind 界面合并为单一「挂载」开关,两列恒同值;
+     *  契约1.41 附件四权限(上传/编辑/查看/下载),仅 OWN 行可设置)。 */
     private static final List<String> PERMISSION_FLAG_NAMES = List.of(
             "canView", "canUpload", "canDownload", "canEdit", "canDelete",
-            "canConfigPermission", "canBindAttachment", "canUnbindAttachment");
+            "canConfigPermission", "canBindAttachment", "canUnbindAttachment",
+            "canAttachmentUpload", "canAttachmentEdit",
+            "canAttachmentView", "canAttachmentDownload", "canAttachmentDelete");
+
+    /** 附件五权限位(契约1.41/1.44):附件无部门归属,仅 OWN 行有意义。 */
+    private static final List<String> ATTACHMENT_FLAG_NAMES = List.of(
+            "canAttachmentUpload", "canAttachmentEdit",
+            "canAttachmentView", "canAttachmentDownload", "canAttachmentDelete");
 
     private static final List<String> SECURITY_LEVELS = List.of(
             "PUBLIC", "INTERNAL", "SECRET", "CONFIDENTIAL", "TOP_SECRET");
     private static final List<String> DEPT_SCOPES = List.of("OWN", "OTHER");
 
-    private static final Set<MatrixKey> MATRIX_KEYS = buildMatrixKeys();
+    /** 矩阵种类维度(契约1.48):MAIN=主体方案;附件四类型顺序对齐前端资料页签。 */
+    static final String DOC_KIND_MAIN = "MAIN";
+    static final List<String> ATTACHMENT_KINDS = List.of(
+            "ACCOMPANYING_PLAN", "SUPPORT_ACHIEVEMENT",
+            "TRAINING_MATERIAL", "TYPICAL_CASE");
 
     private final RoleMapper roleMapper;
     private final RolePermissionMapper rolePermissionMapper;
+    private final CategoryMapper categoryMapper;
     private final UserMapper userMapper;
+    private final UserRoleMapper userRoleMapper;
     private final AuditService auditService;
     private final AdminPermission adminPermission;
     private final Reauth reauth;
 
     public RoleService(RoleMapper roleMapper,
-                       RolePermissionMapper rolePermissionMapper, UserMapper userMapper,
+                       RolePermissionMapper rolePermissionMapper,
+                       CategoryMapper categoryMapper, UserMapper userMapper,
+                       UserRoleMapper userRoleMapper,
                        AuditService auditService, AdminPermission adminPermission,
                        Reauth reauth) {
         this.roleMapper = roleMapper;
         this.rolePermissionMapper = rolePermissionMapper;
+        this.categoryMapper = categoryMapper;
         this.userMapper = userMapper;
+        this.userRoleMapper = userRoleMapper;
         this.auditService = auditService;
         this.adminPermission = adminPermission;
         this.reauth = reauth;
@@ -123,6 +143,8 @@ public class RoleService {
         Long managedOrgId = optionalOrgId(body.get("managedOrgId"));
         Long actorId = AuthContext.get().userId();
         LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+        // 契约1.61:新增与删除角色仅超管可操作(1.60 勾管理位的细分拒绝被全覆盖)
+        guardRoleCreateDelete(AuthContext.get());
 
         try {
             if (roleMapper.findIdByRoleNameExclude(roleName, 0L) != null) {
@@ -187,6 +209,14 @@ public class RoleService {
             throw notFound("角色不存在");
         }
         checkVersion(role, expectedVersion);
+        // 契约1.60:管理员角色非超管全禁;普通角色管理位不可变更(原值回传放行)
+        guardRoleAdminEdit(AuthContext.get(), hasAnyManageFlag(role),
+                manageCategory != Boolean.TRUE.equals(role.getManageCategoryEdit())
+                        || manageOrg != Boolean.TRUE.equals(role.getManageOrgEdit())
+                        || manageUser != Boolean.TRUE.equals(role.getManageUserEdit())
+                        || manageSystem != Boolean.TRUE.equals(role.getManageSystem())
+                        || manageAuditLog != Boolean.TRUE.equals(role.getManageAuditLog())
+                        || manageRoleEdit != Boolean.TRUE.equals(role.getManageRoleEdit()));
         if (roleMapper.findIdByRoleNameExclude(roleName, roleId) != null) {
             throw conflict("角色名已存在", null);
         }
@@ -235,7 +265,9 @@ public class RoleService {
             throw notFound("角色不存在");
         }
         checkVersion(role, rowVersion);
-        long boundUsers = userMapper.countActiveByRoleId(roleId);
+        // 契约1.61:删除角色仅超管可操作(不限目标角色)
+        guardRoleCreateDelete(AuthContext.get());
+        long boundUsers = userRoleMapper.countActiveUsersByRoleId(roleId);
         if (boundUsers > 0) {
             throw new DmsException("ROLE_IN_USE", 409,
                     "角色仍有关联用户,无法删除", Map.of("memberCount", boundUsers));
@@ -262,14 +294,44 @@ public class RoleService {
     @Transactional(readOnly = true)
     public List<Map<String, Object>> listPermissions(long roleId) {
         requireRole(roleId);
-        List<Map<String, Object>> result = new ArrayList<>();
+        // 读时归一化(契约1.48):库行按键索引后按期望键集输出——分类新增的缺失行
+        // 以全 0 补齐、已删分类的残留行不输出;保存(全量替换)时自然收敛
+        Map<MatrixKey, RolePermissionEntity> byKey = new HashMap<>();
         for (RolePermissionEntity row : rolePermissionMapper.listActiveByRole(roleId)) {
-            result.add(permissionRowDto(row));
+            byKey.put(MatrixKey.of(row), row);
+        }
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (MatrixKey key : expectedMatrixKeys()) {
+            RolePermissionEntity row = byKey.get(key);
+            result.add(permissionRowDto(row != null ? row : emptyRow(key)));
         }
         return result;
     }
 
-    /** 对齐 update_role_permissions:15 行矩阵校验 + 差量更新。 */
+    /** 全 0 空行(缺省种子,不入库——保存时按提交值 UPSERT)。 */
+    private static RolePermissionEntity emptyRow(MatrixKey key) {
+        RolePermissionEntity row = new RolePermissionEntity();
+        row.setTopCategoryId(key.topCategoryId());
+        row.setDocKind(key.docKind());
+        row.setSecurityLevel(key.securityLevel());
+        row.setDeptScope(key.deptScope());
+        row.setCanView(false);
+        row.setCanUpload(false);
+        row.setCanDownload(false);
+        row.setCanEdit(false);
+        row.setCanDelete(false);
+        row.setCanConfigPermission(false);
+        row.setCanBindAttachment(false);
+        row.setCanUnbindAttachment(false);
+        row.setCanAttachmentUpload(false);
+        row.setCanAttachmentEdit(false);
+        row.setCanAttachmentView(false);
+        row.setCanAttachmentDownload(false);
+        row.setCanAttachmentDelete(false);
+        return row;
+    }
+
+    /** 对齐 update_role_permissions:三维矩阵校验 + 差量更新 + 缺失行补插(契约1.48)。 */
     @Transactional
     public Map<String, Object> updatePermissions(long roleId, Object payload,
                                                  HttpServletRequest request) {
@@ -285,29 +347,52 @@ public class RoleService {
             throw notFound("角色不存在");
         }
         checkVersion(role, expectedVersion);
+        // 契约1.60:管理员角色的权限矩阵非超管不可保存
+        guardRoleAdminEdit(AuthContext.get(), hasAnyManageFlag(role), false);
         List<RolePermissionEntity> existingRows = rolePermissionMapper.listActiveByRole(roleId);
         Map<MatrixKey, RolePermissionEntity> byKey = new HashMap<>();
         for (RolePermissionEntity row : existingRows) {
-            byKey.put(new MatrixKey(row.getSecurityLevel(), row.getDeptScope()), row);
+            byKey.put(MatrixKey.of(row), row);
         }
-        if (!byKey.keySet().equals(MATRIX_KEYS)) {
+        // 仅拒绝重复脏行(同一矩阵键两行):缺失行(分类新增)在下方 diff 补插,
+        // 已删分类残留行不参与匹配、被提交全集自然冷落
+        if (byKey.size() != existingRows.size()) {
             throw conflict("角色权限矩阵数据不完整,请联系管理员核查", null);
         }
+        List<MatrixKey> expectedKeys = expectedMatrixKeys();
 
         List<Map<String, Object>> changedRows = new ArrayList<>();
         for (Map<String, Object> item : matrix) {
-            RolePermissionEntity row = byKey.get(new MatrixKey(
-                    (String) item.get("securityLevel"), (String) item.get("deptScope")));
+            MatrixKey key = new MatrixKey(longId(item.get("topCategoryId")),
+                    (String) item.get("docKind"),
+                    (String) item.get("securityLevel"), (String) item.get("deptScope"));
+            RolePermissionEntity row = byKey.get(key);
+            if (row == null) {
+                // 分类新增后的缺失行:以全 0 空行参与 diff,变化时按提交值补插
+                row = emptyRow(key);
+            }
             Map<String, Object> beforeFlags = entityFlags(row);
             Map<String, Object> afterFlags = requestFlags(item);
             if (!beforeFlags.equals(afterFlags)) {
-                applyFlags(row, item);
-                row.setUpdatedBy(actorId);
-                row.setUpdatedAt(now);
-                rolePermissionMapper.updateFlags(row);
+                if (row.getId() == null) {
+                    // 分类新增后首存的缺失行:按提交值落库(含审计者信息)
+                    applyFlags(row, item);
+                    row.setRoleId(roleId);
+                    row.setCreatedBy(actorId);
+                    row.setUpdatedBy(actorId);
+                    row.setUpdatedAt(now);
+                    rolePermissionMapper.insert(row);
+                } else {
+                    applyFlags(row, item);
+                    row.setUpdatedBy(actorId);
+                    row.setUpdatedAt(now);
+                    rolePermissionMapper.updateFlags(row);
+                }
                 Map<String, Object> changed = Payloads.orderedMap();
-                changed.put("securityLevel", row.getSecurityLevel());
-                changed.put("deptScope", row.getDeptScope());
+                changed.put("topCategoryId", item.get("topCategoryId"));
+                changed.put("docKind", key.docKind());
+                changed.put("securityLevel", key.securityLevel());
+                changed.put("deptScope", key.deptScope());
                 changed.put("before", beforeFlags);
                 changed.put("after", afterFlags);
                 changedRows.add(changed);
@@ -337,12 +422,14 @@ public class RoleService {
         requireRole(roleId);
         Map<String, Object> result = Payloads.orderedMap();
         result.put("id", serializeId(roleId));
-        result.put("userIds", userMapper.listActiveIdsByRoleId(roleId).stream()
+        result.put("userIds", userRoleMapper.listActiveUserIdsByRoleId(roleId).stream()
                 .map(String::valueOf).toList());
         return result;
     }
 
-    /** 对齐 update_role_users:USER/ADMIN 均可关联,双向 diff 联动 auth_version。 */
+    /** 对齐 update_role_users:USER/ADMIN 均可关联,双向 diff 联动 auth_version。
+     * 多角色化(契约1.46):只增删本角色关联、不动用户其他角色挂载;
+     * 关联变更逐用户 bumpForRoleChange,使旧 token(含会话角色 claim)失效。 */
     @Transactional
     public Map<String, Object> updateUsers(long roleId, Object payload,
                                            HttpServletRequest request) {
@@ -357,6 +444,8 @@ public class RoleService {
             throw notFound("角色不存在");
         }
         checkVersion(role, expectedVersion);
+        // 契约1.60:管理员角色的用户关联非超管不可变更(挂给他人/自己即自我提权)
+        guardRoleAdminEdit(AuthContext.get(), hasAnyManageFlag(role), false);
         Set<Long> targetIds = new HashSet<>(userIds);
         Map<Long, UserEntity> found = new HashMap<>();
         if (!targetIds.isEmpty()) {
@@ -376,18 +465,24 @@ public class RoleService {
             }
         }
 
+        List<Long> currentUserIds = userRoleMapper.listActiveUserIdsByRoleId(roleId);
         List<Long> addedIds = new ArrayList<>();
         List<Long> removedIds = new ArrayList<>();
-        for (UserEntity user : userMapper.listActiveByRoleIdForUpdate(roleId)) {
-            if (!targetIds.contains(user.getId())) {
-                userMapper.clearRole(user.getId(), now, actorId);
-                removedIds.add(user.getId());
+        for (Long currentUserId : currentUserIds) {
+            if (!targetIds.contains(currentUserId)) {
+                userRoleMapper.deleteByUserAndRole(currentUserId, roleId);
+                userMapper.bumpForRoleChange(currentUserId, now, actorId);
+                removedIds.add(currentUserId);
             }
         }
         for (Long userId : new TreeSet<>(targetIds)) {
-            UserEntity user = found.get(userId);
-            if (user != null && !Objects.equals(user.getRoleId(), roleId)) {
-                userMapper.assignRole(userId, roleId, now, actorId);
+            if (!currentUserIds.contains(userId)) {
+                try {
+                    userRoleMapper.insert(userId, roleId, actorId);
+                } catch (DuplicateKeyException ex) {
+                    // 幂等:关联已存在(如停用期间保留的挂载)
+                }
+                userMapper.bumpForRoleChange(userId, now, actorId);
                 addedIds.add(userId);
             }
         }
@@ -469,16 +564,16 @@ public class RoleService {
     }
 
     private void seedPermissionRows(long roleId, Long actorId) {
-        for (String level : SECURITY_LEVELS) {
-            for (String scope : DEPT_SCOPES) {
-                RolePermissionEntity row = new RolePermissionEntity();
-                row.setRoleId(roleId);
-                row.setSecurityLevel(level);
-                row.setDeptScope(scope);
-                row.setCreatedBy(actorId);
-                row.setUpdatedBy(actorId);
-                rolePermissionMapper.insert(row);
-            }
+        for (MatrixKey key : expectedMatrixKeys()) {
+            RolePermissionEntity row = new RolePermissionEntity();
+            row.setRoleId(roleId);
+            row.setTopCategoryId(key.topCategoryId());
+            row.setDocKind(key.docKind());
+            row.setSecurityLevel(key.securityLevel());
+            row.setDeptScope(key.deptScope());
+            row.setCreatedBy(actorId);
+            row.setUpdatedBy(actorId);
+            rolePermissionMapper.insert(row);
         }
     }
 
@@ -487,7 +582,7 @@ public class RoleService {
             return Map.of();
         }
         Map<Long, Long> result = new HashMap<>();
-        for (Map<String, Object> row : userMapper.countMembersByRoleIds(roleIds)) {
+        for (Map<String, Object> row : userRoleMapper.countActiveUsersByRoleIds(roleIds)) {
             result.put(((Number) row.get("roleId")).longValue(),
                     ((Number) row.get("cnt")).longValue());
         }
@@ -570,6 +665,8 @@ public class RoleService {
 
     private static Map<String, Object> permissionRowDto(RolePermissionEntity row) {
         Map<String, Object> dto = Payloads.orderedMap();
+        dto.put("topCategoryId", serializeId(row.getTopCategoryId()));
+        dto.put("docKind", row.getDocKind());
         dto.put("securityLevel", row.getSecurityLevel());
         dto.put("deptScope", row.getDeptScope());
         dto.put("canView", row.getCanView());
@@ -580,18 +677,25 @@ public class RoleService {
         dto.put("canConfigPermission", row.getCanConfigPermission());
         dto.put("canBindAttachment", row.getCanBindAttachment());
         dto.put("canUnbindAttachment", row.getCanUnbindAttachment());
+        dto.put("canAttachmentUpload", row.getCanAttachmentUpload());
+        dto.put("canAttachmentEdit", row.getCanAttachmentEdit());
+        dto.put("canAttachmentView", row.getCanAttachmentView());
+        dto.put("canAttachmentDownload", row.getCanAttachmentDownload());
+        dto.put("canAttachmentDelete", row.getCanAttachmentDelete());
         return dto;
     }
 
-    /** 对齐 _validate_matrix。 */
-    @SuppressWarnings("unchecked")
-    private static List<Map<String, Object>> validateMatrix(Object value) {
+    /** 对齐 _validate_matrix(契约1.48):三维行结构 + 当前期望键集全集校验。 */
+    private List<Map<String, Object>> validateMatrix(Object value) {
         Set<String> expectedRowFields = new TreeSet<>();
+        expectedRowFields.add("topCategoryId");
+        expectedRowFields.add("docKind");
         expectedRowFields.add("securityLevel");
         expectedRowFields.add("deptScope");
         expectedRowFields.addAll(PERMISSION_FLAG_NAMES);
-        if (!(value instanceof List<?> list) || list.size() != MATRIX_KEYS.size()) {
-            throw Errors.invalidArgument("权限矩阵必须为" + MATRIX_KEYS.size() + "行");
+        List<MatrixKey> expectedKeys = expectedMatrixKeys();
+        if (!(value instanceof List<?> list) || list.size() != expectedKeys.size()) {
+            throw Errors.invalidArgument("权限矩阵必须为" + expectedKeys.size() + "行");
         }
         Set<MatrixKey> rows = new HashSet<>();
         for (Object item : list) {
@@ -599,31 +703,91 @@ public class RoleService {
                 throw Errors.invalidArgument(
                         "矩阵行字段必须且只能包含:" + String.join("、", expectedRowFields));
             }
+            @SuppressWarnings("unchecked")
             Map<String, Object> row = (Map<String, Object>) item;
-            Object level = row.get("securityLevel");
-            Object scope = row.get("deptScope");
-            if (!(level instanceof String) || !(scope instanceof String)) {
-                throw Errors.invalidArgument("矩阵行的密级或部门范围不是有效枚举值");
-            }
-            MatrixKey key = new MatrixKey((String) level, (String) scope);
-            if (!MATRIX_KEYS.contains(key)) {
-                throw Errors.invalidArgument("矩阵行的密级或部门范围不是有效枚举值");
+            MatrixKey key = matrixKey(row);
+            if (!expectedKeys.contains(key)) {
+                throw Errors.invalidArgument("矩阵行的分类、种类、密级或部门范围组合无效");
             }
-            if (rows.contains(key)) {
+            if (!rows.add(key)) {
                 throw Errors.invalidArgument("矩阵行不能重复");
             }
             for (String name : PERMISSION_FLAG_NAMES) {
                 Payloads.booleanValue(row.get(name), name);
             }
+            boolean mainRow = DOC_KIND_MAIN.equals(key.docKind());
+            for (String name : PERMISSION_FLAG_NAMES) {
+                boolean attachmentFlag = ATTACHMENT_FLAG_NAMES.contains(name);
+                // 主体方案行仅主案位有效;附件种类行仅附件位有效(交叉位服务端拒绝置真)
+                if (mainRow == attachmentFlag && Boolean.TRUE.equals(row.get(name))) {
+                    throw Errors.invalidArgument(mainRow
+                            ? "主体方案行不可设置附件权限位"
+                            : "共享资料行不可设置主体方案权限位");
+                }
+            }
             // 上传权限仅限本部门(OWN)行:其他部门行恒不可选(契约1.31)
-            if ("OTHER".equals(scope) && Boolean.TRUE.equals(row.get("canUpload"))) {
+            if ("OTHER".equals(key.deptScope()) && Boolean.TRUE.equals(row.get("canUpload"))) {
                 throw Errors.invalidArgument("上传权限仅可设置本部门方案");
             }
-            rows.add(key);
         }
         return (List<Map<String, Object>>) value;
     }
 
+    /** 请求行的矩阵键(topCategoryId 字符串 id 校验 + null=共享资料库)。 */
+    private static MatrixKey matrixKey(Map<String, Object> row) {
+        Object topId = row.get("topCategoryId");
+        Object kind = row.get("docKind");
+        Object level = row.get("securityLevel");
+        Object scope = row.get("deptScope");
+        if (topId != null && (!(topId instanceof String top) || !top.matches("[0-9]+"))) {
+            throw Errors.invalidArgument("矩阵行的一级分类标识无效");
+        }
+        if (!(kind instanceof String kindValue)
+                || !(DOC_KIND_MAIN.equals(kindValue) || ATTACHMENT_KINDS.contains(kindValue))) {
+            throw Errors.invalidArgument("矩阵行的种类不是有效枚举值");
+        }
+        if (!(level instanceof String) || !SECURITY_LEVELS.contains(level)) {
+            throw Errors.invalidArgument("矩阵行的密级不是有效枚举值");
+        }
+        if (!(scope instanceof String) || !DEPT_SCOPES.contains(scope)) {
+            throw Errors.invalidArgument("矩阵行的部门范围不是有效枚举值");
+        }
+        return new MatrixKey(topId == null ? null : Long.valueOf((String) topId),
+                (String) kind, (String) level, (String) scope);
+    }
+
+    /** 期望矩阵键集(契约1.48):每未删一级分类 = 主体方案(OWN/OTHER)+ 非仅主案
+     *  分类的附件四类型(OWN);外加共享资料库(topCategoryId=null)附件四类型(OWN)。 */
+    private List<MatrixKey> expectedMatrixKeys() {
+        List<MatrixKey> keys = new ArrayList<>();
+        List<Long> topIds = new ArrayList<>();
+        for (CategoryEntity category : categoryMapper.listActiveOrdered()) {
+            if (category.getParentId() == null) {
+                topIds.add(category.getId());
+                boolean mainOnly = Boolean.TRUE.equals(category.getFlatMode());
+                for (String level : SECURITY_LEVELS) {
+                    keys.add(new MatrixKey(category.getId(), DOC_KIND_MAIN, level, "OWN"));
+                    keys.add(new MatrixKey(category.getId(), DOC_KIND_MAIN, level, "OTHER"));
+                    if (!mainOnly) {
+                        for (String kind : ATTACHMENT_KINDS) {
+                            keys.add(new MatrixKey(category.getId(), kind, level, "OWN"));
+                        }
+                    }
+                }
+            }
+        }
+        for (String level : SECURITY_LEVELS) {
+            for (String kind : ATTACHMENT_KINDS) {
+                keys.add(new MatrixKey(null, kind, level, "OWN"));
+            }
+        }
+        return keys;
+    }
+
+    private static Long longId(Object value) {
+        return value == null ? null : Long.valueOf((String) value);
+    }
+
     private static Map<String, Object> entityFlags(RolePermissionEntity row) {
         Map<String, Object> flags = new LinkedHashMap<>();
         flags.put("canView", row.getCanView());
@@ -634,6 +798,11 @@ public class RoleService {
         flags.put("canConfigPermission", row.getCanConfigPermission());
         flags.put("canBindAttachment", row.getCanBindAttachment());
         flags.put("canUnbindAttachment", row.getCanUnbindAttachment());
+        flags.put("canAttachmentUpload", row.getCanAttachmentUpload());
+        flags.put("canAttachmentEdit", row.getCanAttachmentEdit());
+        flags.put("canAttachmentView", row.getCanAttachmentView());
+        flags.put("canAttachmentDownload", row.getCanAttachmentDownload());
+        flags.put("canAttachmentDelete", row.getCanAttachmentDelete());
         return flags;
     }
 
@@ -654,16 +823,11 @@ public class RoleService {
         row.setCanConfigPermission((Boolean) item.get("canConfigPermission"));
         row.setCanBindAttachment((Boolean) item.get("canBindAttachment"));
         row.setCanUnbindAttachment((Boolean) item.get("canUnbindAttachment"));
-    }
-
-    private static Set<MatrixKey> buildMatrixKeys() {
-        Set<MatrixKey> keys = new HashSet<>();
-        for (String level : SECURITY_LEVELS) {
-            for (String scope : DEPT_SCOPES) {
-                keys.add(new MatrixKey(level, scope));
-            }
-        }
-        return keys;
+        row.setCanAttachmentUpload((Boolean) item.get("canAttachmentUpload"));
+        row.setCanAttachmentEdit((Boolean) item.get("canAttachmentEdit"));
+        row.setCanAttachmentView((Boolean) item.get("canAttachmentView"));
+        row.setCanAttachmentDownload((Boolean) item.get("canAttachmentDownload"));
+        row.setCanAttachmentDelete((Boolean) item.get("canAttachmentDelete"));
     }
 
     private static void checkVersion(RoleEntity entity, int expectedVersion) {
@@ -693,6 +857,45 @@ public class RoleService {
         return id == null ? null : String.valueOf(id);
     }
 
+    private static DmsException forbidden(String message) {
+        return new DmsException("FORBIDDEN", 403, message, null);
+    }
+
+    /** 契约1.60 防提权:管理位任一勾选的角色即管理员角色。 */
+    private static boolean hasAnyManageFlag(RoleEntity role) {
+        return Boolean.TRUE.equals(role.getManageCategoryEdit())
+                || Boolean.TRUE.equals(role.getManageOrgEdit())
+                || Boolean.TRUE.equals(role.getManageUserEdit())
+                || Boolean.TRUE.equals(role.getManageSystem())
+                || Boolean.TRUE.equals(role.getManageAuditLog())
+                || Boolean.TRUE.equals(role.getManageRoleEdit());
+    }
+
+    /**
+     * 契约1.60 防提权守卫:非内置超管不能修改管理员角色的任何设置
+     * (基本信息与权限矩阵),也不能设置/变更任何角色的管理位
+     * (新建勾选与编辑变更同样拒绝,原值回传放行)。
+     */
+    private static void guardRoleAdminEdit(AuthContext actor, boolean targetIsAdminRole,
+                                           boolean manageFlagsChanged) {
+        if (AuthorizationService.isSuperAdmin(actor)) {
+            return;
+        }
+        if (targetIsAdminRole) {
+            throw forbidden("管理员角色仅超级管理员可修改");
+        }
+        if (manageFlagsChanged) {
+            throw forbidden("管理权限仅超级管理员可设置");
+        }
+    }
+
+    /** 契约1.61:新增与删除角色仅内置超管可操作(防越权改组角色体系)。 */
+    private static void guardRoleCreateDelete(AuthContext actor) {
+        if (!AuthorizationService.isSuperAdmin(actor)) {
+            throw forbidden("新增与删除角色仅超级管理员可操作");
+        }
+    }
+
     private static DmsException conflict(String message, Object details) {
         return new DmsException("DATA_VERSION_CONFLICT", 409, message, details);
     }
@@ -701,7 +904,12 @@ public class RoleService {
         return new DmsException("RESOURCE_NOT_FOUND", 404, message);
     }
 
-    /** 矩阵定位键:密级 × 部门范围。 */
-    private record MatrixKey(String securityLevel, String deptScope) {
+    /** 矩阵定位键(契约1.48):一级分类 × 种类 × 密级 × 部门范围(topCategoryId null=共享资料库)。 */
+    private record MatrixKey(Long topCategoryId, String docKind,
+                             String securityLevel, String deptScope) {
+        static MatrixKey of(RolePermissionEntity row) {
+            return new MatrixKey(row.getTopCategoryId(), row.getDocKind(),
+                    row.getSecurityLevel(), row.getDeptScope());
+        }
     }
 }

+ 240 - 42
backend-java/src/main/java/com/dms/service/UserService.java

@@ -9,6 +9,7 @@ import com.dms.config.DmsProperties;
 import com.dms.mapper.OrganizationMapper;
 import com.dms.mapper.RoleMapper;
 import com.dms.mapper.UserMapper;
+import com.dms.mapper.UserRoleMapper;
 import com.dms.model.OrganizationEntity;
 import com.dms.model.RoleEntity;
 import com.dms.model.UserEntity;
@@ -23,7 +24,6 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Set;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
@@ -38,18 +38,24 @@ public class UserService {
 
     private static final Set<String> CREATE_FIELDS = Set.of(
             "username", "realName", "initialPassword", "organizationId",
-            "roleCode", "roleId", "securityLevel", "status");
+            "roleCode", "roleIds", "seatName", "userType", "securityLevel", "status");
     private static final Set<String> UPDATE_FIELDS = Set.of(
-            "realName", "organizationId", "roleCode", "roleId",
-            "securityLevel", "status", "rowVersion");
-    /** 用户级管理范围(契约1.30)为可选字段:create 未提交缺省落 SELF(哨兵0),update 未提交保持原值。 */
+            "realName", "organizationId", "roleCode", "roleIds",
+            "seatName", "userType", "securityLevel", "status", "rowVersion");
+    /** 用户级管理范围(契约1.30)为可选字段:create 未提交缺省落 SELF(哨兵0),update 未提交保持原值;
+     *  rolePasswords 为契约1.53 角色密码分配(仅挂载 ≥2 时受理)。 */
     private static final Set<String> OPTIONAL_MANAGED_ORG_FIELDS = Set.of("managedOrgId");
+    private static final Set<String> OPTIONAL_CREATE_FIELDS = Set.of("managedOrgId", "rolePasswords");
+    private static final Set<String> OPTIONAL_UPDATE_FIELDS = Set.of("managedOrgId", "rolePasswords");
     private static final Set<String> RESET_PASSWORD_FIELDS = Set.of("newPassword", "rowVersion");
     private static final Set<String> UI_THEME_FIELDS = Set.of("uiTheme");
     private static final Set<String> REORDER_FIELDS = Set.of("orderedIds");
     private static final Set<String> UI_THEMES = Set.of("blue", "red", "green");
+    /** 用户类型(契约1.46):COMMANDER=首长(仍属普通用户,仅重置密码方式后续差异化)。 */
+    private static final Set<String> USER_TYPES = Set.of("NORMAL", "COMMANDER");
 
     private final UserMapper userMapper;
+    private final UserRoleMapper userRoleMapper;
     private final OrganizationMapper organizationMapper;
     private final RoleMapper roleMapper;
     private final UserSummaryService userSummaryService;
@@ -58,11 +64,13 @@ public class UserService {
     private final Reauth reauth;
     private final DmsProperties dmsProperties;
 
-    public UserService(UserMapper userMapper, OrganizationMapper organizationMapper,
+    public UserService(UserMapper userMapper, UserRoleMapper userRoleMapper,
+                       OrganizationMapper organizationMapper,
                        RoleMapper roleMapper, UserSummaryService userSummaryService,
                        AuditService auditService, AdminPermission adminPermission,
                        Reauth reauth, DmsProperties dmsProperties) {
         this.userMapper = userMapper;
+        this.userRoleMapper = userRoleMapper;
         this.organizationMapper = organizationMapper;
         this.roleMapper = roleMapper;
         this.userSummaryService = userSummaryService;
@@ -91,6 +99,14 @@ public class UserService {
             UserEntity actor = userMapper.findActiveById(currentUserId);
             if (actor != null) {
                 Set<Long> scope = adminPermission.managedScopeIds(actor);
+                if (scope == null && !AuthContext.get().isAdmin()) {
+                    // 契约1.45:普通用户(USER)只读放开但恒收敛本人部门
+                    // 子树(沿ENABLED边,无部门空集)——与机构树裁剪口径
+                    // 一致,防止横向枚举其他部门用户
+                    scope = actor.getOrganizationId() == null
+                            ? new HashSet<>()
+                            : descendantIds(actor.getOrganizationId());
+                }
                 if (scope != null) {
                     if (organizationIds == null) {
                         organizationIds = new ArrayList<>(scope);
@@ -136,22 +152,39 @@ public class UserService {
     @Transactional
     public Map<String, Object> create(Object payload, HttpServletRequest request) {
         Map<String, Object> body = Payloads.exactPayload(
-                payload, CREATE_FIELDS, OPTIONAL_MANAGED_ORG_FIELDS);
+                payload, CREATE_FIELDS, OPTIONAL_CREATE_FIELDS);
         String username = Payloads.requiredText(body.get("username"), "username", 64);
+        // 超级管理员登录开关(安全需求7)关闭时拒绝创建内置 admin 用户名:
+        // 报错不提示"已存在",不泄露系统中存在该账号;置于其余字段
+        // 校验与任何查库(角色解析、唯一性查重)之前
+        if (AdminPermission.SUPER_ADMIN_USERNAME.equals(username)
+                && !dmsProperties.adminLoginAllowed()) {
+            throw Errors.invalidArgument("不能创建该名称的用户");
+        }
         String realName = Payloads.requiredText(body.get("realName"), "realName", 64);
         String initialPassword = requiredPassword(body.get("initialPassword"));
         Long organizationId = bodyStringId(body.get("organizationId"), "organizationId");
         String roleCode = Payloads.roleCodeValue(body.get("roleCode"));
-        Long roleId = bodyStringId(body.get("roleId"), "roleId");
+        List<RoleEntity> roles = resolveRoles(body.get("roleIds"));
+        // 角色密码分配(契约1.53 密码即角色):挂载 ≥2 时必填——恰一个
+        // 角色使用用户密码,其余独立密码两两互异且不等于初始密码;
+        // 挂载 ≤1 不接受该字段(单角色即用户级密码)
+        Map<Long, String> rolePasswords = null;
+        if (roles.size() >= 2) {
+            Set<Long> roleIdSet = new HashSet<>();
+            for (RoleEntity role : roles) {
+                roleIdSet.add(role.getId());
+            }
+            rolePasswords = RolePasswords.parse(body.get("rolePasswords"), roleIdSet,
+                    initialPassword, null);
+        } else if (body.containsKey("rolePasswords")) {
+            throw Errors.invalidArgument("仅多角色需要分配角色密码");
+        }
+        String seatName = optionalSeatName(body.get("seatName"));
+        String userType = userTypeValue(body.get("userType"));
         String securityLevel = Payloads.securityLevelValue(body.get("securityLevel"));
         String status = Payloads.statusValue(body.get("status"));
         Long actorId = AuthContext.get().userId();
-        // 超级管理员登录开关(安全需求7)关闭时拒绝创建内置 admin 用户名:
-        // 报错不提示"已存在",不泄露系统中存在该账号
-        if (AdminPermission.SUPER_ADMIN_USERNAME.equals(username)
-                && !dmsProperties.adminLoginAllowed()) {
-            throw Errors.invalidArgument("不能创建该名称的用户");
-        }
 
         try {
             UserEntity actor = adminPermission.requireUserEditor();
@@ -159,8 +192,12 @@ public class UserService {
             if (userMapper.findActiveByUsername(username) != null) {
                 throw conflict("用户名已存在", null);
             }
+            // 姓名唯一性(契约1.47,应用层判断不加库约束):用户名与姓名
+            // 同语义且支持姓名登录,重名会使姓名登录歧义
+            if (userMapper.countActiveByRealName(realName, null) > 0) {
+                throw conflict("姓名已存在", null);
+            }
             OrganizationEntity organization = resolveOrganization(organizationId);
-            RoleEntity role = resolveRole(roleId);
             // 缺省落 SELF(哨兵0):显式赋值不依赖数据库缺省,
             // 保证未提交该字段的新建用户与迁移回填的存量行语义一致。
             // 显式提交 null(全局)时 optionalManagedOrg 返回 null,
@@ -171,12 +208,10 @@ public class UserService {
             } else {
                 managedOrgId = AdminPermission.MANAGED_ORG_SELF_ANCHOR;
             }
-            // 防提权(契约1.30修订):目标 ADMIN 的生效管理范围不得超出操作者
+            // 防提权(契约1.30修订;1.46 多角色遍历):目标 ADMIN 任一挂载
+            // 角色的生效管理范围不得超出操作者
             adminPermission.ensureUserScopeInManagedScope(
-                    actor, roleCode,
-                    adminPermission.effectiveRoleSnapshot(
-                            role == null ? null : role.getId()),
-                    managedOrgId,
+                    actor, roleCode, roles, managedOrgId,
                     organization == null ? null : organization.getId());
 
             UserEntity user = new UserEntity();
@@ -186,15 +221,29 @@ public class UserService {
             user.setOrganizationId(organization == null ? null : organization.getId());
             user.setOrganizationName(organization == null ? null : organization.getOrgName());
             user.setRoleCode(roleCode);
-            user.setRoleId(role == null ? null : role.getId());
             user.setSecurityLevel(securityLevel);
             user.setStatus(status);
+            user.setSeatName(seatName);
+            user.setUserType(userType);
             user.setManagedOrgId(managedOrgId);
             user.setCreatedBy(actorId);
             user.setUpdatedBy(actorId);
             user.setAuthVersion(0);
             user.setRowVersion(0);
             userMapper.insertFull(user);
+            // 多角色挂载写入(契约1.46)
+            for (RoleEntity role : roles) {
+                attachRole(user.getId(), role.getId(), actorId);
+            }
+            // 挂载行独立密码写入(契约1.53):使用用户密码的角色保持 NULL
+            if (rolePasswords != null) {
+                for (Map.Entry<Long, String> entry : rolePasswords.entrySet()) {
+                    if (entry.getValue() != null) {
+                        userRoleMapper.updatePassword(user.getId(), entry.getKey(),
+                                Passwords.hash(entry.getValue()));
+                    }
+                }
+            }
             // 回读落库行:ui_theme 等列不在 INSERT 清单(走 DB 缺省值),
             // 不回读则响应 uiTheme=null 违反契约(isUserSummary 要求 undefined 或字符串)
             UserEntity created = userMapper.findActiveById(user.getId());
@@ -207,11 +256,24 @@ public class UserService {
             detail.put("organizationId", serializeId(user.getOrganizationId()));
             detail.put("organizationName", user.getOrganizationName());
             detail.put("roleCode", user.getRoleCode());
-            detail.put("roleId", serializeId(user.getRoleId()));
-            detail.put("roleName", role == null ? null : role.getRoleName());
+            detail.put("roleIds", roles.stream()
+                    .map(role -> String.valueOf(role.getId())).toList());
+            detail.put("roleNames", roles.stream()
+                    .map(RoleEntity::getRoleName).toList());
+            detail.put("seatName", user.getSeatName());
+            detail.put("userType", user.getUserType());
             detail.put("securityLevel", user.getSecurityLevel());
             detail.put("status", user.getStatus());
             detail.put("managedOrgId", managedOrgValue(user.getManagedOrgId()));
+            if (rolePasswords != null) {
+                List<String> rolePasswordRoleIds = new ArrayList<>();
+                for (Map.Entry<Long, String> entry : rolePasswords.entrySet()) {
+                    if (entry.getValue() != null) {
+                        rolePasswordRoleIds.add(String.valueOf(entry.getKey()));
+                    }
+                }
+                detail.put("rolePasswordRoleIds", rolePasswordRoleIds);
+            }
             audit(request, AuditService.Action.CREATE_USER,
                     user.getId(), user.getRealName(), detail);
             return userSummaryService.userSummary(created);
@@ -220,15 +282,19 @@ public class UserService {
         }
     }
 
-    /** 对齐 update_user:角色/分配/停用联动 auth_version 与最后管理员守卫。 */
+    /** 对齐 update_user:角色/分配/停用联动 auth_version 与最后管理员守卫。
+     * 角色挂载改多对多(契约1.46):diff 只替换活跃启用部分,
+     * 停用角色的关联保留(角色恢复启用后自动还原挂载)。 */
     @Transactional
     public Map<String, Object> update(long userId, Object payload, HttpServletRequest request) {
         Map<String, Object> body = Payloads.exactPayload(
-                payload, UPDATE_FIELDS, OPTIONAL_MANAGED_ORG_FIELDS);
+                payload, UPDATE_FIELDS, OPTIONAL_UPDATE_FIELDS);
         String realName = Payloads.requiredText(body.get("realName"), "realName", 64);
         Long organizationId = bodyStringId(body.get("organizationId"), "organizationId");
         String newRoleCode = Payloads.roleCodeValue(body.get("roleCode"));
-        Long newRoleId = bodyStringId(body.get("roleId"), "roleId");
+        List<RoleEntity> newRoles = resolveRoles(body.get("roleIds"));
+        String newSeatName = optionalSeatName(body.get("seatName"));
+        String newUserType = userTypeValue(body.get("userType"));
         String newSecurityLevel = Payloads.securityLevelValue(body.get("securityLevel"));
         String newStatus = Payloads.statusValue(body.get("status"));
         int expectedVersion = Payloads.rowVersion(body.get("rowVersion"));
@@ -245,6 +311,12 @@ public class UserService {
         ensureNotBuiltinAdmin(user, "编辑");
         adminPermission.ensureOrgInManagedScope(actorUser, user.getOrganizationId());
         checkVersion(user, expectedVersion);
+        // 姓名唯一性(契约1.47,应用层判断不加库约束):排除自身,
+        // 与新建同口径——重名会使姓名登录歧义
+        if (!realName.equals(user.getRealName())
+                && userMapper.countActiveByRealName(realName, userId) > 0) {
+            throw conflict("姓名已存在", null);
+        }
         Map<String, Object> before = userSnapshot(user);
 
         boolean roleChanges = !newRoleCode.equals(user.getRoleCode());
@@ -259,9 +331,29 @@ public class UserService {
         // 迁入机构同样须在管理范围内:锚点管理员不可将用户移出自己管理的子树
         adminPermission.ensureOrgInManagedScope(actorUser,
                 organization == null ? null : organization.getId());
-        RoleEntity role = resolveRole(newRoleId);
-        Long resolvedRoleId = role == null ? null : role.getId();
-        boolean roleAssignmentChanges = !Objects.equals(resolvedRoleId, user.getRoleId());
+        // 多角色挂载 diff(契约1.46):基线=当前活跃启用挂载,停用角色关联
+        // 不在基线内故不被清除;变更时旧 token 的会话角色可能失效,须联动失效
+        Set<Long> targetRoleIds = new HashSet<>();
+        for (RoleEntity role : newRoles) {
+            targetRoleIds.add(role.getId());
+        }
+        Set<Long> currentRoleIds =
+                new HashSet<>(userRoleMapper.listActiveRoleIdsByUserId(userId));
+        boolean roleAssignmentChanges = !targetRoleIds.equals(currentRoleIds);
+        // 角色密码分配(契约1.53):仅挂载变化时受理——≥2 角色必填重新
+        // 分配(恰一个使用用户密码,独立密码互异且不等于用户级密码,
+        // 用户密码未修改时以旧哈希 verify 比对);≤1 角色清空挂载行密码
+        Map<Long, String> rolePasswords = null;
+        if (roleAssignmentChanges) {
+            if (targetRoleIds.size() >= 2) {
+                rolePasswords = RolePasswords.parse(body.get("rolePasswords"),
+                        targetRoleIds, null, user.getPasswordHash());
+            } else if (body.containsKey("rolePasswords")) {
+                throw Errors.invalidArgument("仅多角色需要分配角色密码");
+            }
+        } else if (body.containsKey("rolePasswords")) {
+            throw Errors.invalidArgument("挂载角色未变化,无需重新分配角色密码");
+        }
         // 可选字段(契约1.30):未提交保持原值;调整不改变认证身份,
         // 不触发二次认证、不失效旧 Token(与角色锚点同口径)
         boolean managedOrgProvided = body.containsKey("managedOrgId");
@@ -269,15 +361,13 @@ public class UserService {
         if (managedOrgProvided) {
             newManagedOrgId = optionalManagedOrg(body.get("managedOrgId"));
         }
-        // 防提权(契约1.30修订):目标 ADMIN 最终生效管理范围不得超出操作者;
-        // 未提交 managedOrgId 时按保持原值判定。校验置于 reauth 之前,
-        // 避免换角色场景先抛 REAUTH_REQUIRED 掩盖越权拒绝。
+        // 防提权(契约1.30修订;1.46 多角色遍历):目标 ADMIN 任一挂载角色的
+        // 生效管理范围不得超出操作者;未提交 managedOrgId 时按保持原值判定。
+        // 校验置于 reauth 之前,避免换角色场景先抛 REAUTH_REQUIRED 掩盖越权拒绝。
         Long finalManagedOrgId = managedOrgProvided
                 ? newManagedOrgId : user.getManagedOrgId();
         adminPermission.ensureUserScopeInManagedScope(
-                actorUser, newRoleCode,
-                adminPermission.effectiveRoleSnapshot(resolvedRoleId),
-                finalManagedOrgId,
+                actorUser, newRoleCode, newRoles, finalManagedOrgId,
                 organization == null ? null : organization.getId());
 
         // 敏感字段(密级/角色/状态)实际变化:强制操作人二次认证(契约1.21)
@@ -290,9 +380,10 @@ public class UserService {
         user.setOrganizationId(organization == null ? null : organization.getId());
         user.setOrganizationName(organization == null ? null : organization.getOrgName());
         user.setRoleCode(newRoleCode);
-        user.setRoleId(resolvedRoleId);
         user.setSecurityLevel(newSecurityLevel);
         user.setStatus(newStatus);
+        user.setSeatName(newSeatName);
+        user.setUserType(newUserType);
         if (managedOrgProvided) {
             user.setManagedOrgId(newManagedOrgId);
         }
@@ -300,19 +391,50 @@ public class UserService {
         user.setUpdatedAt(now);
         user.setRowVersion(user.getRowVersion() + 1);
 
-        boolean authBump = roleChanges || roleAssignmentChanges
+        boolean authBump = roleChanges
                 || (statusChanges && "DISABLED".equals(newStatus));
         userMapper.updateFull(user, authBump ? 1 : 0, managedOrgProvided ? 1 : 0);
         if (authBump) {
             user.setAuthVersion(user.getAuthVersion() + 1);
         }
+        // 挂载 diff 落地(契约1.46):删差集、增新增;变更联动
+        // auth_version+1 使旧 token(含会话角色 claim)失效
+        if (roleAssignmentChanges) {
+            for (Long currentRoleId : currentRoleIds) {
+                if (!targetRoleIds.contains(currentRoleId)) {
+                    userRoleMapper.deleteByUserAndRole(userId, currentRoleId);
+                }
+            }
+            for (RoleEntity role : newRoles) {
+                if (!currentRoleIds.contains(role.getId())) {
+                    attachRole(userId, role.getId(), actorId);
+                }
+            }
+            // 挂载行密码重分配(契约1.53):≥2 角色按分配写入(使用用户
+            // 密码的角色置 NULL),≤1 角色全部清空回用户级密码
+            if (rolePasswords != null) {
+                for (Map.Entry<Long, String> entry : rolePasswords.entrySet()) {
+                    userRoleMapper.updatePassword(userId, entry.getKey(),
+                            entry.getValue() == null ? null : Passwords.hash(entry.getValue()));
+                }
+            } else {
+                userRoleMapper.clearAllPasswords(userId);
+            }
+            userMapper.bumpForRoleChange(userId, now, actorId);
+            user.setAuthVersion(user.getAuthVersion() + 1);
+            user.setRowVersion(user.getRowVersion() + 1);
+        }
 
         Map<String, Object> after = userSnapshot(user);
         Map<String, Object> detail = Payloads.orderedMap();
         detail.put("before", before);
         detail.put("after", after);
-        detail.put("roleName", role == null ? null : role.getRoleName());
-        detail.put("authVersionBumped", authBump);
+        detail.put("roleNames", newRoles.stream()
+                .map(RoleEntity::getRoleName).toList());
+        if (roleAssignmentChanges) {
+            detail.put("rolePasswordsReassigned", rolePasswords != null);
+        }
+        detail.put("authVersionBumped", authBump || roleAssignmentChanges);
         audit(request, AuditService.Action.EDIT_USER,
                 user.getId(), user.getRealName(), detail);
         return userSummaryService.userSummary(user);
@@ -370,15 +492,23 @@ public class UserService {
             throw notFound("用户不存在");
         }
         ensureNotBuiltinAdmin(user, "重置密码");
+        // 首长密码仅可经登录页自助申请重置(契约1.49:审批流由安全管理员处理)
+        if ("COMMANDER".equals(user.getUserType())) {
+            throw Errors.forbidden("首长的密码不支持此方式重置,请本人通过登录页的重置密码申请办理");
+        }
         adminPermission.ensureOrgInManagedScope(actorUser, user.getOrganizationId());
         checkVersion(user, expectedVersion);
 
         userMapper.resetPassword(userId, Passwords.hash(newPassword), now, actorId);
+        // 重置=回单密码态(契约1.53):清空全部挂载行独立密码,多角色
+        // 用户临时回到过渡态(登录弹角色选择),重新分配后恢复密码即角色
+        userRoleMapper.clearAllPasswords(userId);
         user.setAuthVersion(user.getAuthVersion() + 1);
         user.setRowVersion(user.getRowVersion() + 1);
 
         Map<String, Object> detail = Payloads.orderedMap();
         detail.put("username", user.getUsername());
+        detail.put("clearedRolePasswords", true);
         detail.put("rowVersion", user.getRowVersion());
         audit(request, AuditService.Action.RESET_USER_PASSWORD,
                 userId, user.getRealName(), detail);
@@ -527,6 +657,70 @@ public class UserService {
         return role;
     }
 
+    /**
+     * roleIds 解析(契约1.46):null/空数组=无挂载;元素为角色ID字符串,
+     * 逐个校验有效(未删除即可,停用角色允许挂载、关联保留),
+     * 重复元素幂等去重。
+     */
+    private List<RoleEntity> resolveRoles(Object value) {
+        if (value == null) {
+            return List.of();
+        }
+        if (!(value instanceof List<?> list)) {
+            throw Errors.invalidArgument("roleIds必须是字符串数组");
+        }
+        List<RoleEntity> roles = new ArrayList<>();
+        Set<Long> seen = new HashSet<>();
+        for (Object item : list) {
+            Long roleId = bodyStringId(item, "roleIds");
+            if (roleId == null) {
+                throw Errors.invalidArgument("roleIds不能包含空值");
+            }
+            if (seen.add(roleId)) {
+                roles.add(resolveRole(roleId));
+            }
+        }
+        return roles;
+    }
+
+    /** 挂载写入(契约1.46):唯一约束冲突(关联已存在,如停用角色存量挂载)幂等跳过。 */
+    private void attachRole(long userId, long roleId, Long actorId) {
+        try {
+            userRoleMapper.insert(userId, roleId, actorId);
+        } catch (DuplicateKeyException ex) {
+            // 幂等:目标关联已存在
+        }
+    }
+
+    /** 席位(契约1.46):可空文本,strip 后 ≤64。 */
+    private static String optionalSeatName(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (!(value instanceof String text)) {
+            throw Errors.invalidArgument("seatName必须是字符串");
+        }
+        String stripped = text.strip();
+        if (stripped.isEmpty()) {
+            return null;
+        }
+        if (stripped.length() > 64) {
+            throw Errors.invalidArgument("seatName长度不能超过64个字符");
+        }
+        return stripped;
+    }
+
+    /** 用户类型(契约1.46):NORMAL=普通用户、COMMANDER=首长(仍属 USER 类别);缺省 NORMAL。 */
+    private static String userTypeValue(Object value) {
+        if (value == null || (value instanceof String text && text.isEmpty())) {
+            return "NORMAL";
+        }
+        if (!(value instanceof String type) || !USER_TYPES.contains(type)) {
+            throw Errors.invalidArgument("userType必须是NORMAL或COMMANDER");
+        }
+        return type;
+    }
+
     /** 对齐 parse_string_id(请求体场景:null/空串 → null)。 */
     private static Long bodyStringId(Object value, String name) {
         if (value == null) {
@@ -609,14 +803,18 @@ public class UserService {
         }
     }
 
-    /** update 审计快照(before/after 同构)。 */
-    private static Map<String, Object> userSnapshot(UserEntity user) {
+    /** update 审计快照(before/after 同构;挂载取实时活跃启用集合)。 */
+    private Map<String, Object> userSnapshot(UserEntity user) {
         Map<String, Object> snapshot = Payloads.orderedMap();
         snapshot.put("realName", user.getRealName());
         snapshot.put("organizationId", serializeId(user.getOrganizationId()));
         snapshot.put("organizationName", user.getOrganizationName());
         snapshot.put("roleCode", user.getRoleCode());
-        snapshot.put("roleId", serializeId(user.getRoleId()));
+        snapshot.put("roleIds", userRoleMapper.listActiveRoleIdsByUserId(user.getId())
+                .stream().map(String::valueOf).toList());
+        snapshot.put("seatName", user.getSeatName());
+        snapshot.put("userType", user.getUserType() == null
+                ? "NORMAL" : user.getUserType());
         snapshot.put("securityLevel", user.getSecurityLevel());
         snapshot.put("status", user.getStatus());
         snapshot.put("managedOrgId", managedOrgValue(user.getManagedOrgId()));

+ 47 - 6
backend-java/src/main/java/com/dms/service/UserSummaryService.java

@@ -2,6 +2,7 @@ package com.dms.service;
 
 import com.dms.mapper.OrganizationMapper;
 import com.dms.mapper.RoleMapper;
+import com.dms.mapper.UserRoleMapper;
 import com.dms.model.OrganizationEntity;
 import com.dms.model.RoleEntity;
 import com.dms.model.UserEntity;
@@ -30,14 +31,17 @@ public class UserSummaryService {
 
     private final OrganizationMapper organizationMapper;
     private final RoleMapper roleMapper;
+    private final UserRoleMapper userRoleMapper;
     private final AuthorizationService authorizationService;
     private final AdminPermission adminPermission;
 
     public UserSummaryService(OrganizationMapper organizationMapper, RoleMapper roleMapper,
+                              UserRoleMapper userRoleMapper,
                               AuthorizationService authorizationService,
                               AdminPermission adminPermission) {
         this.organizationMapper = organizationMapper;
         this.roleMapper = roleMapper;
+        this.userRoleMapper = userRoleMapper;
         this.authorizationService = authorizationService;
         this.adminPermission = adminPermission;
     }
@@ -51,6 +55,9 @@ public class UserSummaryService {
         RoleEntity manageRole = adminPermission.effectiveManageRole(user);
         // 内置 admin 恒为全权超管(契约1.17③):perm 位恒开、锚点恒全局
         boolean superAdmin = AdminPermission.isSuperAdmin(user);
+        // roleId/roleName 展示口径(契约1.46):本人=会话选定角色,
+        // 非本人(用户列表行)=第一挂载角色(按 sortNo);roles=全部挂载(活跃启用)
+        Long displayRoleId = displayRoleId(user);
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("id", serializeId(user.getId()));
         result.put("username", user.getUsername());
@@ -58,10 +65,13 @@ public class UserSummaryService {
         result.put("organizationId", serializeId(user.getOrganizationId()));
         result.put("organizationName", effectiveOrganizationName(user));
         result.put("roleCode", user.getRoleCode());
-        result.put("roleId", serializeId(user.getRoleId()));
-        result.put("roleName", effectiveRoleName(user));
+        result.put("roleId", serializeId(displayRoleId));
+        result.put("roleName", effectiveRoleName(displayRoleId));
+        result.put("roles", mountedRoles(user));
         result.put("securityLevel", user.getSecurityLevel());
         result.put("status", user.getStatus());
+        result.put("seatName", user.getSeatName());
+        result.put("userType", user.getUserType() == null ? "NORMAL" : user.getUserType());
         result.put("authVersion", user.getAuthVersion());
         result.put("rowVersion", user.getRowVersion());
         // 管理权限改挂角色:字段名保留,值取自生效角色
@@ -80,6 +90,12 @@ public class UserSummaryService {
         // 上传权限(契约1.31):生效角色方案权限矩阵任一 OWN 行 can_upload=1
         result.put("permUpload", superAdmin || (manageRole != null
                 && authorizationService.hasAnyUploadPermission(manageRole.getId())));
+        // 附件权限(契约1.43):仅内置超管恒可,受限ADMIN与USER同按矩阵附件位——
+        // 库位任一附件位命中即可见共享资料库页签;上传位命中才亮上传/批量导入按钮
+        result.put("permAttachmentLibrary", superAdmin || (manageRole != null
+                && authorizationService.hasAnyAttachmentLibraryPermission(manageRole.getId())));
+        result.put("permAttachmentUpload", superAdmin || (manageRole != null
+                && authorizationService.hasAnyAttachmentUploadPermission(manageRole.getId())));
         // 用户级管理范围(契约1.30):null=全局、"SELF"=跟随本人所在机构、机构ID=固定锚点;
         // 实际生效范围以角色锚点为准,角色为 SELF 时才回落本设置;内置 admin 恒全局。
         result.put("managedOrgId", managedOrgValue(superAdmin, user.getManagedOrgId()));
@@ -114,11 +130,14 @@ public class UserSummaryService {
             return List.of("BACKEND_MANAGEMENT", "AUDIT_LOG",
                     "ORGANIZATION_USER_MANAGEMENT", "SYSTEM_MANAGEMENT");
         }
+        // 会话角色(契约1.46):登录线程由 AuthService 预先 set 上下文,这里继承
+        AuthContext current = AuthContext.getOrNull();
         AuthContext context = new AuthContext(
                 user.getId(), user.getUsername(), user.getRealName(),
                 user.getOrganizationId(), user.getOrganizationName(),
                 user.getRoleCode(), user.getSecurityLevel(), user.getAuthVersion(),
-                null);
+                null, current != null && current.userId() == user.getId()
+                        ? current.roleId() : null);
         List<String> modules = new ArrayList<>();
         if (authorizationService.hasAnyMatrixPermission(
                 context, AuthorizationService.PLAN_MANAGE_MATRIX_COLUMNS)
@@ -169,12 +188,34 @@ public class UserSummaryService {
         return organization != null ? organization.getOrgName() : user.getOrganizationName();
     }
 
+    /** roleId 展示口径(契约1.46):本人=会话角色,非本人=第一挂载角色。 */
+    private Long displayRoleId(UserEntity user) {
+        AuthContext context = AuthContext.getOrNull();
+        if (context != null && context.userId() == user.getId() && context.roleId() != null) {
+            return context.roleId();
+        }
+        List<RoleEntity> roles = userRoleMapper.listActiveRolesByUserId(user.getId());
+        return roles.isEmpty() ? null : roles.get(0).getId();
+    }
+
+    /** 全部挂载角色简表(活跃启用,登录选择与编辑表单回显同口径)。 */
+    private List<Map<String, Object>> mountedRoles(UserEntity user) {
+        List<Map<String, Object>> items = new ArrayList<>();
+        for (RoleEntity role : userRoleMapper.listActiveRolesByUserId(user.getId())) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("id", serializeId(role.getId()));
+            item.put("roleName", role.getRoleName());
+            items.add(item);
+        }
+        return items;
+    }
+
     /** 角色存在且未删除时取实时名称,否则为 null(roleId 仍保留)。 */
-    private String effectiveRoleName(UserEntity user) {
-        if (user.getRoleId() == null) {
+    private String effectiveRoleName(Long roleId) {
+        if (roleId == null) {
             return null;
         }
-        RoleEntity role = roleMapper.findById(user.getRoleId());
+        RoleEntity role = roleMapper.findById(roleId);
         if (role == null || Boolean.TRUE.equals(role.getIsDeleted())) {
             return null;
         }

+ 36 - 10
backend-java/src/main/java/com/dms/service/VoucherService.java

@@ -41,7 +41,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.web.multipart.MultipartFile;
 
 /**
- * B6借阅授权审批单(PDF凭证)的上传、下载与清除,
+ * B6借阅授权审批单(PDF/图片凭证,契约1.72)的上传、下载与清除,
  * 对齐 backend/dms/services/voucher_service.py。
  *
  * <p>凭证与授权条目一对一(0026迁移,契约1.19),读写均要求授权配置位
@@ -169,7 +169,7 @@ public class VoucherService {
         String name = entry.getVoucherFileName() != null
                 ? entry.getVoucherFileName() : "voucher.pdf";
         return ResponseEntity.ok()
-                .contentType(MediaType.APPLICATION_PDF)
+                .contentType(voucherMediaType(name))
                 .header(HttpHeaders.CONTENT_DISPOSITION,
                         FileReadService.attachmentDisposition(name))
                 .header("X-Content-Type-Options", "nosniff")
@@ -218,9 +218,34 @@ public class VoucherService {
 
     // ------------------------------------------------------------ 内部实现
 
-    /** 对齐 _stage_voucher:暂存审批单并校验,仅允许PDF,大小受DMS_MAX_FILE_SIZE约束。
-     * package-private:借阅申请单的共用审批单上传复用同一暂存与校验口径(契约1.37)。
-     * 凭证随所属文档密级分域落盘:{domain}/voucher/{uuid}.pdf(绝密隔离存储)。 */
+    /** 审批单下载响应类型(契约1.72):按文件扩展名映射 PDF 与图片,未知回落 PDF。
+     * public static:BorrowRequestService 申请审批单下载共用同一映射。 */
+    public static MediaType voucherMediaType(String fileName) {
+        int dot = fileName == null ? -1 : fileName.lastIndexOf('.');
+        String extension = dot >= 0
+                ? fileName.substring(dot + 1).toLowerCase(Locale.ROOT) : "";
+        return switch (extension) {
+            case "jpg", "jpeg" -> MediaType.IMAGE_JPEG;
+            case "png" -> MediaType.IMAGE_PNG;
+            case "gif" -> MediaType.IMAGE_GIF;
+            case "bmp" -> MediaType.parseMediaType("image/bmp");
+            case "webp" -> MediaType.parseMediaType("image/webp");
+            default -> MediaType.APPLICATION_PDF;
+        };
+    }
+
+    /** 对齐 _stage_voucher:暂存审批单并校验,允许PDF与常见图片(契约1.72),
+     * 大小受DMS_MAX_FILE_SIZE约束。package-private:借阅申请单的共用审批单
+     * 上传复用同一暂存与校验口径(契约1.37)。
+     * 凭证随所属文档密级分域落盘:{domain}/voucher/{uuid}.{扩展名}(绝密隔离存储)。 */
+    /** 审批单凭证允许的扩展名(契约1.72):PDF + 常见图片(魔数校验复用 UploadStorage)。 */
+    private static final Set<String> VOUCHER_EXTENSIONS;
+    static {
+        Set<String> allowed = new HashSet<>(UploadStorage.IMAGE_EXTENSIONS);
+        allowed.add("pdf");
+        VOUCHER_EXTENSIONS = Set.copyOf(allowed);
+    }
+
     StagedUpload stageVoucher(MultipartFile file, String securityLevel) {
         String originalName = baseName(file.getOriginalFilename());
         if (originalName == null || originalName.isEmpty() || !originalName.contains(".")) {
@@ -228,12 +253,12 @@ public class VoucherService {
         }
         String extension = originalName.substring(originalName.lastIndexOf('.') + 1)
                 .toLowerCase(Locale.ROOT);
-        if (!"pdf".equals(extension)) {
-            throw unsupported("审批单凭证只允许PDF文件");
+        if (!VOUCHER_EXTENSIONS.contains(extension)) {
+            throw unsupported("审批单凭证只允许PDF或图片文件");
         }
         Path root = storagePaths.ensureStorageDirectories();
         Path temporary = root.resolve("temporary").resolve(UUID.randomUUID() + ".upload");
-        String finalName = UUID.randomUUID() + ".pdf";
+        String finalName = UUID.randomUUID() + "." + extension;
         String relativePath = StoragePaths.voucherRelativePath(securityLevel, finalName);
         Path finalPath = storagePaths.resolveStoragePath(relativePath);
         MessageDigest digest = sha256();
@@ -258,9 +283,10 @@ public class VoucherService {
             if (size == 0) {
                 throw unsupported("不允许上传空文件");
             }
-            uploadStorage.validate(temporary, "pdf");
+            uploadStorage.validate(temporary, extension);
             return new StagedUpload(temporary, finalPath, relativePath,
-                    originalName, extension, "application/pdf", size,
+                    originalName, extension,
+                    UploadStorage.MIME_TYPES.getOrDefault(extension, "application/pdf"), size,
                     HexFormat.of().formatHex(digest.digest()), envelopeCodec);
         } catch (IOException ex) {
             deleteQuietly(temporary);

+ 37 - 0
backend-java/src/main/resources/db/migration-dm/V25__attachment_permissions_lock_merge.sql

@@ -0,0 +1,37 @@
+-- V25__attachment_permissions_lock_merge 的达梦版(DM8):MySQL 原版
+-- ../migration/V25__attachment_permissions_lock_merge.sql 的等价迁移。
+-- 用途:达梦不在 Flyway 支持列表、内置迁移默认关闭(见 application.yml),本脚本
+--   供 DISQL / DM 管理工具手工执行或外部迁移工具引用;目录与 db/migration 平级,
+--   不参与 Flyway 扫描(放 db/migration 子目录会与 MySQL 版冲突为重复 V25)。
+-- 语义与 MySQL 版完全一致:
+--   SYS_ROLE_PERMISSION 加 4 个附件权限列(CAN_ATTACHMENT_VIEW/DOWNLOAD/EDIT/
+--   UPLOAD,契约1.41,仅 OWN 行有意义);DOC_DOCUMENT 存量
+--   PROTECT_UNBIND_ATTACHMENT=1 并入 PROTECT_BIND_ATTACHMENT(锁位合并)。
+-- 幂等:列先探测后执行;UPDATE 天然幂等;与 Python 版 Alembic 0039
+--   双引擎互冲安全(谁先跑,后者跳过)。
+-- 语法说明:达梦无 MySQL 的 SET @var / IF() / PREPARE 动态 SQL,改用 DMSQL 匿名块 +
+--   EXECUTE IMMEDIATE;系统字典用 Oracle 兼容视图 USER_TAB_COLUMNS。
+
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_ROLE_PERMISSION'
+          AND COLUMN_NAME = 'CAN_ATTACHMENT_VIEW';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_ROLE_PERMISSION ADD COLUMN CAN_ATTACHMENT_VIEW TINYINT NOT NULL DEFAULT 0';
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_ROLE_PERMISSION ADD COLUMN CAN_ATTACHMENT_DOWNLOAD TINYINT NOT NULL DEFAULT 0';
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_ROLE_PERMISSION ADD COLUMN CAN_ATTACHMENT_EDIT TINYINT NOT NULL DEFAULT 0';
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_ROLE_PERMISSION ADD COLUMN CAN_ATTACHMENT_UPLOAD TINYINT NOT NULL DEFAULT 0';
+    END IF;
+    UPDATE DOC_DOCUMENT
+       SET PROTECT_BIND_ATTACHMENT = 1
+     WHERE PROTECT_UNBIND_ATTACHMENT = 1
+       AND PROTECT_BIND_ATTACHMENT = 0;
+END;
+/

+ 24 - 0
backend-java/src/main/resources/db/migration-dm/V26__attachment_delete_permission.sql

@@ -0,0 +1,24 @@
+-- V26__attachment_delete_permission 的达梦版(DM8):MySQL 原版
+--   ../migration/V26__attachment_delete_permission.sql 的等价迁移。
+-- 用途:达梦不在 Flyway 支持列表、内置迁移默认关闭(见 application.yml),本脚本
+--   供 DISQL / DM 管理工具手工执行或外部迁移工具引用;目录与 db/migration 平级,
+--   不参与 Flyway 扫描(放 db/migration 子目录会与 MySQL 版冲突为重复 V26)。
+-- 语义与 MySQL 版完全一致:SYS_ROLE_PERMISSION 加附件删除权限列
+--   CAN_ATTACHMENT_DELETE(契约1.44,仅 OWN 行有意义)。
+-- 幂等:列先探测后执行。
+-- 语法说明:达梦无 MySQL 的 SET @var / IF() / PREPARE 动态 SQL,改用 DMSQL 匿名块 +
+--   EXECUTE IMMEDIATE;系统字典用 Oracle 兼容视图 USER_TAB_COLUMNS。
+
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_ROLE_PERMISSION'
+          AND COLUMN_NAME = 'CAN_ATTACHMENT_DELETE';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_ROLE_PERMISSION ADD COLUMN CAN_ATTACHMENT_DELETE TINYINT NOT NULL DEFAULT 0';
+    END IF;
+END;
+/

+ 63 - 0
backend-java/src/main/resources/db/migration-dm/V27__multi_role_seat_user_type.sql

@@ -0,0 +1,63 @@
+-- V27__multi_role_seat_user_type 的达梦版(DM8):MySQL 原版
+-- ../migration/V27__multi_role_seat_user_type.sql 的等价迁移。
+-- 语义与 MySQL 版完全一致:
+--   1. 新建 SYS_USER_ROLE 用户-角色多对多关联表(UNIQUE(USER_ID, ROLE_ID)),
+--      存量 SYS_USER.ROLE_ID 单值回填为一条关联记录(MERGE 防重),
+--      ROLE_ID 列保留但停用;
+--   2. SYS_USER 加 SEAT_NAME VARCHAR(64)(席位,可空);
+--   3. SYS_USER 加 USER_TYPE VARCHAR(16) NOT NULL DEFAULT 'NORMAL'(首长类型标记)。
+-- 语法说明:达梦无 SET @var / IF() / PREPARE,改用 DMSQL 匿名块 + EXECUTE IMMEDIATE;
+--   系统字典用 Oracle 兼容视图 USER_TABLES / USER_TAB_COLUMNS(名称大写存储);
+--   回填防重借唯一约束 + NOT EXISTS 过滤。
+
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TABLES
+        WHERE TABLE_NAME = 'SYS_USER_ROLE';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE 'CREATE TABLE SYS_USER_ROLE (
+  ID BIGINT IDENTITY(1, 1) NOT NULL,
+  USER_ID BIGINT NOT NULL,
+  ROLE_ID BIGINT NOT NULL,
+  CREATED_AT TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL,
+  CREATED_BY BIGINT DEFAULT NULL,
+  PRIMARY KEY (ID),
+  CONSTRAINT UQ_SYS_USER_ROLE UNIQUE (USER_ID, ROLE_ID)
+)';
+        EXECUTE IMMEDIATE 'CREATE INDEX IX_SYS_USER_ROLE_USER '
+            || 'ON SYS_USER_ROLE (USER_ID, ROLE_ID)';
+        EXECUTE IMMEDIATE 'CREATE INDEX IX_SYS_USER_ROLE_ROLE '
+            || 'ON SYS_USER_ROLE (ROLE_ID)';
+    END IF;
+
+    -- 存量回填(NOT EXISTS 过滤幂等,双引擎互冲安全)
+    EXECUTE IMMEDIATE 'INSERT INTO SYS_USER_ROLE (USER_ID, ROLE_ID)
+        SELECT U.ID, U.ROLE_ID FROM SYS_USER U
+        WHERE U.ROLE_ID IS NOT NULL AND U.IS_DELETED = 0
+          AND NOT EXISTS (SELECT 1 FROM SYS_USER_ROLE R
+                          WHERE R.USER_ID = U.ID AND R.ROLE_ID = U.ROLE_ID)';
+    COMMIT;
+
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_USER'
+          AND COLUMN_NAME = 'SEAT_NAME';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_USER ADD COLUMN SEAT_NAME VARCHAR(64) DEFAULT NULL';
+        COMMIT;
+    END IF;
+
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_USER'
+          AND COLUMN_NAME = 'USER_TYPE';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_USER ADD COLUMN USER_TYPE VARCHAR(16) DEFAULT ''NORMAL'' NOT NULL';
+        COMMIT;
+    END IF;
+END;
+/

+ 5 - 0
backend-java/src/main/resources/db/migration-dm/V28__admin_real_name.sql

@@ -0,0 +1,5 @@
+-- V28__admin_real_name 的达梦版(DM8):MySQL 原版
+-- ../migration/V28__admin_real_name.sql 的等价迁移。
+-- 语义与 MySQL 版完全一致:内置 ADMIN 的 REAL_NAME 改为「超级管理员」
+-- (契约1.47 姓名同语义唯一化,支持姓名登录)。
+UPDATE SYS_USER SET REAL_NAME = '超级管理员' WHERE USERNAME = 'admin';

+ 80 - 0
backend-java/src/main/resources/db/migration-dm/V29__matrix_top_category_kind.sql

@@ -0,0 +1,80 @@
+-- V29__matrix_top_category_kind 的达梦版(DM8):
+-- ../migration/V29__matrix_top_category_kind.sql 的等价迁移。
+-- 语义与 MySQL 版完全一致(契约1.48 方案权限矩阵三维化,存量按一级分类同权展开)。
+-- 注意①:本库存在手工创建的活跃行唯一索引 UQ_SYS_ROLE_PERMISSION_ACTIVE
+-- (ROLE_ID, DEPT_SCOPE, SECURITY_LEVEL, IS_DELETED)(不在迁移链内),
+-- 不含新维度列——同角色同密级同范围只能一行,分类展开必然撞索引(-6602),
+-- 必须先删除,待数据展开后按新维度重建。
+-- 注意②:三段 INSERT 的源行均限定 TOP_CATEGORY_ID IS NULL AND DOC_KIND='MAIN'
+-- (即迁移前旧结构行),避免前段插入的新行被后段当作源再次展开(重复脏数据)。
+
+DROP INDEX UQ_SYS_ROLE_PERMISSION_ACTIVE;
+
+ALTER TABLE SYS_ROLE_PERMISSION ADD TOP_CATEGORY_ID BIGINT;
+ALTER TABLE SYS_ROLE_PERMISSION ADD DOC_KIND VARCHAR(32) DEFAULT 'MAIN' NOT NULL;
+
+-- 1) 主体方案行:每未删一级分类 × 原行(主案 8 位复制,附件位走默认 0)
+INSERT INTO SYS_ROLE_PERMISSION
+    (ROLE_ID, TOP_CATEGORY_ID, DOC_KIND, SECURITY_LEVEL, DEPT_SCOPE,
+     CAN_VIEW, CAN_UPLOAD, CAN_DOWNLOAD, CAN_EDIT, CAN_DELETE,
+     CAN_CONFIG_PERMISSION, CAN_BIND_ATTACHMENT, CAN_UNBIND_ATTACHMENT,
+     CREATED_BY, UPDATED_BY)
+SELECT r.ROLE_ID, c.ID, 'MAIN', r.SECURITY_LEVEL, r.DEPT_SCOPE,
+       r.CAN_VIEW, r.CAN_UPLOAD, r.CAN_DOWNLOAD, r.CAN_EDIT, r.CAN_DELETE,
+       r.CAN_CONFIG_PERMISSION, r.CAN_BIND_ATTACHMENT, r.CAN_UNBIND_ATTACHMENT,
+       r.CREATED_BY, r.UPDATED_BY
+FROM SYS_ROLE_PERMISSION r
+JOIN DOC_CATEGORY c ON c.PARENT_ID IS NULL AND c.IS_DELETED = 0
+WHERE r.IS_DELETED = 0
+  AND r.TOP_CATEGORY_ID IS NULL AND r.DOC_KIND = 'MAIN';
+
+-- 2) 分类下共享资料行:非仅主案模式分类 × 四类型 × 原 OWN 行附件 5 位
+INSERT INTO SYS_ROLE_PERMISSION
+    (ROLE_ID, TOP_CATEGORY_ID, DOC_KIND, SECURITY_LEVEL, DEPT_SCOPE,
+     CAN_ATTACHMENT_UPLOAD, CAN_ATTACHMENT_EDIT, CAN_ATTACHMENT_VIEW,
+     CAN_ATTACHMENT_DOWNLOAD, CAN_ATTACHMENT_DELETE,
+     CREATED_BY, UPDATED_BY)
+SELECT r.ROLE_ID, c.ID, k.KIND, r.SECURITY_LEVEL, 'OWN',
+       r.CAN_ATTACHMENT_UPLOAD, r.CAN_ATTACHMENT_EDIT, r.CAN_ATTACHMENT_VIEW,
+       r.CAN_ATTACHMENT_DOWNLOAD, r.CAN_ATTACHMENT_DELETE,
+       r.CREATED_BY, r.UPDATED_BY
+FROM SYS_ROLE_PERMISSION r
+JOIN DOC_CATEGORY c
+  ON c.PARENT_ID IS NULL AND c.IS_DELETED = 0 AND c.FLAT_MODE = 0
+CROSS JOIN (
+    SELECT 'ACCOMPANYING_PLAN' AS KIND FROM DUAL UNION ALL
+    SELECT 'SUPPORT_ACHIEVEMENT' FROM DUAL UNION ALL
+    SELECT 'TRAINING_MATERIAL' FROM DUAL UNION ALL
+    SELECT 'TYPICAL_CASE' FROM DUAL
+) k
+WHERE r.IS_DELETED = 0 AND r.DEPT_SCOPE = 'OWN'
+  AND r.TOP_CATEGORY_ID IS NULL AND r.DOC_KIND = 'MAIN';
+
+-- 3) 共享资料库行组:TOP_CATEGORY_ID NULL × 四类型 × 原 OWN 行附件 5 位
+INSERT INTO SYS_ROLE_PERMISSION
+    (ROLE_ID, TOP_CATEGORY_ID, DOC_KIND, SECURITY_LEVEL, DEPT_SCOPE,
+     CAN_ATTACHMENT_UPLOAD, CAN_ATTACHMENT_EDIT, CAN_ATTACHMENT_VIEW,
+     CAN_ATTACHMENT_DOWNLOAD, CAN_ATTACHMENT_DELETE,
+     CREATED_BY, UPDATED_BY)
+SELECT r.ROLE_ID, NULL, k.KIND, r.SECURITY_LEVEL, 'OWN',
+       r.CAN_ATTACHMENT_UPLOAD, r.CAN_ATTACHMENT_EDIT, r.CAN_ATTACHMENT_VIEW,
+       r.CAN_ATTACHMENT_DOWNLOAD, r.CAN_ATTACHMENT_DELETE,
+       r.CREATED_BY, r.UPDATED_BY
+FROM SYS_ROLE_PERMISSION r
+CROSS JOIN (
+    SELECT 'ACCOMPANYING_PLAN' AS KIND FROM DUAL UNION ALL
+    SELECT 'SUPPORT_ACHIEVEMENT' FROM DUAL UNION ALL
+    SELECT 'TRAINING_MATERIAL' FROM DUAL UNION ALL
+    SELECT 'TYPICAL_CASE' FROM DUAL
+) k
+WHERE r.IS_DELETED = 0 AND r.DEPT_SCOPE = 'OWN'
+  AND r.TOP_CATEGORY_ID IS NULL AND r.DOC_KIND = 'MAIN';
+
+-- 4) 删除旧结构行(DOC_KIND='MAIN' 且 TOP_CATEGORY_ID IS NULL,即迁移前的 10 行/角色)
+DELETE FROM SYS_ROLE_PERMISSION WHERE DOC_KIND = 'MAIN' AND TOP_CATEGORY_ID IS NULL;
+
+-- 5) 重建活跃行唯一索引(扩展为含新维度:一级分类 × 种类;
+--    共享资料库行组 TOP_CATEGORY_ID 为 NULL,每个键组合仍唯一)
+CREATE UNIQUE INDEX UQ_SYS_ROLE_PERMISSION_ACTIVE
+    ON SYS_ROLE_PERMISSION (ROLE_ID, TOP_CATEGORY_ID, DOC_KIND,
+                             DEPT_SCOPE, SECURITY_LEVEL, IS_DELETED);

+ 36 - 0
backend-java/src/main/resources/db/migration-dm/V30__password_reset_request.sql

@@ -0,0 +1,36 @@
+-- V30__password_reset_request 的达梦版(DM8):
+-- ../migration/V30__password_reset_request.sql 的等价迁移。
+-- 语义与 MySQL 版完全一致(契约1.49 登录界面重置密码审批流):
+-- 新建 SYS_PASSWORD_RESET_REQUEST 表(BIGINT IDENTITY 主键 + CHECK 约束
+-- + 索引),USER_TABLES 探测幂等。
+-- 语法说明:DMSQL 字符串内的单引号以两个连续单引号转义(CHECK 白名单)。
+
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TABLES
+        WHERE TABLE_NAME = 'SYS_PASSWORD_RESET_REQUEST';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE 'CREATE TABLE SYS_PASSWORD_RESET_REQUEST (
+  ID BIGINT IDENTITY(1, 1) NOT NULL,
+  USER_ID BIGINT NOT NULL,
+  USERNAME VARCHAR(64) NOT NULL,
+  REAL_NAME VARCHAR(100) NOT NULL,
+  USER_TYPE VARCHAR(16) NOT NULL,
+  APPROVER_ROLE_NAME VARCHAR(64) NOT NULL,
+  STATUS VARCHAR(16) NOT NULL,
+  REQUEST_IP VARCHAR(64) NULL,
+  REQUESTED_AT TIMESTAMP NOT NULL,
+  HANDLED_BY BIGINT NULL,
+  HANDLED_BY_NAME VARCHAR(100) NULL,
+  HANDLED_AT TIMESTAMP NULL,
+  CREATED_AT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  CONSTRAINT PK_SYS_PASSWORD_RESET_REQUEST PRIMARY KEY (ID),
+  CONSTRAINT CK_SYS_PASSWORD_RESET_REQUEST_STATUS
+      CHECK (STATUS IN (''PENDING'', ''APPROVED'', ''REJECTED''))
+)';
+        EXECUTE IMMEDIATE 'CREATE INDEX IX_PWD_RESET_REQUEST_USER
+            ON SYS_PASSWORD_RESET_REQUEST (USER_ID, STATUS)';
+    END IF;
+END;

+ 18 - 0
backend-java/src/main/resources/db/migration-dm/V31__role_password.sql

@@ -0,0 +1,18 @@
+-- V31__role_password 的达梦版(DM8):
+-- ../migration/V31__role_password.sql 的等价迁移。
+-- 语义与 MySQL 版完全一致(契约1.53 密码即角色):
+-- SYS_USER_ROLE 加 PASSWORD_HASH 列(NULL=使用用户级密码),
+-- USER_TAB_COLUMNS 探测幂等,存量数据不搬迁。
+
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_USER_ROLE'
+          AND COLUMN_NAME = 'PASSWORD_HASH';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE 'ALTER TABLE SYS_USER_ROLE ADD COLUMN PASSWORD_HASH VARCHAR(255)';
+    END IF;
+END;
+/

+ 25 - 0
backend-java/src/main/resources/db/migration-dm/V32__borrow_settings.sql

@@ -0,0 +1,25 @@
+-- V32__borrow_settings 的达梦版(DM8):
+-- ../migration/V32__borrow_settings.sql 的等价迁移。
+-- 语义与 MySQL 版完全一致(契约1.54 审批相关设置):
+-- 新建 SYS_SETTING 通用键值设置表(SETTING_KEY 主键),
+-- USER_TABLES 探测幂等。
+
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TABLES
+        WHERE TABLE_NAME = 'SYS_SETTING';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE 'CREATE TABLE SYS_SETTING (
+  SETTING_KEY VARCHAR(64) NOT NULL,
+  SETTING_VALUE CLOB NULL,
+  FILE_NAME VARCHAR(255) NULL,
+  FILE_SIZE BIGINT NULL,
+  UPDATED_AT TIMESTAMP NULL,
+  UPDATED_BY BIGINT NULL,
+  CONSTRAINT PK_SYS_SETTING PRIMARY KEY (SETTING_KEY)
+)';
+    END IF;
+END;
+/

+ 13 - 0
backend-java/src/main/resources/db/migration-dm/V33__attachment_matrix_simplify.sql

@@ -0,0 +1,13 @@
+-- V33__attachment_matrix_simplify 的达梦版(DM8):
+-- ../migration/V33__attachment_matrix_simplify.sql 的等价迁移。
+-- 语义与 MySQL 版完全一致(契约1.59 分类下附件矩阵行五位清零,行保留)。
+
+UPDATE SYS_ROLE_PERMISSION
+SET CAN_ATTACHMENT_UPLOAD = 0,
+    CAN_ATTACHMENT_EDIT = 0,
+    CAN_ATTACHMENT_VIEW = 0,
+    CAN_ATTACHMENT_DOWNLOAD = 0,
+    CAN_ATTACHMENT_DELETE = 0
+WHERE TOP_CATEGORY_ID IS NOT NULL
+  AND DOC_KIND != 'MAIN'
+  AND IS_DELETED = 0;

+ 33 - 0
backend-java/src/main/resources/db/migration-dm/V34__notification_notify_type_check.sql

@@ -0,0 +1,33 @@
+-- V34__notification_notify_type_check 的达梦版(DM8):MySQL 原版
+--   ../migration/V34__notification_notify_type_check.sql 的等价迁移。
+-- 用途:达梦不在 Flyway 支持列表、内置迁移默认关闭(见 application.yml),本脚本
+--   供 DISQL / DM 管理工具手工执行或外部迁移工具引用;目录与 db/migration 平级,
+--   不参与 Flyway 扫描(放 db/migration 子目录会与 MySQL 版冲突为重复 V34)。
+-- 语义与 MySQL 版完全一致:删除 SYS_NOTIFICATION 的 NOTIFY_TYPE CHECK 白名单约束
+--   (重置密码审批流契约1.49 新增通知类型 PASSWORD_RESET,V21 白名单仅 APPROVAL
+--    未同步,导致重置密码申请提交时通知写入违约、接口连带 500「服务器内部错误」;
+--    与 V20/V23 对 sys_audit_log 两列同理,通知类型为持续扩展枚举,合法性收敛到
+--    应用层字典;ACTION/STATUS 的 CHECK 保留)。
+-- 幂等:约束先探测后删除,重复运行无副作用。
+-- 语法说明:达梦无 MySQL 的 SET @var / IF() / PREPARE 动态 SQL,改用 DMSQL
+--   匿名块 + 游标 + EXECUTE IMMEDIATE;系统字典用 Oracle 兼容视图
+--   USER_CONSTRAINTS;约束名为 V21 建表时的大写(报错名
+--   CK_SYS_NOTIFICATION_NOTIFY_TYPE),按 UPPER 归一匹配后以实际名加双引号删除。
+
+DECLARE
+    v_name VARCHAR(128);
+    CURSOR cur IS
+        SELECT CONSTRAINT_NAME
+            FROM USER_CONSTRAINTS
+            WHERE UPPER(TABLE_NAME) = 'SYS_NOTIFICATION'
+              AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_NOTIFICATION_NOTIFY_TYPE';
+BEGIN
+    OPEN cur;
+    LOOP
+        FETCH cur INTO v_name;
+        EXIT WHEN cur%NOTFOUND;
+        EXECUTE IMMEDIATE 'ALTER TABLE SYS_NOTIFICATION DROP CONSTRAINT "' || v_name || '"';
+    END LOOP;
+    CLOSE cur;
+END;
+/

+ 76 - 0
backend-java/src/main/resources/db/migration-dm/V35__drop_enum_check_constraints.sql

@@ -0,0 +1,76 @@
+-- V35__drop_enum_check_constraints 的达梦版(DM8):MySQL 原版
+--   ../migration/V35__drop_enum_check_constraints.sql 的等价迁移。
+-- 用途:达梦不在 Flyway 支持列表、内置迁移默认关闭(见 application.yml),本脚本
+--   供 DISQL / DM 管理工具手工执行或外部迁移工具引用;目录与 db/migration 平级,
+--   不参与 Flyway 扫描(放 db/migration 子目录会与 MySQL 版冲突为重复 V35)。
+-- 语义与 MySQL 版完全一致:删除全部 20 个枚举型 CHECK 白名单约束,枚举合法性
+--   统一由应用端保证(Payloads.statusValue/roleCodeValue/securityLevelValue、
+--   CategoryService.categoryType、UserService.UI_THEMES、RoleService 矩阵校验
+--   与服务端常量写入);
+--   保留三个跨列业务规则约束(PASSWORD_MAIN_ONLY / TYPE_SHAPE_V3 /
+--   NO_SELF,非枚举型)。
+-- 清单(20):DOC_BORROW_REQUEST.STATUS、DOC_CATEGORY.STATUS/CATEGORY_TYPE、
+--   DOC_DOCUMENT.ATTACHMENT_TYPE_V2/CONTENT_EXTRACT_STATUS/SECURITY_LEVEL/
+--   DOCUMENT_STATUS/DOCUMENT_TYPE、SYS_AUDIT_LOG.OPERATION_RESULT、
+--   SYS_NOTIFICATION.ACTION/STATUS、SYS_ORGANIZATION.STATUS、
+--   SYS_PASSWORD_RESET_REQUEST.STATUS、SYS_ROLE_PERMISSION.DEPT_SCOPE_V2/
+--   SECURITY_LEVEL、SYS_ROLE.STATUS、SYS_USER.ROLE_CODE/SECURITY_LEVEL/STATUS/
+--   UI_THEME。
+-- 幂等:约束先探测后删除,重复运行无副作用。
+-- 语法说明:达梦无 MySQL 的 SET @var / IF() / PREPARE 动态 SQL,改用 DMSQL
+--   匿名块;系统字典用 Oracle 兼容视图 USER_CONSTRAINTS,按 UPPER 归一匹配。
+--   达梦特性规避:①DDL 对象须为表名而非模式名;②DDL 隐式提交会使打开的游标
+--   失效——先 BULK COLLECT 收集(约束名, 表名)成对入内存集合再逐条删除;
+--   ③约束名大小写存储不一致(库里存在小写名约束,而双引号在大小写敏感库中
+--   精确匹配,-2109「无效的约束名」)——每条先按双引号原样名删,失败回落
+--   不带引号的大写归一形式再删,仍失败输出跳过明细不阻塞其余条目
+--   (执行前 SET SERVEROUTPUT ON 可见)。
+
+DECLARE
+    TYPE t_name_tab  IS TABLE OF VARCHAR(128);
+    TYPE t_table_tab IS TABLE OF VARCHAR(128);
+    v_names  t_name_tab;
+    v_tables t_table_tab;
+BEGIN
+    SELECT CONSTRAINT_NAME, TABLE_NAME
+      BULK COLLECT INTO v_names, v_tables
+      FROM USER_CONSTRAINTS
+     WHERE CONSTRAINT_TYPE = 'C'
+       AND UPPER(CONSTRAINT_NAME) IN (
+           'CK_DOC_BORROW_REQUEST_STATUS',
+           'CK_DOC_CATEGORY_CATEGORY_STATUS',
+           'CK_DOC_CATEGORY_CATEGORY_TYPE',
+           'CK_DOC_DOCUMENT_ATTACHMENT_TYPE_V2',
+           'CK_DOC_DOCUMENT_DOCUMENT_CONTENT_EXTRACT_STATUS',
+           'CK_DOC_DOCUMENT_DOCUMENT_SECURITY_LEVEL',
+           'CK_DOC_DOCUMENT_DOCUMENT_STATUS',
+           'CK_DOC_DOCUMENT_DOCUMENT_TYPE',
+           'CK_SYS_AUDIT_LOG_AUDIT_OPERATION_RESULT',
+           'CK_SYS_NOTIFICATION_ACTION',
+           'CK_SYS_NOTIFICATION_STATUS',
+           'CK_SYS_ORGANIZATION_ORGANIZATION_STATUS',
+           'CK_SYS_PASSWORD_RESET_REQUEST_STATUS',
+           'CK_SYS_ROLE_PERMISSION_DEPT_SCOPE_V2',
+           'CK_SYS_ROLE_PERMISSION_ROLE_PERMISSION_SECURITY_LEVEL',
+           'CK_SYS_ROLE_ROLE_STATUS',
+           'CK_SYS_USER_USER_ROLE_CODE',
+           'CK_SYS_USER_USER_SECURITY_LEVEL',
+           'CK_SYS_USER_USER_STATUS',
+           'CK_SYS_USER_USER_UI_THEME');
+
+    FOR i IN 1 .. v_names.COUNT LOOP
+        BEGIN
+            EXECUTE IMMEDIATE 'ALTER TABLE "' || v_tables(i)
+                || '" DROP CONSTRAINT "' || v_names(i) || '"';
+        EXCEPTION WHEN OTHERS THEN
+            BEGIN
+                EXECUTE IMMEDIATE 'ALTER TABLE ' || UPPER(v_tables(i))
+                    || ' DROP CONSTRAINT ' || UPPER(v_names(i));
+            EXCEPTION WHEN OTHERS THEN
+                DBMS_OUTPUT.PUT_LINE('V35 跳过 ' || v_tables(i) || '.'
+                    || v_names(i) || ': ' || SQLERRM);
+            END;
+        END;
+    END LOOP;
+END;
+/

+ 19 - 0
backend-java/src/main/resources/db/migration-dm/V36__user_password_hash_nullable.sql

@@ -0,0 +1,19 @@
+-- V36__user_password_hash_nullable 的达梦版(DM8):
+-- ../migration/V36__user_password_hash_nullable.sql 的等价迁移。
+-- 契约1.49 清空密码路径(SET password_hash = NULL)与基线 NOT NULL 约束冲突,
+-- 审批重置通过时报「违反列[PASSWORD_HASH]非空约束」——放开为可空。
+-- USER_TAB_COLUMNS 探测幂等(NULLABLE='Y' 即已可空,跳过)。
+
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_USER'
+          AND COLUMN_NAME = 'PASSWORD_HASH'
+          AND NULLABLE = 'Y';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE 'ALTER TABLE SYS_USER MODIFY PASSWORD_HASH NULL';
+    END IF;
+END;
+/

+ 30 - 0
backend-java/src/main/resources/db/migration/V25__attachment_permissions_lock_merge.sql

@@ -0,0 +1,30 @@
+-- 0039_attachment_permissions_lock_merge 的 Flyway 等价迁移(契约1.41)。
+-- 变更要点:
+--   1. sys_role_permission 新增 4 个附件权限布尔列(默认 0):
+--      can_attachment_view / can_attachment_download / can_attachment_edit /
+--      can_attachment_upload——附件权限独立于主案权限矩阵,不跟随挂载主案权限;
+--      仅 OWN 行有意义(附件无部门归属,OTHER 行服务端校验拒绝置真)。
+--   2. 方案密码锁「挂载/解除挂载」合并为单一「挂载」保护位:存量
+--      protect_unbind_attachment=1 的行并入 protect_bind_attachment(列保留,
+--      代码不再读写,兼容旧备份恢复)。
+-- 幂等:加列先探测;UPDATE 天然幂等;与 Python 版 Alembic 0039
+--   并行执行互不冲突。
+
+SET @column_exists = (
+    SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'sys_role_permission'
+      AND COLUMN_NAME = 'can_attachment_view'
+);
+SET @ddl = IF(@column_exists = 0,
+    'ALTER TABLE sys_role_permission ADD COLUMN can_attachment_view TINYINT(1) NOT NULL DEFAULT 0, ADD COLUMN can_attachment_download TINYINT(1) NOT NULL DEFAULT 0, ADD COLUMN can_attachment_edit TINYINT(1) NOT NULL DEFAULT 0, ADD COLUMN can_attachment_upload TINYINT(1) NOT NULL DEFAULT 0',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 方案锁合并:解除挂载保护位并入挂载位(一位同保护挂载与解除挂载)
+UPDATE doc_document
+   SET protect_bind_attachment = 1
+ WHERE protect_unbind_attachment = 1
+   AND protect_bind_attachment = 0;

+ 18 - 0
backend-java/src/main/resources/db/migration/V26__attachment_delete_permission.sql

@@ -0,0 +1,18 @@
+-- 契约1.44:sys_role_permission 新增附件删除权限布尔列(默认 0):
+--   can_attachment_delete——附件删除不再为 ADMIN 类别专属,改按矩阵附件位
+--   判定(仅内置超管恒可);仅 OWN 行有意义(OTHER 行服务端校验拒绝置真,
+--   与其余四个附件权限位同口径)。
+-- 幂等:加列先探测。
+
+SET @column_exists = (
+    SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'sys_role_permission'
+      AND COLUMN_NAME = 'can_attachment_delete'
+);
+SET @ddl = IF(@column_exists = 0,
+    'ALTER TABLE sys_role_permission ADD COLUMN can_attachment_delete TINYINT(1) NOT NULL DEFAULT 0',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 48 - 0
backend-java/src/main/resources/db/migration/V27__multi_role_seat_user_type.sql

@@ -0,0 +1,48 @@
+-- 用户多角色 + 席位 + 首长类型(契约1.46):
+--   1) 新建 sys_user_role 用户-角色多对多关联表:一个用户可挂多个角色,
+--      登录时若有多个启用角色需选择其一(会话锁定单角色,JWT 增 roleId claim);
+--      存量 sys_user.role_id 单值回填为一条关联记录,role_id 列保留但停用
+--      (读写全部改走关联表,不删列避免迁移风险);
+--   2) sys_user 加 seat_name 席位列(VARCHAR(64) NULL):右上角用户信息
+--      第二行「机构 + 空格 + 席位」展示;
+--   3) sys_user 加 user_type 用户类型列('NORMAL'/'COMMANDER',缺省 NORMAL):
+--      「首长」仍属普通用户(roleCode=USER),仅重置密码方式后续差异化。
+-- 幂等:CREATE TABLE IF NOT EXISTS + INSERT IGNORE(唯一约束兜底)+
+--   information_schema 探测加列,与 Alembic 0040 并行执行互不冲突。
+
+CREATE TABLE IF NOT EXISTS sys_user_role (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  user_id BIGINT NOT NULL,
+  role_id BIGINT NOT NULL,
+  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+  created_by BIGINT NULL,
+  PRIMARY KEY (id),
+  KEY ix_sys_user_role_user (user_id, role_id),
+  KEY ix_sys_user_role_role (role_id),
+  CONSTRAINT uq_sys_user_role UNIQUE (user_id, role_id)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;
+
+-- 存量回填:role_id 单值 → 一条关联记录(重复执行被唯一约束兜底)
+INSERT IGNORE INTO sys_user_role (user_id, role_id)
+SELECT id, role_id FROM sys_user
+WHERE role_id IS NOT NULL AND is_deleted = 0;
+
+-- sys_user 加列 seat_name(席位,可空)
+SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_user'
+          AND COLUMN_NAME = 'seat_name'),
+    'SELECT 1',
+    'ALTER TABLE sys_user ADD COLUMN seat_name VARCHAR(64) NULL');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- sys_user 加列 user_type(用户类型,NORMAL/COMMANDER,缺省普通)
+SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_user'
+          AND COLUMN_NAME = 'user_type'),
+    'SELECT 1',
+    'ALTER TABLE sys_user ADD COLUMN user_type VARCHAR(16) NOT NULL DEFAULT ''NORMAL''');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 4 - 0
backend-java/src/main/resources/db/migration/V28__admin_real_name.sql

@@ -0,0 +1,4 @@
+-- V28__admin_real_name(契约1.47):内置 admin 的姓名改为「超级管理员」。
+-- 背景:用户名与姓名同语义且支持姓名登录,姓名需唯一(应用层校验);
+-- admin 存量姓名「系统管理员」易与其他用户重名,迁移为专有姓名。
+UPDATE sys_user SET real_name = '超级管理员' WHERE username = 'admin';

+ 73 - 0
backend-java/src/main/resources/db/migration/V29__matrix_top_category_kind.sql

@@ -0,0 +1,73 @@
+-- V29__matrix_top_category_kind(契约1.48):方案权限矩阵三维化。
+-- 行键由「密级 × 部门范围」扩展为「一级分类 × 种类 × 密级 × 部门范围」:
+--   top_category_id——一级分类 id;NULL = 共享资料库行组(未挂载附件判定专用);
+--   doc_kind——MAIN=主体方案;ACCOMPANYING_PLAN/SUPPORT_ACHIEVEMENT/
+--   TRAINING_MATERIAL/TYPICAL_CASE=共享资料四类型(恒 OWN 行)。
+-- 存量迁移:每角色现有行按一级分类同权展开(实际权限升级前后不变);
+-- 仅主案模式(flat_mode=1)的一级分类不生成共享资料行。
+-- 注意:三段 INSERT 的源行均限定 top_category_id IS NULL AND doc_kind='MAIN'
+-- (即迁移前旧结构行),避免前段插入的新行被后段当作源再次展开(重复脏数据)。
+
+ALTER TABLE sys_role_permission
+    ADD COLUMN top_category_id BIGINT NULL AFTER dept_scope,
+    ADD COLUMN doc_kind VARCHAR(32) NOT NULL DEFAULT 'MAIN' AFTER top_category_id;
+
+-- 1) 主体方案行:每未删一级分类 × 原行(主案 8 位复制,附件位走默认 0)
+INSERT INTO sys_role_permission
+    (role_id, top_category_id, doc_kind, security_level, dept_scope,
+     can_view, can_upload, can_download, can_edit, can_delete,
+     can_config_permission, can_bind_attachment, can_unbind_attachment,
+     created_by, updated_by)
+SELECT r.role_id, c.id, 'MAIN', r.security_level, r.dept_scope,
+       r.can_view, r.can_upload, r.can_download, r.can_edit, r.can_delete,
+       r.can_config_permission, r.can_bind_attachment, r.can_unbind_attachment,
+       r.created_by, r.updated_by
+FROM sys_role_permission r
+JOIN doc_category c ON c.parent_id IS NULL AND c.is_deleted = 0
+WHERE r.is_deleted = 0
+  AND r.top_category_id IS NULL AND r.doc_kind = 'MAIN';
+
+-- 2) 分类下共享资料行:非仅主案模式分类 × 四类型 × 原 OWN 行附件 5 位
+INSERT INTO sys_role_permission
+    (role_id, top_category_id, doc_kind, security_level, dept_scope,
+     can_attachment_upload, can_attachment_edit, can_attachment_view,
+     can_attachment_download, can_attachment_delete,
+     created_by, updated_by)
+SELECT r.role_id, c.id, k.kind, r.security_level, 'OWN',
+       r.can_attachment_upload, r.can_attachment_edit, r.can_attachment_view,
+       r.can_attachment_download, r.can_attachment_delete,
+       r.created_by, r.updated_by
+FROM sys_role_permission r
+JOIN doc_category c
+  ON c.parent_id IS NULL AND c.is_deleted = 0 AND COALESCE(c.flat_mode, 0) = 0
+JOIN (
+    SELECT 'ACCOMPANYING_PLAN' AS kind UNION ALL
+    SELECT 'SUPPORT_ACHIEVEMENT' UNION ALL
+    SELECT 'TRAINING_MATERIAL' UNION ALL
+    SELECT 'TYPICAL_CASE'
+) k
+WHERE r.is_deleted = 0 AND r.dept_scope = 'OWN'
+  AND r.top_category_id IS NULL AND r.doc_kind = 'MAIN';
+
+-- 3) 共享资料库行组:top_category_id NULL × 四类型 × 原 OWN 行附件 5 位
+INSERT INTO sys_role_permission
+    (role_id, top_category_id, doc_kind, security_level, dept_scope,
+     can_attachment_upload, can_attachment_edit, can_attachment_view,
+     can_attachment_download, can_attachment_delete,
+     created_by, updated_by)
+SELECT r.role_id, NULL, k.kind, r.security_level, 'OWN',
+       r.can_attachment_upload, r.can_attachment_edit, r.can_attachment_view,
+       r.can_attachment_download, r.can_attachment_delete,
+       r.created_by, r.updated_by
+FROM sys_role_permission r
+JOIN (
+    SELECT 'ACCOMPANYING_PLAN' AS kind UNION ALL
+    SELECT 'SUPPORT_ACHIEVEMENT' UNION ALL
+    SELECT 'TRAINING_MATERIAL' UNION ALL
+    SELECT 'TYPICAL_CASE'
+) k
+WHERE r.is_deleted = 0 AND r.dept_scope = 'OWN'
+  AND r.top_category_id IS NULL AND r.doc_kind = 'MAIN';
+
+-- 4) 删除旧结构行(doc_kind='MAIN' 且 top_category_id IS NULL,即迁移前的 10 行/角色)
+DELETE FROM sys_role_permission WHERE doc_kind = 'MAIN' AND top_category_id IS NULL;

+ 28 - 0
backend-java/src/main/resources/db/migration/V30__password_reset_request.sql

@@ -0,0 +1,28 @@
+-- V30__password_reset_request(契约1.49):登录界面重置密码审批流。
+--   1) 新建 sys_password_reset_request 重置密码申请单表:登录前自助提交,
+--      按申请用户类型路由审批角色(首长→安全管理员、其他→系统管理员,
+--      DMS_RESET_PASSWORD_APPROVER_ROLE_COMMANDER/NORMAL 可配),
+--      状态 PENDING/APPROVED/REJECTED,任一审批人处理即定(先到先得);
+--   2) 审批通过效果=回首次登录态:password_hash 置 NULL、last_login_at 置
+--      NULL(门户验签据此进入 SET_PASSWORD 阶段重新设置独立密码)、
+--      auth_version+1(旧会话全部失效),由服务层执行,本迁移仅建表。
+-- 幂等:CREATE TABLE IF NOT EXISTS,可重复执行。
+
+CREATE TABLE IF NOT EXISTS sys_password_reset_request (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  user_id BIGINT NOT NULL,
+  username VARCHAR(64) NOT NULL,
+  real_name VARCHAR(100) NOT NULL,
+  user_type VARCHAR(16) NOT NULL,
+  approver_role_name VARCHAR(64) NOT NULL,
+  status VARCHAR(16) NOT NULL,
+  request_ip VARCHAR(64) NULL,
+  requested_at DATETIME NOT NULL,
+  handled_by BIGINT NULL,
+  handled_by_name VARCHAR(100) NULL,
+  handled_at DATETIME NULL,
+  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+  PRIMARY KEY (id),
+  KEY ix_password_reset_request_user (user_id, status),
+  CONSTRAINT ck_password_reset_request_status CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED'))
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;

+ 17 - 0
backend-java/src/main/resources/db/migration/V31__role_password.sql

@@ -0,0 +1,17 @@
+-- 角色密码(契约1.53 密码即角色):
+--   sys_user_role 加 password_hash 列——N 个密码对应 N 个角色一一对应,
+--   登录时输入哪个角色的密码即以该角色直接进入(无需选择)。
+--   NULL = 该角色使用用户级密码(挂 ≥2 角色时恰一个角色可为 NULL,
+--   其余角色独立密码两两互异且不等于用户级密码);
+--   存量数据不搬迁:全部 NULL = 过渡态,登录按 1.46 行为回退用户级
+--   密码验证(多角色弹选择),编辑分配角色密码后即迁入新体系。
+-- 幂等:information_schema 探测,重复执行无副作用。
+
+SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_user_role'
+          AND COLUMN_NAME = 'password_hash'),
+    'SELECT 1',
+    'ALTER TABLE sys_user_role ADD COLUMN password_hash VARCHAR(255) NULL');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 17 - 0
backend-java/src/main/resources/db/migration/V32__borrow_settings.sql

@@ -0,0 +1,17 @@
+-- V32__borrow_settings(契约1.54):系统管理审批相关设置。
+--   新建 sys_setting 通用键值设置表:调阅说明(borrow.notice 行,
+--   setting_value 存大段文本,NULL=未设置)与调阅审批单模板元数据
+--   (borrow.template 行,file_name/file_size 存原始名与大小,模板文件
+--   落盘存储根目录 settings/borrow-template.pdf,固定路径不入库);
+--   行不存在即未设置。读写与审计由服务层执行,本迁移仅建表。
+-- 幂等:CREATE TABLE IF NOT EXISTS,可重复执行。
+
+CREATE TABLE IF NOT EXISTS sys_setting (
+  setting_key VARCHAR(64) NOT NULL,
+  setting_value MEDIUMTEXT NULL,
+  file_name VARCHAR(255) NULL,
+  file_size BIGINT NULL,
+  updated_at DATETIME(3) NULL,
+  updated_by BIGINT NULL,
+  PRIMARY KEY (setting_key)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;

+ 16 - 0
backend-java/src/main/resources/db/migration/V33__attachment_matrix_simplify.sql

@@ -0,0 +1,16 @@
+-- V33__attachment_matrix_simplify(契约1.59):附件矩阵配置简化为库行组统一入口。
+--   分类下附件行(top_category_id IS NOT NULL AND doc_kind != 'MAIN')的五位
+--   权限全部清零;行保留(日后恢复分类页签附件矩阵时可重新配置)。
+--   判定侧同步改为分类行与库行组行任一命中即放行(库行组兜底),
+--   分类行清零后实际仅库行组生效。
+-- 幂等:纯 UPDATE,可重复执行。
+
+UPDATE sys_role_permission
+SET can_attachment_upload = 0,
+    can_attachment_edit = 0,
+    can_attachment_view = 0,
+    can_attachment_download = 0,
+    can_attachment_delete = 0
+WHERE top_category_id IS NOT NULL
+  AND doc_kind != 'MAIN'
+  AND is_deleted = 0;

+ 22 - 0
backend-java/src/main/resources/db/migration/V34__notification_notify_type_check.sql

@@ -0,0 +1,22 @@
+-- V34__notification_notify_type_check(契约1.63):删除 sys_notification.notify_type
+--   的 CHECK 白名单约束。
+-- 变更要点:重置密码审批流(契约1.49)新增通知类型 PASSWORD_RESET,V21 建表
+--   白名单仅 APPROVAL 未同步,导致重置密码申请提交时通知写入违约、接口连带
+--   500「服务器内部错误」;与 V20/V23 对 sys_audit_log 两列的处理同理——
+--   通知类型是持续扩展的枚举(APPROVAL 之后已新增 PASSWORD_RESET),库白名单
+--   每加一种通知都要跟着改,易漏且只在真实库上炸(单测 mock mapper 测不出),
+--   合法性收敛到应用层字典;action/status 的 CHECK 保留(稳定小枚举)。
+-- 幂等:探测后删除,重复执行无副作用。
+
+SET @ck_name = (
+    SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'sys_notification'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_NOTIFICATION_NOTIFY_TYPE'
+    LIMIT 1
+);
+SET @ddl = IF(@ck_name IS NULL, 'SELECT 1',
+    CONCAT('ALTER TABLE sys_notification DROP CONSTRAINT ', @ck_name));
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 153 - 0
backend-java/src/main/resources/db/migration/V35__drop_enum_check_constraints.sql

@@ -0,0 +1,153 @@
+-- V35__drop_enum_check_constraints(契约1.64):删除全部枚举型 CHECK 白名单约束,
+--   枚举合法性统一由应用端保证。
+-- 背景:通知类型 CHECK(V34 删)、审计两列 CHECK(V20/V23 删)三次证明——
+--   枚举白名单在扩展时(新增通知类型/审计动作/对象类型)必然遗漏同步,
+--   且单测 mock mapper 测不出库层约束,只在真实库操作时连带 500。
+--   本迁移一次性清除全部剩余枚举 IN 白名单约束(含 V12 的 ui_theme 主题白名单),
+--   收敛到应用层(Payloads.statusValue/roleCodeValue/securityLevelValue、
+--   CategoryService.categoryType、UserService.UI_THEMES、RoleService 矩阵校验
+--   与服务端常量写入)。
+-- 保留:跨列业务规则约束(非枚举)——CK_DOC_DOCUMENT_DOCUMENT_PASSWORD_MAIN_ONLY
+--   (密码仅主案可设)、CK_DOC_DOCUMENT_TYPE_SHAPE_V3(三类文档形状一致性)、
+--   CK_DOC_DOCUMENT_RELATION_DOCUMENT_RELATION_NO_SELF(关联不得自指)。
+-- 幂等:逐个探测后删除,重复执行无副作用。
+
+-- doc_borrow_request.status:PENDING/APPROVED/REJECTED(服务端状态机写入)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'doc_borrow_request'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_DOC_BORROW_REQUEST_STATUS' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE doc_borrow_request DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- doc_category.status:ENABLED/DISABLED(Payloads.statusValue)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'doc_category'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_DOC_CATEGORY_CATEGORY_STATUS' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE doc_category DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- doc_category.category_type:五分类类型(CategoryService.categoryType)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'doc_category'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_DOC_CATEGORY_CATEGORY_TYPE' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE doc_category DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- doc_document.attachment_type:附件四类型(服务端枚举校验)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'doc_document'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_DOC_DOCUMENT_ATTACHMENT_TYPE_V2' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE doc_document DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- doc_document.content_extract_status:正文提取四态(服务端常量写入)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'doc_document'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_DOC_DOCUMENT_DOCUMENT_CONTENT_EXTRACT_STATUS' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE doc_document DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- doc_document.security_level:五密级(Payloads.securityLevelValue)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'doc_document'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_DOC_DOCUMENT_DOCUMENT_SECURITY_LEVEL' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE doc_document DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- doc_document.document_status:DRAFT/PUBLISHED/ARCHIVED(服务端流转常量)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'doc_document'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_DOC_DOCUMENT_DOCUMENT_STATUS' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE doc_document DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- doc_document.document_type:MAIN/SUB_PLAN/ATTACHMENT(服务端枚举)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'doc_document'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_DOC_DOCUMENT_DOCUMENT_TYPE' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE doc_document DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- sys_audit_log.operation_result:SUCCESS/FAILURE(AuditService 常量;V23 当时保留,本次统一清除)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_audit_log'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_AUDIT_LOG_AUDIT_OPERATION_RESULT' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sys_audit_log DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- sys_notification.action:NONE/PENDING/AGREE/REJECT(服务端常量)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_notification'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_NOTIFICATION_ACTION' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sys_notification DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- sys_notification.status:UNREAD/READ(服务端常量)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_notification'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_NOTIFICATION_STATUS' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sys_notification DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- sys_organization.status:ENABLED/DISABLED(Payloads.statusValue)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_organization'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_ORGANIZATION_ORGANIZATION_STATUS' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sys_organization DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- sys_password_reset_request.status:PENDING/APPROVED/REJECTED(服务端状态机)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_password_reset_request'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_PASSWORD_RESET_REQUEST_STATUS' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sys_password_reset_request DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- sys_role_permission.dept_scope:OWN/OTHER(RoleService.validateMatrix)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_role_permission'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_ROLE_PERMISSION_DEPT_SCOPE_V2' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sys_role_permission DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- sys_role_permission.security_level:五密级(RoleService.validateMatrix)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_role_permission'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_ROLE_PERMISSION_ROLE_PERMISSION_SECURITY_LEVEL' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sys_role_permission DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- sys_role.status:ENABLED/DISABLED(Payloads.statusValue)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_role'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_ROLE_ROLE_STATUS' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sys_role DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- sys_user.role_code:USER/ADMIN(Payloads.roleCodeValue)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_user'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_USER_USER_ROLE_CODE' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sys_user DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- sys_user.security_level:五密级(Payloads.securityLevelValue)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_user'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_USER_USER_SECURITY_LEVEL' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sys_user DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- sys_user.status:ENABLED/DISABLED(Payloads.statusValue)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_user'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_USER_USER_STATUS' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sys_user DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- sys_user.ui_theme:blue/red/green(V12 建表小写名;UserService.UI_THEMES 白名单)
+SET @ck = (SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_user'
+      AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_USER_USER_UI_THEME' LIMIT 1);
+SET @ddl = IF(@ck IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sys_user DROP CONSTRAINT ', @ck));
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

+ 7 - 0
backend-java/src/main/resources/db/migration/V36__user_password_hash_nullable.sql

@@ -0,0 +1,7 @@
+-- 契约1.49 重置密码审批通过清空密码(clearPasswordForReset 置 password_hash=NULL)
+-- 与基线约束冲突的修复:Alembic 0001 建表 sys_user.password_hash 为 NOT NULL,
+-- SET NULL 在达梦/严格模式 MySQL 上报「违反列[PASSWORD_HASH]非空约束」。
+-- 放开为可空——NULL=密码被审批清空、待用户重设(本地登录判 null/空两态、
+-- 门户验签按 last_login_at 判设密阶段,应用层均兼容,无需改代码)。
+
+ALTER TABLE sys_user MODIFY password_hash VARCHAR(255) NULL;

+ 3 - 3
backend-java/src/test/java/com/dms/api/v1/PortalAuthControllerTest.java

@@ -43,7 +43,7 @@ class PortalAuthControllerTest {
     @Test
     void 设密请求携带可选机构ID不被拒绝() {
         when(portalAuthService.setupPortalPassword(eq("ticket-1"), eq("Portal#9001"),
-                eq(false), eq("3"), any())).thenReturn(Map.of("token", "t"));
+                eq(false), eq("3"), isNull(), any())).thenReturn(Map.of("token", "t"));
 
         assertDoesNotThrow(() -> controller.setupPassword(body(
                 "portalTicket", "ticket-1",
@@ -55,7 +55,7 @@ class PortalAuthControllerTest {
     @Test
     void 设密请求不带机构ID同样合法() {
         when(portalAuthService.setupPortalPassword(eq("ticket-1"), eq("Portal#9001"),
-                eq(true), isNull(), any())).thenReturn(Map.of("token", "t"));
+                eq(true), isNull(), isNull(), any())).thenReturn(Map.of("token", "t"));
 
         assertDoesNotThrow(() -> controller.setupPassword(body(
                 "portalTicket", "ticket-1",
@@ -72,7 +72,7 @@ class PortalAuthControllerTest {
                 "organizationId", "3",
                 "extra", "x"), null));
 
-        assertTrue(ex.getMessage().contains("organizationId可选"));
+        assertTrue(ex.getMessage().contains("rolePasswords可选"));
     }
 
     @Test

+ 5 - 5
backend-java/src/test/java/com/dms/security/TokenRotationInterceptorTest.java

@@ -60,7 +60,7 @@ class TokenRotationInterceptorTest {
 
     private static AuthContext context(String tokenJti) {
         return new AuthContext(USER_ID, "admin", "系统管理员", 10L,
-                "机关", "ADMIN", "TOP_SECRET", 3, tokenJti);
+                "机关", "ADMIN", "TOP_SECRET", 3, tokenJti, null);
     }
 
     private static UserEntity user(String sessionJti) {
@@ -132,7 +132,7 @@ class TokenRotationInterceptorTest {
     @Test
     void successfulWriteRotatesTokenInResponseHeader() throws Exception {
         JwtTokens.Issued original = JwtTokens.issue("jti-current", USER_ID, "admin",
-                "ADMIN", 3, Duration.ofMinutes(30), SECRET);
+                "ADMIN", 3, null, Duration.ofMinutes(30), SECRET);
         when(userMapper.rotateSessionJti(eq(USER_ID), eq("jti-current"), anyString()))
                 .thenReturn(1);
 
@@ -160,7 +160,7 @@ class TokenRotationInterceptorTest {
     void casLoserResignsWithLatestJti() throws Exception {
         // 并发写乱序自愈:CAS 落败者回读库中最新会话代签发响应码
         JwtTokens.Issued original = JwtTokens.issue("jti-current", USER_ID, "admin",
-                "ADMIN", 3, Duration.ofMinutes(30), SECRET);
+                "ADMIN", 3, null, Duration.ofMinutes(30), SECRET);
         when(userMapper.rotateSessionJti(eq(USER_ID), eq("jti-current"), anyString()))
                 .thenReturn(0);
         when(userMapper.selectSessionJti(USER_ID)).thenReturn("jti-latest");
@@ -178,7 +178,7 @@ class TokenRotationInterceptorTest {
     void failedWriteDoesNotRotate() throws Exception {
         MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/categories");
         request.addHeader("Authorization", "Bearer " + JwtTokens.issue("jti-current", USER_ID,
-                "admin", "ADMIN", 3, Duration.ofMinutes(30), SECRET).token());
+                "admin", "ADMIN", 3, null, Duration.ofMinutes(30), SECRET).token());
         MockHttpServletResponse response = new MockHttpServletResponse();
         response.setStatus(400);
 
@@ -192,7 +192,7 @@ class TokenRotationInterceptorTest {
     void successfulReadDoesNotRotate() throws Exception {
         MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/documents");
         request.addHeader("Authorization", "Bearer " + JwtTokens.issue("jti-current", USER_ID,
-                "admin", "ADMIN", 3, Duration.ofMinutes(30), SECRET).token());
+                "admin", "ADMIN", 3, null, Duration.ofMinutes(30), SECRET).token());
         MockHttpServletResponse response = new MockHttpServletResponse();
 
         interceptor.afterCompletion(request, response, handler(), null);

+ 26 - 15
backend-java/src/test/java/com/dms/service/AdminLoginSwitchTest.java

@@ -39,7 +39,7 @@ class AdminLoginSwitchTest {
     }
 
     private static AuthContext adminContext() {
-        return new AuthContext(1L, "op", "操作员", 1L, "机构", "ADMIN", "PUBLIC", 0, null);
+        return new AuthContext(1L, "op", "操作员", 1L, "机构", "ADMIN", "PUBLIC", 0, null, null);
     }
 
     @AfterEach
@@ -50,12 +50,13 @@ class AdminLoginSwitchTest {
     @Test
     void loginBlockedAdminShortCircuitsBeforeDbLookup() {
         UserMapper userMapper = mock(UserMapper.class);
-        AuthService service = new AuthService(userMapper, mock(AuditService.class),
+        AuthService service = new AuthService(userMapper,
+                mock(com.dms.mapper.UserRoleMapper.class), mock(AuditService.class),
                 mock(LoginIpService.class), mock(UserSummaryService.class),
                 mock(LoginLockoutService.class), mock(CaptchaService.class),
-                null, props(false));
+                null, props(false), mock(PortalAuthService.class));
         // 与密码错误同出口 INVALID_CREDENTIALS,从任何维度无法区分"admin被禁"与"不存在"
-        assertThatThrownBy(() -> service.login("admin", "whatever", false, null, null, null))
+        assertThatThrownBy(() -> service.login("admin", "whatever", false, null, null, null, null))
                 .isInstanceOf(DmsException.class)
                 .hasFieldOrPropertyWithValue("code", "INVALID_CREDENTIALS");
         // 拦截发生在查库之前:用户名按不存在处理,不触数据库
@@ -66,11 +67,12 @@ class AdminLoginSwitchTest {
     void loginOtherUsernameNotBlockedWhenDisabled() {
         UserMapper userMapper = mock(UserMapper.class);
         when(userMapper.findActiveByUsername("someone")).thenReturn(null);
-        AuthService service = new AuthService(userMapper, mock(AuditService.class),
+        AuthService service = new AuthService(userMapper,
+                mock(com.dms.mapper.UserRoleMapper.class), mock(AuditService.class),
                 mock(LoginIpService.class), mock(UserSummaryService.class),
                 mock(LoginLockoutService.class), mock(CaptchaService.class),
-                null, props(false));
-        assertThatThrownBy(() -> service.login("someone", "bad", false, null, null, null))
+                null, props(false), mock(PortalAuthService.class));
+        assertThatThrownBy(() -> service.login("someone", "bad", false, null, null, null, null))
                 .isInstanceOf(DmsException.class)
                 .hasFieldOrPropertyWithValue("code", "INVALID_CREDENTIALS");
         // 开关只针对内置 admin:其他用户名照常查库
@@ -80,16 +82,23 @@ class AdminLoginSwitchTest {
     @Test
     void createAdminUsernameRejectedWithGenericErrorWhenDisabled() {
         UserMapper userMapper = mock(UserMapper.class);
-        UserService service = new UserService(userMapper, mock(OrganizationMapper.class),
+        UserService service = new UserService(userMapper,
+                mock(com.dms.mapper.UserRoleMapper.class), mock(OrganizationMapper.class),
                 mock(RoleMapper.class), mock(UserSummaryService.class),
                 mock(AuditService.class), mock(AdminPermission.class),
                 mock(Reauth.class), props(false));
         AuthContext.set(adminContext());
-        Map<String, Object> body = Map.of(
-                "username", "admin", "realName", "冒名者",
-                "initialPassword", "Passw0rd!2024",
-                "organizationId", "1", "roleCode", "USER", "roleId", "2",
-                "securityLevel", "PUBLIC", "status", "ENABLED");
+        Map<String, Object> body = new java.util.LinkedHashMap<>();
+        body.put("username", "admin");
+        body.put("realName", "冒名者");
+        body.put("initialPassword", "Passw0rd!2024");
+        body.put("organizationId", "1");
+        body.put("roleCode", "USER");
+        body.put("roleIds", List.of("2"));
+        body.put("seatName", null);
+        body.put("userType", null);
+        body.put("securityLevel", "PUBLIC");
+        body.put("status", "ENABLED");
         // 伪报错 400 INVALID_ARGUMENT:不提示"用户名已存在",不泄露账号存在
         assertThatThrownBy(() -> service.create(body, null))
                 .isInstanceOf(DmsException.class)
@@ -105,7 +114,8 @@ class AdminLoginSwitchTest {
         when(userMapper.countByFilters(any(), any(), any(), any())).thenReturn(0L);
         when(userMapper.pageByFilters(any(), any(), any(), any(), anyInt(), anyInt()))
                 .thenReturn(List.of());
-        UserService service = new UserService(userMapper, mock(OrganizationMapper.class),
+        UserService service = new UserService(userMapper,
+                mock(com.dms.mapper.UserRoleMapper.class), mock(OrganizationMapper.class),
                 mock(RoleMapper.class), mock(UserSummaryService.class),
                 mock(AuditService.class), mock(AdminPermission.class),
                 mock(Reauth.class), props(false));
@@ -122,7 +132,8 @@ class AdminLoginSwitchTest {
         when(userMapper.countByFilters(any(), any(), any(), any())).thenReturn(0L);
         when(userMapper.pageByFilters(any(), any(), any(), any(), anyInt(), anyInt()))
                 .thenReturn(List.of());
-        UserService service = new UserService(userMapper, mock(OrganizationMapper.class),
+        UserService service = new UserService(userMapper,
+                mock(com.dms.mapper.UserRoleMapper.class), mock(OrganizationMapper.class),
                 mock(RoleMapper.class), mock(UserSummaryService.class),
                 mock(AuditService.class), mock(AdminPermission.class),
                 mock(Reauth.class), props(true));

+ 17 - 5
backend-java/src/test/java/com/dms/service/AdminPermissionUserScopeTest.java

@@ -9,10 +9,13 @@ import com.dms.common.DmsException;
 import com.dms.mapper.OrganizationMapper;
 import com.dms.mapper.RoleMapper;
 import com.dms.mapper.UserMapper;
+import com.dms.mapper.UserRoleMapper;
 import com.dms.model.OrganizationEntity;
 import com.dms.model.RoleEntity;
 import com.dms.model.UserEntity;
+import com.dms.security.AuthContext;
 import java.util.List;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
@@ -20,6 +23,7 @@ import org.junit.jupiter.api.Test;
  * 用户级防提权单测(契约1.30修订,对齐 test_admin_permissions.py):
  * 目标 ADMIN 最终生效管理范围不得超出操作者。
  * 仅当目标 ADMIN 且生效角色具备机构/用户编辑位时校验。
+ * 契约1.46:多角色下逐一校验目标挂载的每个角色;操作者按会话角色解析。
  */
 class AdminPermissionUserScopeTest {
 
@@ -30,28 +34,36 @@ class AdminPermissionUserScopeTest {
     private final UserMapper userMapper = mock(UserMapper.class);
     private final OrganizationMapper organizationMapper = mock(OrganizationMapper.class);
     private final RoleMapper roleMapper = mock(RoleMapper.class);
+    private final UserRoleMapper userRoleMapper = mock(UserRoleMapper.class);
     private final AdminPermission adminPermission = new AdminPermission(
-            userMapper, organizationMapper, roleMapper);
+            userMapper, organizationMapper, roleMapper, userRoleMapper);
 
     private UserEntity actor;
 
     @BeforeEach
     void setUpScope() {
-        // 操作者:角色锚点 OPS(manageUserEdit),生效子树 {OPS, OPS_CHILD}
+        // 操作者:会话角色77锚点 OPS(manageUserEdit),生效子树 {OPS, OPS_CHILD}
         RoleEntity actorRole = new RoleEntity();
         actorRole.setManageUserEdit(true);
         actorRole.setManagedOrgId(OPS);
         when(roleMapper.findActiveEnabledById(77L)).thenReturn(actorRole);
 
         actor = new UserEntity();
+        actor.setId(9L);
         actor.setUsername("scoped");
         actor.setRoleCode("ADMIN");
-        actor.setRoleId(77L);
+        AuthContext.set(new AuthContext(9L, "scoped", "范围管理员", null, null,
+                "ADMIN", "TOP_SECRET", 0, null, 77L));
 
         when(organizationMapper.listAllNotDeleted())
                 .thenReturn(List.of(org(OPS), org(OPS_CHILD), org(COMMS)));
     }
 
+    @AfterEach
+    void clearContext() {
+        AuthContext.clear();
+    }
+
     private static OrganizationEntity org(long id) {
         OrganizationEntity entity = new OrganizationEntity();
         entity.setId(id);
@@ -71,7 +83,7 @@ class AdminPermissionUserScopeTest {
 
     private void check(RoleEntity effectiveRole, Long managedOrgId, Long organizationId) {
         adminPermission.ensureUserScopeInManagedScope(
-                actor, "ADMIN", effectiveRole, managedOrgId, organizationId);
+                actor, "ADMIN", List.of(effectiveRole), managedOrgId, organizationId);
     }
 
     @Test
@@ -139,7 +151,7 @@ class AdminPermissionUserScopeTest {
     @Test
     void USER类别目标不校验() {
         assertThatCode(() -> adminPermission.ensureUserScopeInManagedScope(
-                actor, "USER", targetRole(null, true), null, null))
+                actor, "USER", List.of(targetRole(null, true)), null, null))
                 .doesNotThrowAnyException();
     }
 

+ 336 - 0
backend-java/src/test/java/com/dms/service/AdvancedSearchScopeTest.java

@@ -0,0 +1,336 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.dms.common.DmsException;
+import com.dms.mapper.AttachmentBindingMapper;
+import com.dms.mapper.CategoryMapper;
+import com.dms.mapper.DocumentMapper;
+import com.dms.mapper.OrganizationMapper;
+import com.dms.mapper.SubPlanBindingMapper;
+import com.dms.mapper.UserMapper;
+import com.dms.model.DocumentEntity;
+import com.dms.model.OrganizationEntity;
+import com.dms.security.AuthContext;
+import com.dms.service.AuthorizationService.PlanAccess;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.transaction.support.TransactionTemplate;
+
+/**
+ * 高级检索四维度(契约1.55):数据内容(keywordScope:TITLE/CONTENT/ALL)、
+ * 数据密级(单选)、提供单位(第 N 级机构选项 + 子树过滤)的过滤接线
+ * 与聚合检索范围限制;正文命中片段在 CONTENT/ALL 时回落到检索关键词。
+ */
+class AdvancedSearchScopeTest {
+
+    private final DocumentMapper documentMapper = mock(DocumentMapper.class);
+    private final AuthorizationService authorizationService = mock(AuthorizationService.class);
+
+    private final DocumentQueryService queryService = new DocumentQueryService(
+            documentMapper, mock(AttachmentBindingMapper.class), mock(CategoryMapper.class),
+            mock(SubPlanBindingMapper.class), authorizationService, mock(AuditService.class),
+            new ObjectMapper(), mock(TransactionTemplate.class));
+
+    @BeforeEach
+    void setUp() {
+        AuthContext.set(new AuthContext(9L, "seeker", "检索用户", 10L, "本部门",
+                "USER", "SECRET", 1, null, 100L));
+    }
+
+    @AfterEach
+    void clearContext() {
+        AuthContext.clear();
+    }
+
+    // ------------------------------------------------ 数据内容(keywordScope)
+
+    @Test
+    void keywordScopeMapsToMatchingPattern() {
+        Map<String, Object> filters = new HashMap<>();
+        DocumentQueryService.applyKeywordScope(filters, "雷达", "TITLE", false);
+        assertThat(filters).containsOnlyKeys("titlePattern");
+        assertThat(filters.get("titlePattern")).isEqualTo("%雷达%");
+
+        filters = new HashMap<>();
+        DocumentQueryService.applyKeywordScope(filters, "雷达", null, false);
+        assertThat(filters).containsOnlyKeys("titlePattern");
+
+        filters = new HashMap<>();
+        DocumentQueryService.applyKeywordScope(filters, "雷达", "CONTENT", false);
+        assertThat(filters).containsOnlyKeys("contentPattern");
+
+        filters = new HashMap<>();
+        DocumentQueryService.applyKeywordScope(filters, "雷达", "ALL", false);
+        assertThat(filters).containsOnlyKeys("titleOrContentPattern");
+    }
+
+    @Test
+    void emptyKeywordSkipsScopeEntirely() {
+        Map<String, Object> filters = new HashMap<>();
+        DocumentQueryService.applyKeywordScope(filters, null, "CONTENT", false);
+        DocumentQueryService.applyKeywordScope(filters, "   ", "ALL", false);
+        assertThat(filters).isEmpty();
+    }
+
+    @Test
+    void invalidScopeRejected() {
+        Map<String, Object> filters = new HashMap<>();
+        assertThatThrownBy(() ->
+                DocumentQueryService.applyKeywordScope(filters, "雷达", "BODY", false))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("keywordScope不是有效枚举值");
+    }
+
+    @Test
+    void aggregateRejectsContentAndAllScopesButKeepsTitle() {
+        Map<String, Object> filters = new HashMap<>();
+        assertThatThrownBy(() ->
+                DocumentQueryService.applyKeywordScope(filters, "雷达", "CONTENT", true))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("aggregateHits仅支持keywordScope为TITLE");
+        assertThatThrownBy(() ->
+                DocumentQueryService.applyKeywordScope(filters, "雷达", "ALL", true))
+                .isInstanceOf(DmsException.class);
+        // 聚合检索命中按标题在内存冒泡,TITLE 不进 SQL
+        DocumentQueryService.applyKeywordScope(filters, "雷达", "TITLE", true);
+        assertThat(filters).isEmpty();
+    }
+
+    @Test
+    void keywordScopeContentLikeFlagsContentParticipatingScopes() {
+        Map<String, String> params = new HashMap<>();
+        params.put("keywordScope", "CONTENT");
+        assertThat(DocumentQueryService.keywordScopeContentLike(params)).isTrue();
+        params.put("keywordScope", "ALL");
+        assertThat(DocumentQueryService.keywordScopeContentLike(params)).isTrue();
+        params.put("keywordScope", "TITLE");
+        assertThat(DocumentQueryService.keywordScopeContentLike(params)).isFalse();
+        params.remove("keywordScope");
+        assertThat(DocumentQueryService.keywordScopeContentLike(params)).isFalse();
+    }
+
+    // ------------------------------------------------ 提供单位(organizationId)
+
+    @SuppressWarnings("unchecked")
+    private static List<Object> asObjectList(Object value) {
+        return (List<Object>) value;
+    }
+
+    @Test
+    void organizationFilterExpandsSubtree() {
+        when(authorizationService.organizationSubtreeIds(5L))
+                .thenReturn(Set.of(5L, 7L, 8L));
+        Map<String, Object> filters = new HashMap<>();
+        queryService.applyOrganizationFilter(filters, "5");
+        assertThat(filters).containsOnlyKeys("organizationIds");
+        assertThat(asObjectList(filters.get("organizationIds")))
+                .containsExactlyInAnyOrder(5L, 7L, 8L);
+    }
+
+    @Test
+    void emptySubtreeFallsBackToSentinel() {
+        when(authorizationService.organizationSubtreeIds(99L)).thenReturn(Set.of());
+        Map<String, Object> filters = new HashMap<>();
+        queryService.applyOrganizationFilter(filters, "99");
+        assertThat(asObjectList(filters.get("organizationIds"))).containsExactly(-1L);
+    }
+
+    @Test
+    void blankOrganizationRawSkipsFilter() {
+        Map<String, Object> filters = new HashMap<>();
+        queryService.applyOrganizationFilter(filters, null);
+        queryService.applyOrganizationFilter(filters, "");
+        assertThat(filters).isEmpty();
+    }
+
+    @Test
+    void invalidOrganizationIdRejected() {
+        Map<String, Object> filters = new HashMap<>();
+        assertThatThrownBy(() -> queryService.applyOrganizationFilter(filters, "abc"))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("organizationId");
+    }
+
+    // ------------------------------------------------ listDocuments 接线
+
+    @Test
+    void allScopeAndAdvancedDimensionsFlowIntoSqlFilters() {
+        when(authorizationService.organizationSubtreeIds(5L))
+                .thenReturn(Set.of(5L, 7L, 8L));
+        when(documentMapper.listOverviewByFilters(any())).thenReturn(List.of());
+
+        Map<String, String> params = new HashMap<>();
+        params.put("keyword", "雷达");
+        params.put("keywordScope", "ALL");
+        params.put("securityLevel", "SECRET");
+        params.put("organizationId", "5");
+        queryService.listDocuments(params);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Map<String, Object>> captor =
+                ArgumentCaptor.forClass(Map.class);
+        verify(documentMapper).listOverviewByFilters(captor.capture());
+        Map<String, Object> filters = captor.getValue();
+        assertThat(filters.get("titleOrContentPattern")).isEqualTo("%雷达%");
+        assertThat(filters.get("securityLevel")).isEqualTo("SECRET");
+        assertThat(asObjectList(filters.get("organizationIds")))
+                .containsExactlyInAnyOrder(5L, 7L, 8L);
+    }
+
+    @Test
+    void listDocumentsRejectsAggregateWithContentScope() {
+        Map<String, String> params = new HashMap<>();
+        params.put("aggregateHits", "true");
+        params.put("keyword", "雷达");
+        params.put("keywordScope", "CONTENT");
+        assertThatThrownBy(() -> queryService.listDocuments(params))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("aggregateHits仅支持keywordScope为TITLE");
+    }
+
+    @Test
+    void contentLikeScopeBackfillsSnippetKeyword() {
+        DocumentEntity doc = new DocumentEntity();
+        doc.setId(1L);
+        doc.setDocumentType("MAIN");
+        doc.setDocumentStatus("PUBLISHED");
+        doc.setSecurityLevel("SECRET");
+        doc.setDocumentName("年度演练方案");
+        LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+        doc.setCreatedAt(now);
+        doc.setUpdatedAt(now);
+        when(documentMapper.listOverviewByFilters(any())).thenReturn(List.of(doc));
+        when(authorizationService.evaluatePlanAccess(any(DocumentEntity.class),
+                any(AuthContext.class), any(AccessMemo.class)))
+                .thenReturn(new PlanAccess(true, "PUBLIC", null, List.of(), true,
+                        Set.of("VIEW")));
+        when(documentMapper.listContentTextByIds(any())).thenReturn(List.of());
+
+        Map<String, String> params = new HashMap<>();
+        params.put("keyword", "雷达");
+        params.put("keywordScope", "ALL");
+        Map<String, Object> result = queryService.listDocuments(params);
+        assertThat((List<?>) result.get("items")).hasSize(1);
+        // 正文参与匹配(ALL):摘要正文补查以检索关键词触发
+        verify(documentMapper).listContentTextByIds(List.of(1L));
+    }
+
+    // ------------------------------------------------ 附件列表四维度接线
+
+    @Test
+    void attachmentListAppliesKeywordScopeSecurityLevelAndOrganization() {
+        AttachmentBindingMapper attachmentBindingMapper =
+                mock(AttachmentBindingMapper.class);
+        // 参数为原生 long:any() 返回 null 会拆箱 NPE,必须用 anyLong()
+        when(attachmentBindingMapper.countActiveByAttachment(anyLong()))
+                .thenReturn(0L);
+        when(documentMapper.listOverviewByFilters(any())).thenReturn(List.of());
+        when(authorizationService.organizationSubtreeIds(5L))
+                .thenReturn(Set.of(5L, 8L));
+        AttachmentQueryService attachmentService = new AttachmentQueryService(
+                attachmentBindingMapper, documentMapper, authorizationService,
+                queryService);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("keyword", "雷达");
+        params.put("keywordScope", "CONTENT");
+        params.put("securityLevel", "SECRET");
+        params.put("organizationId", "5");
+        attachmentService.listAttachments(params);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Map<String, Object>> captor =
+                ArgumentCaptor.forClass(Map.class);
+        verify(documentMapper).listOverviewByFilters(captor.capture());
+        Map<String, Object> filters = captor.getValue();
+        assertThat(filters.get("types")).isEqualTo(List.of("ATTACHMENT"));
+        assertThat(filters.get("contentPattern")).isEqualTo("%雷达%");
+        // 修复回归点:附件路单数 securityLevel 此前漏接线(密级筛选不生效)
+        assertThat(filters.get("securityLevel")).isEqualTo("SECRET");
+        assertThat(asObjectList(filters.get("organizationIds")))
+                .containsExactlyInAnyOrder(5L, 8L);
+    }
+
+    @Test
+    void attachmentListRejectsInvalidSecurityLevel() {
+        AttachmentQueryService attachmentService = new AttachmentQueryService(
+                mock(AttachmentBindingMapper.class), documentMapper,
+                authorizationService, queryService);
+        Map<String, String> params = new HashMap<>();
+        params.put("securityLevel", "TOPSECRET");
+        assertThatThrownBy(() -> attachmentService.listAttachments(params))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("securityLevel不是有效枚举值");
+    }
+
+    // ------------------------------------------------ 提供单位选项(filterOptions)
+
+    private static OrganizationEntity org(long id, String orgPath, String name,
+                                          Integer sortNo, String status) {
+        OrganizationEntity entity = new OrganizationEntity();
+        entity.setId(id);
+        entity.setParentId(null);
+        entity.setOrgCode("C" + id);
+        entity.setOrgName(name);
+        entity.setOrgPath(orgPath);
+        entity.setSortNo(sortNo);
+        entity.setStatus(status);
+        return entity;
+    }
+
+    private static OrganizationService organizationService(
+            List<OrganizationEntity> organizations) {
+        OrganizationMapper organizationMapper = mock(OrganizationMapper.class);
+        when(organizationMapper.listAllNotDeleted()).thenReturn(organizations);
+        return new OrganizationService(organizationMapper, mock(com.dms.mapper.DocumentMapper.class),
+                mock(AuditService.class), mock(AdminPermission.class),
+                mock(UserMapper.class));
+    }
+
+    @Test
+    void filterOptionsPicksEnabledOrgsAtConfiguredLevelSorted() {
+        OrganizationService service = organizationService(List.of(
+                org(1, "/1/", "总部", 1, "ENABLED"),
+                org(2, "/1/2/", "分局B", 2, "ENABLED"),
+                org(3, "/1/3/", "分局A", 1, "ENABLED"),
+                org(4, "/1/4/", "分局C", null, "ENABLED"),
+                org(5, "/1/5/", "停用分局", 1, "DISABLED")));
+        Map<String, Object> result = service.filterOptions(2);
+        assertThat(result.get("orgFilterLevel")).isEqualTo(2);
+        List<Map<String, Object>> organizations =
+                (List<Map<String, Object>>) result.get("organizations");
+        // 启用二级机构按 sortNo(null 最后)+ id 排序,停用与一级不入选项
+        assertThat(organizations).extracting(item -> item.get("id"))
+                .containsExactly(String.valueOf(3L), String.valueOf(2L), String.valueOf(4L));
+        assertThat(organizations).extracting(item -> item.get("orgName"))
+                .containsExactly("分局A", "分局B", "分局C");
+    }
+
+    @Test
+    void filterOptionsLevelOneReturnsRootsOnly() {
+        OrganizationService service = organizationService(List.of(
+                org(1, "/1/", "总部", 1, "ENABLED"),
+                org(2, "/1/2/", "分局B", 2, "ENABLED")));
+        Map<String, Object> result = service.filterOptions(1);
+        List<Map<String, Object>> organizations =
+                (List<Map<String, Object>>) result.get("organizations");
+        assertThat(organizations).hasSize(1);
+        assertThat(organizations.get(0).get("orgName")).isEqualTo("总部");
+    }
+}

+ 369 - 0
backend-java/src/test/java/com/dms/service/AttachmentPermissionSplitTest.java

@@ -0,0 +1,369 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.dms.common.DmsException;
+import com.dms.mapper.AttachmentBindingMapper;
+import com.dms.mapper.CategoryMapper;
+import com.dms.mapper.DocumentMapper;
+import com.dms.mapper.OrganizationMapper;
+import com.dms.mapper.PermissionMapper;
+import com.dms.mapper.RoleMapper;
+import com.dms.mapper.RolePermissionMapper;
+import com.dms.mapper.UserMapper;
+import com.dms.model.DocumentEntity;
+import com.dms.model.RoleEntity;
+import com.dms.model.RolePermissionEntity;
+import com.dms.model.UserEntity;
+import com.dms.security.AuthContext;
+import com.dms.security.Reauth;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 附件四权限(契约1.41/1.43):矩阵附件位(仅 OWN 行)独立判定——
+ * 附件查看/下载/编辑/上传不再跟随挂载主案权限与 ADMIN 类别,
+ * 仅内置超管恒可;删除维持 ADMIN 类别。
+ */
+class AttachmentPermissionSplitTest {
+
+    private final DocumentMapper documentMapper = mock(DocumentMapper.class);
+    private final PermissionMapper permissionMapper = mock(PermissionMapper.class);
+    private final AttachmentBindingMapper attachmentBindingMapper =
+            mock(AttachmentBindingMapper.class);
+    private final OrganizationMapper organizationMapper = mock(OrganizationMapper.class);
+    private final UserMapper userMapper = mock(UserMapper.class);
+    private final RoleMapper roleMapper = mock(RoleMapper.class);
+    private final RolePermissionMapper rolePermissionMapper = mock(RolePermissionMapper.class);
+
+    private final CategoryMapper categoryMapper = mock(CategoryMapper.class);
+
+    private final AuthorizationService service = new AuthorizationService(
+            documentMapper, categoryMapper, permissionMapper, attachmentBindingMapper,
+            organizationMapper, userMapper, roleMapper, rolePermissionMapper);
+
+    private static final AuthContext USER =
+            new AuthContext(9L, "alice", "张三", null, null, "USER", "PUBLIC", 0, null, 88L);
+    private static final AuthContext ADMIN =
+            new AuthContext(2L, "op", "管理员", 1L, "机关", "ADMIN", "PUBLIC", 0, null, 89L);
+    /** 内置超管(username=admin):附件四权限恒可(契约1.43)。 */
+    private static final AuthContext SUPER_ADMIN =
+            new AuthContext(1L, "admin", "超管", 1L, "机关", "ADMIN", "TOP_SECRET", 0, null, null);
+
+    @AfterEach
+    void clearContext() {
+        AuthContext.clear();
+    }
+
+    /** 会话角色(契约1.46)直达:effectiveRole 按 context.roleId 解析,
+     * 不再查 sys_user.role_id,stub 角色即可。 */
+    private void stubUserRole(long roleId) {
+        stubUserRole(9L, roleId);
+    }
+
+    private void stubUserRole(long userId, long roleId) {
+        RoleEntity role = new RoleEntity();
+        role.setId(roleId);
+        when(roleMapper.findActiveEnabledById(roleId)).thenReturn(role);
+    }
+
+    private static DocumentEntity attachment() {
+        DocumentEntity attachment = new DocumentEntity();
+        attachment.setId(500L);
+        attachment.setDocumentType("ATTACHMENT");
+        attachment.setAttachmentType("ACCOMPANYING_PLAN");
+        attachment.setSecurityLevel("PUBLIC");
+        return attachment;
+    }
+
+    private static RolePermissionEntity matrixRow(Boolean view, Boolean download,
+                                                  Boolean edit, Boolean upload) {
+        return matrixRow(view, download, edit, upload, false);
+    }
+
+    private static RolePermissionEntity matrixRow(Boolean view, Boolean download,
+                                                  Boolean edit, Boolean upload,
+                                                  Boolean delete) {
+        RolePermissionEntity row = new RolePermissionEntity();
+        row.setSecurityLevel("PUBLIC");
+        row.setDeptScope("OWN");
+        row.setCanAttachmentView(view);
+        row.setCanAttachmentDownload(download);
+        row.setCanAttachmentEdit(edit);
+        row.setCanAttachmentUpload(upload);
+        row.setCanAttachmentDelete(delete);
+        return row;
+    }
+
+    @Test
+    void matrixViewFlagGrantsAccessWithoutAnyMountedMain() {
+        // 无任何挂载主案:仅靠 OWN 行附件查看位即可查看(不跟随主案权限)
+        stubUserRole(88L);
+        // 契约1.48:未挂载附件落共享资料库行组(附件类型 × 密级,OWN)
+        when(attachmentBindingMapper.listActiveMainDocumentIds(500L)).thenReturn(List.of());
+        when(rolePermissionMapper.findLibraryMatrixRow(88L, "ACCOMPANYING_PLAN", "PUBLIC"))
+                .thenReturn(matrixRow(true, false, false, false));
+        AuthorizationService.AttachmentAccess access =
+                service.evaluateAttachmentAccess(attachment(), USER);
+        assertThat(access.canView()).isTrue();
+        assertThat(access.canDownload()).isFalse();
+    }
+
+    @Test
+    void matrixDownloadFlagGrantsAccessWithoutAttachmentView() {
+        // 仅下载位(无查看位、无挂载聚合路径):下载放行、查看拒绝
+        stubUserRole(88L);
+        when(rolePermissionMapper.findLibraryMatrixRow(88L, "ACCOMPANYING_PLAN", "PUBLIC"))
+                .thenReturn(matrixRow(false, true, false, false));
+        AuthorizationService.AttachmentAccess access =
+                service.evaluateAttachmentAccess(attachment(), USER);
+        assertThat(access.canView()).isFalse();
+        assertThat(access.canDownload()).isTrue();
+    }
+
+    @Test
+    void mountedMainPlanTopCategoryRowGrantsAttachment() {
+        // 契约1.48:已挂载附件按挂载主案的一级分类 × 附件类型 × 密级(OWN)行判定;
+        // 主案自身的 MAIN 行位(查看/下载)不参与附件判定
+        stubUserRole(88L);
+        DocumentEntity main = new DocumentEntity();
+        main.setId(1L);
+        main.setDocumentType("MAIN");
+        main.setCategoryId(11L);
+        com.dms.model.CategoryEntity top = new com.dms.model.CategoryEntity();
+        top.setId(11L);
+        top.setParentId(null);
+        when(attachmentBindingMapper.listActiveMainDocumentIds(500L)).thenReturn(List.of(1L));
+        when(documentMapper.findActiveMainById(1L)).thenReturn(main);
+        when(categoryMapper.findByIdNotDeleted(11L)).thenReturn(top);
+        // 主案 MAIN 行(附件类型维度外):附件判定不读取该行
+        RolePermissionEntity mainRow = new RolePermissionEntity();
+        mainRow.setSecurityLevel("SECRET");
+        mainRow.setDeptScope("OWN");
+        mainRow.setCanView(true);
+        mainRow.setCanDownload(true);
+        when(rolePermissionMapper.findMatrixRow(88L, 11L, "MAIN", "SECRET", "OWN"))
+                .thenReturn(mainRow);
+        when(rolePermissionMapper.findMatrixRow(
+                88L, 11L, "ACCOMPANYING_PLAN", "PUBLIC", "OWN"))
+                .thenReturn(matrixRow(true, false, false, false));
+        AuthorizationService.AttachmentAccess access =
+                service.evaluateAttachmentAccess(attachment(), USER);
+        assertThat(access.canView()).isTrue();
+        assertThat(access.canDownload()).isFalse();
+    }
+
+    @Test
+    void mountedAttachmentHonorsLibraryRowFallback() {
+        // 契约1.59 库行组兜底:已挂载附件除挂载主案一级分类行外,
+        // 同时认库行组行——分类行无位、库行组勾下载时下载放行
+        stubUserRole(88L);
+        DocumentEntity main = new DocumentEntity();
+        main.setId(1L);
+        main.setDocumentType("MAIN");
+        main.setCategoryId(11L);
+        com.dms.model.CategoryEntity top = new com.dms.model.CategoryEntity();
+        top.setId(11L);
+        top.setParentId(null);
+        when(attachmentBindingMapper.listActiveMainDocumentIds(500L)).thenReturn(List.of(1L));
+        when(documentMapper.findActiveMainById(1L)).thenReturn(main);
+        when(categoryMapper.findByIdNotDeleted(11L)).thenReturn(top);
+        // 分类行无任何附件位(迁移 V33 清零后的常态)
+        when(rolePermissionMapper.findMatrixRow(
+                88L, 11L, "ACCOMPANYING_PLAN", "PUBLIC", "OWN"))
+                .thenReturn(matrixRow(false, false, false, false));
+        // 库行组兜底命中下载位
+        when(rolePermissionMapper.findLibraryMatrixRow(88L, "ACCOMPANYING_PLAN", "PUBLIC"))
+                .thenReturn(matrixRow(false, true, false, false));
+        AuthorizationService.AttachmentAccess access =
+                service.evaluateAttachmentAccess(attachment(), USER);
+        assertThat(access.canView()).isFalse();
+        assertThat(access.canDownload()).isTrue();
+    }
+
+    @Test
+    void requirePlanActionLetsEditAndDeletePassViaMatrixFlags() {
+        stubUserRole(88L);
+        when(rolePermissionMapper.findLibraryMatrixRow(88L, "ACCOMPANYING_PLAN", "PUBLIC"))
+                .thenReturn(matrixRow(false, false, true, false, true));
+        assertThatCode(() -> service.requirePlanAction(attachment(), USER, "EDIT"))
+                .doesNotThrowAnyException();
+        // 契约1.44:矩阵附件删除位放行 DELETE
+        assertThatCode(() -> service.requirePlanAction(attachment(), USER, "DELETE"))
+                .doesNotThrowAnyException();
+        // 受限 ADMIN(契约1.43):无附件位时 EDIT/DELETE 均拒绝
+        stubUserRole(2L, 89L);
+        when(rolePermissionMapper.findLibraryMatrixRow(89L, "ACCOMPANYING_PLAN", "PUBLIC"))
+                .thenReturn(matrixRow(false, false, false, false));
+        assertThatThrownBy(() -> service.requirePlanAction(attachment(), ADMIN, "EDIT"))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("角色权限未授予该操作");
+        assertThatThrownBy(() -> service.requirePlanAction(attachment(), ADMIN, "DELETE"))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("角色权限未授予该操作");
+        assertThatCode(() -> service.requirePlanAction(attachment(), SUPER_ADMIN, "EDIT"))
+                .doesNotThrowAnyException();
+        assertThatCode(() -> service.requirePlanAction(attachment(), SUPER_ADMIN, "DELETE"))
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    void attachmentAllowedActionsIncludeDeleteViaMatrixFlag() {
+        stubUserRole(88L);
+        when(rolePermissionMapper.findLibraryMatrixRow(88L, "ACCOMPANYING_PLAN", "PUBLIC"))
+                .thenReturn(matrixRow(true, false, true, false, true));
+        assertThat(service.attachmentAllowedActions(USER, attachment(), null))
+                .containsExactly("VIEW", "EDIT", "DELETE");
+        // 超管恒全量四项
+        assertThat(service.attachmentAllowedActions(SUPER_ADMIN, attachment(), null))
+                .containsExactly("VIEW", "DOWNLOAD", "EDIT", "DELETE");
+        // 受限 ADMIN 按矩阵位(契约1.44:DELETE 也按位,不再类别专属)
+        assertThat(service.attachmentAllowedActions(ADMIN, attachment(), null))
+                .isEmpty();
+    }
+
+    @Test
+    void uploadAttachmentRequiresSuperAdminOrOwnRowFlag() {
+        // 超管恒可;受限 ADMIN 与 USER 同按矩阵上传位(契约1.43)
+        assertThat(service.canUploadAttachment(SUPER_ADMIN, "PUBLIC",
+                "ACCOMPANYING_PLAN")).isTrue();
+        stubUserRole(88L);
+        when(rolePermissionMapper.findLibraryMatrixRow(88L, "ACCOMPANYING_PLAN", "PUBLIC"))
+                .thenReturn(matrixRow(false, false, false, true));
+        assertThat(service.canUploadAttachment(USER, "PUBLIC", "ACCOMPANYING_PLAN")).isTrue();
+        assertThat(service.canUploadAttachment(ADMIN, "PUBLIC", "ACCOMPANYING_PLAN")).isFalse();
+        when(rolePermissionMapper.findLibraryMatrixRow(88L, "ACCOMPANYING_PLAN", "PUBLIC"))
+                .thenReturn(matrixRow(false, false, true, false));
+        assertThat(service.canUploadAttachment(USER, "PUBLIC", "ACCOMPANYING_PLAN")).isFalse();
+    }
+
+    @Test
+    void sessionFlagsCountOwnAttachmentRows() {
+        assertThat(service.hasAnyAttachmentLibraryPermission(null)).isFalse();
+        assertThat(service.hasAnyAttachmentUploadPermission(null)).isFalse();
+
+        when(rolePermissionMapper.countOwnAnyAttachment(88L)).thenReturn(1);
+        assertThat(service.hasAnyAttachmentLibraryPermission(88L)).isTrue();
+        when(rolePermissionMapper.countOwnAnyAttachment(89L)).thenReturn(0);
+        assertThat(service.hasAnyAttachmentLibraryPermission(89L)).isFalse();
+
+        when(rolePermissionMapper.countOwnAttachmentUpload(88L)).thenReturn(2);
+        assertThat(service.hasAnyAttachmentUploadPermission(88L)).isTrue();
+        when(rolePermissionMapper.countOwnAttachmentUpload(89L)).thenReturn(0);
+        assertThat(service.hasAnyAttachmentUploadPermission(89L)).isFalse();
+    }
+
+    @Test
+    void userSummarySessionFlagsFollowMatrixNotAdminCategory() {
+        AuthorizationService authorization = mock(AuthorizationService.class);
+        UserSummaryService summary = new UserSummaryService(
+                organizationMapper, roleMapper, mock(com.dms.mapper.UserRoleMapper.class),
+                authorization, mock(AdminPermission.class));
+
+        // 受限 ADMIN(契约1.43):无矩阵附件位时会话位为 false(不再按类别恒 true)
+        UserEntity adminUser = new UserEntity();
+        adminUser.setId(2L);
+        adminUser.setUsername("op");
+        adminUser.setRoleCode("ADMIN");
+        assertThat(summary.userSummary(adminUser, List.of()).get("permAttachmentLibrary"))
+                .isEqualTo(false);
+        assertThat(summary.userSummary(adminUser, List.of()).get("permAttachmentUpload"))
+                .isEqualTo(false);
+
+        // 内置超管恒 true
+        UserEntity superUser = new UserEntity();
+        superUser.setId(1L);
+        superUser.setUsername(AdminPermission.SUPER_ADMIN_USERNAME);
+        superUser.setRoleCode("ADMIN");
+        assertThat(summary.userSummary(superUser, List.of()).get("permAttachmentLibrary"))
+                .isEqualTo(true);
+        assertThat(summary.userSummary(superUser, List.of()).get("permAttachmentUpload"))
+                .isEqualTo(true);
+
+        UserEntity user = new UserEntity();
+        user.setId(9L);
+        user.setUsername("alice");
+        user.setRoleCode("USER");
+        RoleEntity role = new RoleEntity();
+        role.setId(88L);
+        AdminPermission adminPermission = mock(AdminPermission.class);
+        when(adminPermission.effectiveManageRole(user)).thenReturn(role);
+        UserSummaryService withRole = new UserSummaryService(
+                organizationMapper, roleMapper, mock(com.dms.mapper.UserRoleMapper.class),
+                authorization, adminPermission);
+        when(authorization.hasAnyAttachmentLibraryPermission(88L)).thenReturn(true);
+        when(authorization.hasAnyAttachmentUploadPermission(88L)).thenReturn(false);
+        assertThat(withRole.userSummary(user, List.of()).get("permAttachmentLibrary"))
+                .isEqualTo(true);
+        assertThat(withRole.userSummary(user, List.of()).get("permAttachmentUpload"))
+                .isEqualTo(false);
+    }
+
+    @Test
+    void matrixSaveRejectsAttachmentFlagsOnMainPlanRows() {
+        // 契约1.48:主体方案行(MAIN)不可设置附件位——交叉位服务端拒绝置真
+        com.dms.mapper.CategoryMapper categoryMapper =
+                mock(com.dms.mapper.CategoryMapper.class);
+        com.dms.model.CategoryEntity top = new com.dms.model.CategoryEntity();
+        top.setId(21L);
+        top.setParentId(null);
+        top.setFlatMode(false);
+        when(categoryMapper.listActiveOrdered()).thenReturn(List.of(top));
+        RoleService roleService = new RoleService(roleMapper, rolePermissionMapper,
+                categoryMapper, userMapper, mock(com.dms.mapper.UserRoleMapper.class),
+                mock(AuditService.class), mock(AdminPermission.class),
+                mock(Reauth.class));
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("rowVersion", 1);
+        List<Map<String, Object>> matrix = new ArrayList<>();
+        List<String> flags = List.of("canView", "canUpload", "canDownload", "canEdit",
+                "canDelete", "canConfigPermission", "canBindAttachment",
+                "canUnbindAttachment", "canAttachmentUpload", "canAttachmentEdit",
+                "canAttachmentView", "canAttachmentDownload", "canAttachmentDelete");
+        for (String level : List.of("PUBLIC", "INTERNAL", "SECRET", "CONFIDENTIAL", "TOP_SECRET")) {
+            // 主体方案行:OWN/OTHER 各一行
+            for (String scope : List.of("OWN", "OTHER")) {
+                Map<String, Object> row = new LinkedHashMap<>();
+                row.put("topCategoryId", "21");
+                row.put("docKind", "MAIN");
+                row.put("securityLevel", level);
+                row.put("deptScope", scope);
+                for (String flag : flags) {
+                    row.put(flag, false);
+                }
+                matrix.add(row);
+            }
+            // 分类下共享资料行(四类型,仅 OWN)+ 共享资料库行组(topCategoryId null)
+            for (String kind : List.of("ACCOMPANYING_PLAN", "SUPPORT_ACHIEVEMENT",
+                    "TRAINING_MATERIAL", "TYPICAL_CASE")) {
+                for (String topCategory : java.util.Arrays.asList("21", null)) {
+                    Map<String, Object> row = new LinkedHashMap<>();
+                    row.put("topCategoryId", topCategory);
+                    row.put("docKind", kind);
+                    row.put("securityLevel", level);
+                    row.put("deptScope", "OWN");
+                    for (String flag : flags) {
+                        row.put(flag, false);
+                    }
+                    matrix.add(row);
+                }
+            }
+        }
+        // 主体方案 OTHER 行附件位置真应被拒收(交叉位校验)
+        matrix.get(1).put("canAttachmentView", true);
+        body.put("matrix", matrix);
+        assertThatThrownBy(() -> roleService.updatePermissions(88L, body,
+                mock(jakarta.servlet.http.HttpServletRequest.class)))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "INVALID_ARGUMENT")
+                .hasMessage("主体方案行不可设置附件权限位");
+    }
+}

+ 171 - 0
backend-java/src/test/java/com/dms/service/AttachmentSecurityLevelTest.java

@@ -0,0 +1,171 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.dms.common.DeadlockRetry;
+import com.dms.common.DmsException;
+import com.dms.mapper.CategoryMapper;
+import com.dms.mapper.DocumentMapper;
+import com.dms.mapper.OrganizationMapper;
+import com.dms.mapper.PermissionMapper;
+import com.dms.mapper.AttachmentBindingMapper;
+import com.dms.model.DocumentEntity;
+import com.dms.security.AuthContext;
+import com.dms.storage.EnvelopeCodec;
+import com.dms.storage.StoragePaths;
+import com.dms.storage.UploadStorage;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import org.apache.ibatis.annotations.Update;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.transaction.support.TransactionCallback;
+import org.springframework.transaction.support.TransactionTemplate;
+import org.springframework.beans.factory.ObjectProvider;
+
+/**
+ * 附件密级开放(契约1.42):创建/编辑 metadata 均含必填 securityLevel,
+ * 编辑落库携带 security_level,密级跨域变更复用主案的域迁移(绝密要求 KEK)。
+ */
+class AttachmentSecurityLevelTest {
+
+    private final DocumentMapper documentMapper = mock(DocumentMapper.class);
+    private final CategoryMapper categoryMapper = mock(CategoryMapper.class);
+    private final OrganizationMapper organizationMapper = mock(OrganizationMapper.class);
+    private final PermissionMapper permissionMapper = mock(PermissionMapper.class);
+    private final AttachmentBindingMapper attachmentBindingMapper = mock(AttachmentBindingMapper.class);
+    private final AuditService auditService = mock(AuditService.class);
+    private final DocumentQueryService documentQueryService = mock(DocumentQueryService.class);
+    private final AttachmentQueryService attachmentQueryService = mock(AttachmentQueryService.class);
+    private final AuthorizationService authorizationService = mock(AuthorizationService.class);
+    private final DocumentContentService documentContentService = mock(DocumentContentService.class);
+    @SuppressWarnings("unchecked")
+    private final ObjectProvider<DocumentToPdfConverter> converterProvider = mock(ObjectProvider.class);
+    private final UploadStorage uploadStorage = mock(UploadStorage.class);
+    private final StoragePaths storagePaths = mock(StoragePaths.class);
+    private final EnvelopeCodec envelopeCodec = mock(EnvelopeCodec.class);
+    private final TransactionTemplate transactionTemplate = mock(TransactionTemplate.class);
+    private final DeadlockRetry deadlockRetry = mock(DeadlockRetry.class);
+
+    private final DocumentMutationService service = new DocumentMutationService(
+            documentMapper, categoryMapper, organizationMapper, permissionMapper,
+            attachmentBindingMapper, auditService, documentQueryService,
+            attachmentQueryService, authorizationService, documentContentService,
+            converterProvider, uploadStorage, storagePaths, envelopeCodec,
+            transactionTemplate, deadlockRetry, new ObjectMapper());
+
+    private static final AuthContext USER =
+            new AuthContext(9L, "alice", "张三", 1L, "机关", "USER", "PUBLIC", 0, null, null);
+
+    @BeforeEach
+    void setUp() {
+        AuthContext.set(USER);
+        when(transactionTemplate.execute(any())).thenAnswer(invocation ->
+                ((TransactionCallback<?>) invocation.getArgument(0))
+                        .doInTransaction(null));
+        when(auditService.businessAudit(any(), any(), any(), anyLong(), any(), any()))
+                .thenReturn(null);
+    }
+
+    @AfterEach
+    void clearContext() {
+        AuthContext.clear();
+    }
+
+    private static DocumentEntity attachment(String securityLevel) {
+        DocumentEntity document = new DocumentEntity();
+        document.setId(500L);
+        document.setDocumentType("ATTACHMENT");
+        document.setDocumentName("既有资料");
+        document.setAttachmentType("SUPPORT_ACHIEVEMENT");
+        document.setSecurityLevel(securityLevel);
+        document.setOrganizationId(1L);
+        document.setOrganizationName("机关");
+        document.setRowVersion(3);
+        return document;
+    }
+
+    private static Map<String, Object> payload(String securityLevel) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("documentName", "资料");
+        body.put("attachmentType", "SUPPORT_ACHIEVEMENT");
+        body.put("summary", null);
+        body.put("tags", java.util.List.of());
+        if (securityLevel != null) {
+            body.put("securityLevel", securityLevel);
+        }
+        body.put("rowVersion", 3);
+        return body;
+    }
+
+    private void stubLocked(String currentLevel) {
+        when(documentMapper.findAttachmentForUpdate(500L)).thenReturn(attachment(currentLevel));
+        when(documentMapper.findContentTextById(500L)).thenReturn(null);
+    }
+
+    @Test
+    void whitelistConstantsIncludeSecurityLevel() throws Exception {
+        Field create = DocumentMutationService.class.getDeclaredField("ATTACHMENT_CREATE_FIELDS");
+        create.setAccessible(true);
+        assertThat(String.valueOf(create.get(null))).contains("securityLevel");
+        Field edit = DocumentMutationService.class.getDeclaredField("ATTACHMENT_EDIT_FIELDS");
+        edit.setAccessible(true);
+        assertThat(String.valueOf(edit.get(null))).contains("securityLevel");
+    }
+
+    @Test
+    void updateAttachmentMetaSqlCarriesSecurityColumn() throws Exception {
+        Method method = DocumentMapper.class.getMethod("updateAttachmentMeta", DocumentEntity.class);
+        assertThat(String.join(" ", method.getAnnotation(Update.class).value()))
+                .contains("security_level = #{securityLevel}");
+    }
+
+    @Test
+    void updateAttachmentWritesSecurityLevelWithoutDomainMigration() {
+        // PUBLIC → SECRET 同属 general 域:仅落库,不迁移原件路径
+        stubLocked("PUBLIC");
+        service.updateAttachment(500L, payload("SECRET"), mock(HttpServletRequest.class));
+        ArgumentCaptor<DocumentEntity> captor = ArgumentCaptor.forClass(DocumentEntity.class);
+        verify(documentMapper).updateAttachmentMeta(captor.capture());
+        assertThat(captor.getValue().getSecurityLevel()).isEqualTo("SECRET");
+        assertThat(captor.getValue().getRowVersion()).isEqualTo(4);
+        verify(documentMapper, never()).updateFileRelativePath(anyLong(), any());
+    }
+
+    @Test
+    void updateAttachmentRejectsMissingSecurityLevel() {
+        stubLocked("PUBLIC");
+        // exactFields 全集校验先拒:securityLevel 成为必填字段
+        assertThatThrownBy(() -> service.updateAttachment(500L, payload(null),
+                mock(HttpServletRequest.class)))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("metadata字段必须且只能包含");
+        verify(documentMapper, never()).updateAttachmentMeta(any());
+    }
+
+    @Test
+    void updateAttachmentToTopSecretRequiresKek() {
+        // PUBLIC → TOP_SECRET 跨绝密域:未配置 KEK 拒绝(域迁移在落库后执行,由事务回滚兜底)
+        stubLocked("PUBLIC");
+        when(envelopeCodec.isConfigured()).thenReturn(false);
+        assertThatThrownBy(() -> service.updateAttachment(500L, payload("TOP_SECRET"),
+                mock(HttpServletRequest.class)))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "STORAGE_KEK_REQUIRED")
+                .hasMessageContaining("调整为绝密级要求先配置存储加密密钥");
+    }
+}

+ 97 - 0
backend-java/src/test/java/com/dms/service/AuthRealNameLoginTest.java

@@ -0,0 +1,97 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.dms.common.DmsException;
+import com.dms.config.DmsProperties;
+import com.dms.mapper.UserMapper;
+import com.dms.mapper.UserRoleMapper;
+import com.dms.model.UserEntity;
+import com.dms.security.JwtTokens;
+import com.dms.security.Passwords;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.env.MockEnvironment;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+/**
+ * 姓名登录回退(契约1.47):用户名与姓名同语义——账号未命中时按姓名匹配
+ * (唯一命中才放行);重名歧义一律按凭证错误处理,不泄露重名情况。
+ */
+class AuthRealNameLoginTest {
+
+    private static DmsProperties props() {
+        return new DmsProperties(new MockEnvironment()
+                .withProperty("DMS_JWT_SECRET", "real-name-test-secret"));
+    }
+
+    /** 可登录用户(id=7,账号 multi,姓名 张三,密码 Passw0rd!)。 */
+    private static UserEntity user() {
+        UserEntity user = new UserEntity();
+        user.setId(7L);
+        user.setUsername("multi");
+        user.setRealName("张三");
+        user.setPasswordHash(Passwords.hash("Passw0rd!"));
+        user.setRoleCode("USER");
+        user.setSecurityLevel("PUBLIC");
+        user.setStatus("ENABLED");
+        user.setAuthVersion(0);
+        user.setRowVersion(0);
+        return user;
+    }
+
+    private static AuthService service(UserMapper userMapper) {
+        return new AuthService(userMapper, mock(UserRoleMapper.class),
+                mock(AuditService.class), mock(LoginIpService.class),
+                mock(UserSummaryService.class), mock(LoginLockoutService.class),
+                mock(CaptchaService.class), new JwtTokens.Holder(props()), props(), mock(PortalAuthService.class));
+    }
+
+    @Test
+    void loginFallsBackToUniqueRealName() {
+        UserMapper userMapper = mock(UserMapper.class);
+        when(userMapper.findActiveByUsername("张三")).thenReturn(null);
+        when(userMapper.listActiveByRealName("张三")).thenReturn(List.of(user()));
+
+        Map<String, Object> data = service(userMapper).login(
+                "张三", "Passw0rd!", false, null, null, null,
+                new MockHttpServletRequest("POST", "/api/v1/auth/login"));
+
+        assertThat(data.get("accessToken")).isNotNull();
+    }
+
+    @Test
+    void duplicateRealNameIsRejectedWithoutLeak() {
+        UserMapper userMapper = mock(UserMapper.class);
+        when(userMapper.findActiveByUsername("张三")).thenReturn(null);
+        // 重名歧义:锁定任何人都有冒名风险,一律按凭证错误出口
+        when(userMapper.listActiveByRealName("张三"))
+                .thenReturn(List.of(user(), user()));
+
+        assertThatThrownBy(() -> service(userMapper).login(
+                "张三", "Passw0rd!", false, null, null, null,
+                new MockHttpServletRequest("POST", "/api/v1/auth/login")))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "INVALID_CREDENTIALS");
+    }
+
+    @Test
+    void usernameStillWinsOverRealName() {
+        UserMapper userMapper = mock(UserMapper.class);
+        // 账号命中后不再触发姓名回退(同名他人无法借账号名登录他人账号)
+        UserEntity user = user();
+        when(userMapper.findActiveByUsername("multi")).thenReturn(user);
+
+        Map<String, Object> data = service(userMapper).login(
+                "multi", "Passw0rd!", false, null, null, null,
+                new MockHttpServletRequest("POST", "/api/v1/auth/login"));
+
+        assertThat(data.get("accessToken")).isNotNull();
+        org.mockito.Mockito.verify(userMapper, org.mockito.Mockito.never())
+                .listActiveByRealName(org.mockito.ArgumentMatchers.any());
+    }
+}

+ 16 - 7
backend-java/src/test/java/com/dms/service/AuthorizationServicePlanSplitTest.java

@@ -8,6 +8,7 @@ import static org.mockito.Mockito.when;
 
 import com.dms.common.DmsException;
 import com.dms.mapper.AttachmentBindingMapper;
+import com.dms.mapper.CategoryMapper;
 import com.dms.mapper.DocumentMapper;
 import com.dms.mapper.OrganizationMapper;
 import com.dms.mapper.PermissionMapper;
@@ -41,8 +42,10 @@ class AuthorizationServicePlanSplitTest {
     private final RoleMapper roleMapper = mock(RoleMapper.class);
     private final RolePermissionMapper rolePermissionMapper = mock(RolePermissionMapper.class);
 
+    private final CategoryMapper categoryMapper = mock(CategoryMapper.class);
+
     private final AuthorizationService service = new AuthorizationService(
-            documentMapper, permissionMapper, attachmentBindingMapper,
+            documentMapper, categoryMapper, permissionMapper, attachmentBindingMapper,
             organizationMapper, userMapper, roleMapper, rolePermissionMapper);
 
     // ------------------------------------------------------------ 固定场景
@@ -55,13 +58,14 @@ class AuthorizationServicePlanSplitTest {
         doc.setDocumentStatus("PUBLISHED");
         doc.setSecurityLevel("SECRET");
         doc.setOrganizationId(10L);
+        doc.setCategoryId(21L);
         return doc;
     }
 
-    /** USER 角色用户(id=9),机构 10,密级 SECRET(满足文档密级)。 */
+    /** USER 角色用户(id=9),机构 10,密级 SECRET(满足文档密级),会话角色100。 */
     private AuthContext userContext() {
         return new AuthContext(9L, "editor", "编辑用户", 10L, "本部门",
-                "USER", "SECRET", 1, null);
+                "USER", "SECRET", 1, null, 100L);
     }
 
     /** 矩阵行(角色100 × SECRET × OWN),按入参开位。 */
@@ -78,9 +82,9 @@ class AuthorizationServicePlanSplitTest {
         return row;
     }
 
-    /** 生效角色100、用户与文档同机构(OWN),矩阵行按入参生效。 */
+    /** 生效角色100(会话角色直达,不查库)、用户与文档同机构(OWN)、
+     *  文档一级分类 21(契约1.48),矩阵行按入参生效。 */
     private void stubUserRoleOrgAndMatrix(RolePermissionEntity row) {
-        when(userMapper.findRoleIdActive(9L)).thenReturn(100L);
         RoleEntity role = new RoleEntity();
         role.setId(100L);
         when(roleMapper.findActiveEnabledById(100L)).thenReturn(role);
@@ -88,7 +92,12 @@ class AuthorizationServicePlanSplitTest {
         org.setId(10L);
         org.setOrgPath("/root/dept-a/");
         when(organizationMapper.findByIdNotDeleted(10L)).thenReturn(org);
-        when(rolePermissionMapper.findMatrixRow(100L, "SECRET", "OWN")).thenReturn(row);
+        com.dms.model.CategoryEntity top = new com.dms.model.CategoryEntity();
+        top.setId(21L);
+        top.setParentId(null);
+        when(categoryMapper.findByIdNotDeleted(21L)).thenReturn(top);
+        when(rolePermissionMapper.findMatrixRow(100L, 21L, "MAIN", "SECRET", "OWN"))
+                .thenReturn(row);
     }
 
     /** 借阅 ACL 行:本用户、限时生效、按入参开位。 */
@@ -189,7 +198,7 @@ class AuthorizationServicePlanSplitTest {
         stubUserRoleOrgAndMatrix(matrix(false, false, true, false));
         DocumentEntity doc = mainDoc();
         AuthContext context = new AuthContext(9L, "editor", "低密级用户", 10L,
-                "本部门", "USER", "INTERNAL", 1, null);
+                "本部门", "USER", "INTERNAL", 1, null, 100L);
 
         assertThatThrownBy(() -> service.requirePlanAction(doc, context, "EDIT"))
                 .isInstanceOf(DmsException.class)

+ 79 - 0
backend-java/src/test/java/com/dms/service/BorrowRequestCreateGuardTest.java

@@ -0,0 +1,79 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import com.dms.common.DmsException;
+import com.dms.mapper.BorrowRequestMapper;
+import com.dms.mapper.DocumentMapper;
+import com.dms.mapper.NotificationMapper;
+import com.dms.mapper.OrganizationMapper;
+import com.dms.mapper.PermissionMapper;
+import com.dms.mapper.UserMapper;
+import com.dms.security.AuthContext;
+import com.dms.security.Reauth;
+import com.dms.storage.StoragePaths;
+import java.util.Map;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockMultipartFile;
+
+/**
+ * 调阅申请提交守卫(契约1.66):不再要求二次认证(reauth 不参与申请提交,
+ * 审批动作仍保留),审批单(PDF)必传——无凭证直接 400 不受理。
+ */
+class BorrowRequestCreateGuardTest {
+
+    @AfterEach
+    void clearContext() {
+        AuthContext.clear();
+    }
+
+    private static BorrowRequestService service(Reauth reauth) {
+        return new BorrowRequestService(mock(DocumentMapper.class),
+                mock(PermissionMapper.class), mock(UserMapper.class),
+                mock(OrganizationMapper.class), mock(BorrowRequestMapper.class),
+                mock(NotificationMapper.class), mock(AuthorizationService.class),
+                mock(AuditService.class), reauth, mock(VoucherService.class),
+                mock(StoragePaths.class));
+    }
+
+    @Test
+    void missingVoucherIsRejected() {
+        Reauth reauth = mock(Reauth.class);
+        Map<String, Object> payload = Map.of(
+                "entries", java.util.List.of(Map.of(
+                        "subjectId", "1", "canView", true, "canDownload", true)),
+                "expiresAt", "2026-10-05T00:00:00Z");
+
+        assertThatThrownBy(() -> service(reauth).createRequest(
+                9L, payload, null, new MockHttpServletRequest("POST", "/api/v1/documents/9/borrow-requests")))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("必须上传审批单");
+
+        assertThatThrownBy(() -> service(reauth).createRequest(
+                9L, payload, new MockMultipartFile("file", "v.pdf",
+                        "application/pdf", new byte[0]),
+                new MockHttpServletRequest("POST", "/api/v1/documents/9/borrow-requests")))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("必须上传审批单");
+    }
+
+    @Test
+    void createNoLongerRequiresReauth() {
+        Reauth reauth = mock(Reauth.class);
+        Map<String, Object> payload = Map.of(
+                "entries", java.util.List.of(Map.of(
+                        "subjectId", "1", "canView", true, "canDownload", true)),
+                "expiresAt", "2026-10-05T00:00:00Z");
+
+        // 无审批单即 400 拒绝,且全程未触发二次认证(申请提交不再 reauth)
+        assertThatThrownBy(() -> service(reauth).createRequest(
+                9L, payload, null, new MockHttpServletRequest("POST", "/api/v1/documents/9/borrow-requests")))
+                .isInstanceOf(DmsException.class);
+        verifyNoInteractions(reauth);
+    }
+}

+ 170 - 0
backend-java/src/test/java/com/dms/service/BorrowSettingsServiceTest.java

@@ -0,0 +1,170 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.dms.common.DeadlockRetry;
+import com.dms.common.DmsException;
+import com.dms.config.DmsProperties;
+import com.dms.mapper.SettingMapper;
+import com.dms.model.SettingEntity;
+import com.dms.security.AuthContext;
+import com.dms.storage.StoragePaths;
+import com.dms.storage.UploadStorage;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.function.Supplier;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockMultipartFile;
+
+/**
+ * 审批相关设置(契约1.54):调阅说明保存(可清空、长度上限)与调阅
+ * 审批单模板上传(仅 PDF、非空、大小上限)、清除与下载(未上传 404)。
+ */
+class BorrowSettingsServiceTest {
+
+    @TempDir
+    Path tempDir;
+
+    private SettingMapper settingMapper;
+    private StoragePaths storagePaths;
+    private UploadStorage uploadStorage;
+    private DmsProperties properties;
+    private BorrowSettingsService service;
+
+    @BeforeEach
+    void setUp() {
+        settingMapper = mock(SettingMapper.class);
+        storagePaths = mock(StoragePaths.class);
+        uploadStorage = mock(UploadStorage.class);
+        properties = mock(DmsProperties.class);
+        when(properties.maxFileSize()).thenReturn(1024L * 1024L);
+        DeadlockRetry deadlockRetry = mock(DeadlockRetry.class);
+        when(deadlockRetry.execute(any())).thenAnswer(invocation ->
+                ((Supplier<?>) invocation.getArgument(0)).get());
+        service = new BorrowSettingsService(settingMapper, storagePaths,
+                uploadStorage, properties, mock(AuditService.class), deadlockRetry);
+        when(storagePaths.resolveStoragePath(BorrowSettingsService.TEMPLATE_PATH))
+                .thenReturn(tempDir.resolve("borrow-template.pdf"));
+        AuthContext.set(new AuthContext(1L, "op", "操作员", null, null,
+                "ADMIN", "TOP_SECRET", 0, null, null));
+    }
+
+    @AfterEach
+    void clearContext() {
+        AuthContext.clear();
+    }
+
+    @Test
+    void updateNoticeUpsertsAndAcceptsBlankAsClear() {
+        when(settingMapper.updateValue(eq("borrow.notice"), eq("调阅须知"),
+                any(LocalDateTime.class), eq(1L))).thenReturn(1);
+
+        Map<String, Object> data = service.updateNotice("调阅须知",
+                new MockHttpServletRequest("PUT", "/api/v1/borrow-settings"));
+
+        assertThat(data.get("notice")).isEqualTo("调阅须知");
+        verify(settingMapper).updateValue(eq("borrow.notice"), eq("调阅须知"),
+                any(LocalDateTime.class), eq(1L));
+        verify(settingMapper, never()).insert(any(), any(), any(), any(),
+                any(), anyLong());
+
+        // 空白即清空(落 NULL 值行),非字符串拒绝
+        service.updateNotice("  ", new MockHttpServletRequest("PUT", ""));
+        verify(settingMapper).updateValue(eq("borrow.notice"), eq(null),
+                any(LocalDateTime.class), eq(1L));
+        assertThatThrownBy(() -> service.updateNotice(Map.of(),
+                new MockHttpServletRequest("PUT", "")))
+                .isInstanceOf(DmsException.class)
+                .hasMessage("notice必须是字符串");
+    }
+
+    @Test
+    void updateNoticeRejectsOverlongText() {
+        assertThatThrownBy(() -> service.updateNotice("长".repeat(4001),
+                new MockHttpServletRequest("PUT", "")))
+                .isInstanceOf(DmsException.class)
+                .hasMessage("调阅说明不能超过4000字");
+    }
+
+    @Test
+    void uploadTemplateAcceptsPdfAndWritesFileAndMeta() throws Exception {
+        when(settingMapper.updateFileMeta(eq("borrow.template"), eq("调阅审批单模板.pdf"),
+                eq(3L), any(LocalDateTime.class), eq(1L))).thenReturn(1);
+        MockMultipartFile file = new MockMultipartFile("file",
+                "调阅审批单模板.pdf", "application/pdf", new byte[]{1, 2, 3});
+
+        Map<String, Object> data = service.uploadTemplate(file,
+                new MockHttpServletRequest("POST", "/api/v1/borrow-settings/template"));
+
+        assertThat(data.get("templateFileName")).isEqualTo("调阅审批单模板.pdf");
+        // 固定路径落盘 + 元数据行 upsert
+        assertThat(Files.size(tempDir.resolve("borrow-template.pdf"))).isEqualTo(3);
+        verify(settingMapper).updateFileMeta(eq("borrow.template"),
+                eq("调阅审批单模板.pdf"), eq(3L), any(LocalDateTime.class), eq(1L));
+        verify(settingMapper, never()).insert(any(), any(), any(), any(),
+                any(), anyLong());
+        // 上传成功后临时文件不留残
+        try (var list = Files.list(tempDir)) {
+            assertThat(list.count()).isEqualTo(1);
+        }
+    }
+
+    @Test
+    void uploadTemplateRejectsNonPdfAndEmpty() {
+        MockMultipartFile text = new MockMultipartFile("file",
+                "template.txt", "text/plain", new byte[]{1});
+        assertThatThrownBy(() -> service.uploadTemplate(text,
+                new MockHttpServletRequest("POST", "")))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "UNSUPPORTED_FILE_TYPE")
+                .hasMessage("调阅审批单模板只允许PDF文件");
+
+        assertThatThrownBy(() -> service.uploadTemplate(null,
+                new MockHttpServletRequest("POST", "")))
+                .isInstanceOf(DmsException.class)
+                .hasMessage("不允许上传空文件");
+    }
+
+    @Test
+    void deleteTemplateRemovesRowAndFile() throws Exception {
+        Files.writeString(tempDir.resolve("borrow-template.pdf"), "old");
+
+        Map<String, Object> data = service.deleteTemplate(
+                new MockHttpServletRequest("DELETE", ""));
+
+        assertThat(data.get("templateFileName")).isNull();
+        verify(settingMapper).deleteByKey("borrow.template");
+        assertThat(Files.exists(tempDir.resolve("borrow-template.pdf"))).isFalse();
+    }
+
+    @Test
+    void downloadTemplateRequiresUploadedFile() {
+        // 未上传元数据行:404
+        assertThatThrownBy(() -> service.downloadTemplate())
+                .isInstanceOf(DmsException.class)
+                .hasMessage("调阅审批单模板未上传");
+
+        // 有元数据行但文件缺失:同样 404(库文件不一致按未上传处理)
+        SettingEntity meta = new SettingEntity();
+        meta.setSettingKey("borrow.template");
+        meta.setFileName("调阅审批单模板.pdf");
+        when(settingMapper.findByKey("borrow.template")).thenReturn(meta);
+        assertThatThrownBy(() -> service.downloadTemplate())
+                .isInstanceOf(DmsException.class)
+                .hasMessage("调阅审批单模板未上传");
+    }
+}

+ 12 - 4
backend-java/src/test/java/com/dms/service/CaptchaServiceTest.java

@@ -18,12 +18,18 @@ class CaptchaServiceTest {
 
     private static CaptchaService service(boolean enabled) {
         MockEnvironment environment = new MockEnvironment();
-        if (!enabled) {
-            environment.setProperty("DMS_CAPTCHA_ENABLED", "false");
-        }
+        // 显式设置:缺省值自 1.40 起改为 false,用例不再隐式依赖缺省
+        environment.setProperty("DMS_CAPTCHA_ENABLED", enabled ? "true" : "false");
         return new CaptchaService(new DmsProperties(environment));
     }
 
+    @Test
+    void enabledDefaultsToFalse() {
+        // 契约1.40:未配置时验证码缺省关闭,登录回归三键请求
+        assertThat(new CaptchaService(new DmsProperties(new MockEnvironment())).enabled())
+                .isFalse();
+    }
+
     @Test
     void issueReturnsCaptchaIdAndPngDataUrl() {
         CaptchaService captchaService = service(true);
@@ -56,7 +62,9 @@ class CaptchaServiceTest {
         var data = captchaService.issue();
         String id = (String) data.get("captchaId");
         String code = captchaService.entriesForTest().get(id);
-        assertThatThrownBy(() -> captchaService.verify(id, "X" + code.substring(1)))
+        // 替换首字符构造错误码:首字符随机可能恰为 X(1/31 偶发撞码),换字符保证必不相同
+        char replacement = code.charAt(0) == 'X' ? 'Y' : 'X';
+        assertThatThrownBy(() -> captchaService.verify(id, replacement + code.substring(1)))
                 .isInstanceOf(DmsException.class);
         assertThatThrownBy(() -> captchaService.verify(id, code))
                 .isInstanceOf(DmsException.class);

+ 89 - 0
backend-java/src/test/java/com/dms/service/DocumentPasswordMergeTest.java

@@ -0,0 +1,89 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.dms.common.DmsException;
+import com.dms.model.DocumentEntity;
+import com.dms.security.AuthContext;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 方案锁挂载/解除挂载合并(契约1.41):勾选位收敛为 6 项,挂载位同保护
+ * 挂载与解除挂载(操作码仍区分以保持审计粒度),UNBIND 不再是合法勾选码。
+ */
+class DocumentPasswordMergeTest {
+
+    private static final AuthContext USER =
+            new AuthContext(9L, "alice", "张三", 1L, "机关", "USER", "PUBLIC", 0, null, null);
+
+    private static DocumentEntity lockedMain(boolean protectBind, boolean protectUnbind) {
+        DocumentEntity document = new DocumentEntity();
+        document.setId(100L);
+        document.setPasswordHash("hash");
+        document.setProtectBindAttachment(protectBind);
+        document.setProtectUnbindAttachment(protectUnbind);
+        return document;
+    }
+
+    @Test
+    void actionOrderShrinksToSixWithoutUnbind() {
+        assertThat(DocumentPasswordService.ACTION_ORDER).containsExactly(
+                "VIEW", "DOWNLOAD", "EDIT", "DELETE",
+                "CONFIG_PERMISSION", "BIND_ATTACHMENT");
+    }
+
+    @Test
+    void parseRejectsUnbindCode() {
+        // UNBIND_ATTACHMENT 不再是合法勾选码:提交即 400 未知操作码
+        assertThatThrownBy(() -> DocumentPasswordService.parseProtectedActions(
+                List.of("VIEW", "UNBIND_ATTACHMENT")))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "INVALID_ARGUMENT")
+                .hasMessage("protectedActions包含未知操作码:UNBIND_ATTACHMENT");
+        // 结构性错误消息同步提示合并语义
+        assertThatThrownBy(() -> DocumentPasswordService.parseProtectedActions(List.of()))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("挂载与解除挂载已合并为挂载位");
+    }
+
+    @Test
+    void parseAcceptsSixCodesAndStableOrder() {
+        assertThat(DocumentPasswordService.parseProtectedActions(
+                List.of("BIND_ATTACHMENT", "VIEW"))).containsExactly("VIEW", "BIND_ATTACHMENT");
+    }
+
+    @Test
+    void bindFlagProtectsBothBindAndUnbindOperations() {
+        DocumentEntity document = lockedMain(true, false);
+        assertThat(DocumentPasswordService.passwordRequiredFor(document, "BIND_ATTACHMENT", USER))
+                .isTrue();
+        assertThat(DocumentPasswordService.passwordRequiredFor(document, "UNBIND_ATTACHMENT", USER))
+                .isTrue();
+        // 未勾挂载位:两类操作均无需密码
+        DocumentEntity unlocked = lockedMain(false, true);
+        assertThat(DocumentPasswordService.passwordRequiredFor(unlocked, "BIND_ATTACHMENT", USER))
+                .isFalse();
+        assertThat(DocumentPasswordService.passwordRequiredFor(unlocked, "UNBIND_ATTACHMENT", USER))
+                .isFalse();
+    }
+
+    @Test
+    void protectedActionsOfNeverEmitsUnbindEvenWithLegacyColumnSet() {
+        // 存量 unbind 位列残留真值(V25 迁移前语义):序列化仍只输出 6 码
+        assertThat(DocumentPasswordService.protectedActionsOf(lockedMain(true, true)))
+                .containsExactly("BIND_ATTACHMENT");
+    }
+
+    @Test
+    void applyPasswordLockAlwaysClearsUnbindColumn() {
+        DocumentEntity document = new DocumentEntity();
+        document.setProtectUnbindAttachment(true);
+        DocumentPasswordService.applyPasswordLock(document, "pw",
+                List.of("VIEW", "BIND_ATTACHMENT"));
+        assertThat(document.getProtectView()).isTrue();
+        assertThat(document.getProtectBindAttachment()).isTrue();
+        assertThat(document.getProtectUnbindAttachment()).isFalse();
+    }
+}

+ 236 - 0
backend-java/src/test/java/com/dms/service/EditAccessIsolationTest.java

@@ -0,0 +1,236 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.dms.common.DeadlockRetry;
+import com.dms.common.DmsException;
+import com.dms.common.Errors;
+import com.dms.config.DmsProperties;
+import com.dms.mapper.AttachmentBindingMapper;
+import com.dms.mapper.CategoryMapper;
+import com.dms.mapper.DocumentMapper;
+import com.dms.mapper.DocumentRelationMapper;
+import com.dms.mapper.SubPlanBindingMapper;
+import com.dms.model.DocumentEntity;
+import com.dms.security.AuthContext;
+import com.dms.service.AuthorizationService.AttachmentAccess;
+import com.dms.service.AuthorizationService.PlanAccess;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.transaction.support.TransactionTemplate;
+
+/**
+ * 操作位隔离补全(编辑视图):无查看权但具备任一操作位的用户,详情、
+ * 附件详情与关联列表放行元数据视图——正文不下发、查看计数与查看审计
+ * 不记录;无任何操作位与密级不足维持拒绝。写路径判定不受影响。
+ */
+class EditAccessIsolationTest {
+
+    private final DocumentMapper documentMapper = mock(DocumentMapper.class);
+    private final AttachmentBindingMapper attachmentBindingMapper =
+            mock(AttachmentBindingMapper.class);
+    private final CategoryMapper categoryMapper = mock(CategoryMapper.class);
+    private final SubPlanBindingMapper subPlanBindingMapper = mock(SubPlanBindingMapper.class);
+    private final AuthorizationService authorizationService = mock(AuthorizationService.class);
+    private final AuditService auditService = mock(AuditService.class);
+    private final TransactionTemplate transactionTemplate = mock(TransactionTemplate.class);
+
+    private final DocumentQueryService queryService = new DocumentQueryService(
+            documentMapper, attachmentBindingMapper, categoryMapper, subPlanBindingMapper,
+            authorizationService, auditService, new ObjectMapper(), transactionTemplate);
+
+    @BeforeEach
+    void setUp() {
+        AuthContext.set(new AuthContext(9L, "editor", "编辑用户", 10L, "本部门",
+                "USER", "SECRET", 1, null, 100L));
+    }
+
+    @AfterEach
+    void clearContext() {
+        AuthContext.clear();
+    }
+
+    /** 已发布 MAIN 主案,无方案密码,查看计数 7、rowVersion 3、含正文。 */
+    private DocumentEntity mainDoc() {
+        DocumentEntity doc = new DocumentEntity();
+        doc.setId(1L);
+        doc.setDocumentType("MAIN");
+        doc.setDocumentStatus("PUBLISHED");
+        doc.setSecurityLevel("SECRET");
+        doc.setDocumentName("年度演练方案");
+        doc.setOrganizationId(10L);
+        doc.setCategoryId(21L);
+        doc.setRowVersion(3);
+        doc.setViewCount(7L);
+        doc.setContentText("机密正文内容");
+        java.time.LocalDateTime now = java.time.LocalDateTime.now(java.time.ZoneOffset.UTC);
+        doc.setCreatedAt(now);
+        doc.setUpdatedAt(now);
+        return doc;
+    }
+
+    /** 无查看权、矩阵命中 EDIT 的访问结论(reason=ACL 借贷语义)。 */
+    private PlanAccess editOnlyAccess(DocumentEntity doc) {
+        return new PlanAccess(false, "ACL", doc, List.of(), false, Set.of("EDIT"));
+    }
+
+    // ------------------------------------------------------------ 文档详情
+
+    @Test
+    void documentDetailServesMetadataViewForEditOnlyUser() {
+        DocumentEntity doc = mainDoc();
+        PlanAccess access = editOnlyAccess(doc);
+        when(documentMapper.findActivePlanById(1L)).thenReturn(doc);
+        when(authorizationService.evaluatePlanAccess(doc, AuthContext.get())).thenReturn(access);
+        when(authorizationService.planAllowedActions(doc, AuthContext.get(), access))
+                .thenReturn(List.of("EDIT"));
+        when(authorizationService.detailAccessAllowed(doc, AuthContext.get(), access))
+                .thenReturn(true);
+
+        var result = queryService.getDocument(1L,
+                new MockHttpServletRequest("GET", "/api/v1/documents/1"));
+
+        assertThat(result.get("rowVersion")).isEqualTo(3);
+        // 编辑视图:正文属查看类内容不下发;查看计数仅回显
+        assertThat(result.get("contentText")).isNull();
+        assertThat(result.get("viewCount")).isEqualTo(7L);
+        verify(documentMapper, never()).incrementViewCount(1L);
+        verify(auditService, never()).insert(any());
+    }
+
+    @Test
+    void documentDetailStillForbiddenWithoutAnyAction() {
+        DocumentEntity doc = mainDoc();
+        PlanAccess access = new PlanAccess(false, "ACL", doc, List.of(), false, Set.of());
+        when(documentMapper.findActivePlanById(1L)).thenReturn(doc);
+        when(authorizationService.evaluatePlanAccess(doc, AuthContext.get())).thenReturn(access);
+        when(authorizationService.planAllowedActions(doc, AuthContext.get(), access))
+                .thenReturn(List.of());
+
+        assertThatThrownBy(() -> queryService.getDocument(1L,
+                        new MockHttpServletRequest("GET", "/api/v1/documents/1")))
+                .isInstanceOf(DmsException.class)
+                .hasMessage("无权查看该文档");
+    }
+
+    @Test
+    void documentDetailStillForbiddenForSecurityLevel() {
+        DocumentEntity doc = mainDoc();
+        PlanAccess access = new PlanAccess(false, "SECURITY", doc, List.of(), false, Set.of());
+        when(documentMapper.findActivePlanById(1L)).thenReturn(doc);
+        when(authorizationService.evaluatePlanAccess(doc, AuthContext.get())).thenReturn(access);
+
+        assertThatThrownBy(() -> queryService.getDocument(1L,
+                        new MockHttpServletRequest("GET", "/api/v1/documents/1")))
+                .isInstanceOf(DmsException.class)
+                .hasMessage(Errors.securityLevelForbidden().getMessage());
+    }
+
+    // ------------------------------------------------------------ 关联列表
+
+    @Test
+    void relationsReadableInEditView(@TempDir Path tempDir) throws Exception {
+        DmsProperties properties = mock(DmsProperties.class);
+        when(properties.documentRelationPresetConfigPath())
+                .thenReturn(tempDir.resolve("relation-presets.json"));
+        java.nio.file.Files.writeString(tempDir.resolve("relation-presets.json"),
+                "{\"presets\":[{\"code\":\"BASIS\",\"label\":\"依据\",\"sortNo\":10}]}",
+                StandardCharsets.UTF_8);
+        DocumentRelationMapper relationMapper = mock(DocumentRelationMapper.class);
+        DocumentRelationService relationService = new DocumentRelationService(
+                documentMapper, relationMapper, queryService, authorizationService,
+                auditService, mock(DeadlockRetry.class), properties);
+
+        DocumentEntity doc = mainDoc();
+        PlanAccess access = editOnlyAccess(doc);
+        when(documentMapper.findActivePlanById(1L)).thenReturn(doc);
+        when(authorizationService.evaluatePlanAccess(doc, AuthContext.get())).thenReturn(access);
+        when(authorizationService.planAllowedActions(doc, AuthContext.get(), access))
+                .thenReturn(List.of("EDIT"));
+        when(authorizationService.detailAccessAllowed(doc, AuthContext.get(), access))
+                .thenReturn(true);
+        when(relationMapper.listRelationItems(1L)).thenReturn(List.of());
+
+        var result = relationService.listDocumentRelations(1L);
+        assertThat(result.get("items")).isEqualTo(List.of());
+    }
+
+    // ------------------------------------------------------------ 附件详情
+
+    private DocumentEntity attachmentDoc() {
+        DocumentEntity attachment = new DocumentEntity();
+        attachment.setId(5L);
+        attachment.setDocumentType("ATTACHMENT");
+        attachment.setDocumentStatus("PUBLISHED");
+        attachment.setSecurityLevel("SECRET");
+        attachment.setDocumentName("配套方案.pdf");
+        attachment.setRowVersion(2);
+        attachment.setViewCount(4L);
+        java.time.LocalDateTime now = java.time.LocalDateTime.now(java.time.ZoneOffset.UTC);
+        attachment.setCreatedAt(now);
+        attachment.setUpdatedAt(now);
+        return attachment;
+    }
+
+    @Test
+    void attachmentDetailServesMetadataViewForEditOnlyUser() {
+        DocumentQueryService documentQueryService = mock(DocumentQueryService.class);
+        AttachmentQueryService attachmentService = new AttachmentQueryService(
+                attachmentBindingMapper, documentMapper, authorizationService,
+                documentQueryService);
+        DocumentEntity attachment = attachmentDoc();
+        when(documentMapper.findActiveAttachmentById(5L)).thenReturn(attachment);
+        when(authorizationService.requireAttachmentAccess(attachment, AuthContext.get(), false))
+                .thenThrow(Errors.documentViewForbidden());
+        when(authorizationService.attachmentAllowedActions(
+                eq(AuthContext.get()), eq(attachment), any()))
+                .thenReturn(List.of("EDIT"));
+        when(authorizationService.evaluateAttachmentAccess(attachment, AuthContext.get()))
+                .thenReturn(new AttachmentAccess(false, false));
+        when(authorizationService.attachmentEditAllowed(attachment, AuthContext.get()))
+                .thenReturn(true);
+
+        var result = attachmentService.getAttachment(5L,
+                new MockHttpServletRequest("GET", "/api/v1/attachments/5"));
+
+        // 编辑视图:VIEW 位不下发、查看计数仅回显不增计
+        assertThat(result.get("allowedActions")).isEqualTo(List.of("EDIT"));
+        assertThat(result.get("viewCount")).isEqualTo(4L);
+        verify(documentQueryService, never()).recordView(any(), any());
+    }
+
+    @Test
+    void attachmentDetailStillForbiddenWithoutAnyAction() {
+        DocumentQueryService documentQueryService = mock(DocumentQueryService.class);
+        AttachmentQueryService attachmentService = new AttachmentQueryService(
+                attachmentBindingMapper, documentMapper, authorizationService,
+                documentQueryService);
+        DocumentEntity attachment = attachmentDoc();
+        when(documentMapper.findActiveAttachmentById(5L)).thenReturn(attachment);
+        when(authorizationService.requireAttachmentAccess(attachment, AuthContext.get(), false))
+                .thenThrow(Errors.documentViewForbidden());
+        when(authorizationService.attachmentAllowedActions(
+                eq(AuthContext.get()), eq(attachment), any()))
+                .thenReturn(List.of());
+
+        assertThatThrownBy(() -> attachmentService.getAttachment(5L,
+                        new MockHttpServletRequest("GET", "/api/v1/attachments/5")))
+                .isInstanceOf(DmsException.class)
+                .hasMessage("无权查看该文档");
+    }
+}

+ 141 - 0
backend-java/src/test/java/com/dms/service/LoginBrandingServiceTest.java

@@ -0,0 +1,141 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.dms.common.DeadlockRetry;
+import com.dms.common.DmsException;
+import com.dms.mapper.SettingMapper;
+import com.dms.model.SettingEntity;
+import com.dms.security.AuthContext;
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.function.Supplier;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+/**
+ * 登录页文案设置(契约1.62):大标题与底部小字读写——未设置 null、
+ * 空白清除、超长拒绝、两键独立 upsert、审计 LOGIN_BRANDING_UPDATE。
+ */
+class LoginBrandingServiceTest {
+
+    private SettingMapper settingMapper;
+    private AuditService auditService;
+    private LoginBrandingService service;
+
+    @BeforeEach
+    void setUp() {
+        settingMapper = mock(SettingMapper.class);
+        auditService = mock(AuditService.class);
+        DeadlockRetry deadlockRetry = mock(DeadlockRetry.class);
+        when(deadlockRetry.execute(any())).thenAnswer(invocation ->
+                ((Supplier<?>) invocation.getArgument(0)).get());
+        service = new LoginBrandingService(settingMapper, auditService, deadlockRetry);
+        AuthContext.set(new AuthContext(1L, "op", "操作员", null, null,
+                "ADMIN", "TOP_SECRET", 0, null, null));
+    }
+
+    @AfterEach
+    void clearContext() {
+        AuthContext.clear();
+    }
+
+    private static SettingEntity row(String value) {
+        SettingEntity entity = new SettingEntity();
+        entity.setSettingKey("login.title");
+        entity.setSettingValue(value);
+        return entity;
+    }
+
+    @Test
+    void brandingReturnsNullWhenUnset() {
+        when(settingMapper.findByKey(any())).thenReturn(null);
+
+        Map<String, Object> data = service.branding();
+
+        assertThat(data.get("title")).isNull();
+        assertThat(data.get("footerText")).isNull();
+    }
+
+    @Test
+    void brandingReturnsStoredValues() {
+        when(settingMapper.findByKey("login.title")).thenReturn(row("试验数据管理系统"));
+        when(settingMapper.findByKey("login.footer")).thenReturn(null);
+
+        Map<String, Object> data = service.branding();
+
+        assertThat(data.get("title")).isEqualTo("试验数据管理系统");
+        assertThat(data.get("footerText")).isNull();
+    }
+
+    @Test
+    void updateBrandingUpsertsBothKeysAndAudits() {
+        when(settingMapper.updateValue(any(), any(), any(LocalDateTime.class), anyLong()))
+                .thenReturn(0);
+        when(settingMapper.findByKey("login.title")).thenReturn(row("试验数据管理系统"));
+        when(settingMapper.findByKey("login.footer")).thenReturn(row("内部资料 严禁扩散"));
+
+        Map<String, Object> data = service.updateBranding("试验数据管理系统", "内部资料 严禁扩散",
+                new MockHttpServletRequest("PUT", "/api/v1/config/login-branding"));
+
+        assertThat(data.get("title")).isEqualTo("试验数据管理系统");
+        assertThat(data.get("footerText")).isEqualTo("内部资料 严禁扩散");
+        verify(settingMapper).insert(eq("login.title"), eq("试验数据管理系统"),
+                eq(null), eq(null), any(LocalDateTime.class), eq(1L));
+        verify(settingMapper).insert(eq("login.footer"), eq("内部资料 严禁扩散"),
+                eq(null), eq(null), any(LocalDateTime.class), eq(1L));
+        verify(auditService).insert(auditService.businessAudit(any(),
+                eq(AuditService.Action.LOGIN_BRANDING_UPDATE), eq("SYSTEM"), eq(0L),
+                eq("login-branding"), any()));
+    }
+
+    @Test
+    void updateBrandingClearsBlankAsUnset() {
+        when(settingMapper.updateValue(any(), any(), any(LocalDateTime.class), anyLong()))
+                .thenReturn(1);
+        when(settingMapper.findByKey(any())).thenReturn(null);
+
+        Map<String, Object> data = service.updateBranding("  ", null,
+                new MockHttpServletRequest("PUT", "/api/v1/config/login-branding"));
+
+        // 空白/未传 → 落 NULL 行(等价未设置),不执行 INSERT
+        assertThat(data.get("title")).isNull();
+        assertThat(data.get("footerText")).isNull();
+        verify(settingMapper).updateValue(eq("login.title"), eq(null),
+                any(LocalDateTime.class), eq(1L));
+        verify(settingMapper, never()).insert(any(), any(), any(), any(),
+                any(LocalDateTime.class), anyLong());
+    }
+
+    @Test
+    void updateBrandingRejectsOverlongText() {
+        String longTitle = "标".repeat(65);
+        assertThatThrownBy(() -> service.updateBranding(longTitle, null,
+                new MockHttpServletRequest("PUT", "/api/v1/config/login-branding")))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("登录页大标题不能超过64字");
+        String longFooter = "字".repeat(201);
+        assertThatThrownBy(() -> service.updateBranding(null, longFooter,
+                new MockHttpServletRequest("PUT", "/api/v1/config/login-branding")))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("登录页底部小字不能超过200字");
+    }
+
+    @Test
+    void updateBrandingRejectsNonStringValue() {
+        assertThatThrownBy(() -> service.updateBranding(123, null,
+                new MockHttpServletRequest("PUT", "/api/v1/config/login-branding")))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("title必须是字符串");
+    }
+}

+ 269 - 0
backend-java/src/test/java/com/dms/service/MultiRoleLoginSelectionTest.java

@@ -0,0 +1,269 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.dms.common.DmsException;
+import com.dms.config.DmsProperties;
+import com.dms.mapper.UserMapper;
+import com.dms.mapper.UserRoleMapper;
+import com.dms.model.RoleEntity;
+import com.dms.model.RoleMountEntity;
+import com.dms.model.UserEntity;
+import com.dms.security.JwtTokens;
+import com.dms.security.Passwords;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.env.MockEnvironment;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+/**
+ * 多角色登录(契约1.46/1.53 密码即角色):挂载行全 NULL=存量过渡态,用户
+ * 密码命中后多角色仍返回角色选择步骤(不签发、不记成功);带 roleId 须在
+ * 挂载列表内;单角色自动选中;无角色按默认角色(roleId=null)登录。
+ */
+class MultiRoleLoginSelectionTest {
+
+    private static final String SECRET = "multi-role-test-secret";
+
+    private static DmsProperties props() {
+        return new DmsProperties(new MockEnvironment()
+                .withProperty("DMS_JWT_SECRET", SECRET));
+    }
+
+    private static RoleEntity role(long id, String name) {
+        RoleEntity role = new RoleEntity();
+        role.setId(id);
+        role.setRoleName(name);
+        return role;
+    }
+
+    /** 挂载行(契约1.53):passwordHash=null 即该角色使用用户级密码。 */
+    private static RoleMountEntity mount(long id, String name) {
+        RoleMountEntity mount = new RoleMountEntity();
+        mount.setRoleId(id);
+        mount.setRoleName(name);
+        return mount;
+    }
+
+    private static RoleMountEntity mount(long id, String name, String passwordHash) {
+        RoleMountEntity mount = mount(id, name);
+        mount.setPasswordHash(passwordHash);
+        return mount;
+    }
+
+    /** 可登录用户(id=7,密码 Passw0rd!,已启用未锁定)。 */
+    private static UserEntity user() {
+        UserEntity user = new UserEntity();
+        user.setId(7L);
+        user.setUsername("multi");
+        user.setRealName("多角色用户");
+        user.setPasswordHash(Passwords.hash("Passw0rd!"));
+        user.setRoleCode("USER");
+        user.setSecurityLevel("PUBLIC");
+        user.setStatus("ENABLED");
+        user.setAuthVersion(0);
+        user.setRowVersion(0);
+        return user;
+    }
+
+    private static AuthService service(UserMapper userMapper, UserRoleMapper userRoleMapper,
+                                       UserSummaryService summaryService) {
+        return new AuthService(userMapper, userRoleMapper, mock(AuditService.class),
+                mock(LoginIpService.class), summaryService,
+                mock(LoginLockoutService.class), mock(CaptchaService.class),
+                new JwtTokens.Holder(props()), props(), mock(PortalAuthService.class));
+    }
+
+    @Test
+    void multiRoleWithoutRoleIdReturnsSelectionStep() {
+        UserMapper userMapper = mock(UserMapper.class);
+        when(userMapper.findActiveByUsername("multi")).thenReturn(user());
+        UserRoleMapper userRoleMapper = mock(UserRoleMapper.class);
+        when(userRoleMapper.listLoginMounts(7L))
+                .thenReturn(List.of(mount(11L, "作战值班"), mount(22L, "情报分析")));
+
+        Map<String, Object> data = service(userMapper, userRoleMapper,
+                mock(UserSummaryService.class))
+                .login("multi", "Passw0rd!", false, null, null, null,
+                new MockHttpServletRequest("POST", "/api/v1/auth/login"));
+
+        assertThat(data.get("roleSelectionRequired")).isEqualTo(true);
+        assertThat(data).doesNotContainKey("accessToken");
+        assertThat(data.get("username")).isEqualTo("multi");
+        assertThat(data.get("realName")).isEqualTo("多角色用户");
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> roles = (List<Map<String, Object>>) data.get("roles");
+        assertThat(roles).hasSize(2);
+        assertThat(roles.get(0)).containsEntry("id", "11").containsEntry("roleName", "作战值班");
+        // 选择步骤不签发、不更新登录痕迹(第二轮携带 roleId 再提交)
+        verify(userMapper, never()).updateLoginSuccess(anyLong(), any(), any(), any());
+    }
+
+    @Test
+    void multiRoleWithMountedRoleIdIssuesTokenForSelectedRole() {
+        UserMapper userMapper = mock(UserMapper.class);
+        UserEntity user = user();
+        when(userMapper.findActiveByUsername("multi")).thenReturn(user);
+        UserRoleMapper userRoleMapper = mock(UserRoleMapper.class);
+        when(userRoleMapper.listActiveRolesByUserId(7L))
+                .thenReturn(List.of(role(11L, "作战值班"), role(22L, "情报分析")));
+        UserSummaryService summaryService = mock(UserSummaryService.class);
+        when(summaryService.userSummary(eq(user), any())).thenReturn(Map.of());
+
+        Map<String, Object> data = service(userMapper, userRoleMapper, summaryService)
+                .login("multi", "Passw0rd!", false, null, null, "22",
+                new MockHttpServletRequest("POST", "/api/v1/auth/login"));
+
+        assertThat(data.get("roleSelectionRequired")).isNull();
+        assertThat(String.valueOf(data.get("accessToken"))).isNotBlank();
+        // 选定角色写入 token claim(停用/越权选定均不可达)
+        JwtTokens.Payload payload =
+                JwtTokens.decode(String.valueOf(data.get("accessToken")), SECRET);
+        assertThat(payload.roleId()).isEqualTo(22L);
+        assertThat(data.get("user")).isNotNull();
+    }
+
+    @Test
+    void multiRoleWithForeignRoleIdRejected() {
+        UserMapper userMapper = mock(UserMapper.class);
+        when(userMapper.findActiveByUsername("multi")).thenReturn(user());
+        UserRoleMapper userRoleMapper = mock(UserRoleMapper.class);
+        when(userRoleMapper.listActiveRolesByUserId(7L))
+                .thenReturn(List.of(role(11L, "作战值班"), role(22L, "情报分析")));
+
+        assertThatThrownBy(() -> service(userMapper, userRoleMapper,
+                mock(UserSummaryService.class))
+                .login("multi", "Passw0rd!", false, null, null, "999", null))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "INVALID_ARGUMENT")
+                .hasMessage("roleId必须指向已挂载的角色");
+        assertThatThrownBy(() -> service(userMapper, userRoleMapper,
+                mock(UserSummaryService.class))
+                .login("multi", "Passw0rd!", false, null, null, "abc", null))
+                .isInstanceOf(DmsException.class)
+                .hasMessage("roleId必须是数字字符串");
+    }
+
+    @Test
+    void singleRoleAutoSelectedWithoutExplicitRoleId() {
+        UserMapper userMapper = mock(UserMapper.class);
+        UserEntity user = user();
+        when(userMapper.findActiveByUsername("multi")).thenReturn(user);
+        UserRoleMapper userRoleMapper = mock(UserRoleMapper.class);
+        when(userRoleMapper.listLoginMounts(7L)).thenReturn(List.of(mount(11L, "作战值班")));
+        UserSummaryService summaryService = mock(UserSummaryService.class);
+        when(summaryService.userSummary(eq(user), any())).thenReturn(Map.of());
+
+        Map<String, Object> data = service(userMapper, userRoleMapper, summaryService)
+                .login("multi", "Passw0rd!", false, null, null, null,
+                new MockHttpServletRequest("POST", "/api/v1/auth/login"));
+
+        // 单角色不返回选择步骤,自动选中唯一角色
+        assertThat(data.get("roleSelectionRequired")).isNull();
+        JwtTokens.Payload payload =
+                JwtTokens.decode(String.valueOf(data.get("accessToken")), SECRET);
+        assertThat(payload.roleId()).isEqualTo(11L);
+    }
+
+    @Test
+    void mountedIndependentPasswordEntersThatRoleDirectly() {
+        UserMapper userMapper = mock(UserMapper.class);
+        UserEntity user = user();
+        when(userMapper.findActiveByUsername("multi")).thenReturn(user);
+        UserRoleMapper userRoleMapper = mock(UserRoleMapper.class);
+        // 11 使用用户密码、22 挂独立密码:输入 22 的密码即以 22 进入
+        when(userRoleMapper.listLoginMounts(7L)).thenReturn(List.of(
+                mount(11L, "作战值班"),
+                mount(22L, "情报分析", Passwords.hash("Role#9001"))));
+        UserSummaryService summaryService = mock(UserSummaryService.class);
+        when(summaryService.userSummary(eq(user), any())).thenReturn(Map.of());
+
+        Map<String, Object> data = service(userMapper, userRoleMapper, summaryService)
+                .login("multi", "Role#9001", false, null, null, null,
+                new MockHttpServletRequest("POST", "/api/v1/auth/login"));
+
+        assertThat(data.get("roleSelectionRequired")).isNull();
+        JwtTokens.Payload payload =
+                JwtTokens.decode(String.valueOf(data.get("accessToken")), SECRET);
+        assertThat(payload.roleId()).isEqualTo(22L);
+        verify(userMapper).updateLoginSuccess(eq(7L), any(), any(), any());
+    }
+
+    @Test
+    void userPasswordEntersSingleNullMountRole() {
+        UserMapper userMapper = mock(UserMapper.class);
+        UserEntity user = user();
+        when(userMapper.findActiveByUsername("multi")).thenReturn(user);
+        UserRoleMapper userRoleMapper = mock(UserRoleMapper.class);
+        // 用户级密码命中:进入唯一「使用用户密码」的挂载行(11)
+        when(userRoleMapper.listLoginMounts(7L)).thenReturn(List.of(
+                mount(11L, "作战值班"),
+                mount(22L, "情报分析", Passwords.hash("Role#9001"))));
+        UserSummaryService summaryService = mock(UserSummaryService.class);
+        when(summaryService.userSummary(eq(user), any())).thenReturn(Map.of());
+
+        Map<String, Object> data = service(userMapper, userRoleMapper, summaryService)
+                .login("multi", "Passw0rd!", false, null, null, null,
+                new MockHttpServletRequest("POST", "/api/v1/auth/login"));
+
+        assertThat(data.get("roleSelectionRequired")).isNull();
+        JwtTokens.Payload payload =
+                JwtTokens.decode(String.valueOf(data.get("accessToken")), SECRET);
+        assertThat(payload.roleId()).isEqualTo(11L);
+    }
+
+    @Test
+    void wrongPasswordMatchesNoMountAndCountsLockout() {
+        UserMapper userMapper = mock(UserMapper.class);
+        UserEntity user = user();
+        when(userMapper.findActiveByUsername("multi")).thenReturn(user);
+        UserRoleMapper userRoleMapper = mock(UserRoleMapper.class);
+        when(userRoleMapper.listLoginMounts(7L)).thenReturn(List.of(
+                mount(11L, "作战值班"),
+                mount(22L, "情报分析", Passwords.hash("Role#9001"))));
+        LoginLockoutService lockoutService = mock(LoginLockoutService.class);
+        AuthService authService = new AuthService(userMapper, userRoleMapper,
+                mock(AuditService.class), mock(LoginIpService.class),
+                mock(UserSummaryService.class), lockoutService,
+                mock(CaptchaService.class), new JwtTokens.Holder(props()), props(), mock(PortalAuthService.class));
+
+        assertThatThrownBy(() -> authService.login("multi", "Wrong#9999", false,
+                null, null, null, new MockHttpServletRequest("POST", "/api/v1/auth/login")))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "INVALID_CREDENTIALS");
+        // 不匹配任何挂载行也不匹配用户级密码:计入锁定
+        verify(lockoutService).registerFailure(7L);
+        verify(userMapper, never()).updateLoginSuccess(anyLong(), any(), any(), any());
+    }
+
+    @Test
+    void rolelessUserFallsBackToDefaultRoleClaim() {
+        UserMapper userMapper = mock(UserMapper.class);
+        UserEntity user = user();
+        when(userMapper.findActiveByUsername("multi")).thenReturn(user);
+        UserRoleMapper userRoleMapper = mock(UserRoleMapper.class);
+        when(userRoleMapper.listLoginMounts(7L)).thenReturn(List.of());
+        UserSummaryService summaryService = mock(UserSummaryService.class);
+        when(summaryService.userSummary(eq(user), any())).thenReturn(Map.of());
+
+        Map<String, Object> data = service(userMapper, userRoleMapper, summaryService)
+                .login("multi", "Passw0rd!", false, null, null, null,
+                new MockHttpServletRequest("POST", "/api/v1/auth/login"));
+
+        // 未挂角色:roleId claim 缺省(授权层回退默认角色)
+        JwtTokens.Payload payload =
+                JwtTokens.decode(String.valueOf(data.get("accessToken")), SECRET);
+        assertThat(payload.roleId()).isNull();
+        verify(userRoleMapper).listLoginMounts(7L);
+        verify(userMapper).updateLoginSuccess(eq(7L), any(), any(), any());
+    }
+}

+ 3 - 2
backend-java/src/test/java/com/dms/service/NotificationServiceTest.java

@@ -33,9 +33,10 @@ class NotificationServiceTest {
     void setUp() {
         notificationMapper = mock(NotificationMapper.class);
         borrowRequestService = mock(BorrowRequestService.class);
-        service = new NotificationService(notificationMapper, borrowRequestService);
+        service = new NotificationService(notificationMapper, borrowRequestService,
+                mock(PasswordResetRequestService.class));
         AuthContext.set(new AuthContext(1L, "admin", "系统管理员", 10L,
-                "机关", "ADMIN", "TOP_SECRET", 1, null));
+                "机关", "ADMIN", "TOP_SECRET", 1, null, null));
     }
 
     @AfterEach

+ 45 - 2
backend-java/src/test/java/com/dms/service/OrganizationTreeScopeTest.java

@@ -57,7 +57,7 @@ class OrganizationTreeScopeTest {
         AdminPermission adminPermission = mock(AdminPermission.class);
         when(adminPermission.managedScopeIds(actor)).thenReturn(Set.of(2L, 4L));
         AuthContext.set(new AuthContext(9L, "anchored", "锚点管理员",
-                2L, "分局A", "ADMIN", "PUBLIC", 0, null));
+                2L, "分局A", "ADMIN", "PUBLIC", 0, null, null));
 
         List<Map<String, Object>> roots = service(orgMapper, userMapper, adminPermission)
                 .organizationTree(null, null);
@@ -81,7 +81,7 @@ class OrganizationTreeScopeTest {
         // managedScopeIds 返回 null = 全局管理员:不裁剪
         when(adminPermission.managedScopeIds(actor)).thenReturn(null);
         AuthContext.set(new AuthContext(9L, "global", "全局管理员",
-                null, null, "ADMIN", "PUBLIC", 0, null));
+                null, null, "ADMIN", "PUBLIC", 0, null, null));
 
         List<Map<String, Object>> roots = service(orgMapper, userMapper, adminPermission)
                 .organizationTree(null, null);
@@ -91,4 +91,47 @@ class OrganizationTreeScopeTest {
         List<Map<String, Object>> children = (List<Map<String, Object>>) roots.get(0).get("children");
         assertThat(children).extracting(n -> n.get("orgName")).containsExactly("分局A", "分局B");
     }
+
+    @Test
+    void regularUserSeesOnlyOwnDepartmentSubtree() {
+        // 契约1.45:GET /organizations/tree 放开类别拦截,普通用户(USER)
+        // 恒见本人部门为根的子树,与用户列表收敛口径一致
+        OrganizationMapper orgMapper = mock(OrganizationMapper.class);
+        when(orgMapper.listAllNotDeleted()).thenReturn(List.of(
+                org(1, null, "总部"), org(2, 1L, "分局A"),
+                org(3, 1L, "分局B"), org(4, 2L, "科室")));
+        UserMapper userMapper = mock(UserMapper.class);
+        UserEntity actor = new UserEntity();
+        actor.setOrganizationId(2L);
+        when(userMapper.findActiveById(9L)).thenReturn(actor);
+        AdminPermission adminPermission = mock(AdminPermission.class);
+        when(adminPermission.managedScopeIds(actor)).thenReturn(null);
+        AuthContext.set(new AuthContext(9L, "regular", "普通用户",
+                2L, "分局A", "USER", "PUBLIC", 0, null, null));
+
+        List<Map<String, Object>> roots = service(orgMapper, userMapper, adminPermission)
+                .organizationTree(null, null);
+        // 森林根为本人部门"分局A",仅含子级"科室";范围外 1/3 不出现
+        assertThat(roots).hasSize(1);
+        assertThat(roots.get(0).get("orgName")).isEqualTo("分局A");
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> children = (List<Map<String, Object>>) roots.get(0).get("children");
+        assertThat(children).extracting(n -> n.get("orgName")).containsExactly("科室");
+    }
+
+    @Test
+    void regularUserWithoutDepartmentSeesEmptyTree() {
+        OrganizationMapper orgMapper = mock(OrganizationMapper.class);
+        when(orgMapper.listAllNotDeleted()).thenReturn(List.of(org(1, null, "总部")));
+        UserMapper userMapper = mock(UserMapper.class);
+        UserEntity actor = new UserEntity();
+        when(userMapper.findActiveById(9L)).thenReturn(actor);
+        AdminPermission adminPermission = mock(AdminPermission.class);
+        when(adminPermission.managedScopeIds(actor)).thenReturn(null);
+        AuthContext.set(new AuthContext(9L, "regular", "普通用户",
+                null, null, "USER", "PUBLIC", 0, null, null));
+
+        assertThat(service(orgMapper, userMapper, adminPermission)
+                .organizationTree(null, null)).isEmpty();
+    }
 }

+ 79 - 0
backend-java/src/test/java/com/dms/service/PasswordLoginSwitchTest.java

@@ -0,0 +1,79 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.dms.common.DmsException;
+import com.dms.config.DmsProperties;
+import com.dms.mapper.UserMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.env.MockEnvironment;
+
+/**
+ * 禁用用户名密码登录开关(安全管控,DMS_PASSWORD_LOGIN_DISABLED 缺省 false):
+ * 开启后 login 最前置 403 拒绝(不消耗验证码、不查询用户),关闭时登录照常走原路径。
+ */
+class PasswordLoginSwitchTest {
+
+    private static DmsProperties props(boolean disabled) {
+        MockEnvironment environment = new MockEnvironment();
+        if (disabled) {
+            environment.setProperty("DMS_PASSWORD_LOGIN_DISABLED", "true");
+        }
+        return new DmsProperties(environment);
+    }
+
+    private static AuthService service(UserMapper userMapper,
+            CaptchaService captchaService, DmsProperties props) {
+        return new AuthService(userMapper, mock(com.dms.mapper.UserRoleMapper.class),
+                mock(AuditService.class),
+                mock(LoginIpService.class), mock(UserSummaryService.class),
+                mock(LoginLockoutService.class), captchaService, null, props, mock(PortalAuthService.class));
+    }
+
+    @Test
+    void loginRejectedBeforeCaptchaAndDbLookupWhenDisabled() {
+        UserMapper userMapper = mock(UserMapper.class);
+        CaptchaService captchaService = mock(CaptchaService.class);
+        AuthService service = service(userMapper, captchaService, props(true));
+        // 403 FORBIDDEN:对全部用户名(含内置 admin)一律拒绝,消息引导单点登录
+        assertThatThrownBy(() -> service.login("someone", "Passw0rd!", false, null, null, null, null))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "FORBIDDEN")
+                .hasMessage("用户名密码登录已被禁用,请使用单点登录");
+        // 守卫先于验证码消费与用户查询:两者均不触达,不泄露任何账号信息
+        verify(captchaService, never()).verify(any(), any());
+        verifyNoInteractions(userMapper);
+    }
+
+    @Test
+    void loginProceedsOnOriginalPathWhenEnabled() {
+        UserMapper userMapper = mock(UserMapper.class);
+        when(userMapper.findActiveByUsername("someone")).thenReturn(null);
+        CaptchaService captchaService = mock(CaptchaService.class);
+        AuthService service = service(userMapper, captchaService, props(false));
+        // 未配置开关(缺省 false):照常查库,用户不存在报密码错误(原语义不变)
+        assertThatThrownBy(() -> service.login("someone", "bad", false, null, null, null, null))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "INVALID_CREDENTIALS");
+        verify(userMapper).findActiveByUsername("someone");
+    }
+
+    @Test
+    void propsParseTruthinessAndDefault() {
+        assertThat(props(false).passwordLoginDisabled()).isFalse();
+        MockEnvironment on = new MockEnvironment();
+        on.setProperty("DMS_PASSWORD_LOGIN_DISABLED", "1");
+        assertThat(new DmsProperties(on).passwordLoginDisabled()).isTrue();
+        MockEnvironment bogus = new MockEnvironment();
+        bogus.setProperty("DMS_PASSWORD_LOGIN_DISABLED", "yes-on-please");
+        // 仅 1/true/yes/on 视为开启,其余(含乱写)一律关闭
+        assertThat(new DmsProperties(bogus).passwordLoginDisabled()).isFalse();
+    }
+}

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません