Quellcode durchsuchen

1. 实现了登录图片验证码(契约 1.32),GET /auth/captcha 签发 132×44 PNG 验证码图片(4 位字符剔除易混淆 0O1I、120 秒有效、一次性消费),DMS_CAPTCHA_ENABLED 缺省启用,开启时登录请求体扩为五键,校验先于用户查询与锁定检查,错误统一返回 400 INVALID_CAPTCHA,前端支持点击刷新与登录失败自动刷新,并修复开关关闭时 Python 版登录仍被拦截的双端不一致;
2. 实现了超级管理员隐藏开关(契约 1.33),DMS_ALLOW_ADMIN_LOGIN 关闭时内置 admin 登录按用户名不存在处理(报错、审计、锁定与密码错误完全一致)、用户列表隐藏该账号、创建同名用户报「不能创建该名称的用户」;
3. 实现了机构树按管理范围裁剪(契约 1.33),锚点管理员仅返回管理范围子树(森林形式、范围外机构与祖先链不出现),全局管理员不受限,机构页/用户页/机构调整下拉统一生效;
4. 实现了方案权限矩阵上传位(契约 1.31),canUpload 仅本部门行可设置、其他部门行恒拒,permUpload 字段门控前端上传与批量导入按钮,迁移 0033/V18;界面「权限矩阵」更名「方案权限」,挂载/解除挂载合并为单一「挂载」开关;
5. 简化了角色管理范围锚点(契约 1.31),角色维护界面移除「机构及用户管理范围」下拉、恒按跟随用户提交,存量锚点迁移 0034/V19 回填 SELF,生效范围统一按用户级三态解析;
6. 实现了登录背景图按主题切换(契约 1.31),login-background 新增 theme 参数,优先返回 background-{theme}.jpg/png、缺失回落通用 background 兜底,前端按当前主题请求并独立缓存;
7. 收窄了登录 IP 限制豁免(契约 1.31),由 ADMIN 类别整体豁免收窄为仅内置 admin 账号豁免,其余管理员与普通用户同受所在机构 IP 限制;
8. 优化了用户/角色管理与共享资料界面,左右分栏支持拖拽调宽,角色筛选删除「全部状态」下拉且不显示成员/排序数;共享资料库筛选行移除、上传与批量导入按钮上移顶部,全站统一「共享资料」说法;
9. 加固了安全防护,新增安全响应头过滤器(网页防篡改 G-13),未登录访问任意 URL 一律提示无权限、不跳转登录页;
10. 完成了达梦迁移适配,migration-dm V12-V17 表/列/约束名统一大写并新增 V12_to_V17~V16_to_V17 合并迁移脚本,移除 sys_audit_log.action_type CHECK 白名单约束(0035/V20,消除新增审计动作入库失败);
11. 调整「党政红」主题为国旗红主色+金橙黄点缀并加入渐变;
12. 修复了一批问题:rsync 目标配错时备份配置区整体加载失败(配置与历史解耦、历史失败可重试、保存后自动重拉)、弹出框被顶部横幅遮挡(层级提升、遮罩统一半透明黑)、双击方案误触右侧预览(单击延迟 250ms 判定)、正文搜索命中解析失败方案、部分方案无权限时统计数据加载失败、借阅审批单改为添加用户即可同步上传;将左侧分类树默认宽度由固定 272px 改为窗口的 1/4(220px~窗口宽-640 收敛);
13. 更新了接口契约至 1.33 与 OpenAPI、UI 字典、.env.example,新增验证码、开关、裁剪、上传位等双端测试(前端 453 用例、Python 与 Java 全量通过),prompts.md 记录本批需求。

weisijie vor 1 Monat
Ursprung
Commit
1b5f7b3061
100 geänderte Dateien mit 3687 neuen und 275 gelöschten Zeilen
  1. 45 4
      DMS_API_CONTRACT.md
  2. 9 0
      backend-java/.env.example
  3. 42 5
      backend-java/src/main/java/com/dms/api/v1/AuthController.java
  4. 29 5
      backend-java/src/main/java/com/dms/api/v1/LoginBackgroundController.java
  5. 5 0
      backend-java/src/main/java/com/dms/common/Errors.java
  6. 50 0
      backend-java/src/main/java/com/dms/common/SecurityHeadersFilter.java
  7. 24 0
      backend-java/src/main/java/com/dms/config/DmsProperties.java
  8. 3 2
      backend-java/src/main/java/com/dms/mapper/DocumentMapper.java
  9. 6 0
      backend-java/src/main/java/com/dms/mapper/RolePermissionMapper.java
  10. 6 2
      backend-java/src/main/java/com/dms/mapper/UserMapper.java
  11. 3 1
      backend-java/src/main/java/com/dms/model/RolePermissionEntity.java
  12. 14 3
      backend-java/src/main/java/com/dms/service/AuthService.java
  13. 34 0
      backend-java/src/main/java/com/dms/service/AuthorizationService.java
  14. 149 0
      backend-java/src/main/java/com/dms/service/CaptchaService.java
  15. 7 0
      backend-java/src/main/java/com/dms/service/DocumentMutationService.java
  16. 5 1
      backend-java/src/main/java/com/dms/service/LoginIpService.java
  17. 18 1
      backend-java/src/main/java/com/dms/service/OrganizationService.java
  18. 10 2
      backend-java/src/main/java/com/dms/service/RoleService.java
  19. 16 3
      backend-java/src/main/java/com/dms/service/UserService.java
  20. 3 0
      backend-java/src/main/java/com/dms/service/UserSummaryService.java
  21. 4 3
      backend-java/src/main/java/com/dms/web/SpaController.java
  22. 10 0
      backend-java/src/main/resources/application.yml
  23. 4 4
      backend-java/src/main/resources/db/migration-dm/V12__ui_theme.sql
  24. 354 0
      backend-java/src/main/resources/db/migration-dm/V12_to_V17__merge.sql
  25. 6 6
      backend-java/src/main/resources/db/migration-dm/V13__backup_audit_actions.sql
  26. 354 0
      backend-java/src/main/resources/db/migration-dm/V13_to_V17__merge.sql
  27. 16 16
      backend-java/src/main/resources/db/migration-dm/V14__system_management.sql
  28. 298 0
      backend-java/src/main/resources/db/migration-dm/V14_to_V17__merge.sql
  29. 2 2
      backend-java/src/main/resources/db/migration-dm/V15__role_edit_permission.sql
  30. 280 0
      backend-java/src/main/resources/db/migration-dm/V15_to_V17__merge.sql
  31. 52 17
      backend-java/src/main/resources/db/migration-dm/V16__user_sort_no.sql
  32. 148 0
      backend-java/src/main/resources/db/migration-dm/V16_to_V17__merge.sql
  33. 3 3
      backend-java/src/main/resources/db/migration-dm/V17__user_managed_org.sql
  34. 24 0
      backend-java/src/main/resources/db/migration-dm/V18__role_upload_permission.sql
  35. 10 0
      backend-java/src/main/resources/db/migration-dm/V19__role_anchor_self.sql
  36. 28 0
      backend-java/src/main/resources/db/migration-dm/V20__drop_audit_action_check.sql
  37. 18 0
      backend-java/src/main/resources/db/migration/V18__role_upload_permission.sql
  38. 8 0
      backend-java/src/main/resources/db/migration/V19__role_anchor_self.sql
  39. 21 0
      backend-java/src/main/resources/db/migration/V20__drop_audit_action_check.sql
  40. 136 0
      backend-java/src/test/java/com/dms/service/AdminLoginSwitchTest.java
  41. 87 0
      backend-java/src/test/java/com/dms/service/CaptchaServiceTest.java
  42. 14 0
      backend-java/src/test/java/com/dms/service/LoginIpServiceMatchesTest.java
  43. 94 0
      backend-java/src/test/java/com/dms/service/OrganizationTreeScopeTest.java
  44. 84 0
      backend-java/src/test/java/com/dms/service/UserSummaryPermUploadTest.java
  45. 1 1
      backend-java/src/test/java/com/dms/web/SpaControllerTest.java
  46. 9 0
      backend/.env.example
  47. 5 4
      backend/app.py
  48. 48 6
      backend/dms/api/v1/auth.py
  49. 16 1
      backend/dms/api/v1/blueprint.py
  50. 21 9
      backend/dms/api/v1/login_background.py
  51. 7 0
      backend/dms/common/errors.py
  52. 11 0
      backend/dms/config.py
  53. 3 20
      backend/dms/models/audit_log.py
  54. 8 1
      backend/dms/models/role.py
  55. 5 1
      backend/dms/services/audit_service.py
  56. 52 0
      backend/dms/services/authorization_service.py
  57. 100 0
      backend/dms/services/captcha_service.py
  58. 8 0
      backend/dms/services/document_mutation_service.py
  59. 5 5
      backend/dms/services/document_query_service.py
  60. 8 3
      backend/dms/services/ip_entry_service.py
  61. 7 0
      backend/dms/services/organization_service.py
  62. 6 1
      backend/dms/services/role_service.py
  63. 20 0
      backend/dms/services/user_service.py
  64. 54 0
      backend/migrations/versions/0033_role_upload_permission.py
  65. 37 0
      backend/migrations/versions/0034_role_anchor_self.py
  66. 48 0
      backend/migrations/versions/0035_drop_audit_action_check.py
  67. 14 1
      backend/openapi/components/schemas.yaml
  68. 0 1
      backend/openapi/openapi.yaml
  69. 37 1
      backend/openapi/paths/auth.yaml
  70. 9 0
      backend/openapi/paths/configuration.yaml
  71. 1 0
      backend/openapi/paths/organizations.yaml
  72. 4 0
      backend/tests/dms/conftest.py
  73. 63 0
      backend/tests/dms/test_admin_login_switch.py
  74. 134 0
      backend/tests/dms/test_captcha.py
  75. 10 4
      backend/tests/dms/test_db_upgrade.py
  76. 17 1
      backend/tests/dms/test_global_search.py
  77. 32 1
      backend/tests/dms/test_ip_entries.py
  78. 28 0
      backend/tests/dms/test_login_background.py
  79. 1 1
      backend/tests/dms/test_migration.py
  80. 2 1
      backend/tests/dms/test_openapi_storage.py
  81. 1 1
      backend/tests/dms/test_q1b_ui_dictionaries.py
  82. 1 1
      backend/tests/dms/test_q2b_updated_range.py
  83. 1 1
      backend/tests/dms/test_q3b_business_content_preview.py
  84. 72 0
      backend/tests/dms/test_role_management.py
  85. 139 59
      frontend/src/app/App.tsx
  86. 4 4
      frontend/src/features/attachments/attachmentApi.ts
  87. 3 3
      frontend/src/features/audit/AuditDashboard.tsx
  88. 1 1
      frontend/src/features/auth/ReauthPromptModal.tsx
  89. 1 1
      frontend/src/features/auth/__tests__/appScope.test.ts
  90. 1 0
      frontend/src/features/auth/__tests__/authSession.test.ts
  91. 11 0
      frontend/src/features/auth/authApi.ts
  92. 25 0
      frontend/src/features/auth/authTypes.ts
  93. 43 42
      frontend/src/features/backup/BackupDashboard.tsx
  94. 10 10
      frontend/src/features/bindings/AttachmentBindingModal.tsx
  95. 1 1
      frontend/src/features/bindings/SubPlanBindingModal.tsx
  96. 2 2
      frontend/src/features/bindings/UnbindAttachmentModal.tsx
  97. 2 2
      frontend/src/features/bindings/bindingApi.ts
  98. 1 1
      frontend/src/features/bindings/bindingState.ts
  99. 2 2
      frontend/src/features/dictionaries/__tests__/dictionaryTypes.test.ts
  100. 2 1
      frontend/src/features/dictionaries/dictionaryLabels.ts

Datei-Diff unterdrückt, da er zu groß ist
+ 45 - 4
DMS_API_CONTRACT.md


+ 9 - 0
backend-java/.env.example

@@ -26,6 +26,15 @@ DMS_JWT_SECRET=replace-with-a-random-secret
 # 达到该时长后前端自动退出登录;设为 0 表示关闭该功能
 DMS_IDLE_LOGOUT_MINUTES=5
 
+# 登录图片验证码(缺省 true 开启,键与 Python 版一致):开启时登录页显示图片
+# 验证码输入,登录请求需携带 captchaId/captchaCode;false 关闭后回归三键请求
+DMS_CAPTCHA_ENABLED=true
+
+# 超级管理员登录开关(缺省 true 允许,键与 Python 版一致):设为 false 后内置
+# admin 禁止登录(报错与用户名或密码错误完全一致)、用户列表隐藏 admin、创建
+# 同名用户报"不能创建该名称的用户",外部无法感知系统中存在该账号
+DMS_ALLOW_ADMIN_LOGIN=true
+
 # 系统管理页监控信息展示(缺省 false):false 时系统管理页仅显示维护操作与备份管理,
 # 隐藏系统信息/资源监控/存储用量/运行参数等区块;true 时完整显示
 DMS_SYSTEM_MONITOR_ENABLED=false

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

@@ -4,37 +4,61 @@ import com.dms.common.ApiResponse;
 import com.dms.common.Errors;
 import com.dms.security.AuthRequired;
 import com.dms.service.AuthService;
+import com.dms.service.CaptchaService;
 import jakarta.servlet.http.HttpServletRequest;
+import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Set;
+import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
 /**
- * 登录与注销接口,对齐 backend/dms/api/v1/auth.py(严格三键请求体校验)。
+ * 登录与注销接口,对齐 backend/dms/api/v1/auth.py(严格请求体键集校验;
+ * 验证码开启时登录扩为五键,安全需求4)。
  */
 @RestController
 @RequestMapping("/api/v1/auth")
 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");
 
     private final AuthService authService;
+    private final CaptchaService captchaService;
 
-    public AuthController(AuthService authService) {
+    public AuthController(AuthService authService, CaptchaService captchaService) {
         this.authService = authService;
+        this.captchaService = captchaService;
+    }
+
+    /** 登录图片验证码(安全需求4):开启时返回 data URL 图片,关闭时仅返回 enabled=false。 */
+    @GetMapping("/captcha")
+    public ApiResponse<Object> captcha() {
+        Map<String, Object> data = new LinkedHashMap<>();
+        if (!captchaService.enabled()) {
+            data.put("enabled", false);
+            return ApiResponse.ok(data);
+        }
+        data.putAll(captchaService.issue());
+        data.put("enabled", true);
+        return ApiResponse.ok(data);
     }
 
     @PostMapping("/login")
     public ApiResponse<Object> login(@RequestBody(required = false) Map<String, Object> body,
                                      HttpServletRequest request) {
+        boolean captchaEnabled = captchaService.enabled();
         if (body == null) {
             throw Errors.invalidArgument("请求体必须是JSON对象");
         }
-        if (!LOGIN_FIELDS.equals(body.keySet())) {
-            throw Errors.invalidArgument("请求体必须且只能包含username、password和keepSignedIn");
+        if (!captchaEnabled ? !LOGIN_FIELDS.equals(body.keySet())
+                : !LOGIN_FIELDS_CAPTCHA.equals(body.keySet())) {
+            throw Errors.invalidArgument("请求体必须且只能包含username、password和keepSignedIn"
+                    + (captchaEnabled ? "、captchaId和captchaCode" : ""));
         }
         if (!(body.get("username") instanceof String username) || username.strip().isEmpty()) {
             throw Errors.invalidArgument("username不能为空");
@@ -45,7 +69,20 @@ public class AuthController {
         if (!(body.get("keepSignedIn") instanceof Boolean keepSignedIn)) {
             throw Errors.invalidArgument("keepSignedIn必须是布尔值");
         }
-        return ApiResponse.ok(authService.login(username.strip(), password, keepSignedIn, request));
+        String captchaId = null;
+        String captchaCode = null;
+        if (captchaEnabled) {
+            if (!(body.get("captchaId") instanceof String id) || id.strip().isEmpty()) {
+                throw Errors.invalidArgument("captchaId不能为空");
+            }
+            if (!(body.get("captchaCode") instanceof String code) || code.strip().isEmpty()) {
+                throw Errors.invalidArgument("captchaCode不能为空");
+            }
+            captchaId = id.strip();
+            captchaCode = code;
+        }
+        return ApiResponse.ok(authService.login(
+                username.strip(), password, keepSignedIn, captchaId, captchaCode, request));
     }
 
     @AuthRequired

+ 29 - 5
backend-java/src/main/java/com/dms/api/v1/LoginBackgroundController.java

@@ -10,12 +10,14 @@ import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.Set;
 import org.springframework.core.env.Environment;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
 /**
@@ -23,14 +25,19 @@ import org.springframework.web.bind.annotation.RestController;
  *
  * <p>用户将 background.jpg 或 background.png 放到服务端运行目录
  * (发行包为 jar 同级目录,开发模式为工作目录)即可让登录界面自动展示
- * 自定义背景;两个文件同时存在时优先 jpg。接口公开(登录前无凭证访问),
- * 候选文件名为固定白名单,不接收路径参数,无目录穿越风险;
- * 均不存在时返回 404 FILE_NOT_FOUND,前端回退默认纯色背景。</p>
+ * 自定义背景;两个文件同时存在时优先 jpg。按主题定制(契约1.31):可选查询
+ * 参数 theme=blue|red|green 时优先返回 background-{theme}.jpg/png,主题图
+ * 不存在时回落通用 background。接口公开(登录前无凭证访问),候选文件名为
+ * 固定白名单(通用 + 三主题),theme 参数经白名单校验,不接收路径参数,
+ * 无目录穿越风险;均不存在时返回 404 FILE_NOT_FOUND,前端回退默认纯色背景。</p>
  */
 @RestController
 public class LoginBackgroundController {
 
-    /** 候选文件名(固定白名单):同时存在时按声明顺序优先。 */
+    /** 主题白名单(与界面三主题一致);theme 缺省/非法时仅查通用兜底文件。 */
+    private static final Set<String> BACKGROUND_THEMES = Set.of("blue", "red", "green");
+
+    /** 通用兜底候选文件名(固定白名单):同时存在时按声明顺序优先。 */
     private static final Map<String, String> BACKGROUND_FILES = new LinkedHashMap<>();
 
     static {
@@ -48,7 +55,24 @@ public class LoginBackgroundController {
     }
 
     @GetMapping("/api/v1/config/login-background")
-    public ResponseEntity<byte[]> getLoginBackground() {
+    public ResponseEntity<byte[]> getLoginBackground(
+            @RequestParam(name = "theme", required = false) String theme) {
+        String normalized = theme == null ? "" : theme.trim().toLowerCase();
+        if (BACKGROUND_THEMES.contains(normalized)) {
+            for (String extension : new String[]{"jpg", "png"}) {
+                Path path = candidateRoot().resolve(
+                        "background-" + normalized + "." + extension);
+                if (!Files.isRegularFile(path)) {
+                    continue;
+                }
+                try {
+                    String mimeType = "jpg".equals(extension) ? "image/jpeg" : "image/png";
+                    return respond(path, Files.readAllBytes(path), mimeType);
+                } catch (Exception ex) {
+                    // 读取失败按不存在处理,回退下一候选/404
+                }
+            }
+        }
         for (Map.Entry<String, String> entry : BACKGROUND_FILES.entrySet()) {
             Path path = candidateRoot().resolve(entry.getKey());
             if (!Files.isRegularFile(path)) {

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

@@ -18,6 +18,11 @@ public final class Errors {
         return invalidArgument(null);
     }
 
+    /** Python: InvalidCaptchaError (400)——登录图片验证码缺失/错误/过期(安全需求4)。 */
+    public static DmsException invalidCaptcha() {
+        return new DmsException("INVALID_CAPTCHA", 400, "验证码错误或已过期");
+    }
+
     /** Python: InvalidCredentialsError (401)。 */
     public static DmsException invalidCredentials() {
         return new DmsException("INVALID_CREDENTIALS", 401, "用户名或密码错误");

+ 50 - 0
backend-java/src/main/java/com/dms/common/SecurityHeadersFilter.java

@@ -0,0 +1,50 @@
+package com.dms.common;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+/**
+ * API 安全响应头过滤器(纵深防御,G-13 整改),范围仅 /api/v1/**(SPA 静态资源不受影响):
+ *
+ * <ul>
+ *   <li>X-Content-Type-Options: nosniff —— 禁止浏览器对 JSON/附件/预览流做 MIME 嗅探;</li>
+ *   <li>X-Frame-Options: DENY —— 拒绝被嵌入 iframe,防点击劫持;</li>
+ *   <li>Content-Security-Policy: default-src 'none'; frame-ancestors 'none' ——
+ *       API 响应不是 HTML 文档、无需加载任何子资源,以最严格策略双保险;</li>
+ *   <li>Referrer-Policy: no-referrer —— 不向跳转目标泄露内网 API 地址;</li>
+ *   <li>Cache-Control: no-store —— 敏感文档元数据与附件流不允许落浏览器/代理缓存。
+ *       控制器/服务层已显式声明的缓存策略(如登录背景图短缓存、预览响应)在过滤链
+ *       更后端执行、自然覆盖本值,无需按路径豁免。</li>
+ * </ul>
+ *
+ * <p>CSRF 说明:本系统认证仅接受 Authorization: Bearer 请求头令牌,
+ * 全链路不使用 Cookie 承载会话(无 Set-Cookie),跨站请求无法自动携带凭证,
+ * 经典 CSRF 在此模型下不可达,故无需同步令牌模式;配合 CorsConfig 的
+ * Origin 白名单与 allowCredentials=false 构成完整闭环。</p>
+ */
+@Component
+public class SecurityHeadersFilter extends OncePerRequestFilter {
+
+    @Override
+    protected boolean shouldNotFilter(HttpServletRequest request) {
+        String path = request.getRequestURI();
+        return !(path.equals("/api/v1") || path.startsWith("/api/v1/"));
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+                                    FilterChain filterChain) throws ServletException, IOException {
+        response.setHeader("X-Content-Type-Options", "nosniff");
+        response.setHeader("X-Frame-Options", "DENY");
+        response.setHeader("Content-Security-Policy",
+                "default-src 'none'; frame-ancestors 'none'");
+        response.setHeader("Referrer-Policy", "no-referrer");
+        response.setHeader("Cache-Control", "no-store");
+        filterChain.doFilter(request, response);
+    }
+}

+ 24 - 0
backend-java/src/main/java/com/dms/config/DmsProperties.java

@@ -46,6 +46,10 @@ public class DmsProperties {
     private final long metricRetentionDays;
     /** 系统管理页监控信息展示(契约1.26):false 时前端仅显示维护操作与备份管理。 */
     private final boolean systemMonitorEnabled;
+    /** 登录图片验证码(安全需求4):缺省启用;关闭时登录回归三键请求体。 */
+    private final boolean captchaEnabled;
+    /** 超级管理员登录开关(安全需求7):false 时内置admin禁止登录、列表隐藏、创建伪报错。 */
+    private final boolean adminLoginAllowed;
     /** 本地单点登录代理(需求2):开关缺省关闭;代理与用户服务地址分别配置。 */
     private final boolean ssoProxyEnabled;
     private final String ssoProxyHost;
@@ -137,6 +141,16 @@ 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。
+        String captchaValue = environment.getProperty(
+                "DMS_CAPTCHA_ENABLED", "true").trim().toLowerCase(Locale.ROOT);
+        this.captchaEnabled =
+                Set.of("1", "true", "yes", "on").contains(captchaValue);
+        // 对齐 config.py(安全需求7):超级管理员登录开关,缺省 true。
+        String adminLoginValue = environment.getProperty(
+                "DMS_ALLOW_ADMIN_LOGIN", "true").trim().toLowerCase(Locale.ROOT);
+        this.adminLoginAllowed =
+                Set.of("1", "true", "yes", "on").contains(adminLoginValue);
         // 需求2:本地单点登录代理。代理与用户服务为两个独立对端,host/port 分别配置;
         // 缺省关闭(不探测、不影响账号密码登录)。
         String ssoEnabledValue = environment.getProperty(
@@ -334,6 +348,16 @@ public class DmsProperties {
         return systemMonitorEnabled;
     }
 
+    /** 登录图片验证码开关(安全需求4);未配置默认启用。 */
+    public boolean captchaEnabled() {
+        return captchaEnabled;
+    }
+
+    /** 超级管理员登录开关(安全需求7);未配置默认允许。 */
+    public boolean adminLoginAllowed() {
+        return adminLoginAllowed;
+    }
+
     /** 本地单点登录代理是否启用(需求2);未启用时前端静默探测直接回落。 */
     public boolean ssoProxyEnabled() {
         return ssoProxyEnabled;

+ 3 - 2
backend-java/src/main/java/com/dms/mapper/DocumentMapper.java

@@ -97,8 +97,9 @@ public interface DocumentMapper {
             + "<foreach collection='parentIds' item='p' open='(' separator=',' close=')'>"
             + "#{p}</foreach></if>"
             + "<if test='titlePattern != null'>AND document_name LIKE #{titlePattern} ESCAPE '\\'</if>"
-            + "<if test='contentPattern != null'>AND (content_text LIKE #{contentPattern} ESCAPE '\\' "
-            + "OR search_text LIKE #{contentPattern} ESCAPE '\\')</if>"
+            // 正文关键词严格匹配 content_text:search_text 含名称/概述/标签兜底词,
+            // OR 匹配会把正文解析失败但名称撞词的行错误召回(对齐 Python _advanced_filters)。
+            + "<if test='contentPattern != null'>AND content_text LIKE #{contentPattern} ESCAPE '\\'</if>"
             + "<if test='securityLevels != null'>AND security_level IN "
             + "<foreach collection='securityLevels' item='l' open='(' separator=',' close=')'>"
             + "#{l}</foreach></if>"

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

@@ -22,6 +22,11 @@ public interface RolePermissionMapper {
                                        @Param("securityLevel") String securityLevel,
                                        @Param("deptScope") String deptScope);
 
+    /** 会话 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")
+    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}, "
@@ -36,6 +41,7 @@ public interface RolePermissionMapper {
                          @Param("actorId") Long actorId);
 
     @Update("UPDATE sys_role_permission SET can_view = #{canView}, "
+            + "can_upload = #{canUpload}, "
             + "can_download = #{canDownload}, can_edit = #{canEdit}, "
             + "can_delete = #{canDelete}, can_config_permission = #{canConfigPermission}, "
             + "can_bind_attachment = #{canBindAttachment}, "

+ 6 - 2
backend-java/src/main/java/com/dms/mapper/UserMapper.java

@@ -91,6 +91,7 @@ public interface UserMapper {
     // ------------------------------------------------------------ 列表(动态过滤)
 
     @Select("<script>SELECT * FROM sys_user WHERE is_deleted = 0"
+            + "<if test='excludeUsername != null'> AND username != #{excludeUsername}</if>"
             + "<if test='organizationIds != null'> AND organization_id IN "
             + "<foreach item='item' collection='organizationIds' open='(' separator=',' close=')'>"
             + "#{item}</foreach></if>"
@@ -98,13 +99,15 @@ public interface UserMapper {
             + "OR real_name LIKE CONCAT('%', #{keyword}, '%'))</if>"
             + "<if test='status != null'> AND status = #{status}</if>"
             + " ORDER BY sort_no ASC, id ASC LIMIT #{limit} OFFSET #{offset}</script>")
-    List<UserEntity> pageByFilters(@Param("organizationIds") List<Long> organizationIds,
+    List<UserEntity> pageByFilters(@Param("excludeUsername") String excludeUsername,
+                                   @Param("organizationIds") List<Long> organizationIds,
                                    @Param("keyword") String keyword,
                                    @Param("status") String status,
                                    @Param("limit") int limit,
                                    @Param("offset") int offset);
 
     @Select("<script>SELECT COUNT(*) FROM sys_user WHERE is_deleted = 0"
+            + "<if test='excludeUsername != null'> AND username != #{excludeUsername}</if>"
             + "<if test='organizationIds != null'> AND organization_id IN "
             + "<foreach item='item' collection='organizationIds' open='(' separator=',' close=')'>"
             + "#{item}</foreach></if>"
@@ -112,7 +115,8 @@ public interface UserMapper {
             + "OR real_name LIKE CONCAT('%', #{keyword}, '%'))</if>"
             + "<if test='status != null'> AND status = #{status}</if>"
             + "</script>")
-    long countByFilters(@Param("organizationIds") List<Long> organizationIds,
+    long countByFilters(@Param("excludeUsername") String excludeUsername,
+                        @Param("organizationIds") List<Long> organizationIds,
                         @Param("keyword") String keyword,
                         @Param("status") String status);
 

+ 3 - 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 表实体(密级 × 部门范围 × 7 操作布尔位)。 */
+/** sys_role_permission 表实体(密级 × 部门范围 × 8 操作布尔位)。 */
 @Data
 public class RolePermissionEntity {
     private Long id;
@@ -18,6 +18,8 @@ public class RolePermissionEntity {
     private String securityLevel;
     private String deptScope;
     private Boolean canView;
+    /** 上传权限(契约1.31):仅 OWN 行有意义,OTHER 行恒为0(服务端拒绝置真) */
+    private Boolean canUpload;
     private Boolean canDownload;
     private Boolean canEdit;
     private Boolean canDelete;

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

@@ -8,6 +8,7 @@ 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;
@@ -27,26 +28,36 @@ public class AuthService {
     private final LoginIpService loginIpService;
     private final UserSummaryService userSummaryService;
     private final LoginLockoutService loginLockoutService;
+    private final CaptchaService captchaService;
     private final JwtTokens.Holder jwtHolder;
     private final DmsProperties dmsProperties;
 
     public AuthService(UserMapper userMapper, AuditService auditService,
                        LoginIpService loginIpService, UserSummaryService userSummaryService,
-                       LoginLockoutService loginLockoutService, JwtTokens.Holder jwtHolder,
-                       DmsProperties dmsProperties) {
+                       LoginLockoutService loginLockoutService, CaptchaService captchaService,
+                       JwtTokens.Holder jwtHolder, DmsProperties dmsProperties) {
         this.userMapper = userMapper;
         this.auditService = auditService;
         this.loginIpService = loginIpService;
         this.userSummaryService = userSummaryService;
         this.loginLockoutService = loginLockoutService;
+        this.captchaService = captchaService;
         this.jwtHolder = jwtHolder;
         this.dmsProperties = dmsProperties;
     }
 
     @Transactional
     public Map<String, Object> login(String username, String password, boolean keepSignedIn,
+                                     String captchaId, String captchaCode,
                                      HttpServletRequest request) {
-        UserEntity user = userMapper.findActiveByUsername(username);
+        // 验证码是防爆破第一道闸:先于用户查询/锁定检查,一次性消费不计锁定(安全需求4)
+        captchaService.verify(captchaId, captchaCode);
+        // 超级管理员登录开关(安全需求7)关闭时按用户不存在处理:锁定/状态/IP
+        // 检查全部短路,报错与密码错误完全一致,不泄露账号存在
+        boolean adminLoginBlocked = AdminPermission.SUPER_ADMIN_USERNAME.equals(username)
+                && !dmsProperties.adminLoginAllowed();
+        UserEntity user = adminLoginBlocked ? null
+                : userMapper.findActiveByUsername(username);
         // 锁定检查先于密码校验:锁定期间一律拒绝,不延长锁定窗口
         if (LoginLockoutService.isLocked(user)) {
             auditService.recordLoginFailure(request, user, username, "ACCOUNT_LOCKED");

+ 34 - 0
backend-java/src/main/java/com/dms/service/AuthorizationService.java

@@ -526,6 +526,40 @@ public class AuthorizationService {
         return ids == null || ids.isEmpty() ? List.of(-1L) : new ArrayList<>(ids);
     }
 
+    /**
+     * 对齐 can_upload_plan:主案上传权限(契约1.31),方案权限矩阵 can_upload 列。
+     *
+     * <p>超管恒可;文档归属本部门子树(或无归属)按 OWN 行判定,
+     * 归属其他部门按 OTHER 行判定(矩阵校验 OTHER 行 can_upload 恒 0,
+     * 即跨部门上传恒拒绝)。</p>
+     */
+    @Transactional(readOnly = true)
+    public boolean canUploadPlan(AuthContext context, String securityLevel,
+                                 Long organizationId) {
+        if (isSuperAdmin(context)) {
+            return true;
+        }
+        RoleEntity role = effectiveRole(context);
+        if (role == null) {
+            return false;
+        }
+        String scope = "OWN";
+        if (organizationId != null
+                && !organizationSubtreeIds(context.organizationId())
+                        .contains(organizationId)) {
+            scope = "OTHER";
+        }
+        RolePermissionEntity row = rolePermissionMapper.findMatrixRow(
+                role.getId(), securityLevel, scope);
+        return row != null && Boolean.TRUE.equals(row.getCanUpload());
+    }
+
+    /** 对齐 has_any_upload_permission:任一 OWN 行 can_upload=1(契约1.31)。 */
+    @Transactional(readOnly = true)
+    public boolean hasAnyUploadPermission(Long roleId) {
+        return roleId != null && rolePermissionMapper.countOwnUpload(roleId) > 0;
+    }
+
     /** 密级白名单(含自身及以下,对齐 category_tree 的 allowed_security_levels)。 */
     public static List<String> allowedSecurityLevels(String securityLevel) {
         int rank = order(securityLevel);

+ 149 - 0
backend-java/src/main/java/com/dms/service/CaptchaService.java

@@ -0,0 +1,149 @@
+package com.dms.service;
+
+import com.dms.common.DmsException;
+import com.dms.common.Errors;
+import com.dms.config.DmsProperties;
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.security.SecureRandom;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import org.springframework.stereotype.Service;
+
+/**
+ * 登录图片验证码(安全需求4),对齐 backend/dms/services/captcha_service.py:
+ *
+ * <ul>
+ *   <li>内存签发/校验(单实例部署),4 位字符(剔除易混淆 0O1I),120 秒过期;</li>
+ *   <li>校验一次性消费(无论成败),防止同一验证码反复试密码;</li>
+ *   <li>校验忽略大小写与首尾空白;BufferedImage 零依赖渲染 PNG(data URL)。</li>
+ * </ul>
+ */
+@Service
+public class CaptchaService {
+
+    private static final String CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
+    private static final int CODE_LENGTH = 4;
+    private static final long EXPIRES_MILLIS = 120_000L;
+    private static final int MAX_ENTRIES = 1000;
+    private static final int WIDTH = 132;
+    private static final int HEIGHT = 44;
+
+    private record Entry(String code, long expiresAtMillis) {
+    }
+
+    private final ConcurrentHashMap<String, Entry> entries = new ConcurrentHashMap<>();
+    private final SecureRandom random = new SecureRandom();
+    private final DmsProperties dmsProperties;
+
+    public CaptchaService(DmsProperties dmsProperties) {
+        this.dmsProperties = dmsProperties;
+    }
+
+    public boolean enabled() {
+        return dmsProperties.captchaEnabled();
+    }
+
+    /** 签发新验证码:captchaId + data:image/png;base64 图片。 */
+    public Map<String, Object> issue() {
+        String code = randomCode();
+        String id = UUID.randomUUID().toString().replace("-", "");
+        long now = System.currentTimeMillis();
+        purgeExpired(now);
+        if (entries.size() >= MAX_ENTRIES) {
+            // 上限保护:极端刷量时整体清空(存量本就即将过期,不影响正常用户)
+            entries.clear();
+        }
+        entries.put(id, new Entry(code, now + EXPIRES_MILLIS));
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("captchaId", id);
+        data.put("image", "data:image/png;base64," + renderPngBase64(code));
+        return data;
+    }
+
+    /**
+     * 校验并消费验证码;开关关闭时直接放行(登录回归三键契约)。
+     * 缺失/错误/过期均抛 INVALID_CAPTCHA,不泄露具体原因。
+     */
+    public void verify(String captchaId, String captchaCode) {
+        if (!enabled()) {
+            return;
+        }
+        if (captchaId == null || captchaId.isBlank()
+                || captchaCode == null || captchaCode.isBlank()) {
+            throw Errors.invalidCaptcha();
+        }
+        Entry entry = entries.remove(captchaId);
+        if (entry == null || entry.expiresAtMillis() <= System.currentTimeMillis()) {
+            throw Errors.invalidCaptcha();
+        }
+        if (!entry.code().equals(captchaCode.strip().toUpperCase(Locale.ROOT))) {
+            throw Errors.invalidCaptcha();
+        }
+    }
+
+    private String randomCode() {
+        StringBuilder code = new StringBuilder(CODE_LENGTH);
+        for (int i = 0; i < CODE_LENGTH; i++) {
+            code.append(CHARS.charAt(random.nextInt(CHARS.length())));
+        }
+        return code.toString();
+    }
+
+    /** 包内可见:测试断言用明文码快照(生产路径不得调用)。 */
+    Map<String, String> entriesForTest() {
+        Map<String, String> codes = new LinkedHashMap<>();
+        entries.forEach((id, entry) -> codes.put(id, entry.code()));
+        return codes;
+    }
+
+    private void purgeExpired(long nowMillis) {
+        entries.entrySet().removeIf(entry -> entry.getValue().expiresAtMillis() <= nowMillis);
+    }
+
+    private String renderPngBase64(String code) {
+        BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
+        Graphics2D graphics = image.createGraphics();
+        try {
+            graphics.setColor(new Color(245, 250, 255));
+            graphics.fillRect(0, 0, WIDTH, HEIGHT);
+            // 干扰线
+            for (int i = 0; i < 4; i++) {
+                graphics.setColor(darkColor());
+                graphics.drawLine(random.nextInt(WIDTH), random.nextInt(HEIGHT),
+                        random.nextInt(WIDTH), random.nextInt(HEIGHT));
+            }
+            // 字符:随机颜色/基线/字号
+            int x = 14;
+            for (char c : code.toCharArray()) {
+                graphics.setColor(darkColor());
+                graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 22 + random.nextInt(5)));
+                graphics.drawString(String.valueOf(c), x, 28 + random.nextInt(10));
+                x += 27;
+            }
+        } finally {
+            graphics.dispose();
+        }
+        ByteArrayOutputStream output = new ByteArrayOutputStream();
+        try {
+            javax.imageio.ImageIO.write(image, "png", output);
+        } catch (IOException ex) {
+            // 内存流写入不可能 IOException;防御性转内部错误
+            throw new DmsException("INTERNAL_ERROR", 500, "验证码图片生成失败");
+        }
+        return Base64.getEncoder().encodeToString(output.toByteArray());
+    }
+
+    /** 深色随机(保证与浅底对比可读)。 */
+    private Color darkColor() {
+        return new Color(random.nextInt(115), random.nextInt(115), random.nextInt(115));
+    }
+}

+ 7 - 0
backend-java/src/main/java/com/dms/service/DocumentMutationService.java

@@ -227,6 +227,13 @@ public class DocumentMutationService {
             DocumentEntity document = transactionTemplate.execute(status -> {
                 Org organization =
                         resolveOrganization(organizationIdInput, context, null);
+                // 上传权限(契约1.31):主案上传按(密级×部门范围)方案权限
+                // 矩阵 can_upload 校验(超管恒可;归属他部门按 OTHER 行恒拒);
+                // 共享附件挂载走挂载权限位,不受此限
+                if (!attachment && !authorizationService.canUploadPlan(
+                        context, security, organization == null ? null : organization.id())) {
+                    throw Errors.forbidden("无该密级方案的上传权限");
+                }
                 DocumentEntity duplicate = null;
                 if (!attachment) {
                     duplicate = documentMapper.findDuplicateForUpdate(name, docType,

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

@@ -22,8 +22,12 @@ public class LoginIpService {
         this.ipEntryMapper = ipEntryMapper;
     }
 
+    /**
+     * 仅内置 admin 账号豁免(管理员类别不再整体豁免,避免普通机构管理员
+     * 绕过本部门IP限制登录);机构未分配任何IP段仍放行。
+     */
     public void verifyLoginIp(UserEntity user, HttpServletRequest request) {
-        if ("ADMIN".equals(user.getRoleCode()) || user.getOrganizationId() == null) {
+        if (AdminPermission.isSuperAdmin(user) || user.getOrganizationId() == null) {
             return;
         }
         List<String> ipValues =

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

@@ -6,6 +6,7 @@ import com.dms.common.Payloads;
 import com.dms.common.Reorders;
 import com.dms.mapper.DocumentMapper;
 import com.dms.mapper.OrganizationMapper;
+import com.dms.mapper.UserMapper;
 import com.dms.model.OrganizationEntity;
 import com.dms.model.UserEntity;
 import com.dms.security.AuthContext;
@@ -42,15 +43,18 @@ public class OrganizationService {
     private final DocumentMapper documentMapper;
     private final AuditService auditService;
     private final AdminPermission adminPermission;
+    private final UserMapper userMapper;
 
     public OrganizationService(OrganizationMapper organizationMapper,
                                DocumentMapper documentMapper,
                                AuditService auditService,
-                               AdminPermission adminPermission) {
+                               AdminPermission adminPermission,
+                               UserMapper userMapper) {
         this.organizationMapper = organizationMapper;
         this.documentMapper = documentMapper;
         this.auditService = auditService;
         this.adminPermission = adminPermission;
+        this.userMapper = userMapper;
     }
 
     // ------------------------------------------------------------ 树查询
@@ -62,6 +66,19 @@ public class OrganizationService {
     @Transactional(readOnly = true)
     public List<Map<String, Object>> organizationTree(String keyword, String status) {
         List<OrganizationEntity> organizations = organizationMapper.listAllNotDeleted();
+        // 安全需求8:锚点管理员的机构树裁剪到管理范围子树(全局管理员不受限)。
+        // 机构页/用户页/用户机构调整下拉均消费本接口,裁剪后各处只见可管理机构。
+        Long currentUserId = AuthContext.get().userId();
+        if (currentUserId != null) {
+            UserEntity actor = userMapper.findActiveById(currentUserId);
+            if (actor != null) {
+                Set<Long> scope = adminPermission.managedScopeIds(actor);
+                if (scope != null) {
+                    organizations = organizations.stream()
+                            .filter(item -> scope.contains(item.getId())).toList();
+                }
+            }
+        }
         Map<Long, OrganizationEntity> byId = new HashMap<>();
         for (OrganizationEntity item : organizations) {
             byId.put(item.getId(), item);

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

@@ -49,9 +49,10 @@ 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");
 
-    /** 矩阵 7 个操作布尔位(camel → 实体字段一一对应)。 */
+    /** 矩阵 8 个操作布尔位(camel → 实体字段一一对应;契约1.31 新增 canUpload,
+     *  canBind/canUnbind 界面合并为单一「挂载」开关,两列恒同值)。 */
     private static final List<String> PERMISSION_FLAG_NAMES = List.of(
-            "canView", "canDownload", "canEdit", "canDelete",
+            "canView", "canUpload", "canDownload", "canEdit", "canDelete",
             "canConfigPermission", "canBindAttachment", "canUnbindAttachment");
 
     private static final List<String> SECURITY_LEVELS = List.of(
@@ -572,6 +573,7 @@ public class RoleService {
         dto.put("securityLevel", row.getSecurityLevel());
         dto.put("deptScope", row.getDeptScope());
         dto.put("canView", row.getCanView());
+        dto.put("canUpload", row.getCanUpload());
         dto.put("canDownload", row.getCanDownload());
         dto.put("canEdit", row.getCanEdit());
         dto.put("canDelete", row.getCanDelete());
@@ -613,6 +615,10 @@ public class RoleService {
             for (String name : PERMISSION_FLAG_NAMES) {
                 Payloads.booleanValue(row.get(name), name);
             }
+            // 上传权限仅限本部门(OWN)行:其他部门行恒不可选(契约1.31)
+            if ("OTHER".equals(scope) && Boolean.TRUE.equals(row.get("canUpload"))) {
+                throw Errors.invalidArgument("上传权限仅可设置本部门方案");
+            }
             rows.add(key);
         }
         return (List<Map<String, Object>>) value;
@@ -621,6 +627,7 @@ public class RoleService {
     private static Map<String, Object> entityFlags(RolePermissionEntity row) {
         Map<String, Object> flags = new LinkedHashMap<>();
         flags.put("canView", row.getCanView());
+        flags.put("canUpload", row.getCanUpload());
         flags.put("canDownload", row.getCanDownload());
         flags.put("canEdit", row.getCanEdit());
         flags.put("canDelete", row.getCanDelete());
@@ -640,6 +647,7 @@ public class RoleService {
 
     private static void applyFlags(RolePermissionEntity row, Map<String, Object> item) {
         row.setCanView((Boolean) item.get("canView"));
+        row.setCanUpload((Boolean) item.get("canUpload"));
         row.setCanDownload((Boolean) item.get("canDownload"));
         row.setCanEdit((Boolean) item.get("canEdit"));
         row.setCanDelete((Boolean) item.get("canDelete"));

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

@@ -5,6 +5,7 @@ import com.dms.common.Errors;
 import com.dms.common.Page;
 import com.dms.common.Payloads;
 import com.dms.common.Reorders;
+import com.dms.config.DmsProperties;
 import com.dms.mapper.OrganizationMapper;
 import com.dms.mapper.RoleMapper;
 import com.dms.mapper.UserMapper;
@@ -55,11 +56,12 @@ public class UserService {
     private final AuditService auditService;
     private final AdminPermission adminPermission;
     private final Reauth reauth;
+    private final DmsProperties dmsProperties;
 
     public UserService(UserMapper userMapper, OrganizationMapper organizationMapper,
                        RoleMapper roleMapper, UserSummaryService userSummaryService,
                        AuditService auditService, AdminPermission adminPermission,
-                       Reauth reauth) {
+                       Reauth reauth, DmsProperties dmsProperties) {
         this.userMapper = userMapper;
         this.organizationMapper = organizationMapper;
         this.roleMapper = roleMapper;
@@ -67,6 +69,7 @@ public class UserService {
         this.auditService = auditService;
         this.adminPermission = adminPermission;
         this.reauth = reauth;
+        this.dmsProperties = dmsProperties;
     }
 
     // ------------------------------------------------------------ 列表查询
@@ -112,9 +115,13 @@ public class UserService {
         String statusValue = status != null && !status.isEmpty()
                 ? Payloads.statusValue(status) : null;
 
-        long total = userMapper.countByFilters(organizationIds, keywordValue, statusValue);
+        // 超级管理员登录开关(安全需求7)关闭时用户列表隐藏内置 admin
+        String excludeUsername = dmsProperties.adminLoginAllowed() ? null
+                : AdminPermission.SUPER_ADMIN_USERNAME;
+        long total = userMapper.countByFilters(
+                excludeUsername, organizationIds, keywordValue, statusValue);
         List<UserEntity> users = userMapper.pageByFilters(
-                organizationIds, keywordValue, statusValue,
+                excludeUsername, organizationIds, keywordValue, statusValue,
                 pageRequest.pageSize(), pageRequest.offset());
         List<Map<String, Object>> items = new ArrayList<>(users.size());
         for (UserEntity user : users) {
@@ -139,6 +146,12 @@ public class UserService {
         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();

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

@@ -77,6 +77,9 @@ public class UserSummaryService {
                 && Boolean.TRUE.equals(manageRole.getManageAuditLog())));
         result.put("permRoleEdit", superAdmin || (manageRole != null
                 && Boolean.TRUE.equals(manageRole.getManageRoleEdit())));
+        // 上传权限(契约1.31):生效角色方案权限矩阵任一 OWN 行 can_upload=1
+        result.put("permUpload", superAdmin || (manageRole != null
+                && authorizationService.hasAnyUploadPermission(manageRole.getId())));
         // 用户级管理范围(契约1.30):null=全局、"SELF"=跟随本人所在机构、机构ID=固定锚点;
         // 实际生效范围以角色锚点为准,角色为 SELF 时才回落本设置;内置 admin 恒全局。
         result.put("managedOrgId", managedOrgValue(superAdmin, user.getManagedOrgId()));

+ 4 - 3
backend-java/src/main/java/com/dms/web/SpaController.java

@@ -50,7 +50,7 @@ public class SpaController {
         if (relative.startsWith("api/")) {
             return ResponseEntity.status(HttpStatus.NOT_FOUND)
                     .contentType(MediaType.APPLICATION_JSON)
-                    .body(Map.of("error", "Not found"));
+                    .body(Map.of("error", "无权限访问"));
         }
 
         Resource file = frontendAssets.resolveFile(relative);
@@ -68,10 +68,11 @@ public class SpaController {
                     .body(index);
         }
         // 前端无路径路由(仅 query 传参),SPA 回退只需覆盖根路径;
-        // 合法 URL/API 之外的未知路径一律 404,不返回 wildcard 200(安全收敛)
+        // 合法 URL/API 之外的未知路径一律 404 且提示「无权限」(不泄露路径存在性,
+        // 与登录前深链接行为统一:未登录无论输入合法非法路径都只见「无权限」)
         return ResponseEntity.status(HttpStatus.NOT_FOUND)
                 .contentType(MediaType.APPLICATION_JSON)
-                .body(Map.of("error", "Not found"));
+                .body(Map.of("error", "无权限访问"));
     }
 
     private static final String INDEX_NAME = "index.html";

+ 10 - 0
backend-java/src/main/resources/application.yml

@@ -1,6 +1,16 @@
 # 对齐 launcher.py:DMS_HOST 默认 0.0.0.0(即 Spring Boot 默认),DMS_PORT 默认 9345
 server:
   port: ${DMS_PORT:9345}
+  tomcat:
+    # 并发连接限制(默认值与 Spring Boot/Tomcat 缺省一致,可经环境变量收紧):
+    # threads.max 工作线程池上限(同时处理的请求数);
+    # max-connections 任一时刻接受的最大连接数(超出后暂停接受新连接);
+    # accept-count max-connections 满载后等待队列长度(再超出直接拒绝连接)。
+    threads:
+      max: ${DMS_TOMCAT_THREADS_MAX:200}
+      min-spare: ${DMS_TOMCAT_THREADS_MIN_SPARE:10}
+    max-connections: ${DMS_TOMCAT_MAX_CONNECTIONS:8192}
+    accept-count: ${DMS_TOMCAT_ACCEPT_COUNT:100}
 
 # CORS 跨域白名单(逗号分隔 Origin,环境变量 DMS_CORS_ORIGINS 与 Python 版同名同义);
 # 默认为空 = 不启用跨域(前端 dist 由本服务同源托管),开发分域时按需配置。

+ 4 - 4
backend-java/src/main/resources/db/migration-dm/V12__ui_theme.sql

@@ -22,7 +22,7 @@ BEGIN
           AND COLUMN_NAME = 'UI_THEME';
     IF v_count = 0 THEN
         EXECUTE IMMEDIATE
-            'ALTER TABLE sys_user ADD COLUMN ui_theme VARCHAR(12) NOT NULL DEFAULT ''blue''';
+            'ALTER TABLE SYS_USER ADD COLUMN UI_THEME VARCHAR(12) NOT NULL DEFAULT ''blue''';
     END IF;
 END;
 /
@@ -37,11 +37,11 @@ BEGIN
           AND CONSTRAINT_NAME = 'CK_SYS_USER_USER_UI_THEME';
     IF v_count > 0 THEN
         EXECUTE IMMEDIATE
-            'ALTER TABLE sys_user DROP CONSTRAINT CK_SYS_USER_USER_UI_THEME';
+            'ALTER TABLE SYS_USER DROP CONSTRAINT CK_SYS_USER_USER_UI_THEME';
     END IF;
 END;
 /
 
 -- 3. 重建 CHECK 白名单(不带引号,达梦自动大写,与探测名一致)
-ALTER TABLE sys_user ADD CONSTRAINT ck_sys_user_user_ui_theme
-    CHECK (ui_theme IN ('blue', 'red', 'green'));
+ALTER TABLE SYS_USER ADD CONSTRAINT CK_SYS_USER_USER_UI_THEME
+    CHECK (UI_THEME IN ('blue', 'red', 'green'));

+ 354 - 0
backend-java/src/main/resources/db/migration-dm/V12_to_V17__merge.sql

@@ -0,0 +1,354 @@
+-- ============================================================================
+-- 达梦合并升级脚本:V12 → V17(一站式,供 DISQL / DM 管理工具手工执行)
+-- 基线:数据库已执行到 V12(sys_user.ui_theme 已存在)。
+-- 内容:合并 V13~V17 全部变更,并把库中小写表名/列名统一为大写:
+--   0. 小写表名/列名 → 大写(探测式;已全大写的库此步零操作);
+--   1. sys_role 加 manage_system / manage_audit_log(V14);
+--   2. 新建 sys_metric_sample 资源采样表 + 时间索引(V14);
+--   3. sys_role 加 manage_role_edit(V15);
+--   4. sys_user 加 sort_no(V16);
+--   5. sys_user 加 managed_org_id + 存量回填哨兵0(V17,契约1.30);
+--   6. 重建 sys_audit_log 两组 CHECK 白名单为终态(动作45项 / 目标12项,
+--      已涵盖 V13(39/11)、V14(42/12) 各中间态,无需逐版重建)。
+-- 执行方式:DISQL 登录业务模式后先执行 SET SERVEROUTPUT ON; 再运行本文件;
+--   建议先备份。大小写纠正作用于当前登录模式的全部表,若模式内混有
+--   其他业务系统的小写表请先评估。
+-- 大小写说明:达梦大小写敏感实例下不带引号标识符自动转大写存储,
+--   带引号小写标识符保持小写——历史脚本/工具建出的小写对象使后续
+--   不带引号的 DDL 找不到目标而"概率失败",故先统一为大写再执行增量。
+-- 改名健壮性:每条改名语句独立捕获异常(单条失败不影响其余并输出原因);
+--   列改名先试 DM8 形式 ALTER COLUMN ... RENAME TO,失败自动回退 Oracle
+--   兼容形式 RENAME COLUMN ... TO;表改名失败回退 RENAME 语句形式;
+--   结尾输出成功/失败计数。若提示"发现待改名小写表 0 个"但库中仍有
+--   小写对象,说明登录模式不是业务对象所在模式,请切换模式后重跑。
+-- 幂等:全部先探测后执行,重复运行无副作用;若曾部分执行过 V13~V17
+--   单版脚本,本脚本自动跳过已生效部分、约束重建为终态集合。
+-- ============================================================================
+
+-- ----------------------------------------------------------------------------
+-- 0.1 表名小写 → 大写(先物化清单再改名;逐条异常隔离;两名并存时跳过)
+-- ----------------------------------------------------------------------------
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(200) INDEX BY BINARY_INTEGER;
+    v_tables t_list;
+    v_total INT := 0;
+    v_conflict INT;
+    v_ok INT := 0;
+    v_fail INT := 0;
+    v_skip INT := 0;
+BEGIN
+    FOR t IN (SELECT TABLE_NAME FROM USER_TABLES
+              WHERE TABLE_NAME <> UPPER(TABLE_NAME)) LOOP
+        v_total := v_total + 1;
+        v_tables(v_total) := t.TABLE_NAME;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('发现待改名小写表 ' || v_total || ' 个');
+    FOR i IN 1 .. v_total LOOP
+        SELECT COUNT(*) INTO v_conflict FROM USER_TABLES
+            WHERE TABLE_NAME = UPPER(v_tables(i));
+        IF v_conflict > 0 THEN
+            v_skip := v_skip + 1;
+            DBMS_OUTPUT.PUT_LINE('警告: ' || v_tables(i) || ' 与 '
+                || UPPER(v_tables(i)) || ' 并存,请人工合并后重跑本脚本');
+        ELSE
+            BEGIN
+                EXECUTE IMMEDIATE 'ALTER TABLE "' || v_tables(i)
+                    || '" RENAME TO ' || UPPER(v_tables(i));
+                v_ok := v_ok + 1;
+                DBMS_OUTPUT.PUT_LINE('表已改名: ' || v_tables(i)
+                    || ' -> ' || UPPER(v_tables(i)));
+            EXCEPTION WHEN OTHERS THEN
+                BEGIN
+                    EXECUTE IMMEDIATE 'RENAME "' || v_tables(i)
+                        || '" TO ' || UPPER(v_tables(i));
+                    v_ok := v_ok + 1;
+                    DBMS_OUTPUT.PUT_LINE('表已改名(RENAME语句): ' || v_tables(i)
+                        || ' -> ' || UPPER(v_tables(i)));
+                EXCEPTION WHEN OTHERS THEN
+                    v_fail := v_fail + 1;
+                    DBMS_OUTPUT.PUT_LINE('表改名失败: ' || v_tables(i)
+                        || ' , 原因: ' || SQLERRM);
+                END;
+            END;
+        END IF;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('表名处理完成: 成功 ' || v_ok || ' , 失败 '
+        || v_fail || ' , 跳过并存 ' || v_skip);
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 0.2 列名小写 → 大写(仅处理基表列;先物化清单再改名,避免游标失效;
+--     逐条异常隔离;先试 DM8 形式 ALTER TABLE ... ALTER COLUMN "旧"
+--     RENAME TO 新,失败自动回退 Oracle 兼容形式
+--     ALTER TABLE ... RENAME COLUMN "旧" TO 新)
+-- ----------------------------------------------------------------------------
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(300) INDEX BY BINARY_INTEGER;
+    v_cols t_list;
+    v_total INT := 0;
+    v_pos INT;
+    v_table VARCHAR(128);
+    v_column VARCHAR(128);
+    v_ok INT := 0;
+    v_fail INT := 0;
+BEGIN
+    FOR c IN (SELECT TABLE_NAME, COLUMN_NAME FROM USER_TAB_COLUMNS
+              WHERE COLUMN_NAME <> UPPER(COLUMN_NAME)
+                AND TABLE_NAME IN (SELECT TABLE_NAME FROM USER_TABLES)) LOOP
+        v_total := v_total + 1;
+        v_cols(v_total) := c.TABLE_NAME || '.' || c.COLUMN_NAME;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('发现待改名小写列 ' || v_total || ' 个');
+    FOR i IN 1 .. v_total LOOP
+        v_pos := INSTR(v_cols(i), '.');
+        v_table := SUBSTR(v_cols(i), 1, v_pos - 1);
+        v_column := SUBSTR(v_cols(i), v_pos + 1);
+        BEGIN
+            EXECUTE IMMEDIATE 'ALTER TABLE "' || v_table || '" ALTER COLUMN "'
+                || v_column || '" RENAME TO ' || UPPER(v_column);
+            v_ok := v_ok + 1;
+            DBMS_OUTPUT.PUT_LINE('列已改名: ' || v_cols(i)
+                || ' -> ' || UPPER(v_column));
+        EXCEPTION WHEN OTHERS THEN
+            BEGIN
+                EXECUTE IMMEDIATE 'ALTER TABLE "' || v_table
+                    || '" RENAME COLUMN "' || v_column
+                    || '" TO ' || UPPER(v_column);
+                v_ok := v_ok + 1;
+                DBMS_OUTPUT.PUT_LINE('列已改名(Oracle语法): ' || v_cols(i)
+                    || ' -> ' || UPPER(v_column));
+            EXCEPTION WHEN OTHERS THEN
+                v_fail := v_fail + 1;
+                DBMS_OUTPUT.PUT_LINE('列改名失败: ' || v_cols(i)
+                    || ' , 原因: ' || SQLERRM);
+            END;
+        END;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('列名处理完成: 成功 ' || v_ok || ' , 失败 ' || v_fail);
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 1. sys_role 加 manage_system / manage_audit_log(V14;已存在则跳过)
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_ROLE'
+          AND COLUMN_NAME = 'MANAGE_SYSTEM';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_ROLE ADD COLUMN MANAGE_SYSTEM TINYINT NOT NULL DEFAULT 0';
+    END IF;
+END;
+/
+
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_ROLE'
+          AND COLUMN_NAME = 'MANAGE_AUDIT_LOG';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_ROLE ADD COLUMN MANAGE_AUDIT_LOG TINYINT NOT NULL DEFAULT 0';
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 2. 资源采样表 sys_metric_sample(V14;已存在则整段跳过,索引随表创建)
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TABLES
+        WHERE TABLE_NAME = 'SYS_METRIC_SAMPLE';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE 'CREATE TABLE SYS_METRIC_SAMPLE (
+  ID BIGINT IDENTITY(1, 1) NOT NULL,
+  SAMPLED_AT TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL,
+  CPU_PERCENT FLOAT DEFAULT NULL,
+  MEM_USED_BYTES BIGINT NOT NULL,
+  MEM_TOTAL_BYTES BIGINT NOT NULL,
+  PRIMARY KEY (ID)
+)';
+        EXECUTE IMMEDIATE
+            'CREATE INDEX IX_SYS_METRIC_SAMPLE_TIME ON SYS_METRIC_SAMPLE (SAMPLED_AT)';
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 3. sys_role 加 manage_role_edit(V15;已存在则跳过)
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_ROLE'
+          AND COLUMN_NAME = 'MANAGE_ROLE_EDIT';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_ROLE ADD COLUMN MANAGE_ROLE_EDIT TINYINT NOT NULL DEFAULT 0';
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 4. sys_user 加 sort_no(V16;已存在则跳过,NOT NULL DEFAULT 0 自动回填存量行)
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_USER'
+          AND COLUMN_NAME = 'SORT_NO';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_USER ADD COLUMN SORT_NO INT NOT NULL DEFAULT 0';
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 5. sys_user 加 managed_org_id + 存量回填哨兵0(V17,契约1.30)
+--    回填仅在本次真正执行加列时进行(v_count 在加列前取样),
+--    防止 Alembic 0032 先跑后本脚本把显式 NULL 覆盖回 0。
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_USER'
+          AND COLUMN_NAME = 'MANAGED_ORG_ID';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_USER ADD COLUMN MANAGED_ORG_ID BIGINT NULL DEFAULT 0';
+        EXECUTE IMMEDIATE
+            'UPDATE SYS_USER SET MANAGED_ORG_ID = 0 WHERE MANAGED_ORG_ID IS NULL';
+        COMMIT;
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 6. 重建 sys_audit_log 两组 CHECK 白名单为终态(V16 的 45 项动作 / V14 的 12 项目标)
+--    按 UPPER(约束名) 匹配删除任意大小写形态(含历史小写约束,防止与新
+--    大写约束双生效导致新动作插入被拒),再以不带引号大写名重建。
+-- ----------------------------------------------------------------------------
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(200) INDEX BY BINARY_INTEGER;
+    v_names t_list;
+    v_total INT := 0;
+BEGIN
+    FOR c IN (SELECT CONSTRAINT_NAME FROM USER_CONSTRAINTS
+              WHERE TABLE_NAME = 'SYS_AUDIT_LOG'
+                AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE') LOOP
+        v_total := v_total + 1;
+        v_names(v_total) := c.CONSTRAINT_NAME;
+    END LOOP;
+    FOR i IN 1 .. v_total LOOP
+        EXECUTE IMMEDIATE 'ALTER TABLE SYS_AUDIT_LOG DROP CONSTRAINT "'
+            || v_names(i) || '"';
+    END LOOP;
+END;
+/
+
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(200) INDEX BY BINARY_INTEGER;
+    v_names t_list;
+    v_total INT := 0;
+BEGIN
+    FOR c IN (SELECT CONSTRAINT_NAME FROM USER_CONSTRAINTS
+              WHERE TABLE_NAME = 'SYS_AUDIT_LOG'
+                AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE') LOOP
+        v_total := v_total + 1;
+        v_names(v_total) := c.CONSTRAINT_NAME;
+    END LOOP;
+    FOR i IN 1 .. v_total LOOP
+        EXECUTE IMMEDIATE 'ALTER TABLE SYS_AUDIT_LOG DROP CONSTRAINT "'
+            || v_names(i) || '"';
+    END LOOP;
+END;
+/
+
+-- 重建动作白名单(终态 45 项,不带引号自动大写,与探测名一致)
+ALTER TABLE SYS_AUDIT_LOG
+ADD CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE CHECK (
+    ACTION_TYPE IN (
+        'LOGIN',
+        'LOGOUT',
+        'VIEW_DOCUMENT',
+        'DOWNLOAD_DOCUMENT',
+        'UPLOAD_DOCUMENT',
+        'BATCH_IMPORT',
+        'EDIT_DOCUMENT',
+        'DELETE_DOCUMENT',
+        'CREATE_CATEGORY',
+        'EDIT_CATEGORY',
+        'DELETE_CATEGORY',
+        'CHANGE_PERMISSION',
+        'BIND_ATTACHMENT',
+        'UNBIND_ATTACHMENT',
+        'RESTORE_DOCUMENT',
+        'CREATE_ORGANIZATION',
+        'EDIT_ORGANIZATION',
+        'DELETE_ORGANIZATION',
+        'CREATE_USER',
+        'EDIT_USER',
+        'DELETE_USER',
+        'RESET_USER_PASSWORD',
+        'REORDER_DOCUMENT',
+        'REORDER_ATTACHMENT',
+        'REORDER_CATEGORY',
+        'CREATE_ROLE',
+        'EDIT_ROLE',
+        'DELETE_ROLE',
+        'CHANGE_ROLE_PERMISSION',
+        'CHANGE_ROLE_USER',
+        'DOCUMENT_PASSWORD_SET',
+        'DOCUMENT_PASSWORD_REMOVE',
+        'CREATE_IP_ENTRY',
+        'EDIT_IP_ENTRY',
+        'DELETE_IP_ENTRY',
+        'ASSIGN_ORG_IP',
+        'MOVE_DOCUMENT',
+        'MOVE_ATTACHMENT',
+        'BACKUP_CREATE',
+        'PREVIEW_CACHE_CLEAR',
+        'FORCE_OFFLINE',
+        'BACKUP_TEST_TARGET',
+        'REORDER_ORGANIZATION',
+        'REORDER_USER',
+        'REORDER_ROLE'
+    )
+);
+
+-- 重建目标白名单(终态 12 项)
+ALTER TABLE SYS_AUDIT_LOG
+ADD CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE CHECK (
+    TARGET_TYPE IS NULL
+    OR TARGET_TYPE IN (
+        'AUTH',
+        'CATEGORY',
+        'DOCUMENT',
+        'ATTACHMENT',
+        'PERMISSION',
+        'ATTACHMENT_BINDING',
+        'ORGANIZATION',
+        'USER',
+        'ROLE',
+        'IP_ENTRY',
+        'BACKUP',
+        'SYSTEM'
+    )
+);

+ 6 - 6
backend-java/src/main/resources/db/migration-dm/V13__backup_audit_actions.sql

@@ -22,7 +22,7 @@ BEGIN
           AND CONSTRAINT_NAME = 'CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE';
     IF v_count > 0 THEN
         EXECUTE IMMEDIATE
-            'ALTER TABLE sys_audit_log DROP CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE';
+            'ALTER TABLE SYS_AUDIT_LOG DROP CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE';
     END IF;
 END;
 /
@@ -36,14 +36,14 @@ BEGIN
           AND CONSTRAINT_NAME = 'CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE';
     IF v_count > 0 THEN
         EXECUTE IMMEDIATE
-            'ALTER TABLE sys_audit_log DROP CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE';
+            'ALTER TABLE SYS_AUDIT_LOG DROP CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE';
     END IF;
 END;
 /
 
 -- 2. 重建 CHECK 白名单(不带引号,达梦自动大写,与探测名一致)
-ALTER TABLE sys_audit_log ADD CONSTRAINT ck_sys_audit_log_audit_action_type
-    CHECK (action_type IN (
+ALTER TABLE SYS_AUDIT_LOG ADD CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE
+    CHECK (ACTION_TYPE IN (
         'LOGIN', 'LOGOUT', 'VIEW_DOCUMENT', 'DOWNLOAD_DOCUMENT',
         'UPLOAD_DOCUMENT', 'BATCH_IMPORT', 'EDIT_DOCUMENT', 'DELETE_DOCUMENT',
         'CREATE_CATEGORY', 'EDIT_CATEGORY', 'DELETE_CATEGORY', 'CHANGE_PERMISSION',
@@ -56,8 +56,8 @@ ALTER TABLE sys_audit_log ADD CONSTRAINT ck_sys_audit_log_audit_action_type
         'CREATE_IP_ENTRY', 'EDIT_IP_ENTRY', 'DELETE_IP_ENTRY', 'ASSIGN_ORG_IP',
         'MOVE_DOCUMENT', 'MOVE_ATTACHMENT', 'BACKUP_CREATE'));
 
-ALTER TABLE sys_audit_log ADD CONSTRAINT ck_sys_audit_log_audit_target_type
-    CHECK (target_type IS NULL OR target_type IN (
+ALTER TABLE SYS_AUDIT_LOG ADD CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE
+    CHECK (TARGET_TYPE IS NULL OR TARGET_TYPE IN (
         'AUTH', 'CATEGORY', 'DOCUMENT', 'ATTACHMENT', 'PERMISSION',
         'ATTACHMENT_BINDING', 'ORGANIZATION', 'USER', 'ROLE', 'IP_ENTRY',
         'BACKUP'));

+ 354 - 0
backend-java/src/main/resources/db/migration-dm/V13_to_V17__merge.sql

@@ -0,0 +1,354 @@
+-- ============================================================================
+-- 达梦合并升级脚本:V13 → V17(一站式,供 DISQL / DM 管理工具手工执行)
+-- 基线:数据库已执行到 V13(sys_audit_log 白名单为 V13 的 39/11 中间态)。
+-- 内容:合并 V14~V17 全部变更,并把库中小写表名/列名统一为大写:
+--   0. 小写表名/列名 → 大写(探测式;已全大写的库此步零操作);
+--   1. sys_role 加 manage_system / manage_audit_log(V14);
+--   2. 新建 sys_metric_sample 资源采样表 + 时间索引(V14);
+--   3. sys_role 加 manage_role_edit(V15);
+--   4. sys_user 加 sort_no(V16);
+--   5. sys_user 加 managed_org_id + 存量回填哨兵0(V17,契约1.30);
+--   6. 重建 sys_audit_log 两组 CHECK 白名单为终态(动作45项 / 目标12项,
+--      直接覆盖 V13 的 39/11 与 V14 的 42/12 中间态,无需逐版重建)。
+-- 执行方式:DISQL 登录业务模式后先执行 SET SERVEROUTPUT ON; 再运行本文件;
+--   建议先备份。大小写纠正作用于当前登录模式的全部表,若模式内混有
+--   其他业务系统的小写表请先评估。
+-- 大小写说明:达梦大小写敏感实例下不带引号标识符自动转大写存储,
+--   带引号小写标识符保持小写——历史脚本/工具建出的小写对象使后续
+--   不带引号的 DDL 找不到目标而"概率失败",故先统一为大写再执行增量。
+-- 改名健壮性:每条改名语句独立捕获异常(单条失败不影响其余并输出原因);
+--   列改名先试 DM8 形式 ALTER COLUMN ... RENAME TO,失败自动回退 Oracle
+--   兼容形式 RENAME COLUMN ... TO;表改名失败回退 RENAME 语句形式;
+--   结尾输出成功/失败计数。若提示"发现待改名小写表 0 个"但库中仍有
+--   小写对象,说明登录模式不是业务对象所在模式,请切换模式后重跑。
+-- 幂等:全部先探测后执行,重复运行无副作用;若曾部分执行过 V14~V17
+--   单版脚本,本脚本自动跳过已生效部分、约束重建为终态集合。
+-- ============================================================================
+
+-- ----------------------------------------------------------------------------
+-- 0.1 表名小写 → 大写(先物化清单再改名;逐条异常隔离;两名并存时跳过)
+-- ----------------------------------------------------------------------------
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(200) INDEX BY BINARY_INTEGER;
+    v_tables t_list;
+    v_total INT := 0;
+    v_conflict INT;
+    v_ok INT := 0;
+    v_fail INT := 0;
+    v_skip INT := 0;
+BEGIN
+    FOR t IN (SELECT TABLE_NAME FROM USER_TABLES
+              WHERE TABLE_NAME <> UPPER(TABLE_NAME)) LOOP
+        v_total := v_total + 1;
+        v_tables(v_total) := t.TABLE_NAME;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('发现待改名小写表 ' || v_total || ' 个');
+    FOR i IN 1 .. v_total LOOP
+        SELECT COUNT(*) INTO v_conflict FROM USER_TABLES
+            WHERE TABLE_NAME = UPPER(v_tables(i));
+        IF v_conflict > 0 THEN
+            v_skip := v_skip + 1;
+            DBMS_OUTPUT.PUT_LINE('警告: ' || v_tables(i) || ' 与 '
+                || UPPER(v_tables(i)) || ' 并存,请人工合并后重跑本脚本');
+        ELSE
+            BEGIN
+                EXECUTE IMMEDIATE 'ALTER TABLE "' || v_tables(i)
+                    || '" RENAME TO ' || UPPER(v_tables(i));
+                v_ok := v_ok + 1;
+                DBMS_OUTPUT.PUT_LINE('表已改名: ' || v_tables(i)
+                    || ' -> ' || UPPER(v_tables(i)));
+            EXCEPTION WHEN OTHERS THEN
+                BEGIN
+                    EXECUTE IMMEDIATE 'RENAME "' || v_tables(i)
+                        || '" TO ' || UPPER(v_tables(i));
+                    v_ok := v_ok + 1;
+                    DBMS_OUTPUT.PUT_LINE('表已改名(RENAME语句): ' || v_tables(i)
+                        || ' -> ' || UPPER(v_tables(i)));
+                EXCEPTION WHEN OTHERS THEN
+                    v_fail := v_fail + 1;
+                    DBMS_OUTPUT.PUT_LINE('表改名失败: ' || v_tables(i)
+                        || ' , 原因: ' || SQLERRM);
+                END;
+            END;
+        END IF;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('表名处理完成: 成功 ' || v_ok || ' , 失败 '
+        || v_fail || ' , 跳过并存 ' || v_skip);
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 0.2 列名小写 → 大写(仅处理基表列;先物化清单再改名,避免游标失效;
+--     逐条异常隔离;先试 DM8 形式 ALTER TABLE ... ALTER COLUMN "旧"
+--     RENAME TO 新,失败自动回退 Oracle 兼容形式
+--     ALTER TABLE ... RENAME COLUMN "旧" TO 新)
+-- ----------------------------------------------------------------------------
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(300) INDEX BY BINARY_INTEGER;
+    v_cols t_list;
+    v_total INT := 0;
+    v_pos INT;
+    v_table VARCHAR(128);
+    v_column VARCHAR(128);
+    v_ok INT := 0;
+    v_fail INT := 0;
+BEGIN
+    FOR c IN (SELECT TABLE_NAME, COLUMN_NAME FROM USER_TAB_COLUMNS
+              WHERE COLUMN_NAME <> UPPER(COLUMN_NAME)
+                AND TABLE_NAME IN (SELECT TABLE_NAME FROM USER_TABLES)) LOOP
+        v_total := v_total + 1;
+        v_cols(v_total) := c.TABLE_NAME || '.' || c.COLUMN_NAME;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('发现待改名小写列 ' || v_total || ' 个');
+    FOR i IN 1 .. v_total LOOP
+        v_pos := INSTR(v_cols(i), '.');
+        v_table := SUBSTR(v_cols(i), 1, v_pos - 1);
+        v_column := SUBSTR(v_cols(i), v_pos + 1);
+        BEGIN
+            EXECUTE IMMEDIATE 'ALTER TABLE "' || v_table || '" ALTER COLUMN "'
+                || v_column || '" RENAME TO ' || UPPER(v_column);
+            v_ok := v_ok + 1;
+            DBMS_OUTPUT.PUT_LINE('列已改名: ' || v_cols(i)
+                || ' -> ' || UPPER(v_column));
+        EXCEPTION WHEN OTHERS THEN
+            BEGIN
+                EXECUTE IMMEDIATE 'ALTER TABLE "' || v_table
+                    || '" RENAME COLUMN "' || v_column
+                    || '" TO ' || UPPER(v_column);
+                v_ok := v_ok + 1;
+                DBMS_OUTPUT.PUT_LINE('列已改名(Oracle语法): ' || v_cols(i)
+                    || ' -> ' || UPPER(v_column));
+            EXCEPTION WHEN OTHERS THEN
+                v_fail := v_fail + 1;
+                DBMS_OUTPUT.PUT_LINE('列改名失败: ' || v_cols(i)
+                    || ' , 原因: ' || SQLERRM);
+            END;
+        END;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('列名处理完成: 成功 ' || v_ok || ' , 失败 ' || v_fail);
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 1. sys_role 加 manage_system / manage_audit_log(V14;已存在则跳过)
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_ROLE'
+          AND COLUMN_NAME = 'MANAGE_SYSTEM';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_ROLE ADD COLUMN MANAGE_SYSTEM TINYINT NOT NULL DEFAULT 0';
+    END IF;
+END;
+/
+
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_ROLE'
+          AND COLUMN_NAME = 'MANAGE_AUDIT_LOG';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_ROLE ADD COLUMN MANAGE_AUDIT_LOG TINYINT NOT NULL DEFAULT 0';
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 2. 资源采样表 sys_metric_sample(V14;已存在则整段跳过,索引随表创建)
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TABLES
+        WHERE TABLE_NAME = 'SYS_METRIC_SAMPLE';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE 'CREATE TABLE SYS_METRIC_SAMPLE (
+  ID BIGINT IDENTITY(1, 1) NOT NULL,
+  SAMPLED_AT TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL,
+  CPU_PERCENT FLOAT DEFAULT NULL,
+  MEM_USED_BYTES BIGINT NOT NULL,
+  MEM_TOTAL_BYTES BIGINT NOT NULL,
+  PRIMARY KEY (ID)
+)';
+        EXECUTE IMMEDIATE
+            'CREATE INDEX IX_SYS_METRIC_SAMPLE_TIME ON SYS_METRIC_SAMPLE (SAMPLED_AT)';
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 3. sys_role 加 manage_role_edit(V15;已存在则跳过)
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_ROLE'
+          AND COLUMN_NAME = 'MANAGE_ROLE_EDIT';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_ROLE ADD COLUMN MANAGE_ROLE_EDIT TINYINT NOT NULL DEFAULT 0';
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 4. sys_user 加 sort_no(V16;已存在则跳过,NOT NULL DEFAULT 0 自动回填存量行)
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_USER'
+          AND COLUMN_NAME = 'SORT_NO';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_USER ADD COLUMN SORT_NO INT NOT NULL DEFAULT 0';
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 5. sys_user 加 managed_org_id + 存量回填哨兵0(V17,契约1.30)
+--    回填仅在本次真正执行加列时进行(v_count 在加列前取样),
+--    防止 Alembic 0032 先跑后本脚本把显式 NULL 覆盖回 0。
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_USER'
+          AND COLUMN_NAME = 'MANAGED_ORG_ID';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_USER ADD COLUMN MANAGED_ORG_ID BIGINT NULL DEFAULT 0';
+        EXECUTE IMMEDIATE
+            'UPDATE SYS_USER SET MANAGED_ORG_ID = 0 WHERE MANAGED_ORG_ID IS NULL';
+        COMMIT;
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 6. 重建 sys_audit_log 两组 CHECK 白名单为终态(V16 的 45 项动作 / V14 的 12 项目标)
+--    按 UPPER(约束名) 匹配删除任意大小写形态(含历史小写约束,防止与新
+--    大写约束双生效导致新动作插入被拒),再以不带引号大写名重建。
+-- ----------------------------------------------------------------------------
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(200) INDEX BY BINARY_INTEGER;
+    v_names t_list;
+    v_total INT := 0;
+BEGIN
+    FOR c IN (SELECT CONSTRAINT_NAME FROM USER_CONSTRAINTS
+              WHERE TABLE_NAME = 'SYS_AUDIT_LOG'
+                AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE') LOOP
+        v_total := v_total + 1;
+        v_names(v_total) := c.CONSTRAINT_NAME;
+    END LOOP;
+    FOR i IN 1 .. v_total LOOP
+        EXECUTE IMMEDIATE 'ALTER TABLE SYS_AUDIT_LOG DROP CONSTRAINT "'
+            || v_names(i) || '"';
+    END LOOP;
+END;
+/
+
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(200) INDEX BY BINARY_INTEGER;
+    v_names t_list;
+    v_total INT := 0;
+BEGIN
+    FOR c IN (SELECT CONSTRAINT_NAME FROM USER_CONSTRAINTS
+              WHERE TABLE_NAME = 'SYS_AUDIT_LOG'
+                AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE') LOOP
+        v_total := v_total + 1;
+        v_names(v_total) := c.CONSTRAINT_NAME;
+    END LOOP;
+    FOR i IN 1 .. v_total LOOP
+        EXECUTE IMMEDIATE 'ALTER TABLE SYS_AUDIT_LOG DROP CONSTRAINT "'
+            || v_names(i) || '"';
+    END LOOP;
+END;
+/
+
+-- 重建动作白名单(终态 45 项,不带引号自动大写,与探测名一致)
+ALTER TABLE SYS_AUDIT_LOG
+ADD CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE CHECK (
+    ACTION_TYPE IN (
+        'LOGIN',
+        'LOGOUT',
+        'VIEW_DOCUMENT',
+        'DOWNLOAD_DOCUMENT',
+        'UPLOAD_DOCUMENT',
+        'BATCH_IMPORT',
+        'EDIT_DOCUMENT',
+        'DELETE_DOCUMENT',
+        'CREATE_CATEGORY',
+        'EDIT_CATEGORY',
+        'DELETE_CATEGORY',
+        'CHANGE_PERMISSION',
+        'BIND_ATTACHMENT',
+        'UNBIND_ATTACHMENT',
+        'RESTORE_DOCUMENT',
+        'CREATE_ORGANIZATION',
+        'EDIT_ORGANIZATION',
+        'DELETE_ORGANIZATION',
+        'CREATE_USER',
+        'EDIT_USER',
+        'DELETE_USER',
+        'RESET_USER_PASSWORD',
+        'REORDER_DOCUMENT',
+        'REORDER_ATTACHMENT',
+        'REORDER_CATEGORY',
+        'CREATE_ROLE',
+        'EDIT_ROLE',
+        'DELETE_ROLE',
+        'CHANGE_ROLE_PERMISSION',
+        'CHANGE_ROLE_USER',
+        'DOCUMENT_PASSWORD_SET',
+        'DOCUMENT_PASSWORD_REMOVE',
+        'CREATE_IP_ENTRY',
+        'EDIT_IP_ENTRY',
+        'DELETE_IP_ENTRY',
+        'ASSIGN_ORG_IP',
+        'MOVE_DOCUMENT',
+        'MOVE_ATTACHMENT',
+        'BACKUP_CREATE',
+        'PREVIEW_CACHE_CLEAR',
+        'FORCE_OFFLINE',
+        'BACKUP_TEST_TARGET',
+        'REORDER_ORGANIZATION',
+        'REORDER_USER',
+        'REORDER_ROLE'
+    )
+);
+
+-- 重建目标白名单(终态 12 项)
+ALTER TABLE SYS_AUDIT_LOG
+ADD CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE CHECK (
+    TARGET_TYPE IS NULL
+    OR TARGET_TYPE IN (
+        'AUTH',
+        'CATEGORY',
+        'DOCUMENT',
+        'ATTACHMENT',
+        'PERMISSION',
+        'ATTACHMENT_BINDING',
+        'ORGANIZATION',
+        'USER',
+        'ROLE',
+        'IP_ENTRY',
+        'BACKUP',
+        'SYSTEM'
+    )
+);

+ 16 - 16
backend-java/src/main/resources/db/migration-dm/V14__system_management.sql

@@ -23,7 +23,7 @@ BEGIN
           AND COLUMN_NAME = 'MANAGE_SYSTEM';
     IF v_count = 0 THEN
         EXECUTE IMMEDIATE
-            'ALTER TABLE sys_role ADD COLUMN manage_system TINYINT NOT NULL DEFAULT 0';
+            'ALTER TABLE SYS_ROLE ADD COLUMN MANAGE_SYSTEM TINYINT NOT NULL DEFAULT 0';
     END IF;
 END;
 /
@@ -37,7 +37,7 @@ BEGIN
           AND COLUMN_NAME = 'MANAGE_AUDIT_LOG';
     IF v_count = 0 THEN
         EXECUTE IMMEDIATE
-            'ALTER TABLE sys_role ADD COLUMN manage_audit_log TINYINT NOT NULL DEFAULT 0';
+            'ALTER TABLE SYS_ROLE ADD COLUMN MANAGE_AUDIT_LOG TINYINT NOT NULL DEFAULT 0';
     END IF;
 END;
 /
@@ -50,16 +50,16 @@ BEGIN
         FROM USER_TABLES
         WHERE TABLE_NAME = 'SYS_METRIC_SAMPLE';
     IF v_count = 0 THEN
-        EXECUTE IMMEDIATE 'CREATE TABLE sys_metric_sample (
-  id BIGINT IDENTITY(1, 1) NOT NULL,
-  sampled_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL,
-  cpu_percent FLOAT DEFAULT NULL,
-  mem_used_bytes BIGINT NOT NULL,
-  mem_total_bytes BIGINT NOT NULL,
-  PRIMARY KEY (id)
+        EXECUTE IMMEDIATE 'CREATE TABLE SYS_METRIC_SAMPLE (
+  ID BIGINT IDENTITY(1, 1) NOT NULL,
+  SAMPLED_AT TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL,
+  CPU_PERCENT FLOAT DEFAULT NULL,
+  MEM_USED_BYTES BIGINT NOT NULL,
+  MEM_TOTAL_BYTES BIGINT NOT NULL,
+  PRIMARY KEY (ID)
 )';
         EXECUTE IMMEDIATE
-            'CREATE INDEX ix_sys_metric_sample_time ON sys_metric_sample (sampled_at)';
+            'CREATE INDEX IX_SYS_METRIC_SAMPLE_TIME ON SYS_METRIC_SAMPLE (SAMPLED_AT)';
     END IF;
 END;
 /
@@ -74,7 +74,7 @@ BEGIN
           AND CONSTRAINT_NAME = 'CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE';
     IF v_count > 0 THEN
         EXECUTE IMMEDIATE
-            'ALTER TABLE sys_audit_log DROP CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE';
+            'ALTER TABLE SYS_AUDIT_LOG DROP CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE';
     END IF;
 END;
 /
@@ -88,14 +88,14 @@ BEGIN
           AND CONSTRAINT_NAME = 'CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE';
     IF v_count > 0 THEN
         EXECUTE IMMEDIATE
-            'ALTER TABLE sys_audit_log DROP CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE';
+            'ALTER TABLE SYS_AUDIT_LOG DROP CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE';
     END IF;
 END;
 /
 
 -- 4. 重建 CHECK 白名单(不带引号,达梦自动大写,与探测名一致;42 项动作 / 12 项目标)
-ALTER TABLE sys_audit_log ADD CONSTRAINT ck_sys_audit_log_audit_action_type
-    CHECK (action_type IN (
+ALTER TABLE SYS_AUDIT_LOG ADD CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE
+    CHECK (ACTION_TYPE IN (
         'LOGIN', 'LOGOUT', 'VIEW_DOCUMENT', 'DOWNLOAD_DOCUMENT',
         'UPLOAD_DOCUMENT', 'BATCH_IMPORT', 'EDIT_DOCUMENT', 'DELETE_DOCUMENT',
         'CREATE_CATEGORY', 'EDIT_CATEGORY', 'DELETE_CATEGORY', 'CHANGE_PERMISSION',
@@ -109,8 +109,8 @@ ALTER TABLE sys_audit_log ADD CONSTRAINT ck_sys_audit_log_audit_action_type
         'MOVE_DOCUMENT', 'MOVE_ATTACHMENT', 'BACKUP_CREATE',
         'PREVIEW_CACHE_CLEAR', 'FORCE_OFFLINE', 'BACKUP_TEST_TARGET'));
 
-ALTER TABLE sys_audit_log ADD CONSTRAINT ck_sys_audit_log_audit_target_type
-    CHECK (target_type IS NULL OR target_type IN (
+ALTER TABLE SYS_AUDIT_LOG ADD CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE
+    CHECK (TARGET_TYPE IS NULL OR TARGET_TYPE IN (
         'AUTH', 'CATEGORY', 'DOCUMENT', 'ATTACHMENT', 'PERMISSION',
         'ATTACHMENT_BINDING', 'ORGANIZATION', 'USER', 'ROLE', 'IP_ENTRY',
         'BACKUP', 'SYSTEM'));

+ 298 - 0
backend-java/src/main/resources/db/migration-dm/V14_to_V17__merge.sql

@@ -0,0 +1,298 @@
+-- ============================================================================
+-- 达梦合并升级脚本:V14 → V17(一站式,供 DISQL / DM 管理工具手工执行)
+-- 基线:数据库已执行到 V14(sys_role.manage_system / manage_audit_log 已存在,
+--       sys_metric_sample 已建,sys_audit_log 白名单为 V14 的 42/12 中间态)。
+-- 内容:合并 V15~V17 全部变更,并把库中小写表名/列名统一为大写:
+--   0. 小写表名/列名 → 大写(探测式;已全大写的库此步零操作);
+--   1. sys_role 加 manage_role_edit(V15);
+--   2. sys_user 加 sort_no(V16);
+--   3. sys_user 加 managed_org_id + 存量回填哨兵0(V17,契约1.30);
+--   4. 重建 sys_audit_log 两组 CHECK 白名单为终态(动作45项 / 目标12项,
+--      直接覆盖 V14 的 42/12 中间态,无需逐版重建)。
+-- 执行方式:DISQL 登录业务模式后先执行 SET SERVEROUTPUT ON; 再运行本文件;
+--   建议先备份。大小写纠正作用于当前登录模式的全部表,若模式内混有
+--   其他业务系统的小写表请先评估。
+-- 大小写说明:达梦大小写敏感实例下不带引号标识符自动转大写存储,
+--   带引号小写标识符保持小写——历史脚本/工具建出的小写对象使后续
+--   不带引号的 DDL 找不到目标而"概率失败",故先统一为大写再执行增量。
+-- 改名健壮性:每条改名语句独立捕获异常(单条失败不影响其余并输出原因);
+--   列改名先试 DM8 形式 ALTER COLUMN ... RENAME TO,失败自动回退 Oracle
+--   兼容形式 RENAME COLUMN ... TO;表改名失败回退 RENAME 语句形式;
+--   结尾输出成功/失败计数。若提示"发现待改名小写表 0 个"但库中仍有
+--   小写对象,说明登录模式不是业务对象所在模式,请切换模式后重跑。
+-- 幂等:全部先探测后执行,重复运行无副作用;若曾部分执行过 V15~V17
+--   单版脚本,本脚本自动跳过已生效部分、约束重建为终态集合。
+-- ============================================================================
+
+-- ----------------------------------------------------------------------------
+-- 0.1 表名小写 → 大写(先物化清单再改名;逐条异常隔离;两名并存时跳过)
+-- ----------------------------------------------------------------------------
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(200) INDEX BY BINARY_INTEGER;
+    v_tables t_list;
+    v_total INT := 0;
+    v_conflict INT;
+    v_ok INT := 0;
+    v_fail INT := 0;
+    v_skip INT := 0;
+BEGIN
+    FOR t IN (SELECT TABLE_NAME FROM USER_TABLES
+              WHERE TABLE_NAME <> UPPER(TABLE_NAME)) LOOP
+        v_total := v_total + 1;
+        v_tables(v_total) := t.TABLE_NAME;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('发现待改名小写表 ' || v_total || ' 个');
+    FOR i IN 1 .. v_total LOOP
+        SELECT COUNT(*) INTO v_conflict FROM USER_TABLES
+            WHERE TABLE_NAME = UPPER(v_tables(i));
+        IF v_conflict > 0 THEN
+            v_skip := v_skip + 1;
+            DBMS_OUTPUT.PUT_LINE('警告: ' || v_tables(i) || ' 与 '
+                || UPPER(v_tables(i)) || ' 并存,请人工合并后重跑本脚本');
+        ELSE
+            BEGIN
+                EXECUTE IMMEDIATE 'ALTER TABLE "' || v_tables(i)
+                    || '" RENAME TO ' || UPPER(v_tables(i));
+                v_ok := v_ok + 1;
+                DBMS_OUTPUT.PUT_LINE('表已改名: ' || v_tables(i)
+                    || ' -> ' || UPPER(v_tables(i)));
+            EXCEPTION WHEN OTHERS THEN
+                BEGIN
+                    EXECUTE IMMEDIATE 'RENAME "' || v_tables(i)
+                        || '" TO ' || UPPER(v_tables(i));
+                    v_ok := v_ok + 1;
+                    DBMS_OUTPUT.PUT_LINE('表已改名(RENAME语句): ' || v_tables(i)
+                        || ' -> ' || UPPER(v_tables(i)));
+                EXCEPTION WHEN OTHERS THEN
+                    v_fail := v_fail + 1;
+                    DBMS_OUTPUT.PUT_LINE('表改名失败: ' || v_tables(i)
+                        || ' , 原因: ' || SQLERRM);
+                END;
+            END;
+        END IF;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('表名处理完成: 成功 ' || v_ok || ' , 失败 '
+        || v_fail || ' , 跳过并存 ' || v_skip);
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 0.2 列名小写 → 大写(仅处理基表列;先物化清单再改名,避免游标失效;
+--     逐条异常隔离;先试 DM8 形式 ALTER TABLE ... ALTER COLUMN "旧"
+--     RENAME TO 新,失败自动回退 Oracle 兼容形式
+--     ALTER TABLE ... RENAME COLUMN "旧" TO 新)
+-- ----------------------------------------------------------------------------
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(300) INDEX BY BINARY_INTEGER;
+    v_cols t_list;
+    v_total INT := 0;
+    v_pos INT;
+    v_table VARCHAR(128);
+    v_column VARCHAR(128);
+    v_ok INT := 0;
+    v_fail INT := 0;
+BEGIN
+    FOR c IN (SELECT TABLE_NAME, COLUMN_NAME FROM USER_TAB_COLUMNS
+              WHERE COLUMN_NAME <> UPPER(COLUMN_NAME)
+                AND TABLE_NAME IN (SELECT TABLE_NAME FROM USER_TABLES)) LOOP
+        v_total := v_total + 1;
+        v_cols(v_total) := c.TABLE_NAME || '.' || c.COLUMN_NAME;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('发现待改名小写列 ' || v_total || ' 个');
+    FOR i IN 1 .. v_total LOOP
+        v_pos := INSTR(v_cols(i), '.');
+        v_table := SUBSTR(v_cols(i), 1, v_pos - 1);
+        v_column := SUBSTR(v_cols(i), v_pos + 1);
+        BEGIN
+            EXECUTE IMMEDIATE 'ALTER TABLE "' || v_table || '" ALTER COLUMN "'
+                || v_column || '" RENAME TO ' || UPPER(v_column);
+            v_ok := v_ok + 1;
+            DBMS_OUTPUT.PUT_LINE('列已改名: ' || v_cols(i)
+                || ' -> ' || UPPER(v_column));
+        EXCEPTION WHEN OTHERS THEN
+            BEGIN
+                EXECUTE IMMEDIATE 'ALTER TABLE "' || v_table
+                    || '" RENAME COLUMN "' || v_column
+                    || '" TO ' || UPPER(v_column);
+                v_ok := v_ok + 1;
+                DBMS_OUTPUT.PUT_LINE('列已改名(Oracle语法): ' || v_cols(i)
+                    || ' -> ' || UPPER(v_column));
+            EXCEPTION WHEN OTHERS THEN
+                v_fail := v_fail + 1;
+                DBMS_OUTPUT.PUT_LINE('列改名失败: ' || v_cols(i)
+                    || ' , 原因: ' || SQLERRM);
+            END;
+        END;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('列名处理完成: 成功 ' || v_ok || ' , 失败 ' || v_fail);
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 1. sys_role 加 manage_role_edit(V15;已存在则跳过)
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_ROLE'
+          AND COLUMN_NAME = 'MANAGE_ROLE_EDIT';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_ROLE ADD COLUMN MANAGE_ROLE_EDIT TINYINT NOT NULL DEFAULT 0';
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 2. sys_user 加 sort_no(V16;已存在则跳过,NOT NULL DEFAULT 0 自动回填存量行)
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_USER'
+          AND COLUMN_NAME = 'SORT_NO';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_USER ADD COLUMN SORT_NO INT NOT NULL DEFAULT 0';
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 3. sys_user 加 managed_org_id + 存量回填哨兵0(V17,契约1.30)
+--    回填仅在本次真正执行加列时进行(v_count 在加列前取样),
+--    防止 Alembic 0032 先跑后本脚本把显式 NULL 覆盖回 0。
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_USER'
+          AND COLUMN_NAME = 'MANAGED_ORG_ID';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_USER ADD COLUMN MANAGED_ORG_ID BIGINT NULL DEFAULT 0';
+        EXECUTE IMMEDIATE
+            'UPDATE SYS_USER SET MANAGED_ORG_ID = 0 WHERE MANAGED_ORG_ID IS NULL';
+        COMMIT;
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 4. 重建 sys_audit_log 两组 CHECK 白名单为终态(V16 的 45 项动作 / V14 的 12 项目标)
+--    按 UPPER(约束名) 匹配删除任意大小写形态(含历史小写约束,防止与新
+--    大写约束双生效导致新动作插入被拒),再以不带引号大写名重建。
+-- ----------------------------------------------------------------------------
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(200) INDEX BY BINARY_INTEGER;
+    v_names t_list;
+    v_total INT := 0;
+BEGIN
+    FOR c IN (SELECT CONSTRAINT_NAME FROM USER_CONSTRAINTS
+              WHERE TABLE_NAME = 'SYS_AUDIT_LOG'
+                AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE') LOOP
+        v_total := v_total + 1;
+        v_names(v_total) := c.CONSTRAINT_NAME;
+    END LOOP;
+    FOR i IN 1 .. v_total LOOP
+        EXECUTE IMMEDIATE 'ALTER TABLE SYS_AUDIT_LOG DROP CONSTRAINT "'
+            || v_names(i) || '"';
+    END LOOP;
+END;
+/
+
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(200) INDEX BY BINARY_INTEGER;
+    v_names t_list;
+    v_total INT := 0;
+BEGIN
+    FOR c IN (SELECT CONSTRAINT_NAME FROM USER_CONSTRAINTS
+              WHERE TABLE_NAME = 'SYS_AUDIT_LOG'
+                AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE') LOOP
+        v_total := v_total + 1;
+        v_names(v_total) := c.CONSTRAINT_NAME;
+    END LOOP;
+    FOR i IN 1 .. v_total LOOP
+        EXECUTE IMMEDIATE 'ALTER TABLE SYS_AUDIT_LOG DROP CONSTRAINT "'
+            || v_names(i) || '"';
+    END LOOP;
+END;
+/
+
+-- 重建动作白名单(终态 45 项,不带引号自动大写,与探测名一致)
+ALTER TABLE SYS_AUDIT_LOG
+ADD CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE CHECK (
+    ACTION_TYPE IN (
+        'LOGIN',
+        'LOGOUT',
+        'VIEW_DOCUMENT',
+        'DOWNLOAD_DOCUMENT',
+        'UPLOAD_DOCUMENT',
+        'BATCH_IMPORT',
+        'EDIT_DOCUMENT',
+        'DELETE_DOCUMENT',
+        'CREATE_CATEGORY',
+        'EDIT_CATEGORY',
+        'DELETE_CATEGORY',
+        'CHANGE_PERMISSION',
+        'BIND_ATTACHMENT',
+        'UNBIND_ATTACHMENT',
+        'RESTORE_DOCUMENT',
+        'CREATE_ORGANIZATION',
+        'EDIT_ORGANIZATION',
+        'DELETE_ORGANIZATION',
+        'CREATE_USER',
+        'EDIT_USER',
+        'DELETE_USER',
+        'RESET_USER_PASSWORD',
+        'REORDER_DOCUMENT',
+        'REORDER_ATTACHMENT',
+        'REORDER_CATEGORY',
+        'CREATE_ROLE',
+        'EDIT_ROLE',
+        'DELETE_ROLE',
+        'CHANGE_ROLE_PERMISSION',
+        'CHANGE_ROLE_USER',
+        'DOCUMENT_PASSWORD_SET',
+        'DOCUMENT_PASSWORD_REMOVE',
+        'CREATE_IP_ENTRY',
+        'EDIT_IP_ENTRY',
+        'DELETE_IP_ENTRY',
+        'ASSIGN_ORG_IP',
+        'MOVE_DOCUMENT',
+        'MOVE_ATTACHMENT',
+        'BACKUP_CREATE',
+        'PREVIEW_CACHE_CLEAR',
+        'FORCE_OFFLINE',
+        'BACKUP_TEST_TARGET',
+        'REORDER_ORGANIZATION',
+        'REORDER_USER',
+        'REORDER_ROLE'
+    )
+);
+
+-- 重建目标白名单(终态 12 项)
+ALTER TABLE SYS_AUDIT_LOG
+ADD CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE CHECK (
+    TARGET_TYPE IS NULL
+    OR TARGET_TYPE IN (
+        'AUTH',
+        'CATEGORY',
+        'DOCUMENT',
+        'ATTACHMENT',
+        'PERMISSION',
+        'ATTACHMENT_BINDING',
+        'ORGANIZATION',
+        'USER',
+        'ROLE',
+        'IP_ENTRY',
+        'BACKUP',
+        'SYSTEM'
+    )
+);

+ 2 - 2
backend-java/src/main/resources/db/migration-dm/V15__role_edit_permission.sql

@@ -18,7 +18,7 @@ BEGIN
           AND COLUMN_NAME = 'MANAGE_ROLE_EDIT';
     IF v_count = 0 THEN
         EXECUTE IMMEDIATE
-            'ALTER TABLE sys_role ADD COLUMN manage_role_edit TINYINT NOT NULL DEFAULT 0';
+            'ALTER TABLE SYS_ROLE ADD COLUMN MANAGE_ROLE_EDIT TINYINT NOT NULL DEFAULT 0';
     END IF;
 END;
-/
+/

+ 280 - 0
backend-java/src/main/resources/db/migration-dm/V15_to_V17__merge.sql

@@ -0,0 +1,280 @@
+-- ============================================================================
+-- 达梦合并升级脚本:V15 → V17(一站式,供 DISQL / DM 管理工具手工执行)
+-- 基线:数据库已执行到 V15(sys_role.manage_role_edit 已存在;
+--       sys_audit_log 白名单仍为 V14 的 42/12 中间态——V15 未调整 CHECK)。
+-- 内容:合并 V16~V17 全部变更,并把库中小写表名/列名统一为大写:
+--   0. 小写表名/列名 → 大写(探测式;已全大写的库此步零操作);
+--   1. sys_user 加 sort_no(V16);
+--   2. sys_user 加 managed_org_id + 存量回填哨兵0(V17,契约1.30);
+--   3. 重建 sys_audit_log 两组 CHECK 白名单为终态(动作45项 / 目标12项,
+--      直接覆盖 V14 的 42/12 中间态,无需逐版重建)。
+-- 执行方式:DISQL 登录业务模式后先执行 SET SERVEROUTPUT ON; 再运行本文件;
+--   建议先备份。大小写纠正作用于当前登录模式的全部表,若模式内混有
+--   其他业务系统的小写表请先评估。
+-- 大小写说明:达梦大小写敏感实例下不带引号标识符自动转大写存储,
+--   带引号小写标识符保持小写——历史脚本/工具建出的小写对象使后续
+--   不带引号的 DDL 找不到目标而"概率失败",故先统一为大写再执行增量。
+-- 改名健壮性:每条改名语句独立捕获异常(单条失败不影响其余并输出原因);
+--   列改名先试 DM8 形式 ALTER COLUMN ... RENAME TO,失败自动回退 Oracle
+--   兼容形式 RENAME COLUMN ... TO;表改名失败回退 RENAME 语句形式;
+--   结尾输出成功/失败计数。若提示"发现待改名小写表 0 个"但库中仍有
+--   小写对象,说明登录模式不是业务对象所在模式,请切换模式后重跑。
+-- 幂等:全部先探测后执行,重复运行无副作用;若曾部分执行过 V16~V17
+--   单版脚本,本脚本自动跳过已生效部分、约束重建为终态集合。
+-- ============================================================================
+
+-- ----------------------------------------------------------------------------
+-- 0.1 表名小写 → 大写(先物化清单再改名;逐条异常隔离;两名并存时跳过)
+-- ----------------------------------------------------------------------------
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(200) INDEX BY BINARY_INTEGER;
+    v_tables t_list;
+    v_total INT := 0;
+    v_conflict INT;
+    v_ok INT := 0;
+    v_fail INT := 0;
+    v_skip INT := 0;
+BEGIN
+    FOR t IN (SELECT TABLE_NAME FROM USER_TABLES
+              WHERE TABLE_NAME <> UPPER(TABLE_NAME)) LOOP
+        v_total := v_total + 1;
+        v_tables(v_total) := t.TABLE_NAME;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('发现待改名小写表 ' || v_total || ' 个');
+    FOR i IN 1 .. v_total LOOP
+        SELECT COUNT(*) INTO v_conflict FROM USER_TABLES
+            WHERE TABLE_NAME = UPPER(v_tables(i));
+        IF v_conflict > 0 THEN
+            v_skip := v_skip + 1;
+            DBMS_OUTPUT.PUT_LINE('警告: ' || v_tables(i) || ' 与 '
+                || UPPER(v_tables(i)) || ' 并存,请人工合并后重跑本脚本');
+        ELSE
+            BEGIN
+                EXECUTE IMMEDIATE 'ALTER TABLE "' || v_tables(i)
+                    || '" RENAME TO ' || UPPER(v_tables(i));
+                v_ok := v_ok + 1;
+                DBMS_OUTPUT.PUT_LINE('表已改名: ' || v_tables(i)
+                    || ' -> ' || UPPER(v_tables(i)));
+            EXCEPTION WHEN OTHERS THEN
+                BEGIN
+                    EXECUTE IMMEDIATE 'RENAME "' || v_tables(i)
+                        || '" TO ' || UPPER(v_tables(i));
+                    v_ok := v_ok + 1;
+                    DBMS_OUTPUT.PUT_LINE('表已改名(RENAME语句): ' || v_tables(i)
+                        || ' -> ' || UPPER(v_tables(i)));
+                EXCEPTION WHEN OTHERS THEN
+                    v_fail := v_fail + 1;
+                    DBMS_OUTPUT.PUT_LINE('表改名失败: ' || v_tables(i)
+                        || ' , 原因: ' || SQLERRM);
+                END;
+            END;
+        END IF;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('表名处理完成: 成功 ' || v_ok || ' , 失败 '
+        || v_fail || ' , 跳过并存 ' || v_skip);
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 0.2 列名小写 → 大写(仅处理基表列;先物化清单再改名,避免游标失效;
+--     逐条异常隔离;先试 DM8 形式 ALTER TABLE ... ALTER COLUMN "旧"
+--     RENAME TO 新,失败自动回退 Oracle 兼容形式
+--     ALTER TABLE ... RENAME COLUMN "旧" TO 新)
+-- ----------------------------------------------------------------------------
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(300) INDEX BY BINARY_INTEGER;
+    v_cols t_list;
+    v_total INT := 0;
+    v_pos INT;
+    v_table VARCHAR(128);
+    v_column VARCHAR(128);
+    v_ok INT := 0;
+    v_fail INT := 0;
+BEGIN
+    FOR c IN (SELECT TABLE_NAME, COLUMN_NAME FROM USER_TAB_COLUMNS
+              WHERE COLUMN_NAME <> UPPER(COLUMN_NAME)
+                AND TABLE_NAME IN (SELECT TABLE_NAME FROM USER_TABLES)) LOOP
+        v_total := v_total + 1;
+        v_cols(v_total) := c.TABLE_NAME || '.' || c.COLUMN_NAME;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('发现待改名小写列 ' || v_total || ' 个');
+    FOR i IN 1 .. v_total LOOP
+        v_pos := INSTR(v_cols(i), '.');
+        v_table := SUBSTR(v_cols(i), 1, v_pos - 1);
+        v_column := SUBSTR(v_cols(i), v_pos + 1);
+        BEGIN
+            EXECUTE IMMEDIATE 'ALTER TABLE "' || v_table || '" ALTER COLUMN "'
+                || v_column || '" RENAME TO ' || UPPER(v_column);
+            v_ok := v_ok + 1;
+            DBMS_OUTPUT.PUT_LINE('列已改名: ' || v_cols(i)
+                || ' -> ' || UPPER(v_column));
+        EXCEPTION WHEN OTHERS THEN
+            BEGIN
+                EXECUTE IMMEDIATE 'ALTER TABLE "' || v_table
+                    || '" RENAME COLUMN "' || v_column
+                    || '" TO ' || UPPER(v_column);
+                v_ok := v_ok + 1;
+                DBMS_OUTPUT.PUT_LINE('列已改名(Oracle语法): ' || v_cols(i)
+                    || ' -> ' || UPPER(v_column));
+            EXCEPTION WHEN OTHERS THEN
+                v_fail := v_fail + 1;
+                DBMS_OUTPUT.PUT_LINE('列改名失败: ' || v_cols(i)
+                    || ' , 原因: ' || SQLERRM);
+            END;
+        END;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('列名处理完成: 成功 ' || v_ok || ' , 失败 ' || v_fail);
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 1. sys_user 加 sort_no(V16;已存在则跳过,NOT NULL DEFAULT 0 自动回填存量行)
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_USER'
+          AND COLUMN_NAME = 'SORT_NO';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_USER ADD COLUMN SORT_NO INT NOT NULL DEFAULT 0';
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 2. sys_user 加 managed_org_id + 存量回填哨兵0(V17,契约1.30)
+--    回填仅在本次真正执行加列时进行(v_count 在加列前取样),
+--    防止 Alembic 0032 先跑后本脚本把显式 NULL 覆盖回 0。
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_USER'
+          AND COLUMN_NAME = 'MANAGED_ORG_ID';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_USER ADD COLUMN MANAGED_ORG_ID BIGINT NULL DEFAULT 0';
+        EXECUTE IMMEDIATE
+            'UPDATE SYS_USER SET MANAGED_ORG_ID = 0 WHERE MANAGED_ORG_ID IS NULL';
+        COMMIT;
+    END IF;
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 3. 重建 sys_audit_log 两组 CHECK 白名单为终态(V16 的 45 项动作 / V14 的 12 项目标)
+--    按 UPPER(约束名) 匹配删除任意大小写形态(含历史小写约束,防止与新
+--    大写约束双生效导致新动作插入被拒),再以不带引号大写名重建。
+-- ----------------------------------------------------------------------------
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(200) INDEX BY BINARY_INTEGER;
+    v_names t_list;
+    v_total INT := 0;
+BEGIN
+    FOR c IN (SELECT CONSTRAINT_NAME FROM USER_CONSTRAINTS
+              WHERE TABLE_NAME = 'SYS_AUDIT_LOG'
+                AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE') LOOP
+        v_total := v_total + 1;
+        v_names(v_total) := c.CONSTRAINT_NAME;
+    END LOOP;
+    FOR i IN 1 .. v_total LOOP
+        EXECUTE IMMEDIATE 'ALTER TABLE SYS_AUDIT_LOG DROP CONSTRAINT "'
+            || v_names(i) || '"';
+    END LOOP;
+END;
+/
+
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(200) INDEX BY BINARY_INTEGER;
+    v_names t_list;
+    v_total INT := 0;
+BEGIN
+    FOR c IN (SELECT CONSTRAINT_NAME FROM USER_CONSTRAINTS
+              WHERE TABLE_NAME = 'SYS_AUDIT_LOG'
+                AND UPPER(CONSTRAINT_NAME) = 'CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE') LOOP
+        v_total := v_total + 1;
+        v_names(v_total) := c.CONSTRAINT_NAME;
+    END LOOP;
+    FOR i IN 1 .. v_total LOOP
+        EXECUTE IMMEDIATE 'ALTER TABLE SYS_AUDIT_LOG DROP CONSTRAINT "'
+            || v_names(i) || '"';
+    END LOOP;
+END;
+/
+
+-- 重建动作白名单(终态 45 项,不带引号自动大写,与探测名一致)
+ALTER TABLE SYS_AUDIT_LOG
+ADD CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE CHECK (
+    ACTION_TYPE IN (
+        'LOGIN',
+        'LOGOUT',
+        'VIEW_DOCUMENT',
+        'DOWNLOAD_DOCUMENT',
+        'UPLOAD_DOCUMENT',
+        'BATCH_IMPORT',
+        'EDIT_DOCUMENT',
+        'DELETE_DOCUMENT',
+        'CREATE_CATEGORY',
+        'EDIT_CATEGORY',
+        'DELETE_CATEGORY',
+        'CHANGE_PERMISSION',
+        'BIND_ATTACHMENT',
+        'UNBIND_ATTACHMENT',
+        'RESTORE_DOCUMENT',
+        'CREATE_ORGANIZATION',
+        'EDIT_ORGANIZATION',
+        'DELETE_ORGANIZATION',
+        'CREATE_USER',
+        'EDIT_USER',
+        'DELETE_USER',
+        'RESET_USER_PASSWORD',
+        'REORDER_DOCUMENT',
+        'REORDER_ATTACHMENT',
+        'REORDER_CATEGORY',
+        'CREATE_ROLE',
+        'EDIT_ROLE',
+        'DELETE_ROLE',
+        'CHANGE_ROLE_PERMISSION',
+        'CHANGE_ROLE_USER',
+        'DOCUMENT_PASSWORD_SET',
+        'DOCUMENT_PASSWORD_REMOVE',
+        'CREATE_IP_ENTRY',
+        'EDIT_IP_ENTRY',
+        'DELETE_IP_ENTRY',
+        'ASSIGN_ORG_IP',
+        'MOVE_DOCUMENT',
+        'MOVE_ATTACHMENT',
+        'BACKUP_CREATE',
+        'PREVIEW_CACHE_CLEAR',
+        'FORCE_OFFLINE',
+        'BACKUP_TEST_TARGET',
+        'REORDER_ORGANIZATION',
+        'REORDER_USER',
+        'REORDER_ROLE'
+    )
+);
+
+-- 重建目标白名单(终态 12 项)
+ALTER TABLE SYS_AUDIT_LOG
+ADD CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_TARGET_TYPE CHECK (
+    TARGET_TYPE IS NULL
+    OR TARGET_TYPE IN (
+        'AUTH',
+        'CATEGORY',
+        'DOCUMENT',
+        'ATTACHMENT',
+        'PERMISSION',
+        'ATTACHMENT_BINDING',
+        'ORGANIZATION',
+        'USER',
+        'ROLE',
+        'IP_ENTRY',
+        'BACKUP',
+        'SYSTEM'
+    )
+);

+ 52 - 17
backend-java/src/main/resources/db/migration-dm/V16__user_sort_no.sql

@@ -22,7 +22,7 @@ BEGIN
           AND COLUMN_NAME = 'SORT_NO';
     IF v_count = 0 THEN
         EXECUTE IMMEDIATE
-            'ALTER TABLE sys_user ADD COLUMN sort_no INT NOT NULL DEFAULT 0';
+            'ALTER TABLE SYS_USER ADD COLUMN SORT_NO INT NOT NULL DEFAULT 0';
     END IF;
 END;
 /
@@ -37,24 +37,59 @@ BEGIN
           AND CONSTRAINT_NAME = 'CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE';
     IF v_count > 0 THEN
         EXECUTE IMMEDIATE
-            'ALTER TABLE sys_audit_log DROP CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE';
+            'ALTER TABLE SYS_AUDIT_LOG DROP CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE';
     END IF;
 END;
 /
 
 -- 3. 重建动作 CHECK 白名单(不带引号,达梦自动大写,与探测名一致;45 项)
-ALTER TABLE sys_audit_log ADD CONSTRAINT ck_sys_audit_log_audit_action_type
-    CHECK (action_type IN (
-        'LOGIN', 'LOGOUT', 'VIEW_DOCUMENT', 'DOWNLOAD_DOCUMENT',
-        'UPLOAD_DOCUMENT', 'BATCH_IMPORT', 'EDIT_DOCUMENT', 'DELETE_DOCUMENT',
-        'CREATE_CATEGORY', 'EDIT_CATEGORY', 'DELETE_CATEGORY', 'CHANGE_PERMISSION',
-        'BIND_ATTACHMENT', 'UNBIND_ATTACHMENT', 'RESTORE_DOCUMENT',
-        'CREATE_ORGANIZATION', 'EDIT_ORGANIZATION', 'DELETE_ORGANIZATION',
-        'CREATE_USER', 'EDIT_USER', 'DELETE_USER', 'RESET_USER_PASSWORD',
-        'REORDER_DOCUMENT', 'REORDER_ATTACHMENT', 'REORDER_CATEGORY',
-        'CREATE_ROLE', 'EDIT_ROLE', 'DELETE_ROLE', 'CHANGE_ROLE_PERMISSION',
-        'CHANGE_ROLE_USER', 'DOCUMENT_PASSWORD_SET', 'DOCUMENT_PASSWORD_REMOVE',
-        'CREATE_IP_ENTRY', 'EDIT_IP_ENTRY', 'DELETE_IP_ENTRY', 'ASSIGN_ORG_IP',
-        'MOVE_DOCUMENT', 'MOVE_ATTACHMENT', 'BACKUP_CREATE',
-        'PREVIEW_CACHE_CLEAR', 'FORCE_OFFLINE', 'BACKUP_TEST_TARGET',
-        'REORDER_ORGANIZATION', 'REORDER_USER', 'REORDER_ROLE'));
+ALTER TABLE SYS_AUDIT_LOG
+ADD CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE CHECK (
+    ACTION_TYPE IN (
+        'LOGIN',
+        'LOGOUT',
+        'VIEW_DOCUMENT',
+        'DOWNLOAD_DOCUMENT',
+        'UPLOAD_DOCUMENT',
+        'BATCH_IMPORT',
+        'EDIT_DOCUMENT',
+        'DELETE_DOCUMENT',
+        'CREATE_CATEGORY',
+        'EDIT_CATEGORY',
+        'DELETE_CATEGORY',
+        'CHANGE_PERMISSION',
+        'BIND_ATTACHMENT',
+        'UNBIND_ATTACHMENT',
+        'RESTORE_DOCUMENT',
+        'CREATE_ORGANIZATION',
+        'EDIT_ORGANIZATION',
+        'DELETE_ORGANIZATION',
+        'CREATE_USER',
+        'EDIT_USER',
+        'DELETE_USER',
+        'RESET_USER_PASSWORD',
+        'REORDER_DOCUMENT',
+        'REORDER_ATTACHMENT',
+        'REORDER_CATEGORY',
+        'CREATE_ROLE',
+        'EDIT_ROLE',
+        'DELETE_ROLE',
+        'CHANGE_ROLE_PERMISSION',
+        'CHANGE_ROLE_USER',
+        'DOCUMENT_PASSWORD_SET',
+        'DOCUMENT_PASSWORD_REMOVE',
+        'CREATE_IP_ENTRY',
+        'EDIT_IP_ENTRY',
+        'DELETE_IP_ENTRY',
+        'ASSIGN_ORG_IP',
+        'MOVE_DOCUMENT',
+        'MOVE_ATTACHMENT',
+        'BACKUP_CREATE',
+        'PREVIEW_CACHE_CLEAR',
+        'FORCE_OFFLINE',
+        'BACKUP_TEST_TARGET',
+        'REORDER_ORGANIZATION',
+        'REORDER_USER',
+        'REORDER_ROLE'
+    )
+);

+ 148 - 0
backend-java/src/main/resources/db/migration-dm/V16_to_V17__merge.sql

@@ -0,0 +1,148 @@
+-- ============================================================================
+-- 达梦合并升级脚本:V16 → V17(一站式,供 DISQL / DM 管理工具手工执行)
+-- 基线:数据库已执行到 V16(sys_user.sort_no 已存在,sys_audit_log
+--       动作白名单已是 V16 的 45 项终态、目标白名单 12 项)。
+-- 内容:仅 V17 变更,并把库中小写表名/列名统一为大写:
+--   0. 小写表名/列名 → 大写(探测式;已全大写的库此步零操作);
+--   1. sys_user 加 managed_org_id + 存量回填哨兵0(V17,契约1.30)。
+--   注:V17 未调整 CHECK 白名单,基线 V16 已是终态,无需重建约束。
+-- 执行方式:DISQL 登录业务模式后先执行 SET SERVEROUTPUT ON; 再运行本文件;
+--   建议先备份。大小写纠正作用于当前登录模式的全部表,若模式内混有
+--   其他业务系统的小写表请先评估。
+-- 大小写说明:达梦大小写敏感实例下不带引号标识符自动转大写存储,
+--   带引号小写标识符保持小写——历史脚本/工具建出的小写对象使后续
+--   不带引号的 DDL 找不到目标而"概率失败",故先统一为大写再执行增量。
+-- 改名健壮性:每条改名语句独立捕获异常(单条失败不影响其余并输出原因);
+--   列改名先试 DM8 形式 ALTER COLUMN ... RENAME TO,失败自动回退 Oracle
+--   兼容形式 RENAME COLUMN ... TO;表改名失败回退 RENAME 语句形式;
+--   结尾输出成功/失败计数。若提示"发现待改名小写表 0 个"但库中仍有
+--   小写对象,说明登录模式不是业务对象所在模式,请切换模式后重跑。
+-- 幂等:全部先探测后执行,重复运行无副作用;若曾执行过 V17 单版脚本,
+--   本脚本自动跳过已生效部分。
+-- ============================================================================
+
+-- ----------------------------------------------------------------------------
+-- 0.1 表名小写 → 大写(先物化清单再改名;逐条异常隔离;两名并存时跳过)
+-- ----------------------------------------------------------------------------
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(200) INDEX BY BINARY_INTEGER;
+    v_tables t_list;
+    v_total INT := 0;
+    v_conflict INT;
+    v_ok INT := 0;
+    v_fail INT := 0;
+    v_skip INT := 0;
+BEGIN
+    FOR t IN (SELECT TABLE_NAME FROM USER_TABLES
+              WHERE TABLE_NAME <> UPPER(TABLE_NAME)) LOOP
+        v_total := v_total + 1;
+        v_tables(v_total) := t.TABLE_NAME;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('发现待改名小写表 ' || v_total || ' 个');
+    FOR i IN 1 .. v_total LOOP
+        SELECT COUNT(*) INTO v_conflict FROM USER_TABLES
+            WHERE TABLE_NAME = UPPER(v_tables(i));
+        IF v_conflict > 0 THEN
+            v_skip := v_skip + 1;
+            DBMS_OUTPUT.PUT_LINE('警告: ' || v_tables(i) || ' 与 '
+                || UPPER(v_tables(i)) || ' 并存,请人工合并后重跑本脚本');
+        ELSE
+            BEGIN
+                EXECUTE IMMEDIATE 'ALTER TABLE "' || v_tables(i)
+                    || '" RENAME TO ' || UPPER(v_tables(i));
+                v_ok := v_ok + 1;
+                DBMS_OUTPUT.PUT_LINE('表已改名: ' || v_tables(i)
+                    || ' -> ' || UPPER(v_tables(i)));
+            EXCEPTION WHEN OTHERS THEN
+                BEGIN
+                    EXECUTE IMMEDIATE 'RENAME "' || v_tables(i)
+                        || '" TO ' || UPPER(v_tables(i));
+                    v_ok := v_ok + 1;
+                    DBMS_OUTPUT.PUT_LINE('表已改名(RENAME语句): ' || v_tables(i)
+                        || ' -> ' || UPPER(v_tables(i)));
+                EXCEPTION WHEN OTHERS THEN
+                    v_fail := v_fail + 1;
+                    DBMS_OUTPUT.PUT_LINE('表改名失败: ' || v_tables(i)
+                        || ' , 原因: ' || SQLERRM);
+                END;
+            END;
+        END IF;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('表名处理完成: 成功 ' || v_ok || ' , 失败 '
+        || v_fail || ' , 跳过并存 ' || v_skip);
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 0.2 列名小写 → 大写(仅处理基表列;先物化清单再改名,避免游标失效;
+--     逐条异常隔离;先试 DM8 形式 ALTER TABLE ... ALTER COLUMN "旧"
+--     RENAME TO 新,失败自动回退 Oracle 兼容形式
+--     ALTER TABLE ... RENAME COLUMN "旧" TO 新)
+-- ----------------------------------------------------------------------------
+DECLARE
+    TYPE t_list IS TABLE OF VARCHAR(300) INDEX BY BINARY_INTEGER;
+    v_cols t_list;
+    v_total INT := 0;
+    v_pos INT;
+    v_table VARCHAR(128);
+    v_column VARCHAR(128);
+    v_ok INT := 0;
+    v_fail INT := 0;
+BEGIN
+    FOR c IN (SELECT TABLE_NAME, COLUMN_NAME FROM USER_TAB_COLUMNS
+              WHERE COLUMN_NAME <> UPPER(COLUMN_NAME)
+                AND TABLE_NAME IN (SELECT TABLE_NAME FROM USER_TABLES)) LOOP
+        v_total := v_total + 1;
+        v_cols(v_total) := c.TABLE_NAME || '.' || c.COLUMN_NAME;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('发现待改名小写列 ' || v_total || ' 个');
+    FOR i IN 1 .. v_total LOOP
+        v_pos := INSTR(v_cols(i), '.');
+        v_table := SUBSTR(v_cols(i), 1, v_pos - 1);
+        v_column := SUBSTR(v_cols(i), v_pos + 1);
+        BEGIN
+            EXECUTE IMMEDIATE 'ALTER TABLE "' || v_table || '" ALTER COLUMN "'
+                || v_column || '" RENAME TO ' || UPPER(v_column);
+            v_ok := v_ok + 1;
+            DBMS_OUTPUT.PUT_LINE('列已改名: ' || v_cols(i)
+                || ' -> ' || UPPER(v_column));
+        EXCEPTION WHEN OTHERS THEN
+            BEGIN
+                EXECUTE IMMEDIATE 'ALTER TABLE "' || v_table
+                    || '" RENAME COLUMN "' || v_column
+                    || '" TO ' || UPPER(v_column);
+                v_ok := v_ok + 1;
+                DBMS_OUTPUT.PUT_LINE('列已改名(Oracle语法): ' || v_cols(i)
+                    || ' -> ' || UPPER(v_column));
+            EXCEPTION WHEN OTHERS THEN
+                v_fail := v_fail + 1;
+                DBMS_OUTPUT.PUT_LINE('列改名失败: ' || v_cols(i)
+                    || ' , 原因: ' || SQLERRM);
+            END;
+        END;
+    END LOOP;
+    DBMS_OUTPUT.PUT_LINE('列名处理完成: 成功 ' || v_ok || ' , 失败 ' || v_fail);
+END;
+/
+
+-- ----------------------------------------------------------------------------
+-- 1. sys_user 加 managed_org_id + 存量回填哨兵0(V17,契约1.30)
+--    回填仅在本次真正执行加列时进行(v_count 在加列前取样),
+--    防止 Alembic 0032 先跑后本脚本把显式 NULL 覆盖回 0。
+-- ----------------------------------------------------------------------------
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_TAB_COLUMNS
+        WHERE TABLE_NAME = 'SYS_USER'
+          AND COLUMN_NAME = 'MANAGED_ORG_ID';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_USER ADD COLUMN MANAGED_ORG_ID BIGINT NULL DEFAULT 0';
+        EXECUTE IMMEDIATE
+            'UPDATE SYS_USER SET MANAGED_ORG_ID = 0 WHERE MANAGED_ORG_ID IS NULL';
+        COMMIT;
+    END IF;
+END;
+/

+ 3 - 3
backend-java/src/main/resources/db/migration-dm/V17__user_managed_org.sql

@@ -18,10 +18,10 @@ BEGIN
           AND COLUMN_NAME = 'MANAGED_ORG_ID';
     IF v_count = 0 THEN
         EXECUTE IMMEDIATE
-            'ALTER TABLE sys_user ADD COLUMN managed_org_id BIGINT NULL DEFAULT 0';
+            'ALTER TABLE SYS_USER ADD COLUMN MANAGED_ORG_ID BIGINT NULL DEFAULT 0';
         EXECUTE IMMEDIATE
-            'UPDATE sys_user SET managed_org_id = 0 WHERE managed_org_id IS NULL';
+            'UPDATE SYS_USER SET MANAGED_ORG_ID = 0 WHERE MANAGED_ORG_ID IS NULL';
         COMMIT;
     END IF;
 END;
-/
+/

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

@@ -0,0 +1,24 @@
+-- V18__role_upload_permission 的达梦版(DM8):MySQL 原版
+-- ../migration/V18__role_upload_permission.sql 的等价迁移。
+-- 用途:达梦不在 Flyway 支持列表、内置迁移默认关闭(见 application.yml),本脚本
+--   供 DISQL / DM 管理工具手工执行或外部迁移工具引用;目录与 db/migration 平级,
+--   不参与 Flyway 扫描(放 db/migration 子目录会与 MySQL 版冲突为重复 V18)。
+-- 语义与 MySQL 版完全一致:sys_role_permission 加 can_upload TINYINT NOT NULL DEFAULT 0
+--   (契约1.31 上传权限,仅 OWN 行有意义,OTHER 行服务端拒绝置真)。
+-- 幂等:列先探测后执行,重复运行无副作用;与 Python 版 Alembic 0033
+--   双引擎互冲安全(谁先跑,后者跳过)。
+-- 语法说明:达梦无 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_UPLOAD';
+    IF v_count = 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_ROLE_PERMISSION ADD COLUMN CAN_UPLOAD TINYINT NOT NULL DEFAULT 0';
+    END IF;
+END;

+ 10 - 0
backend-java/src/main/resources/db/migration-dm/V19__role_anchor_self.sql

@@ -0,0 +1,10 @@
+-- V19__role_anchor_self 的达梦版(DM8):MySQL 原版
+-- ../migration/V19__role_anchor_self.sql 的等价迁移。
+-- 语义与 MySQL 版完全一致:角色配置界面暂时移除「机构及用户管理范围」
+--   下拉,sys_role.managed_org_id 全部行统一置 0(SELF 哨兵=跟随用户设置,
+--   契约1.31),范围判定回落用户级管理范围(契约1.30);DB 列保留不删。
+-- 幂等:等值 UPDATE 重复执行无副作用。
+
+UPDATE SYS_ROLE SET MANAGED_ORG_ID = 0;
+
+COMMIT;

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

@@ -0,0 +1,28 @@
+-- V20__drop_audit_action_check 的达梦版(DM8):MySQL 原版
+-- ../migration/V20__drop_audit_action_check.sql 的等价迁移。
+-- 用途:达梦不在 Flyway 支持列表、内置迁移默认关闭(见 application.yml),本脚本
+--   供 DISQL / DM 管理工具手工执行或外部迁移工具引用;目录与 db/migration 平级,
+--   不参与 Flyway 扫描(放 db/migration 子目录会与 MySQL 版冲突为重复 V20)。
+-- 语义与 MySQL 版完全一致:删除 sys_audit_log 动作 CHECK 白名单约束
+--   (审计动作持续扩展,DB 白名单落后于代码时新动作写入失败连带业务 500;
+--    合法性收敛到应用层字典;target_type / operation_result 的 CHECK 保留)。
+-- 背景:达梦链上 V16 重建的 45 项白名单缺 V11 的 UPLOAD_PERMISSION_VOUCHER /
+--   DELETE_PERMISSION_VOUCHER,审批单上传审计直接违约——删约束为治本方案。
+-- 幂等:约束先探测后删除,重复运行无副作用;与 Python 版 Alembic 0035
+--   双引擎互冲安全(谁先跑,后者探测不到即跳过)。
+-- 语法说明:达梦无 MySQL 的 SET @var / IF() / PREPARE 动态 SQL,改用 DMSQL 匿名块 +
+--   EXECUTE IMMEDIATE;系统字典用 Oracle 兼容视图 USER_CONSTRAINTS(名称大写存储)。
+
+DECLARE
+    v_count INT;
+BEGIN
+    SELECT COUNT(*) INTO v_count
+        FROM USER_CONSTRAINTS
+        WHERE TABLE_NAME = 'SYS_AUDIT_LOG'
+          AND CONSTRAINT_NAME = 'CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE';
+    IF v_count > 0 THEN
+        EXECUTE IMMEDIATE
+            'ALTER TABLE SYS_AUDIT_LOG DROP CONSTRAINT CK_SYS_AUDIT_LOG_AUDIT_ACTION_TYPE';
+    END IF;
+END;
+/

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

@@ -0,0 +1,18 @@
+-- 0033_role_upload_permission 的 Flyway 等价迁移(Java 版同步,契约1.31)。
+-- 变更要点:sys_role_permission 新增 can_upload 布尔列(默认 0):
+--   仅 OWN 行有意义,控制管理员「上传主体方案/批量导入方案」入口与
+--   POST /documents 链路;OTHER 行恒 0(服务端校验拒绝置真)。
+-- 幂等:加列先探测,与 Python 版 Alembic 0033 并行执行互不冲突。
+
+SET @column_exists = (
+    SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'sys_role_permission'
+      AND COLUMN_NAME = 'can_upload'
+);
+SET @ddl = IF(@column_exists = 0,
+    'ALTER TABLE sys_role_permission ADD COLUMN can_upload TINYINT(1) NOT NULL DEFAULT 0',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 8 - 0
backend-java/src/main/resources/db/migration/V19__role_anchor_self.sql

@@ -0,0 +1,8 @@
+-- 0034_role_anchor_self 的 Flyway 等价迁移(Java 版同步,契约1.31)。
+-- 变更要点:角色配置界面暂时移除「机构及用户管理范围」下拉,
+--   sys_role.managed_org_id 全部行统一置 0(SELF 哨兵=跟随用户设置),
+--   范围判定回落用户级管理范围(sys_user.managed_org_id,契约1.30);
+--   DB 列保留不删(恢复下拉即还原三态)。
+-- 幂等:等值 UPDATE 重复执行无副作用。
+
+UPDATE sys_role SET managed_org_id = 0;

+ 21 - 0
backend-java/src/main/resources/db/migration/V20__drop_audit_action_check.sql

@@ -0,0 +1,21 @@
+-- 0035_drop_audit_action_check 的 Flyway 等价迁移(Java 版同步)。
+-- 变更要点:删除 sys_audit_log.action_type 的 CHECK 白名单约束。
+--   审计动作是持续扩展的枚举(V10~V16 已重建该约束 8 次),DB CHECK 导致
+--   新动作写入失败并连带业务操作 500(如库白名单落后于代码时审批单上传失败);
+--   审计日志作为事实记录不应因枚举扩展而丢失,合法性收敛到应用层字典。
+--   target_type / operation_result 的 CHECK 保留(稳定小枚举)。
+-- 幂等:探测后删除,重复执行无副作用;与 Python 版 Alembic 0035
+--   双引擎并行执行互不冲突(谁先跑,后者探测不到即跳过)。
+
+SET @ck_name = (
+    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_ACTION_TYPE'
+    LIMIT 1
+);
+SET @ddl = IF(@ck_name IS NULL, 'SELECT 1',
+    CONCAT('ALTER TABLE sys_audit_log DROP CONSTRAINT ', @ck_name));
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 136 - 0
backend-java/src/test/java/com/dms/service/AdminLoginSwitchTest.java

@@ -0,0 +1,136 @@
+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.anyInt;
+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.common.Page;
+import com.dms.config.DmsProperties;
+import com.dms.mapper.OrganizationMapper;
+import com.dms.mapper.RoleMapper;
+import com.dms.mapper.UserMapper;
+import com.dms.security.AuthContext;
+import com.dms.security.Reauth;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.mock.env.MockEnvironment;
+
+/**
+ * 超级管理员登录开关(安全需求7),对齐 backend/tests/dms/test_admin_login_switch.py:
+ * 登录按用户不存在短路(报错与密码错误一致)、创建伪报错不泄露账号存在、列表隐藏 admin。
+ */
+class AdminLoginSwitchTest {
+
+    private static DmsProperties props(boolean allowAdmin) {
+        MockEnvironment environment = new MockEnvironment();
+        if (!allowAdmin) {
+            environment.setProperty("DMS_ALLOW_ADMIN_LOGIN", "false");
+        }
+        return new DmsProperties(environment);
+    }
+
+    private static AuthContext adminContext() {
+        return new AuthContext(1L, "op", "操作员", 1L, "机构", "ADMIN", "PUBLIC", 0);
+    }
+
+    @AfterEach
+    void clearContext() {
+        AuthContext.clear();
+    }
+
+    @Test
+    void loginBlockedAdminShortCircuitsBeforeDbLookup() {
+        UserMapper userMapper = mock(UserMapper.class);
+        AuthService service = new AuthService(userMapper, mock(AuditService.class),
+                mock(LoginIpService.class), mock(UserSummaryService.class),
+                mock(LoginLockoutService.class), mock(CaptchaService.class),
+                null, props(false));
+        // 与密码错误同出口 INVALID_CREDENTIALS,从任何维度无法区分"admin被禁"与"不存在"
+        assertThatThrownBy(() -> service.login("admin", "whatever", false, null, null, null))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "INVALID_CREDENTIALS");
+        // 拦截发生在查库之前:用户名按不存在处理,不触数据库
+        verify(userMapper, never()).findActiveByUsername(any());
+    }
+
+    @Test
+    void loginOtherUsernameNotBlockedWhenDisabled() {
+        UserMapper userMapper = mock(UserMapper.class);
+        when(userMapper.findActiveByUsername("someone")).thenReturn(null);
+        AuthService service = new AuthService(userMapper, 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))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "INVALID_CREDENTIALS");
+        // 开关只针对内置 admin:其他用户名照常查库
+        verify(userMapper).findActiveByUsername("someone");
+    }
+
+    @Test
+    void createAdminUsernameRejectedWithGenericErrorWhenDisabled() {
+        UserMapper userMapper = mock(UserMapper.class);
+        UserService service = new UserService(userMapper, 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");
+        // 伪报错 400 INVALID_ARGUMENT:不提示"用户名已存在",不泄露账号存在
+        assertThatThrownBy(() -> service.create(body, null))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "INVALID_ARGUMENT")
+                .hasMessage("不能创建该名称的用户");
+        // 拦截先于唯一性查重,不触数据库
+        verify(userMapper, never()).findActiveByUsername(any());
+    }
+
+    @Test
+    void listUsersExcludesAdminWhenDisabled() {
+        UserMapper userMapper = mock(UserMapper.class);
+        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),
+                mock(RoleMapper.class), mock(UserSummaryService.class),
+                mock(AuditService.class), mock(AdminPermission.class),
+                mock(Reauth.class), props(false));
+        AuthContext.set(adminContext());
+        service.listUsers(null, false, null, null, new Page.Params(1, 20));
+        ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
+        verify(userMapper).countByFilters(captor.capture(), any(), any(), any());
+        assertThat(captor.getValue()).isEqualTo("admin");
+    }
+
+    @Test
+    void listUsersKeepsAdminWhenAllowed() {
+        UserMapper userMapper = mock(UserMapper.class);
+        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),
+                mock(RoleMapper.class), mock(UserSummaryService.class),
+                mock(AuditService.class), mock(AdminPermission.class),
+                mock(Reauth.class), props(true));
+        AuthContext.set(adminContext());
+        service.listUsers(null, false, null, null, new Page.Params(1, 20));
+        ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
+        verify(userMapper).countByFilters(captor.capture(), any(), any(), any());
+        // 开关允许时不过滤:excludeUsername 为 null,admin 正常出现在列表
+        assertThat(captor.getValue()).isNull();
+    }
+}

+ 87 - 0
backend-java/src/test/java/com/dms/service/CaptchaServiceTest.java

@@ -0,0 +1,87 @@
+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 com.dms.common.DmsException;
+import com.dms.config.DmsProperties;
+import java.util.Base64;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.env.MockEnvironment;
+
+/**
+ * 登录图片验证码(安全需求4),对齐 backend/tests/dms/test_captcha.py:
+ * 签发结构、大小写不敏感、一次性消费、过期、缺失/错误拒绝、开关放行。
+ */
+class CaptchaServiceTest {
+
+    private static CaptchaService service(boolean enabled) {
+        MockEnvironment environment = new MockEnvironment();
+        if (!enabled) {
+            environment.setProperty("DMS_CAPTCHA_ENABLED", "false");
+        }
+        return new CaptchaService(new DmsProperties(environment));
+    }
+
+    @Test
+    void issueReturnsCaptchaIdAndPngDataUrl() {
+        CaptchaService captchaService = service(true);
+        var data = captchaService.issue();
+        assertThat(data).containsOnlyKeys("captchaId", "image");
+        String image = (String) data.get("image");
+        assertThat(image).startsWith("data:image/png;base64,");
+        byte[] raw = Base64.getDecoder().decode(image.substring("data:image/png;base64,".length()));
+        // PNG 魔数头
+        assertThat(raw).startsWith((byte) 0x89, (byte) 'P', (byte) 'N', (byte) 'G');
+    }
+
+    @Test
+    void verifyAcceptsCaseInsensitiveAndConsumesOnce() {
+        CaptchaService captchaService = service(true);
+        var data = captchaService.issue();
+        String id = (String) data.get("captchaId");
+        // 明文经 issue() 预生成路径取回(单测拿得到内部码)
+        String code = captchaService.entriesForTest().get(id);
+        assertThatCode(() -> captchaService.verify(id, code.toLowerCase())).doesNotThrowAnyException();
+        // 一次性消费:第二次(即使正确)拒绝
+        assertThatThrownBy(() -> captchaService.verify(id, code))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "INVALID_CAPTCHA");
+    }
+
+    @Test
+    void wrongCodeFailsAndConsumes() {
+        CaptchaService captchaService = service(true);
+        var data = captchaService.issue();
+        String id = (String) data.get("captchaId");
+        String code = captchaService.entriesForTest().get(id);
+        assertThatThrownBy(() -> captchaService.verify(id, "X" + code.substring(1)))
+                .isInstanceOf(DmsException.class);
+        assertThatThrownBy(() -> captchaService.verify(id, code))
+                .isInstanceOf(DmsException.class);
+    }
+
+    @Test
+    void missingOrUnknownIdRejected() {
+        CaptchaService captchaService = service(true);
+        assertThatThrownBy(() -> captchaService.verify(null, null))
+                .isInstanceOf(DmsException.class);
+        assertThatThrownBy(() -> captchaService.verify("  ", "ABCD"))
+                .isInstanceOf(DmsException.class);
+        assertThatThrownBy(() -> captchaService.verify("not-exist", "ABCD"))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "INVALID_CAPTCHA");
+    }
+
+    @Test
+    void disabledSkipsVerification() {
+        CaptchaService captchaService = service(false);
+        assertThat(captchaService.enabled()).isFalse();
+        // 关闭时 verify 直接放行(登录回归三键契约)
+        assertThatCode(() -> captchaService.verify(null, null)).doesNotThrowAnyException();
+        // 接口仅返回 enabled=false,不签发
+        var data = captchaService.issue();
+        assertThat(data).containsOnlyKeys("captchaId", "image");
+    }
+}

+ 14 - 0
backend-java/src/test/java/com/dms/service/LoginIpServiceMatchesTest.java

@@ -50,4 +50,18 @@ class LoginIpServiceMatchesTest {
         assertFalse(matches("10.10.10.10", "1.1.1.1-"));
         assertFalse(matches("10.10.10.10", "10.0.0.0/xx"));
     }
+
+    @Test
+    void 豁免仅限内置admin用户名() {
+        // 登录IP豁免语义:仅内置 admin 用户名豁免,管理员类别(roleCode=ADMIN)不再整体豁免
+        com.dms.model.UserEntity builtIn = new com.dms.model.UserEntity();
+        builtIn.setUsername("admin");
+        builtIn.setRoleCode("ADMIN");
+        assertTrue(AdminPermission.isSuperAdmin(builtIn));
+
+        com.dms.model.UserEntity orgAdmin = new com.dms.model.UserEntity();
+        orgAdmin.setUsername("org_admin");
+        orgAdmin.setRoleCode("ADMIN");
+        assertFalse(AdminPermission.isSuperAdmin(orgAdmin));
+    }
 }

+ 94 - 0
backend-java/src/test/java/com/dms/service/OrganizationTreeScopeTest.java

@@ -0,0 +1,94 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.dms.mapper.DocumentMapper;
+import com.dms.mapper.OrganizationMapper;
+import com.dms.mapper.UserMapper;
+import com.dms.model.OrganizationEntity;
+import com.dms.model.UserEntity;
+import com.dms.security.AuthContext;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 机构树按管理范围裁剪(安全需求8),对齐 backend organization_tree 裁剪语义:
+ * 锚点管理员仅见管理范围子树(森林根从锚点起),全局管理员不受限。
+ */
+class OrganizationTreeScopeTest {
+
+    private static OrganizationEntity org(long id, Long parentId, String name) {
+        OrganizationEntity entity = new OrganizationEntity();
+        entity.setId(id);
+        entity.setParentId(parentId);
+        entity.setOrgCode("C" + id);
+        entity.setOrgName(name);
+        entity.setSortNo(1);
+        entity.setStatus("ENABLED");
+        return entity;
+    }
+
+    private static OrganizationService service(OrganizationMapper orgMapper,
+                                               UserMapper userMapper,
+                                               AdminPermission adminPermission) {
+        return new OrganizationService(orgMapper, mock(DocumentMapper.class),
+                mock(AuditService.class), adminPermission, userMapper);
+    }
+
+    @AfterEach
+    void clearContext() {
+        AuthContext.clear();
+    }
+
+    @Test
+    void anchoredAdminSeesOnlyManagedSubtree() {
+        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();
+        when(userMapper.findActiveById(9L)).thenReturn(actor);
+        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));
+
+        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 globalAdminSeesFullTree() {
+        OrganizationMapper orgMapper = mock(OrganizationMapper.class);
+        when(orgMapper.listAllNotDeleted()).thenReturn(List.of(
+                org(1, null, "总部"), org(2, 1L, "分局A"), org(3, 1L, "分局B")));
+        UserMapper userMapper = mock(UserMapper.class);
+        UserEntity actor = new UserEntity();
+        when(userMapper.findActiveById(9L)).thenReturn(actor);
+        AdminPermission adminPermission = mock(AdminPermission.class);
+        // managedScopeIds 返回 null = 全局管理员:不裁剪
+        when(adminPermission.managedScopeIds(actor)).thenReturn(null);
+        AuthContext.set(new AuthContext(9L, "global", "全局管理员",
+                null, null, "ADMIN", "PUBLIC", 0));
+
+        List<Map<String, Object>> roots = service(orgMapper, userMapper, adminPermission)
+                .organizationTree(null, null);
+        assertThat(roots).hasSize(1);
+        assertThat(roots.get(0).get("orgName")).isEqualTo("总部");
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> children = (List<Map<String, Object>>) roots.get(0).get("children");
+        assertThat(children).extracting(n -> n.get("orgName")).containsExactly("分局A", "分局B");
+    }
+}

+ 84 - 0
backend-java/src/test/java/com/dms/service/UserSummaryPermUploadTest.java

@@ -0,0 +1,84 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.dms.mapper.AttachmentBindingMapper;
+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.RoleEntity;
+import com.dms.model.UserEntity;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 上传权限会话位单测(契约1.31,对齐 test_role_management.py):
+ * permUpload = 内置admin恒true;否则取生效角色方案权限矩阵任一 OWN 行 can_upload。
+ * hasAnyUploadPermission = countOwnUpload(roleId) &gt; 0,roleId 空恒 false。
+ */
+class UserSummaryPermUploadTest {
+
+    private final OrganizationMapper organizationMapper = mock(OrganizationMapper.class);
+    private final RoleMapper roleMapper = mock(RoleMapper.class);
+    private final RolePermissionMapper rolePermissionMapper = mock(RolePermissionMapper.class);
+    private final AuthorizationService authorizationService = mock(AuthorizationService.class);
+    private final AdminPermission adminPermission = mock(AdminPermission.class);
+
+    private UserSummaryService service() {
+        return new UserSummaryService(
+                organizationMapper, roleMapper, authorizationService, adminPermission);
+    }
+
+    @Test
+    void superAdminAlwaysHasUploadPermission() {
+        UserEntity admin = new UserEntity();
+        admin.setUsername("admin");
+        admin.setRoleCode("ADMIN");
+
+        assertThat(service().userSummary(admin, List.of()).get("permUpload"))
+                .isEqualTo(true);
+    }
+
+    @Test
+    void uploadPermissionFollowsOwnUploadRowsOfEffectiveRole() {
+        UserEntity user = new UserEntity();
+        user.setUsername("alice");
+        user.setRoleCode("USER");
+
+        RoleEntity role = new RoleEntity();
+        role.setId(88L);
+        when(adminPermission.effectiveManageRole(user)).thenReturn(role);
+        when(authorizationService.hasAnyUploadPermission(88L)).thenReturn(true);
+        assertThat(service().userSummary(user, List.of()).get("permUpload"))
+                .isEqualTo(true);
+
+        when(authorizationService.hasAnyUploadPermission(88L)).thenReturn(false);
+        assertThat(service().userSummary(user, List.of()).get("permUpload"))
+                .isEqualTo(false);
+    }
+
+    @Test
+    void hasAnyUploadPermissionCountsOwnRowsOnly() {
+        AuthorizationService real = new AuthorizationService(
+                mock(DocumentMapper.class),
+                mock(PermissionMapper.class),
+                mock(AttachmentBindingMapper.class),
+                organizationMapper,
+                mock(UserMapper.class),
+                roleMapper,
+                rolePermissionMapper);
+
+        assertThat(real.hasAnyUploadPermission(null)).isFalse();
+
+        when(rolePermissionMapper.countOwnUpload(88L)).thenReturn(2);
+        assertThat(real.hasAnyUploadPermission(88L)).isTrue();
+
+        when(rolePermissionMapper.countOwnUpload(89L)).thenReturn(0);
+        assertThat(real.hasAnyUploadPermission(89L)).isFalse();
+    }
+}

+ 1 - 1
backend-java/src/test/java/com/dms/web/SpaControllerTest.java

@@ -60,7 +60,7 @@ class SpaControllerTest {
             ResponseEntity<?> response = controller.spa(request(uri));
             assertThat(response.getStatusCode()).as(uri).isEqualTo(HttpStatus.NOT_FOUND);
             assertThat(response.getBody()).as(uri)
-                    .isEqualTo(Map.of("error", "Not found"));
+                    .isEqualTo(Map.of("error", "无权限访问"));
         }
     }
 

+ 9 - 0
backend/.env.example

@@ -33,6 +33,15 @@ DMS_JWT_EXPIRES_SECONDS=3600
 # 达到该时长后前端自动退出登录;设为 0 表示关闭该功能
 DMS_IDLE_LOGOUT_MINUTES=5
 
+# 登录图片验证码(缺省 true 开启):开启时登录页显示图片验证码输入,登录请求
+# 需携带 captchaId/captchaCode;设为 false 关闭后登录回归用户名/密码三键请求
+DMS_CAPTCHA_ENABLED=true
+
+# 超级管理员登录开关(缺省 true 允许):设为 false 后内置 admin 禁止登录(报错
+# 与用户名或密码错误完全一致)、用户列表隐藏 admin、创建同名用户报"不能创建该
+# 名称的用户",外部无法感知系统中存在该账号
+DMS_ALLOW_ADMIN_LOGIN=true
+
 # 系统管理页监控信息展示(缺省 false):false 时系统管理页仅显示维护操作与备份管理,
 # 隐藏系统信息/资源监控/存储用量/运行参数等区块;true 时完整显示
 DMS_SYSTEM_MONITOR_ENABLED=false

+ 5 - 4
backend/app.py

@@ -115,18 +115,19 @@ def serve_spa(path: str):
         # 保持错误结构与X-Request-Id响应头一致(一体化部署下同样生效)
         if path == "api/v1" or path.startswith("api/v1/"):
             raise NotFound()
-        return jsonify({"error": "Not found"}), 404
+        return jsonify({"error": "无权限访问"}), 404
     if FRONTEND_DIST is None:
         # API-only 模式(无前端资源):状态码与原"不注册路由"等价,统一 404
-        return jsonify({"error": "Not found"}), 404
+        return jsonify({"error": "无权限访问"}), 404
     candidate = FRONTEND_DIST / path
     if path and candidate.is_file():
         return send_from_directory(FRONTEND_DIST, path)
     # 前端无路径路由(仅 query 传参),SPA 回退只需覆盖根路径;
-    # 合法 URL/API 之外的未知路径一律 404,不返回 wildcard 200(安全收敛)
+    # 合法 URL/API 之外的未知路径一律 404 且提示「无权限」(不泄露路径存在性,
+    # 与登录前深链接行为统一:未登录无论输入合法非法路径都只见「无权限」)
     if not path:
         return send_from_directory(FRONTEND_DIST, "index.html")
-    return jsonify({"error": "Not found"}), 404
+    return jsonify({"error": "无权限访问"}), 404
 
 if FRONTEND_DIST is not None:
     logger.info("Frontend dist mounted at: %s", FRONTEND_DIST)

+ 48 - 6
backend/dms/api/v1/auth.py

@@ -16,6 +16,7 @@ from dms.common.errors import (
     LoginIpForbiddenError,
     UserDisabledError,
 )
+from dms.services.captcha_service import issue as captcha_issue, verify as captcha_verify
 from dms.common.response import success_response
 from dms.database.transaction import for_update, transaction
 from dms.extensions import db
@@ -24,6 +25,7 @@ from dms.security.auth_context import get_auth_context
 from dms.security.decorators import bearer_auth_required
 from dms.security.jwt_tokens import issue_access_token, issue_reauth_token
 from dms.security.passwords import verify_password
+from dms.services.admin_permission import SUPER_ADMIN_USERNAME
 from dms.services.audit_service import auth_audit, client_ip, record_login_failure
 from dms.services.ip_entry_service import verify_login_ip
 from dms.services.login_lockout import (
@@ -34,15 +36,22 @@ from dms.services.login_lockout import (
 from dms.services.user_service import allowed_modules_for_user, user_summary
 
 
-def _login_payload() -> tuple[str, str, bool]:
+def _login_payload() -> tuple[str, str, bool, str | None, str | None]:
     if not request.is_json:
         raise InvalidArgumentError("请求体必须是JSON对象")
     body = request.get_json(silent=True)
     if not isinstance(body, dict):
         raise InvalidArgumentError("请求体必须是JSON对象")
+    # 验证码开启(安全需求4)时请求体扩为五键,关闭时保持三键不变
+    captcha_enabled = current_app.config.get("DMS_CAPTCHA_ENABLED", True)
     expected = {"username", "password", "keepSignedIn"}
+    if captcha_enabled:
+        expected |= {"captchaId", "captchaCode"}
     if set(body) != expected:
-        raise InvalidArgumentError("请求体必须且只能包含username、password和keepSignedIn")
+        raise InvalidArgumentError(
+            "请求体必须且只能包含username、password和keepSignedIn"
+            + ("、captchaId和captchaCode" if captcha_enabled else "")
+        )
     username = body["username"]
     password = body["password"]
     keep_signed_in = body["keepSignedIn"]
@@ -52,14 +61,47 @@ def _login_payload() -> tuple[str, str, bool]:
         raise InvalidArgumentError("password不能为空")
     if not isinstance(keep_signed_in, bool):
         raise InvalidArgumentError("keepSignedIn必须是布尔值")
-    return username.strip(), password, keep_signed_in
+    captcha_id: str | None = None
+    captcha_code: str | None = None
+    if captcha_enabled:
+        captcha_id = body["captchaId"]
+        captcha_code = body["captchaCode"]
+        if not isinstance(captcha_id, str) or not captcha_id.strip():
+            raise InvalidArgumentError("captchaId不能为空")
+        if not isinstance(captcha_code, str) or not captcha_code.strip():
+            raise InvalidArgumentError("captchaCode不能为空")
+    return username.strip(), password, keep_signed_in, captcha_id, captcha_code
+
+
+@api_v1.get("/auth/captcha")
+def captcha():
+    """登录图片验证码(安全需求4):开启时返回 data URL 图片,关闭时仅返回 enabled=false。"""
+    if not current_app.config.get("DMS_CAPTCHA_ENABLED", True):
+        return success_response({"enabled": False})
+    data: dict[str, object] = captcha_issue()
+    data["enabled"] = True
+    return success_response(data)
 
 
 @api_v1.post("/auth/login")
 def login():
-    username, password, keep_signed_in = _login_payload()
-    user = db.session.scalar(
-        select(User).where(User.username == username, User.is_deleted.is_(False))
+    username, password, keep_signed_in, captcha_id, captcha_code = _login_payload()
+    # 验证码是防爆破第一道闸:先于用户查询/锁定检查,一次性消费不计锁定;
+    # 开关关闭时直接放行(对齐 Java CaptchaService.verify,登录回归三键契约)
+    if current_app.config.get("DMS_CAPTCHA_ENABLED", True):
+        captcha_verify(captcha_id, captcha_code)
+    # 超级管理员登录开关(安全需求7)关闭时按用户不存在处理:锁定/状态/IP
+    # 检查全部短路,报错与密码错误完全一致,不泄露账号存在
+    admin_login_blocked = (
+        username == SUPER_ADMIN_USERNAME
+        and not current_app.config.get("DMS_ALLOW_ADMIN_LOGIN", True)
+    )
+    user = (
+        None
+        if admin_login_blocked
+        else db.session.scalar(
+            select(User).where(User.username == username, User.is_deleted.is_(False))
+        )
     )
     # 锁定检查先于密码校验:锁定期间一律拒绝,不延长锁定窗口
     if user is not None and is_locked(user):

+ 16 - 1
backend/dms/api/v1/blueprint.py

@@ -100,7 +100,14 @@ def _merge_exposed_headers(existing: str | None) -> str:
 
 
 def register_dms_response_headers(app: Flask) -> None:
-    """仅为DMS路径补充跨域可读响应头,覆盖路由级错误。"""
+    """DMS路径补充跨域可读响应头与API安全响应头,覆盖路由级错误。
+
+    安全响应头(对齐 Java 版 SecurityHeadersFilter,G-13 整改):
+    nosniff 禁 MIME 嗅探、X-Frame-Options DENY 防点击劫持、CSP 最严双保险、
+    Referrer-Policy 不泄露内网地址;Cache-Control 默认 no-store,
+    控制器/服务层已显式声明的缓存策略(登录背景图短缓存、预览响应)
+    优先于缺省值,不覆盖。
+    """
 
     @app.after_request
     def _expose_dms_response_headers(response):
@@ -110,6 +117,14 @@ def register_dms_response_headers(app: Flask) -> None:
                     response.headers.get("Access-Control-Expose-Headers")
                 )
             )
+            response.headers["X-Content-Type-Options"] = "nosniff"
+            response.headers["X-Frame-Options"] = "DENY"
+            response.headers["Content-Security-Policy"] = (
+                "default-src 'none'; frame-ancestors 'none'"
+            )
+            response.headers["Referrer-Policy"] = "no-referrer"
+            if "Cache-Control" not in response.headers:
+                response.headers["Cache-Control"] = "no-store"
         return response
 
 

+ 21 - 9
backend/dms/api/v1/login_background.py

@@ -4,9 +4,14 @@
 (打包模式为 exe 同级目录,开发模式为仓库根目录)即可让登录界面
 自动展示自定义背景;两个文件同时存在时优先 jpg。
 
+按主题定制(契约1.31):可选查询参数 theme=blue|red|green 时优先
+返回 background-{theme}.jpg/png,主题图不存在时回落通用 background;
+登录页按当前主题请求,实现每主题一张背景图。
+
 安全说明:
 - 接口公开(登录前无凭证可访问),不返回任何敏感信息;
-- 候选文件名为固定白名单,不接收路径参数,无目录穿越风险。
+- 候选文件名为固定白名单(通用 + 三主题),theme 参数经白名单校验,
+  不接收路径参数,无目录穿越风险。
 """
 
 from __future__ import annotations
@@ -14,16 +19,13 @@ from __future__ import annotations
 import sys
 from pathlib import Path
 
-from flask import current_app, send_file
+from flask import current_app, request, send_file
 
 from dms.api.v1.blueprint import api_v1
 from dms.common.errors import FileNotFoundError as DmsFileNotFoundError
 
-# 候选文件名(固定白名单):同时存在时按列表顺序优先
-_BACKGROUND_FILES: dict[str, str] = {
-    "background.jpg": "image/jpeg",
-    "background.png": "image/png",
-}
+# 主题白名单:界面主题三选一;theme 缺省/非法时仅查通用兜底文件
+_BACKGROUND_THEMES: tuple[str, ...] = ("blue", "red", "green")
 
 
 def _runtime_root() -> Path:
@@ -42,9 +44,19 @@ def _candidate_paths() -> list[tuple[Path, str]]:
     root = Path(
         current_app.config.get("DMS_LOGIN_BACKGROUND_DIR") or _runtime_root()
     )
-    return [
-        (root / name, mime) for name, mime in _BACKGROUND_FILES.items()
+    theme = (request.args.get("theme") or "").strip().lower()
+    names: list[tuple[str, str]] = []
+    if theme in _BACKGROUND_THEMES:
+        names += [
+            (f"background-{theme}.jpg", "image/jpeg"),
+            (f"background-{theme}.png", "image/png"),
+        ]
+    # 通用兜底:主题图缺失或未指定 theme 时使用
+    names += [
+        ("background.jpg", "image/jpeg"),
+        ("background.png", "image/png"),
     ]
+    return [(root / name, mime) for name, mime in names]
 
 
 @api_v1.get("/config/login-background")

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

@@ -29,6 +29,13 @@ class InvalidArgumentError(DmsError):
     default_message = "请求参数错误"
 
 
+class InvalidCaptchaError(InvalidArgumentError):
+    """登录图片验证码缺失/错误/过期(安全需求4),不泄露具体原因。"""
+
+    code = "INVALID_CAPTCHA"
+    default_message = "验证码错误或已过期"
+
+
 class AuthenticationError(DmsError):
     code = "TOKEN_INVALID"
     status_code = 401

+ 11 - 0
backend/dms/config.py

@@ -202,4 +202,15 @@ def load_dms_config() -> dict[str, Any]:
             os.environ.get("DMS_SYSTEM_MONITOR_ENABLED", "false").strip().lower()
             in {"1", "true", "yes", "on"}
         ),
+        # 登录图片验证码(安全需求4):缺省启用;关闭时登录回归三键请求体
+        "DMS_CAPTCHA_ENABLED": (
+            os.environ.get("DMS_CAPTCHA_ENABLED", "true").strip().lower()
+            in {"1", "true", "yes", "on"}
+        ),
+        # 超级管理员登录开关(安全需求7):false 时内置admin禁止登录(报错与
+        # 密码错误一致)、用户列表隐藏admin、创建同名用户报"不能创建该名称的用户"
+        "DMS_ALLOW_ADMIN_LOGIN": (
+            os.environ.get("DMS_ALLOW_ADMIN_LOGIN", "true").strip().lower()
+            in {"1", "true", "yes", "on"}
+        ),
     }

+ 3 - 20
backend/dms/models/audit_log.py

@@ -13,27 +13,10 @@ from dms.extensions import db
 
 class AuditLog(db.Model):
     __tablename__ = "sys_audit_log"
+    # action_type 不设 CHECK 白名单(0035 移除):审计动作是持续扩展的枚举,
+    # DB 约束导致新动作写入失败并连带业务操作 500,合法性由应用层字典约束;
+    # 前端字典无匹配时回退显示英文原码。
     __table_args__ = (
-        CheckConstraint(
-            "action_type IN ("
-            "'LOGIN', 'LOGOUT', 'VIEW_DOCUMENT', 'DOWNLOAD_DOCUMENT', "
-            "'UPLOAD_DOCUMENT', 'BATCH_IMPORT', 'EDIT_DOCUMENT', "
-            "'DELETE_DOCUMENT', 'CREATE_CATEGORY', 'EDIT_CATEGORY', "
-            "'DELETE_CATEGORY', 'CHANGE_PERMISSION', 'BIND_ATTACHMENT', "
-            "'UNBIND_ATTACHMENT', 'RESTORE_DOCUMENT', "
-            "'CREATE_ORGANIZATION', 'EDIT_ORGANIZATION', "
-            "'DELETE_ORGANIZATION', 'CREATE_USER', 'EDIT_USER', "
-            "'DELETE_USER', 'RESET_USER_PASSWORD', "
-            "'REORDER_DOCUMENT', 'REORDER_ATTACHMENT', 'REORDER_CATEGORY', "
-            "'REORDER_ORGANIZATION', 'REORDER_USER', 'REORDER_ROLE', "
-            "'CREATE_ROLE', 'EDIT_ROLE', 'DELETE_ROLE', "
-            "'CHANGE_ROLE_PERMISSION', 'CHANGE_ROLE_USER', "
-            "'DOCUMENT_PASSWORD_SET', 'DOCUMENT_PASSWORD_REMOVE', "
-            "'CREATE_IP_ENTRY', 'EDIT_IP_ENTRY', 'DELETE_IP_ENTRY', "
-            "'ASSIGN_ORG_IP'"
-            ")",
-            name="audit_action_type",
-        ),
         CheckConstraint(
             "target_type IS NULL OR target_type IN ("
             "'AUTH', 'CATEGORY', 'DOCUMENT', 'ATTACHMENT', "

+ 8 - 1
backend/dms/models/role.py

@@ -120,7 +120,7 @@ class Role(BusinessTableMixin, db.Model):
 
 
 class RolePermission(BusinessTableMixin, db.Model):
-    """角色权限矩阵行:密级 × 部门范围 → 7 个操作布尔位。
+    """角色权限矩阵行:密级 × 部门范围 → 8 个操作布尔位。
 
     每角色固定 10 行(5 密级 × 2 部门范围),整存整取;
     与文档 ACL 为 OR 叠加,矩阵是主权限源。
@@ -163,6 +163,13 @@ class RolePermission(BusinessTableMixin, db.Model):
         nullable=False,
         server_default=text("0"),
     )
+    # 上传权限(契约1.31):仅 OWN 行有意义,OTHER 行恒为0(界面不可选);
+    # 控制管理员"上传主体方案/批量导入方案"入口与 POST /documents 链路
+    can_upload: Mapped[bool] = mapped_column(
+        Boolean,
+        nullable=False,
+        server_default=text("0"),
+    )
     can_download: Mapped[bool] = mapped_column(
         Boolean,
         nullable=False,

+ 5 - 1
backend/dms/services/audit_service.py

@@ -31,7 +31,11 @@ def client_ip() -> str | None:
 def _is_loopback(ip: str) -> bool:
     try:
         # 去掉 IPv6 zone id;覆盖 ::1、0:0:0:0:0:0:0:1、127.* 及 v4 映射形态
-        return ipaddress.ip_address(ip.split("%", 1)[0]).is_loopback
+        # (::ffff:a.b.c.d 的 is_loopback 随 Python 版本存在差异,显式解映射后判定)
+        parsed = ipaddress.ip_address(ip.split("%", 1)[0])
+        if isinstance(parsed, ipaddress.IPv6Address) and parsed.ipv4_mapped is not None:
+            parsed = parsed.ipv4_mapped
+        return parsed.is_loopback
     except ValueError:
         return False
 

+ 52 - 0
backend/dms/services/authorization_service.py

@@ -606,3 +606,55 @@ def has_any_manage_action(context: AuthContext) -> bool:
         if hit is not None:
             return True
     return False
+
+
+def can_upload_plan(
+    context: AuthContext,
+    security_level: str,
+    organization_id: int | None,
+) -> bool:
+    """主案上传权限(契约1.31):方案权限矩阵 can_upload 列。
+
+    超管恒可;文档归属本部门子树(或无归属)按 OWN 行判定,
+    归属其他部门按 OTHER 行判定(矩阵校验 OTHER 行 can_upload 恒 0,
+    即跨部门上传恒拒绝)。
+    """
+    if _is_super_admin(context):
+        return True
+    role = _effective_role(context)
+    if role is None:
+        return False
+    own_ids = _organization_subtree_ids(context.organization_id)
+    scope = (
+        DeptScope.OWN
+        if organization_id is None or organization_id in own_ids
+        else DeptScope.OTHER
+    )
+    row = db.session.scalar(
+        select(RolePermission).where(
+            RolePermission.role_id == role.id,
+            RolePermission.security_level == security_level,
+            RolePermission.dept_scope == scope.value,
+            RolePermission.is_deleted.is_(False),
+        )
+    )
+    return row is not None and bool(row.can_upload)
+
+
+def has_any_upload_permission(role_id: int | None) -> bool:
+    """会话 permUpload 位矩阵查询(契约1.31):任一 OWN 行 can_upload=True。
+
+    仅查生效角色的矩阵行,超管特判由调用方三元式处理(与既有 perm 位
+    组装同风格)。
+    """
+    if role_id is None:
+        return False
+    row = db.session.scalar(
+        select(RolePermission).where(
+            RolePermission.role_id == role_id,
+            RolePermission.dept_scope == DeptScope.OWN.value,
+            RolePermission.can_upload.is_(True),
+            RolePermission.is_deleted.is_(False),
+        )
+    )
+    return row is not None

+ 100 - 0
backend/dms/services/captcha_service.py

@@ -0,0 +1,100 @@
+"""登录图片验证码:内存签发/校验 + PyMuPDF 渲染 PNG(安全需求4)。
+
+- 单实例部署(launcher 单进程)下内存存储足够;
+- 4 位字符(剔除易混淆 0O1I),120 秒过期;
+- 校验一次性消费(无论成败),防止同一验证码反复试密码;
+- 校验忽略大小写与首尾空白。
+"""
+
+from __future__ import annotations
+
+import base64
+import secrets
+import threading
+import time
+import uuid
+import random
+
+from dms.common.errors import InvalidCaptchaError
+
+_CAPTCHA_CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
+_CODE_LENGTH = 4
+_EXPIRES_SECONDS = 120
+_MAX_ENTRIES = 1000
+
+_lock = threading.Lock()
+# captcha_id -> (code 大写归一, 过期时刻 monotonic)
+_entries: dict[str, tuple[str, float]] = {}
+
+
+def issue() -> dict[str, str]:
+    """签发新验证码,返回 ``{"captchaId": ..., "image": "data:image/png;base64,..."}``。"""
+    code = "".join(secrets.choice(_CAPTCHA_CHARS) for _ in range(_CODE_LENGTH))
+    captcha_id = uuid.uuid4().hex
+    now = time.monotonic()
+    with _lock:
+        _purge_expired(now)
+        if len(_entries) >= _MAX_ENTRIES:
+            # 上限保护:极端刷量时整体清空(存量本就即将过期,不影响正常用户)
+            _entries.clear()
+        _entries[captcha_id] = (code.upper(), now + _EXPIRES_SECONDS)
+    return {
+        "captchaId": captcha_id,
+        "image": "data:image/png;base64,"
+        + base64.b64encode(_render_png(code)).decode("ascii"),
+    }
+
+
+def verify(captcha_id: object, code: object) -> None:
+    """校验并消费验证码;缺失/错误/过期均抛 :class:`InvalidCaptchaError`。"""
+    if not isinstance(captcha_id, str) or not captcha_id:
+        raise InvalidCaptchaError()
+    if not isinstance(code, str) or not code.strip():
+        raise InvalidCaptchaError()
+    with _lock:
+        entry = _entries.pop(captcha_id, None)
+    if entry is None or entry[1] <= time.monotonic():
+        raise InvalidCaptchaError()
+    if entry[0] != code.strip().upper():
+        raise InvalidCaptchaError()
+
+
+def _purge_expired(now: float) -> None:
+    for key in [k for k, (_, expires) in _entries.items() if expires <= now]:
+        _entries.pop(key, None)
+
+
+def _render_png(code: str) -> bytes:
+    """用 PyMuPDF(项目既有依赖)绘制验证码位图:随机颜色/基线/字号 + 干扰线。"""
+    import fitz
+
+    width, height = 132, 44
+    rng = random.Random()
+    doc = fitz.open()
+    try:
+        page = doc.new_page(width=width, height=height)
+        page.draw_rect(page.rect, color=None, fill=(0.96, 0.98, 1.0))
+        for _ in range(4):
+            page.draw_line(
+                fitz.Point(rng.uniform(0, width), rng.uniform(0, height)),
+                fitz.Point(rng.uniform(0, width), rng.uniform(0, height)),
+                color=(rng.random() * 0.6, rng.random() * 0.6, rng.random() * 0.6),
+                width=1,
+            )
+        x = 14.0
+        for ch in code:
+            page.insert_text(
+                fitz.Point(x, rng.uniform(28, 38)),
+                ch,
+                fontname="hebo",
+                fontsize=rng.uniform(22, 26),
+                color=(
+                    rng.uniform(0, 0.45),
+                    rng.uniform(0, 0.45),
+                    rng.uniform(0, 0.45),
+                ),
+            )
+            x += 27
+        return page.get_pixmap().tobytes("png")
+    finally:
+        doc.close()

+ 8 - 0
backend/dms/services/document_mutation_service.py

@@ -31,6 +31,7 @@ from dms.common.errors import (
     DocumentNotInMoveScopeError,
     FileNotFoundError as DmsFileNotFoundError,
     FilePathInvalidError,
+    ForbiddenError,
     InvalidArgumentError,
     MainPlanHasChildrenError,
     ResourceNotFoundError,
@@ -43,6 +44,7 @@ from dms.security.auth_context import get_auth_context
 from dms.services.attachment_query_service import attachment_detail
 from dms.services.audit_service import business_audit
 from dms.services.authorization_service import (
+    can_upload_plan,
     evaluate_plan_access,
     require_plan_action,
 )
@@ -379,6 +381,12 @@ def _create_record(
             organization_id, organization_name = _resolve_organization(
                 session, organization_id_input, context
             )
+            if not attachment:
+                # 上传权限(契约1.31):主案上传按(密级×部门范围)方案权限
+                # 矩阵 can_upload 校验(超管恒可;归属他部门按 OTHER 行恒拒);
+                # 共享附件挂载走挂载权限位,不受此限
+                if not can_upload_plan(context, security, organization_id):
+                    raise ForbiddenError("无该密级方案的上传权限")
             duplicate = None
             if not attachment:
                 duplicate_conditions = [

+ 5 - 5
backend/dms/services/document_query_service.py

@@ -7,7 +7,7 @@ import logging
 from datetime import datetime, timezone
 from typing import Any, Mapping
 
-from sqlalchemy import String, and_, cast, or_, select
+from sqlalchemy import String, and_, cast, select
 from sqlalchemy.orm import defer
 from sqlalchemy.orm.attributes import set_committed_value
 
@@ -342,11 +342,11 @@ def _advanced_filters(statement, params: Mapping[str, str]):
     content_keyword = params.get("contentKeyword")
     if content_keyword and content_keyword.strip():
         pattern = f"%{_escape_like(content_keyword)}%"
+        # 正文关键词严格匹配提取正文:search_text 含名称/概述/标签兜底词,
+        # OR 匹配会把正文解析失败(content_text 为空)但名称撞词的方案
+        # 错误召回,也会放大"仅元数据命中"的误报。
         statement = statement.where(
-            or_(
-                Document.content_text.like(pattern, escape="\\"),
-                Document.search_text.like(pattern, escape="\\"),
-            )
+            Document.content_text.like(pattern, escape="\\")
         )
     levels = _security_levels(params)
     if levels:

+ 8 - 3
backend/dms/services/ip_entry_service.py

@@ -9,7 +9,7 @@ from typing import Any
 from sqlalchemy import func, or_, select
 from sqlalchemy.exc import IntegrityError
 
-from dms.common.enums import AuditAction, AuditTarget, RoleCode
+from dms.common.enums import AuditAction, AuditTarget
 from dms.common.errors import (
     ConflictError,
     InvalidArgumentError,
@@ -22,6 +22,7 @@ from dms.database.transaction import transaction
 from dms.extensions import db
 from dms.models import IpEntry, OrgIpAssignment, Organization, User
 from dms.security.auth_context import get_auth_context
+from dms.services.admin_permission import is_super_admin
 from dms.services.audit_service import business_audit, client_ip
 
 IP_ENTRY_CREATE_FIELDS = {"ipValue", "remark"}
@@ -442,8 +443,12 @@ def _ip_matches(
 
 
 def verify_login_ip(user: User) -> None:
-    """密码与状态校验通过后调用:部门分配了IP列表则校验来源IP命中。"""
-    if user.role_code == RoleCode.ADMIN.value:
+    """密码与状态校验通过后调用:部门分配了IP列表则校验来源IP命中。
+
+    仅内置 admin 账号豁免(管理员类别不再整体豁免,避免普通机构管理员
+    绕过本部门IP限制登录);机构未分配任何IP段仍放行。
+    """
+    if is_super_admin(user):
         return
     if user.organization_id is None:
         return

+ 7 - 0
backend/dms/services/organization_service.py

@@ -23,6 +23,7 @@ from dms.models import Document, Organization, OrgIpAssignment, User
 from dms.security.auth_context import get_auth_context
 from dms.services.admin_permission import (
     ensure_org_in_managed_scope,
+    managed_scope_ids,
     require_organization_editor,
 )
 from dms.services.audit_service import business_audit
@@ -168,6 +169,12 @@ def organization_tree(
         .where(Organization.is_deleted.is_(False))
         .order_by(Organization.sort_no.asc(), Organization.id.asc())
     ).all()
+    # 安全需求8:锚点管理员的机构树裁剪到管理范围子树(全局管理员不受限)。
+    # 机构页/用户页/用户机构调整下拉均消费本接口,裁剪后各处只见可管理机构。
+    actor = db.session.get(User, get_auth_context().user_id)
+    scope = managed_scope_ids(db.session, actor) if actor is not None else None
+    if scope is not None:
+        organizations = [item for item in organizations if item.id in scope]
     by_id = {item.id: item for item in organizations}
 
     # 不传 status 返回全部(含停用,管理界面可见可重新启用);传值时按状态过滤

+ 6 - 1
backend/dms/services/role_service.py

@@ -68,9 +68,11 @@ ROLE_UPDATE_FIELDS = {
 ROLE_PERMISSION_FIELDS = {"matrix", "rowVersion"}
 ROLE_USER_FIELDS = {"userIds", "rowVersion"}
 
-# 矩阵 7 个操作布尔位与 AllowedAction 一一对应
+# 矩阵 8 个操作布尔位(契约1.31:canUpload 仅 OWN 行有意义,OTHER 行恒false;
+# canBind/canUnbind 界面合并为单一"挂载"开关,两列恒同值)
 PERMISSION_FIELDS: tuple[tuple[str, str], ...] = (
     ("canView", "can_view"),
+    ("canUpload", "can_upload"),
     ("canDownload", "can_download"),
     ("canEdit", "can_edit"),
     ("canDelete", "can_delete"),
@@ -537,6 +539,9 @@ def _validate_matrix(value: Any) -> list[dict[str, Any]]:
         for name, _ in PERMISSION_FIELDS:
             if type(item[name]) is not bool:
                 raise InvalidArgumentError(f"{name}必须是布尔值")
+        # 上传权限仅限本部门(OWN)行:其他部门行恒不可选(契约1.31)
+        if item["deptScope"] == DeptScope.OTHER.value and item["canUpload"]:
+            raise InvalidArgumentError("上传权限仅可设置本部门方案")
         rows[key] = item
     return list(value)
 

+ 20 - 0
backend/dms/services/user_service.py

@@ -5,6 +5,7 @@ from __future__ import annotations
 from datetime import UTC, datetime, timezone
 from typing import Any
 
+from flask import current_app
 from sqlalchemy import func, or_, select
 from sqlalchemy.exc import IntegrityError
 
@@ -47,6 +48,7 @@ from dms.services.admin_permission import (
     require_user_editor,
 )
 from dms.services.audit_service import business_audit
+from dms.services.authorization_service import has_any_upload_permission
 
 
 ALLOWED_MODULES = {
@@ -231,6 +233,14 @@ def user_summary(
                 else False
             )
         ),
+        # 上传权限(契约1.31):生效角色方案权限矩阵任一 OWN 行 can_upload=True
+        "permUpload": (
+            True
+            if super_admin
+            else has_any_upload_permission(
+                manage_role.id if manage_role is not None else None
+            )
+        ),
         # 用户级管理范围(契约1.30):null=全局、"SELF"=跟随本人所在机构(哨兵0)、
         # 机构ID=固定锚点;实际生效范围以角色锚点为准,角色为 SELF 时才回落本设置。
         # 内置 admin 恒全局。
@@ -286,6 +296,9 @@ def list_users(
     page_request: PageRequest,
 ) -> dict[str, object]:
     filters = [User.is_deleted.is_(False)]
+    # 超级管理员登录开关(安全需求7)关闭时用户列表隐藏内置 admin
+    if not current_app.config.get("DMS_ALLOW_ADMIN_LOGIN", True):
+        filters.append(User.username != SUPER_ADMIN_USERNAME)
     # 锚点管理员强制收敛到管理范围子树(全局管理员不受限),
     # 与请求的 organization_id 过滤取交集。
     actor = db.session.get(User, get_auth_context().user_id)
@@ -531,6 +544,13 @@ def create_user(payload: Any) -> dict[str, object]:
     security_level = _security_level(body["securityLevel"])
     status = _status_value(body["status"])
     actor_id = get_auth_context().user_id
+    # 超级管理员登录开关(安全需求7)关闭时拒绝创建内置 admin 用户名:
+    # 报错不提示"已存在",不泄露系统中存在该账号
+    if (
+        username == SUPER_ADMIN_USERNAME
+        and not current_app.config.get("DMS_ALLOW_ADMIN_LOGIN", True)
+    ):
+        raise InvalidArgumentError("不能创建该名称的用户")
 
     try:
         with transaction() as session:

+ 54 - 0
backend/migrations/versions/0033_role_upload_permission.py

@@ -0,0 +1,54 @@
+"""方案权限矩阵新增「上传」权限(契约1.31):sys_role_permission 加 can_upload 列。
+
+Revision ID: 0033_role_upload_permission
+Revises: 0032_user_managed_org
+Create Date: 2026-09-13
+
+变更要点:
+1. sys_role_permission 新增 can_upload 布尔列(默认 0):
+   - 仅 OWN 行有意义,控制管理员「上传主体方案/批量导入方案」入口
+     与 POST /documents 链路;OTHER 行恒 0(服务端校验拒绝置真);
+2. canBindAttachment/canUnbindAttachment 界面合并为单一「挂载」开关、
+   两列恒同值,库列保留不变(兼容存量数据渐进收敛);
+3. 升级幂等:加列先探测后执行(与 Flyway V18 并行互不冲突);
+4. 降级直接删列(布尔权限列无存量语义,无有损风险)。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0033_role_upload_permission"
+down_revision: str | None = "0032_user_managed_org"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _existing_columns() -> set[str]:
+    inspector = sa.inspect(op.get_bind())
+    return {
+        column["name"].lower()
+        for column in inspector.get_columns("sys_role_permission")
+    }
+
+
+def upgrade() -> None:
+    # sys_role_permission.can_upload 加列(先探测后执行,幂等)
+    if "can_upload" not in _existing_columns():
+        op.add_column(
+            "sys_role_permission",
+            sa.Column(
+                "can_upload",
+                sa.Boolean(),
+                nullable=False,
+                server_default=sa.text("0"),
+            ),
+        )
+
+
+def downgrade() -> None:
+    if "can_upload" in _existing_columns():
+        op.drop_column("sys_role_permission", "can_upload")

+ 37 - 0
backend/migrations/versions/0034_role_anchor_self.py

@@ -0,0 +1,37 @@
+"""角色管理范围锚点存量统一置「跟随用户设置」(契约1.31)。
+
+Revision ID: 0034_role_anchor_self
+Revises: 0033_role_upload_permission
+Create Date: 2026-09-13
+
+变更要点:
+1. sys_role.managed_org_id 全部行统一置 0(SELF 哨兵=跟随用户设置):
+   角色配置界面暂时移除「机构及用户管理范围」下拉后,范围判定统一
+   回落用户级管理范围(sys_user.managed_org_id,契约1.30),NULL(全局)
+   与固定锚点ID一并收敛为 0;
+2. DB 列保留不删(存量语义仅界面收敛,恢复下拉即还原三态);
+3. 降级有损拒绝:原始锚点值无法还原。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+
+revision: str = "0034_role_anchor_self"
+down_revision: str | None = "0033_role_upload_permission"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    # 全部角色锚点统一置 SELF(0=跟随用户设置),含软删行保持一致性
+    op.execute("UPDATE sys_role SET managed_org_id = 0")
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "有损降级:sys_role.managed_org_id 原始锚点值(NULL/固定机构ID)"
+        "已统一收敛为 0,无法还原,拒绝降级"
+    )

+ 48 - 0
backend/migrations/versions/0035_drop_audit_action_check.py

@@ -0,0 +1,48 @@
+"""删除 sys_audit_log.action_type 的 CHECK 白名单约束。
+
+Revision ID: 0035_drop_audit_action_check
+Revises: 0034_role_anchor_self
+Create Date: 2026-09-14
+
+变更要点:
+1. 审计动作是持续扩展的枚举(0009~0031 已重建该约束 8 次),DB CHECK 导致
+   新动作写入失败并连带业务操作 500(如库白名单落后于代码时审批单上传失败);
+   审计日志作为事实记录不应因枚举扩展而丢失,合法性收敛到应用层字典;
+2. target_type / operation_result 的 CHECK 保留(稳定小枚举,与本次无关);
+3. 模型层同步删除该 CheckConstraint(dms/models/audit_log.py,新装库不再创建)。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0035_drop_audit_action_check"
+down_revision: str | None = "0034_role_anchor_self"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+# 与 0026 一致:op.drop_constraint 按 naming convention 前缀化约束名,
+# 传原始名渲染最终名;线性链上 0026 已把约束统一为渲染名。
+_ACTION_CONSTRAINT = "audit_action_type"
+
+
+def _existing_check_names() -> set[str]:
+    return {
+        item["name"].lower()
+        for item in sa.inspect(op.get_bind()).get_check_constraints("sys_audit_log")
+    }
+
+
+def upgrade() -> None:
+    if f"ck_sys_audit_log_{_ACTION_CONSTRAINT}" in _existing_check_names():
+        op.drop_constraint(_ACTION_CONSTRAINT, "sys_audit_log", type_="check")
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "0035降级需按当时动作全集重建 CHECK 白名单,且库中不得存在白名单外"
+        "action_type 记录;如确需恢复约束请人工评估后执行"
+    )

+ 14 - 1
backend/openapi/components/schemas.yaml

@@ -255,6 +255,7 @@ UserSummary:
     - permSystemManage
     - permAuditLog
     - permRoleEdit
+    - permUpload
     - managedOrgId
   properties:
     id:
@@ -325,6 +326,9 @@ UserSummary:
     permRoleEdit:
       type: boolean
       description: 角色编辑管理位(契约1.25);值取自生效角色,内置admin恒为true。
+    permUpload:
+      type: boolean
+      description: 上传位(契约1.31);生效角色方案权限矩阵任一 OWN 行 canUpload 为 true 即为 true,内置admin恒为true;上传/批量导入接口按此判定。
     managedOrgId:
       type: string
       nullable: true
@@ -345,6 +349,12 @@ LoginRequest:
       writeOnly: true
     keepSignedIn:
       type: boolean
+    captchaId:
+      type: string
+      description: 图片验证码标识(安全需求4);DMS_CAPTCHA_ENABLED开启时必填(五键),关闭时不得携带(三键)
+    captchaCode:
+      type: string
+      description: 图片验证码字符(安全需求4,忽略大小写);开关开启时必填
 
 LoginData:
   type: object
@@ -2240,11 +2250,14 @@ DeleteRoleResponse:
 RolePermissionRow:
   type: object
   additionalProperties: false
-  required: [securityLevel, deptScope, canView, canDownload, canEdit, canDelete, canConfigPermission, canBindAttachment, canUnbindAttachment]
+  required: [securityLevel, deptScope, canView, canUpload, canDownload, canEdit, canDelete, canConfigPermission, canBindAttachment, canUnbindAttachment]
   properties:
     securityLevel: {$ref: '#/SecurityLevel'}
     deptScope: {$ref: '#/DeptScope'}
     canView: {type: boolean}
+    canUpload:
+      type: boolean
+      description: 上传位(契约1.31);仅 OWN 行有意义,OTHER 行恒为 false,服务端提交校验拒收 OTHER 行为 true 的载荷。
     canDownload: {type: boolean}
     canEdit: {type: boolean}
     canDelete: {type: boolean}

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 1
backend/openapi/openapi.yaml


+ 37 - 1
backend/openapi/paths/auth.yaml

@@ -1,7 +1,39 @@
+Captcha:
+  get:
+    operationId: fetchCaptcha
+    summary: 登录图片验证码签发(安全需求4)
+    description: 环境变量DMS_CAPTCHA_ENABLED(双版同名,缺省true)开启时返回captchaId与data:image/png;base64图片(132×44、4位字符剔除易混淆0O1I、120秒过期、一次性消费);关闭时仅返回enabled=false。登录页据enabled决定是否展示验证码输入;请求与响应均不缓存。
+    security: []
+    parameters:
+      - $ref: '../components/parameters.yaml#/XRequestId'
+    responses:
+      '200':
+        description: 验证码签发结果
+        content:
+          application/json:
+            schema:
+              type: object
+              required: [code, message, data, requestId]
+              properties:
+                code: {type: string, enum: [OK]}
+                message: {type: string, enum: [success]}
+                requestId: {type: string, format: uuid}
+                data:
+                  type: object
+                  required: [enabled]
+                  additionalProperties: false
+                  properties:
+                    enabled: {type: boolean, description: 验证码开关当前值}
+                    captchaId: {type: string, description: 验证码标识,登录时原样携带;enabled=true时必返}
+                    image: {type: string, description: data:image/png;base64图片,img标签src直接使用;enabled=true时必返}
+      '500':
+        $ref: '../components/responses.yaml#/InternalError'
+
 Login:
   post:
     operationId: login
     summary: 用户登录
+    description: 验证码开启(DMS_CAPTCHA_ENABLED,缺省true)时请求体必须且只能包含五键(追加captchaId/captchaCode),验证码校验先于用户查询与锁定检查、一次性消费不计入登录失败锁定,错误返回400 INVALID_CAPTCHA;关闭时保持三键(username/password/keepSignedIn)。
     security: []
     parameters:
       - $ref: '../components/parameters.yaml#/XRequestId'
@@ -19,7 +51,11 @@ Login:
             schema:
               $ref: '../components/schemas.yaml#/LoginResponse'
       '400':
-        $ref: '../components/responses.yaml#/InvalidArgument'
+        description: 请求参数错误或验证码缺失/错误/过期(INVALID_CAPTCHA,安全需求4)
+        content:
+          application/json:
+            schema:
+              $ref: '../components/schemas.yaml#/ErrorResponse'
       '401':
         $ref: '../components/responses.yaml#/AuthenticationError'
       '403':

+ 9 - 0
backend/openapi/paths/configuration.yaml

@@ -29,11 +29,20 @@ LoginBackground:
     summary: 查询登录界面自定义背景图
     description: >-
       返回服务端运行目录下的 background.jpg 或 background.png(jpg优先);
+      携带 theme 查询参数(blue/red/green)时优先返回 background-{theme}.jpg/png,
+      主题背景图不存在时回落通用 background.jpg/png(契约1.31);
       运行目录不存在背景图文件时返回404,前端回退默认纯色背景。
       接口公开(登录前无凭证可访问),允许浏览器缓存60秒。
     security: []
     parameters:
       - $ref: '../components/parameters.yaml#/XRequestId'
+      - name: theme
+        in: query
+        required: false
+        description: 主题键(契约1.31);白名单外的取值按未提供处理,回落通用背景图。
+        schema:
+          type: string
+          enum: [blue, red, green]
     responses:
       '200':
         description: 背景图片二进制

+ 1 - 0
backend/openapi/paths/organizations.yaml

@@ -2,6 +2,7 @@ OrganizationTree:
   get:
     operationId: getOrganizationTree
     summary: 查询组织树
+    description: 响应按请求者生效管理范围裁剪(契约1.33)——锚点管理员仅返回管理范围子树内机构(森林形式,根从锚点机构起,范围外机构与其祖先链不出现,keyword/status 过滤在裁剪后的集合内进行);全局管理员不受限,返回全部机构。
     parameters:
       - $ref: '../components/parameters.yaml#/XRequestId'
       - name: keyword

+ 4 - 0
backend/tests/dms/conftest.py

@@ -20,6 +20,8 @@ def flask_app():
     from app import app
 
     app.config.update(TESTING=True)
+    # 测试环境关闭登录验证码(安全需求4):验证码逻辑由 test_captcha.py 专项覆盖
+    app.config["DMS_CAPTCHA_ENABLED"] = False
     return app
 
 
@@ -67,6 +69,8 @@ def b2_app():
     CORS(app)
     init_dms(app)
     app.config.update(TESTING=True)
+    # 测试环境关闭登录验证码(安全需求4),登录回归三键请求体
+    app.config["DMS_CAPTCHA_ENABLED"] = False
 
     with app.app_context():
         db.session.execute(delete(AuditLog))

+ 63 - 0
backend/tests/dms/test_admin_login_switch.py

@@ -0,0 +1,63 @@
+"""超级管理员登录开关(安全需求7):
+
+- 登录:开关关闭时 admin 按用户不存在处理,报错与密码错误完全一致(同一出口);
+- 创建:开关关闭时拒绝创建 admin 用户名,报"不能创建该名称的用户"
+  而非"用户名已存在",不泄露系统中存在该账号。
+
+不依赖测试库:登录用例将审计落库置空;创建用例的拦截发生在事务开启之前。
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+from dms.common.errors import InvalidArgumentError
+from dms.services import user_service
+
+
+@pytest.fixture()
+def admin_login_off(flask_app, monkeypatch):
+    flask_app.config["DMS_ALLOW_ADMIN_LOGIN"] = False
+    # admin 拦截走"用户名不存在"出口:审计落库置空,全程不触测试库
+    monkeypatch.setattr(
+        "dms.api.v1.auth.record_login_failure", lambda **kwargs: None
+    )
+    yield flask_app
+    flask_app.config["DMS_ALLOW_ADMIN_LOGIN"] = True
+
+
+def test_login_blocked_admin_reports_invalid_credentials(admin_login_off):
+    """开关关闭:admin 正确/错误密码一律 INVALID_CREDENTIALS,与密码错误同形。"""
+    client = admin_login_off.test_client()
+    response = client.post(
+        "/api/v1/auth/login",
+        json={"username": "admin", "password": "whatever", "keepSignedIn": False},
+    )
+    assert response.status_code == 401
+    assert response.get_json()["code"] == "INVALID_CREDENTIALS"
+
+
+def test_create_admin_username_rejected_when_disabled(flask_app, monkeypatch):
+    """开关关闭:创建 admin 报"不能创建该名称的用户",而非"用户名已存在"。"""
+    flask_app.config["DMS_ALLOW_ADMIN_LOGIN"] = False
+    monkeypatch.setattr(
+        user_service, "get_auth_context", lambda: SimpleNamespace(user_id=1)
+    )
+    payload = {
+        "username": "admin",
+        "realName": "冒名者",
+        "initialPassword": "Passw0rd!2024",
+        "organizationId": "1",
+        "roleCode": "USER",
+        "roleId": "2",
+        "securityLevel": "PUBLIC",
+        "status": "ENABLED",
+    }
+    try:
+        with flask_app.app_context(), pytest.raises(InvalidArgumentError) as exc:
+            user_service.create_user(payload)
+        assert "不能创建该名称的用户" in str(exc.value)
+    finally:
+        flask_app.config["DMS_ALLOW_ADMIN_LOGIN"] = True

+ 134 - 0
backend/tests/dms/test_captcha.py

@@ -0,0 +1,134 @@
+"""登录图片验证码(安全需求4):
+
+- 签发结构:captchaId + data URL PNG,明文不外泄;
+- 校验:成功、忽略大小写、一次性消费(无论成败)、过期、错误/缺失;
+- 接口层:开启时 GET /auth/captcha 返回图片与 enabled=true;
+  登录缺验证码字段或校验失败在查库前即被拒(INVALID_CAPTCHA,不计锁定)。
+
+不依赖测试库:全部在 config 覆盖 + 内存 store 上完成。
+"""
+
+from __future__ import annotations
+
+import base64
+import time
+
+import pytest
+
+from dms.common.errors import InvalidCaptchaError
+from dms.services import captcha_service as cs
+
+
+@pytest.fixture()
+def clean_store():
+    cs._entries.clear()
+    yield
+    cs._entries.clear()
+
+
+def _issued_with_plain_code() -> tuple[str, str]:
+    data = cs.issue()
+    return data["captchaId"], cs._entries[data["captchaId"]][0]
+
+
+def test_issue_shape(clean_store):
+    """签发返回 captchaId 与 data:image/png;base64 前缀图片,且为有效 PNG 头。"""
+    data = cs.issue()
+    assert set(data) == {"captchaId", "image"}
+    assert data["captchaId"]
+    prefix = "data:image/png;base64,"
+    assert data["image"].startswith(prefix)
+    raw = base64.b64decode(data["image"][len(prefix):])
+    assert raw.startswith(b"\x89PNG")
+
+
+def test_verify_success_and_case_insensitive(clean_store):
+    captcha_id, code = _issued_with_plain_code()
+    cs.verify(captcha_id, code.lower())
+
+
+def test_verify_consumes_once(clean_store):
+    """一次性消费:第二次校验(即使正确)也拒绝,防重放。"""
+    captcha_id, code = _issued_with_plain_code()
+    cs.verify(captcha_id, code)
+    with pytest.raises(InvalidCaptchaError):
+        cs.verify(captcha_id, code)
+
+
+def test_verify_wrong_code_fails_and_consumes(clean_store):
+    """错误尝试即作废,防止同一验证码反复试密码。"""
+    captcha_id, code = _issued_with_plain_code()
+    with pytest.raises(InvalidCaptchaError):
+        cs.verify(captcha_id, "X" * len(code))
+    with pytest.raises(InvalidCaptchaError):
+        cs.verify(captcha_id, code)
+
+
+def test_verify_expired(clean_store):
+    captcha_id, code = _issued_with_plain_code()
+    # 把过期时刻拉回过去(monotonic 时钟,120 秒窗口)
+    cs._entries[captcha_id] = (code, time.monotonic() - 1)
+    with pytest.raises(InvalidCaptchaError):
+        cs.verify(captcha_id, code)
+
+
+def test_verify_missing_arguments(clean_store):
+    with pytest.raises(InvalidCaptchaError):
+        cs.verify(None, None)
+    with pytest.raises(InvalidCaptchaError):
+        cs.verify("whatever", "  ")
+
+
+def test_captcha_endpoint_and_login_guard(flask_app):
+    """/auth/captcha 开关行为 + 开启时登录请求体扩为五键、校验先于查库。"""
+    client = flask_app.test_client()
+
+    # 开关关闭(conftest 设定):仅返回 enabled=false,登录不再被验证码拦截
+    response = client.get("/api/v1/auth/captcha")
+    assert response.status_code == 200
+    assert response.get_json()["data"] == {"enabled": False}
+    three_keys = {"username": "u", "password": "p", "keepSignedIn": False}
+
+    flask_app.config["DMS_CAPTCHA_ENABLED"] = True
+    cs._entries.clear()
+    try:
+        response = client.get("/api/v1/auth/captcha")
+        assert response.status_code == 200
+        payload = response.get_json()["data"]
+        assert payload["enabled"] is True
+        assert set(payload) == {"enabled", "captchaId", "image"}
+
+        # 缺 captchaId/captchaCode:结构校验 400 INVALID_ARGUMENT
+        response = client.post("/api/v1/auth/login", json=three_keys)
+        assert response.status_code == 400
+        assert response.get_json()["code"] == "INVALID_ARGUMENT"
+
+        # 带未知 captchaId:查库前即 400 INVALID_CAPTCHA(无测试库依赖)
+        response = client.post(
+            "/api/v1/auth/login",
+            json={
+                **three_keys,
+                "captchaId": "not-exist",
+                "captchaCode": "ABCD",
+            },
+        )
+        assert response.status_code == 400
+        assert response.get_json()["code"] == "INVALID_CAPTCHA"
+
+        # 真实签发的验证码先被消费(模拟已使用过),重放同样拒绝
+        captcha_id = payload["captchaId"]
+        code = cs._entries[captcha_id][0]
+        cs.verify(captcha_id, code)
+        response = client.post(
+            "/api/v1/auth/login",
+            json={
+                **three_keys,
+                "captchaId": captcha_id,
+                "captchaCode": code,
+            },
+        )
+        assert response.status_code == 400
+        assert response.get_json()["code"] == "INVALID_CAPTCHA"
+    finally:
+        flask_app.config["DMS_CAPTCHA_ENABLED"] = False
+        cs._entries.clear()

+ 10 - 4
backend/tests/dms/test_db_upgrade.py

@@ -18,9 +18,9 @@ def test_resolve_migrations_dir_contains_env_and_versions():
 
 
 def test_migration_chain_head_and_steps_from_0013():
-    """远端生产库 0013 → head 恰好经过 0014~0032。"""
+    """远端生产库 0013 → head 恰好经过 0014~0035。"""
     script = _script()
-    assert script.get_current_head() == "0032_user_managed_org"
+    assert script.get_current_head() == "0035_drop_audit_action_check"
     steps = [
         r.revision
         for r in reversed(
@@ -48,13 +48,16 @@ def test_migration_chain_head_and_steps_from_0013():
         "0030_role_edit_permission",
         "0031_user_sort_no",
         "0032_user_managed_org",
+        "0033_role_upload_permission",
+        "0034_role_anchor_self",
+        "0035_drop_audit_action_check",
     ]
 
 
 def test_run_upgrade_lists_pending_steps_excluding_current(monkeypatch, capsys):
-    """0016 升级到 head:待执行清单含 0017~0032,当前版本不重复出现。"""
+    """0016 升级到 head:待执行清单含 0017~0035,当前版本不重复出现。"""
     monkeypatch.setenv("DMS_DATABASE_URL", "mysql+pymysql://u:secret@127.0.0.1:3306/dms")
-    versions = iter(["0016_portal_user_provision", "0032_user_managed_org"])
+    versions = iter(["0016_portal_user_provision", "0035_drop_audit_action_check"])
     monkeypatch.setattr(
         "dms.db_upgrade._read_current_version", lambda url: next(versions)
     )
@@ -77,6 +80,9 @@ def test_run_upgrade_lists_pending_steps_excluding_current(monkeypatch, capsys):
     assert "  - 0030_role_edit_permission" in out
     assert "  - 0031_user_sort_no" in out
     assert "  - 0032_user_managed_org" in out
+    assert "  - 0033_role_upload_permission" in out
+    assert "  - 0034_role_anchor_self" in out
+    assert "  - 0035_drop_audit_action_check" in out
     assert "  - 0016" not in out
     assert "secret" not in out  # 输出中的地址必须已脱敏
 

+ 17 - 1
backend/tests/dms/test_global_search.py

@@ -2,7 +2,7 @@
 
 覆盖检索条件矩阵(四条件 AND 叠加):
 - 简单关键词:仅匹配标题;主案聚合模式下命中子案/附件标题也召回主案
-- 正文关键词:匹配 contentText / searchText,命中时输出 contentSnippet
+- 正文关键词:仅匹配 contentText(提取正文),命中时输出 contentSnippet
 - 密级多选:勾选项内 OR
 - 部门关键词:模糊匹配 organization_name
 - 聚合命中徽章:titleHit / subHitCount / attachmentHitCount(不可见子案不计入)
@@ -315,6 +315,22 @@ def test_advanced_content_keyword_excludes_unmatched(gs_client, token_for):
     assert data(response)["total"] == 0
 
 
+def test_advanced_content_keyword_ignores_search_text_fallback(
+    gs_client, token_for
+):
+    """回归:正文关键词严格匹配 content_text,不得经 search_text 命中名称。
+
+    正文解析失败的方案 content_text 为空,但 search_text 仍保留
+    名称/概述/标签;正文关键词与名称撞词时不得被召回。
+    (贝塔主案无正文、名称含"贝塔",secret 密级用 admin 排除可见性干扰。)
+    """
+    response = gs_client.get(
+        "/api/v1/documents?organizationKeyword=GS&contentKeyword=贝塔",
+        headers=auth(token_for("admin")),
+    )
+    assert data(response)["total"] == 0
+
+
 def test_advanced_security_levels_multiselect(gs_client, token_for):
     response = gs_client.get(
         "/api/v1/documents?organizationKeyword=GS"

+ 32 - 1
backend/tests/dms/test_ip_entries.py

@@ -5,7 +5,7 @@
 - IP条目 CRUD、重复409、host bits 400、多余字段400
 - 部门分配整存(乱序输入升序返回、无效条目400、空数组清空、rowVersion递增)
 - 删除守卫(被分配引用409,取消分配后可删)
-- 登录IP校验(网段命中/精确命中/范围闭区间命中/未命中拒绝/ADMIN豁免/清空后放行/失败审计)
+- 登录IP校验(网段命中/精确命中/范围闭区间命中/未命中拒绝/内置admin用户名豁免/管理员类别不再豁免/清空后放行/失败审计)
 - 非 ADMIN 访问 403
 - 审计记录(CREATE/EDIT/DELETE_IP_ENTRY、ASSIGN_ORG_IP)
 """
@@ -332,6 +332,37 @@ def test_login_ip_restriction_enforced(
     admin_exempt = login("admin", "172.16.0.1")
     assert admin_exempt.status_code == 200
 
+    # 管理员类别不再整体豁免:挂在本部门(已分配IP段)的机构管理员,
+    # 来源IP未命中同样拒绝(仅内置 admin 用户名豁免)
+    with b2_app.app_context():
+        from dms.extensions import db
+        from dms.models import Organization, User
+        from dms.security.passwords import hash_password
+
+        org = db.session.scalar(
+            db.select(Organization).where(Organization.org_code == "ORG_OPS")
+        )
+        db.session.add(
+            User(
+                username="org_admin",
+                password_hash=hash_password(b2_password),
+                real_name="机构管理员",
+                organization_id=org.id,
+                organization_name=org.org_name,
+                role_code="ADMIN",
+                security_level="SECRET",
+                status="ENABLED",
+            )
+        )
+        db.session.commit()
+
+    admin_category_miss = login("org_admin", "172.16.0.1")
+    assert admin_category_miss.status_code == 403
+    assert admin_category_miss.get_json()["code"] == "LOGIN_IP_FORBIDDEN"
+
+    admin_category_hit = login("org_admin", "192.168.10.55")
+    assert admin_category_hit.status_code == 200
+
     with b2_app.app_context():
         from dms.extensions import db
         from dms.models import AuditLog

+ 28 - 0
backend/tests/dms/test_login_background.py

@@ -64,3 +64,31 @@ def test_login_background_returns_404_when_absent(client, background_dir):
     body = response.get_json()
     assert body["code"] == "FILE_NOT_FOUND"
     assert body["requestId"]
+
+
+# ─── 主题背景图(契约1.31):theme 查询参数优先 background-{theme},回落通用图 ───
+
+
+def test_login_background_theme_prefers_themed_jpg(client, background_dir):
+    (background_dir / "background.jpg").write_bytes(_JPEG_BYTES)
+    (background_dir / "background-red.png").write_bytes(_PNG_BYTES)
+    response = client.get("/api/v1/config/login-background?theme=red")
+    assert response.status_code == 200
+    assert response.mimetype == "image/png"
+    assert response.data == _PNG_BYTES
+
+
+def test_login_background_theme_falls_back_to_generic(client, background_dir):
+    (background_dir / "background.jpg").write_bytes(_JPEG_BYTES)
+    response = client.get("/api/v1/config/login-background?theme=green")
+    assert response.status_code == 200
+    assert response.mimetype == "image/jpeg"
+    assert response.data == _JPEG_BYTES
+
+
+def test_login_background_theme_whitelist_rejects_unknown(client, background_dir):
+    (background_dir / "background.jpg").write_bytes(_JPEG_BYTES)
+    # 白名单外的 theme 按未提供处理,回落通用背景图
+    response = client.get("/api/v1/config/login-background?theme=..%2Fetc")
+    assert response.status_code == 200
+    assert response.data == _JPEG_BYTES

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

@@ -81,7 +81,7 @@ def _load_revision_module():
 def test_alembic_config_and_head_are_valid():
     config = Config(str(MIGRATIONS_ROOT / "alembic.ini"))
     script = ScriptDirectory.from_config(config)
-    assert script.get_current_head() == "0032_user_managed_org"
+    assert script.get_current_head() == "0035_drop_audit_action_check"
 
 
 def test_initial_migration_exports_upgrade_and_downgrade():

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

@@ -88,6 +88,7 @@ def test_openapi_contains_only_q2b_paths():
     )
     assert set(document["paths"]) == {
         "/api/v1/health",
+        "/api/v1/auth/captcha",
         "/api/v1/auth/login",
         "/api/v1/auth/logout",
         "/api/v1/auth/reauth",
@@ -170,7 +171,7 @@ def test_openapi_contains_only_q2b_paths():
         "/api/v1/system/maintenance/preview-cache",
         "/api/v1/system/users/{user_id}/force-offline",
     }
-    assert document["info"]["version"] == "1.30.0"
+    assert document["info"]["version"] == "1.33.0"
     assert "/api/v1/documents/{id}/restore" not in document["paths"]
     assert document["paths"]["/api/v1/health"]["get"]["security"] == []
     login_path = yaml.safe_load(

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

@@ -280,7 +280,7 @@ def test_openapi_ui_dictionary_contract_and_no_write_operation():
         )
     )
 
-    assert document["info"]["version"] == "1.30.0"
+    assert document["info"]["version"] == "1.31.0"
     assert set(path_item) == {"get"}
     assert path_item["get"]["operationId"] == "getUiDictionaries"
     assert {"200", "401", "500"} <= set(path_item["get"]["responses"])

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

@@ -344,7 +344,7 @@ def test_openapi_declares_q2b_parameters_without_new_paths():
         (root / "paths" / "documents.yaml").read_text(encoding="utf-8")
     )
 
-    assert document["info"]["version"] == "1.30.0"
+    assert document["info"]["version"] == "1.31.0"
     for operation in ("Documents", "SubPlans", "MainPlanAttachments"):
         references = {
             parameter.get("$ref")

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

@@ -459,7 +459,7 @@ def test_q3_openapi_contract_has_no_new_preview_path():
     paths = yaml.safe_load(
         (root / "paths" / "documents.yaml").read_text("utf-8")
     )
-    assert document["info"]["version"] == "1.30.0"
+    assert document["info"]["version"] == "1.31.0"
     assert schemas["RoleCode"]["enum"] == ["USER", "ADMIN"]
     parameters = paths["Documents"]["get"]["parameters"]
     assert any(item.get("name") == "includeDescendants" for item in parameters)

+ 72 - 0
backend/tests/dms/test_role_management.py

@@ -22,6 +22,7 @@ LEVELS = ("PUBLIC", "INTERNAL", "SECRET", "CONFIDENTIAL", "TOP_SECRET")
 SCOPES = ("OWN", "OTHER")
 ACTION_KEYS = (
     "canView",
+    "canUpload",
     "canDownload",
     "canEdit",
     "canDelete",
@@ -283,6 +284,77 @@ def test_matrix_update_persists_flags(b2_client, login_token, role_cleanup):
     assert by_key[("PUBLIC", "OTHER")]["canView"] is False
 
 
+@pytest.mark.integration
+def test_matrix_upload_flag_own_only(b2_client, login_token, role_cleanup):
+    """上传位(契约1.31):仅 OWN 行可设,OTHER 行为 true 拒收 400。"""
+    role = _create_role(b2_client, login_token, name="上传位角色").get_json()["data"]
+
+    rejected = b2_client.put(
+        f"/api/v1/roles/{role['id']}/permissions",
+        json={
+            "matrix": _matrix({("PUBLIC", "OTHER"): {"canUpload": True}}),
+            "rowVersion": 0,
+        },
+        headers=_auth(login_token),
+    )
+    assert rejected.status_code == 400
+    assert rejected.get_json()["code"] == "INVALID_ARGUMENT"
+
+    saved = b2_client.put(
+        f"/api/v1/roles/{role['id']}/permissions",
+        json={
+            "matrix": _matrix({("SECRET", "OWN"): {"canUpload": True}}),
+            "rowVersion": 0,
+        },
+        headers=_auth(login_token),
+    )
+    assert saved.status_code == 200, saved.get_json()
+    reloaded = b2_client.get(
+        f"/api/v1/roles/{role['id']}/permissions", headers=_auth(login_token)
+    ).get_json()["data"]
+    by_key = {(row["securityLevel"], row["deptScope"]): row for row in reloaded}
+    assert by_key[("SECRET", "OWN")]["canUpload"] is True
+    assert by_key[("SECRET", "OTHER")]["canUpload"] is False
+
+
+@pytest.mark.integration
+def test_perm_upload_derived_from_matrix(
+    b2_app, b2_client, login_token, b2_password, role_cleanup
+):
+    """permUpload(契约1.31):任一 OWN 行 canUpload=true 的角色生效后登录响应为 true。"""
+    role = _create_role(b2_client, login_token, name="上传位会话角色").get_json()[
+        "data"
+    ]
+    saved = b2_client.put(
+        f"/api/v1/roles/{role['id']}/permissions",
+        json={
+            "matrix": _matrix({("PUBLIC", "OWN"): {"canUpload": True}}),
+            "rowVersion": 0,
+        },
+        headers=_auth(login_token),
+    )
+    assert saved.status_code == 200, saved.get_json()
+
+    user_id = _user_id(b2_app, "user")
+    linked = b2_client.put(
+        f"/api/v1/roles/{role['id']}/users",
+        json={"userIds": [str(user_id)], "rowVersion": 0},
+        headers=_auth(login_token),
+    )
+    assert linked.status_code == 200, linked.get_json()
+
+    login = b2_client.post(
+        "/api/v1/auth/login",
+        json={
+            "username": "user",
+            "password": b2_password,
+            "keepSignedIn": False,
+        },
+    )
+    assert login.status_code == 200, login.get_json()
+    assert login.get_json()["data"]["user"]["permUpload"] is True
+
+
 @pytest.mark.integration
 def test_role_users_plain_user_only_and_auth_version_bumped(
     b2_app, b2_client, login_token, role_cleanup

+ 139 - 59
frontend/src/app/App.tsx

@@ -40,7 +40,7 @@ import {
 import { authApi } from "../features/auth/authApi";
 import { portalAuthApi } from "../features/auth/portalAuthApi";
 import { setTheme, UI_THEMES, useUiTheme, type UiTheme } from "../shared/theme/themeStore";
-import type { LoginRequest, PortalOrganizationOption, UserSummary } from "../features/auth/authTypes";
+import type { CaptchaData, LoginRequest, PortalOrganizationOption, UserSummary } from "../features/auth/authTypes";
 import { CategoryType, EnabledStatus } from "../shared/api/enums";
 import { categoryApi } from "../features/categories/categoryApi";
 import {
@@ -129,6 +129,7 @@ import { useDictionaries } from "../features/dictionaries/dictionaryContext";
 
 const N = "var(--dms-deep)";   // 深主色 — all text, never black
 const B = "var(--dms-bright)"; // 亮主色 — header, buttons, accents
+const H = "var(--dms-highlight)"; // 高亮点缀色 — 上传区/徽标/统计数值;红主题为国徽金,蓝绿映射各自主色
 const W = "#FFFFFF";   // white — cards, canvas
 const PBG = "var(--dms-canvas)"; // page background
 
@@ -864,7 +865,7 @@ function CategoryEditModal({ title, mode, target, tree, submitting, externalErro
   };
 
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),0.5)" }}>
+    <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }}>
       <div style={{ background: W, width: 420 }} className="rounded-sm shadow-2xl">
         <div style={{ background: N, color: W }} className="h-10 flex items-center justify-between px-4 rounded-t-sm">
           <span className="text-base font-black tracking-wide">{title}</span>
@@ -951,8 +952,15 @@ function LeftPanel({ children, admin, selectedCategoryId, onSelectCategory, allC
   // 拖动中不实时改面板宽度(避免分类树反复重排),只移动全屏蓝色指示线,
   // 松手时一次性提交最终宽度。
   const MIN_PANEL_WIDTH = 220;
-  const DEFAULT_PANEL_WIDTH = 272;
-  const [panelWidth, setPanelWidth] = useState<number>(DEFAULT_PANEL_WIDTH);
+  // 默认宽度取窗口的 1/4,并收敛到 [最小宽, 窗口宽-640](与拖拽上限一致,
+  // 为右侧内容区留足空间);仅首次进入时计算,手动拖宽后以用户值为准
+  const [panelWidth, setPanelWidth] = useState<number>(() =>
+    Math.round(
+      Math.min(
+        Math.max(window.innerWidth / 4, MIN_PANEL_WIDTH),
+        Math.max(MIN_PANEL_WIDTH, window.innerWidth - 640),
+      ),
+    ));
   const panelEl = useRef<HTMLDivElement | null>(null);
   const guideEl = useRef<HTMLDivElement | null>(null);
   const dragState = useRef({ active: false, x: 0, width: 0, latest: 0 });
@@ -984,7 +992,7 @@ function LeftPanel({ children, admin, selectedCategoryId, onSelectCategory, allC
           className="absolute right-0 top-0 bottom-0 z-10 w-2.5 -mr-[5px] cursor-col-resize transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.35)]"
           onPointerDown={(event) => {
             if (event.button !== 0) return;
-            const width = panelEl.current?.offsetWidth ?? DEFAULT_PANEL_WIDTH;
+            const width = panelEl.current?.offsetWidth ?? panelWidth;
             dragState.current = { active: true, x: event.clientX, width, latest: width };
             event.currentTarget.setPointerCapture(event.pointerId);
             if (guideEl.current) {
@@ -1056,7 +1064,7 @@ function LeftPanel({ children, admin, selectedCategoryId, onSelectCategory, allC
 // ─── Word viewer modal ────────────────────────────────────────────────────────
 function WordViewer({ doc, onClose }: { doc: Doc; onClose: () => void }) {
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),0.5)" }} onClick={onClose}>
+    <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }} onClick={onClose}>
       <div style={{ background: "#D6E8F6", width: 900, height: "88vh" }} className="flex flex-col" onClick={e => e.stopPropagation()}>
         {/* Title bar — government blue */}
         <div style={{ background: B, color: W }} className="h-10 flex items-center justify-between px-4 flex-shrink-0">
@@ -1111,7 +1119,7 @@ function UploadModal({ onClose }: { onClose: () => void }) {
   const inputStyle = { borderColor: "rgba(var(--dms-deep-rgb),0.2)", color: N };
 
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),0.5)" }} onClick={onClose}>
+    <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }} onClick={onClose}>
       <div style={{ background: W, width: 660, border: `1px solid rgba(11,37,69,0.15)` }} className="rounded-sm shadow-2xl" onClick={e => e.stopPropagation()}>
         <div style={{ background: N, color: W }} className="h-11 flex items-center justify-between px-5 rounded-t-sm">
           <span className="text-base font-black tracking-widest">上传文档</span>
@@ -1119,7 +1127,7 @@ function UploadModal({ onClose }: { onClose: () => void }) {
         </div>
         <div className="p-6 space-y-4">
           <div
-            style={{ border: `2px dashed ${dragging ? B : "rgba(var(--dms-deep-rgb),0.25)"}`, background: dragging ? `rgba(var(--dms-primary-rgb),0.05)` : `rgba(var(--dms-line-rgb),0.04)` }}
+            style={{ border: `2px dashed ${dragging ? H : "rgba(var(--dms-deep-rgb),0.25)"}`, background: dragging ? `rgba(var(--dms-primary-rgb),0.05)` : `rgba(var(--dms-line-rgb),0.04)` }}
             className="rounded-sm h-28 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all"
             onDragOver={e => { e.preventDefault(); setDragging(true); }}
             onDragLeave={() => setDragging(false)}
@@ -1127,8 +1135,8 @@ function UploadModal({ onClose }: { onClose: () => void }) {
             onClick={() => setUploaded(true)}
           >
             {uploaded
-              ? <><Check size={22} style={{ color: B }} /><span className="text-base font-black" style={{ color: B }}>文件已选择:综合应急预案.docx</span></>
-              : <><Upload size={22} style={{ color: B }} /><span className="text-base font-black" style={{ color: N }}>点击选择或拖拽文件至此处</span><span className="text-sm font-bold" style={{ color: `rgba(var(--dms-deep-rgb),0.4)` }}>支持 .doc .docx .pdf .xls 格式</span></>
+              ? <><Check size={22} style={{ color: H }} /><span className="text-base font-black" style={{ color: H }}>文件已选择:综合应急预案.docx</span></>
+              : <><Upload size={22} style={{ color: H }} /><span className="text-base font-black" style={{ color: N }}>点击选择或拖拽文件至此处</span><span className="text-sm font-bold" style={{ color: `rgba(var(--dms-deep-rgb),0.4)` }}>支持 .doc .docx .pdf .xls 格式</span></>
             }
           </div>
           <div className="grid grid-cols-2 gap-4">
@@ -1198,7 +1206,7 @@ function BatchImportModal({ onClose }: { onClose: () => void }) {
   };
 
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),0.5)" }} onClick={onClose}>
+    <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }} onClick={onClose}>
       <div style={{ background: W, width: 660, border: `1px solid rgba(11,37,69,0.15)` }} className="rounded-sm shadow-2xl" onClick={e => e.stopPropagation()}>
         <div style={{ background: N, color: W }} className="h-11 flex items-center justify-between px-5 rounded-t-sm">
           <span className="text-base font-black tracking-widest">批量导入文档</span>
@@ -1208,7 +1216,7 @@ function BatchImportModal({ onClose }: { onClose: () => void }) {
           {/* Drop zone */}
           <div
             style={{
-              border: `2px dashed ${dragging ? B : "rgba(var(--dms-deep-rgb),0.25)"}`,
+              border: `2px dashed ${dragging ? H : "rgba(var(--dms-deep-rgb),0.25)"}`,
               background: dragging ? `rgba(var(--dms-primary-rgb),0.06)` : `rgba(var(--dms-line-rgb),0.04)`,
             }}
             className="rounded-sm h-32 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all mb-4"
@@ -1224,7 +1232,7 @@ function BatchImportModal({ onClose }: { onClose: () => void }) {
               el.click();
             }}
           >
-            <Upload size={28} style={{ color: dragging ? B : `rgba(var(--dms-deep-rgb),0.3)` }} />
+            <Upload size={28} style={{ color: dragging ? H : `rgba(var(--dms-deep-rgb),0.3)` }} />
             <span className="text-base font-black" style={{ color: N }}>拖拽多个文件至此处,或点击选择</span>
             <span className="text-sm font-bold" style={{ color: `rgba(var(--dms-deep-rgb),0.4)` }}>支持 .doc .docx .pdf .xls .xlsx,可同时导入多个</span>
           </div>
@@ -1349,7 +1357,7 @@ function PermissionModal({ doc, onClose }: { doc: Doc; onClose: () => void }) {
   });
 
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),0.5)" }} onClick={onClose}>
+    <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }} onClick={onClose}>
       <div style={{ background: W, width: 540, border: `1px solid rgba(11,37,69,0.15)` }} className="rounded-sm shadow-2xl" onClick={e => e.stopPropagation()}>
         <div style={{ background: N, color: W }} className="h-11 flex items-center justify-between px-5 rounded-t-sm">
           <span className="text-base font-black tracking-widest">借阅授权 — {doc.name}</span>
@@ -1463,7 +1471,7 @@ async function persistUiTheme(theme: UiTheme): Promise<void> {
 /** 主题预览色样:固定色值(选择界面本身不随主题变),取各主题强调色。 */
 const THEME_SWATCHES: Record<UiTheme, string> = {
   blue: "#2060C8",
-  red: "#C7000B",
+  red: "#C8102E",
   green: "#228B5E",
 };
 
@@ -1530,7 +1538,9 @@ function PageFrame({ title, current, navigationItems, onNav, onLogout, logoutPen
 
   return (
     <div className="w-full h-screen flex flex-col overflow-hidden" style={{ fontFamily: "'Noto Sans SC', sans-serif", background: PBG }}>
-      {/* Header — auto height from padding, fits menu naturally;z-45 需高于主内容区 z-40,否则用户卡片下拉被遮挡 */}
+      {/* Header — auto height from padding, fits menu naturally;z-[45] 高于主内容普通元素,
+          人员下拉卡片(z-50,本上下文内)不被主内容遮挡;主内容不建层叠上下文,
+          业务弹窗 fixed z-[80]+ 反过来可覆盖横幅 */}
       <div className="relative z-[45] flex-shrink-0 flex items-center justify-between px-8 py-3" style={{ background: "var(--dms-banner)" }}>
         {/* Left: 设置界面时为返回按钮;常规为人员下拉(基本信息 + 设置入口) */}
         {settingsOpen ? (
@@ -1572,9 +1582,11 @@ function PageFrame({ title, current, navigationItems, onNav, onLogout, logoutPen
           </div>
         )}
         {/* Center: title */}
-        <h1 className="absolute left-1/2 -translate-x-1/2 font-black tracking-[0.18em] whitespace-nowrap"
+        <h1 className="absolute left-1/2 -translate-x-1/2 font-black tracking-[0.18em] whitespace-nowrap flex items-center gap-3"
           style={{ color: W, fontSize: 30 }}>
+          <span aria-hidden className="inline-block w-8 h-[3px] rounded-full" style={{ background: "var(--dms-highlight-gradient)" }} />
           {settingsOpen ? "设置" : title}
+          <span aria-hidden className="inline-block w-8 h-[3px] rounded-full" style={{ background: "var(--dms-highlight-gradient)" }} />
         </h1>
         {/* Right: nav — all items horizontal, never wrap */}
         <div className="flex items-center gap-0.5">
@@ -1599,8 +1611,11 @@ function PageFrame({ title, current, navigationItems, onNav, onLogout, logoutPen
       <div aria-hidden className="relative z-20 flex-shrink-0"
         style={{ height: 20, background: `linear-gradient(to bottom, var(--dms-deep) 0%, ${PBG} 100%)` }} />
 
-      {/* Main content — sits below header;设置界面时替换为主题设置面板 */}
-      <div className="relative z-40 flex flex-1 min-h-0">
+      {/* Main content — sits below header;设置界面时替换为主题设置面板。
+          不设 z-index(relative + z-auto 不创建层叠上下文):页面内业务弹窗
+          fixed z-[80]+ 参与根上下文比较,可完整覆盖顶部横幅 z-[45];人员
+          下拉卡片(z-50,位于横幅上下文内)仍高于主内容普通元素不被遮挡 */}
+      <div className="relative flex flex-1 min-h-0">
         {settingsOpen ? <SettingsPanel onPersist={persistUiTheme} /> : children}
       </div>
     </div>
@@ -1895,7 +1910,7 @@ function AdminDocRow({ doc, index, selected, childCount, attachmentCount, onSele
         <div className="flex items-center gap-2"><FileText size={14} style={{ color: B }} className="flex-shrink-0" /><span className="text-base font-black" style={{ color: N }}>{doc.name}</span></div>
       </td>
       <td className="px-4 py-3" style={{ background: "inherit" }}>
-        <span style={{ color: doc.type === "主案" ? N : B, background: doc.type === "主案" ? `rgba(var(--dms-line-rgb),0.1)` : `rgba(var(--dms-primary-rgb),0.1)`, border: `1px solid ${doc.type === "主案" ? "rgba(var(--dms-deep-rgb),0.2)" : "rgba(var(--dms-primary-rgb),0.25)"}` }} className="text-base font-black px-2 py-0.5 rounded-sm">{doc.type}</span>
+        <span style={{ color: doc.type === "主案" ? N : H, background: doc.type === "主案" ? `rgba(var(--dms-line-rgb),0.1)` : `rgba(var(--dms-highlight-rgb),0.12)`, border: `1px solid ${doc.type === "主案" ? "rgba(var(--dms-deep-rgb),0.2)" : "rgba(var(--dms-highlight-rgb),0.3)"}` }} className="text-base font-black px-2 py-0.5 rounded-sm">{doc.type}</span>
       </td>
       <td className="px-4 py-3 whitespace-nowrap" style={{ background: "inherit" }}><LevelBadge level={doc.level} /></td>
       <td className="px-4 py-3 text-base font-bold" style={{ color: `rgba(var(--dms-deep-rgb),0.6)`, background: "inherit" }}>{doc.visibility}</td>
@@ -2130,7 +2145,7 @@ function MountAttachmentModal({ plan, attachments, mountedIds, onConfirm, onClos
     return next;
   });
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),0.5)" }} onClick={onClose}>
+    <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }} onClick={onClose}>
       <div className="w-[780px] max-h-[82vh] flex flex-col rounded-sm shadow-2xl" style={{ background: W }} onClick={e => e.stopPropagation()}>
         <div className="h-11 flex items-center justify-between px-5 flex-shrink-0" style={{ background: N, color: W }}>
           <span className="text-base font-black">从共享附件库挂载到「{plan.name}」</span>
@@ -2512,16 +2527,38 @@ function LoginPage({ onLogin, notice }: {
   const [error, setError] = useState("");
   const [errorRequestId, setErrorRequestId] = useState("");
   const [submitting, setSubmitting] = useState(false);
+  const [captcha, setCaptcha] = useState<CaptchaData | null>(null);
+  const [captchaCode, setCaptchaCode] = useState("");
   const loginBackground = useLoginBackground();
 
+  // 登录图片验证码(安全需求4):后端开启时显示图片与输入框,点击图片可刷新
+  const refreshCaptcha = useCallback(async () => {
+    setCaptchaCode("");
+    try {
+      setCaptcha(await authApi.fetchCaptcha());
+    } catch {
+      setCaptcha(null);
+    }
+  }, []);
+
+  useEffect(() => { void refreshCaptcha(); }, [refreshCaptcha]);
+
   const handleLogin = async () => {
     if (submitting) return;
     if (!username.trim() || !password.trim()) { setError("请输入用户名和密码"); return; }
+    if (captcha?.enabled && !captchaCode.trim()) { setError("请输入验证码"); return; }
     setSubmitting(true);
     setError("");
     setErrorRequestId("");
     try {
-      await onLogin({ username: username.trim(), password, keepSignedIn });
+      await onLogin({
+        username: username.trim(),
+        password,
+        keepSignedIn,
+        ...(captcha?.enabled && captcha.captchaId
+          ? { captchaId: captcha.captchaId, captchaCode: captchaCode.trim() }
+          : {}),
+      });
     } catch (loginError) {
       if (loginError instanceof HttpError) {
         setError(loginError.message);
@@ -2529,6 +2566,8 @@ function LoginPage({ onLogin, notice }: {
       } else {
         setError("登录失败,请稍后重试");
       }
+      // 尝试过一次即消费:无论成败刷新验证码
+      void refreshCaptcha();
     } finally {
       setSubmitting(false);
     }
@@ -2584,6 +2623,26 @@ function LoginPage({ onLogin, notice }: {
                     </div>
                   </div>
                 ))}
+                {captcha?.enabled && (
+                  <div>
+                    <label className="text-base font-black mb-1.5 block" style={{ color: N }}>验证码</label>
+                    <div className="flex gap-2">
+                      <div className="dms-input flex-1 flex items-center rounded-sm border h-11 px-3 transition-colors" style={{ borderColor: "rgba(var(--dms-deep-rgb),0.2)", background: `rgba(var(--dms-line-rgb),0.04)` }}>
+                        <input type="text" disabled={submitting} maxLength={4} className="flex-1 text-base bg-transparent outline-none font-bold tracking-[0.3em] disabled:cursor-not-allowed" style={{ color: N }}
+                          placeholder="请输入验证码"
+                          value={captchaCode}
+                          onChange={e => { setCaptchaCode(e.target.value); setError(""); setErrorRequestId(""); }}
+                          onKeyDown={e => { if (e.key === "Enter") void handleLogin(); }} />
+                      </div>
+                      {captcha.image && (
+                        <img src={captcha.image} alt="验证码" title="点击刷新验证码"
+                          onClick={() => { if (!submitting) void refreshCaptcha(); }}
+                          className="h-11 rounded-sm cursor-pointer select-none"
+                          style={{ border: "1px solid rgba(var(--dms-deep-rgb),0.2)" }} />
+                      )}
+                    </div>
+                  </div>
+                )}
                 <div className="flex items-center">
                   <label className="flex items-center gap-2 cursor-pointer">
                     <input type="checkbox" disabled={submitting} checked={keepSignedIn} onChange={e => setKeepSignedIn(e.target.checked)} className="accent-[var(--primary)]" />
@@ -2964,6 +3023,32 @@ function F4DocumentTable({ items, keyword, contentKeyword, selectedId, currentUs
         .filter((id) => !mountedRowIds.has(id)),
     );
   };
+  // 单击/双击区分:单击延迟 250ms 判定,期间同一行第二次点击视为双击,取消单击
+  // (双击仅触发 onDoubleClick 打开查看,不再联动第一次 click 的选中与右侧预览);
+  // 快速点击不同行时旧单击作废,按新行重新计时。
+  const pendingClickRef = useRef<{ timer: number; rowKey: string } | null>(null);
+  const cancelPendingClick = () => {
+    if (pendingClickRef.current) {
+      window.clearTimeout(pendingClickRef.current.timer);
+      pendingClickRef.current = null;
+    }
+  };
+  useEffect(() => cancelPendingClick, []);
+  const handleRowClick = (item: DocumentSummary | AttachmentBindingSummary) => {
+    const rowKey = bindingMode ? (item as AttachmentBindingSummary).bindingId : item.id;
+    const pending = pendingClickRef.current;
+    if (pending) {
+      cancelPendingClick();
+      if (pending.rowKey === rowKey) return; // 双击的第二次点击:交给 onDoubleClick
+    }
+    pendingClickRef.current = {
+      rowKey,
+      timer: window.setTimeout(() => {
+        pendingClickRef.current = null;
+        onSelect?.(item as DocumentSummary);
+      }, 250),
+    };
+  };
   return (
     <>
     <table className="w-full border-collapse">
@@ -2998,7 +3083,7 @@ function F4DocumentTable({ items, keyword, contentKeyword, selectedId, currentUs
           return (
             <Fragment key={binding?.bindingId ?? item.id}>
             <tr
-              onClick={() => onSelect?.(item)}
+              onClick={() => handleRowClick(item)}
               onDoubleClick={() => {
                 if (canView && onView) onView(item);
               }}
@@ -3043,14 +3128,14 @@ function F4DocumentTable({ items, keyword, contentKeyword, selectedId, currentUs
                 {showTypeBadge && (
                   <div className="mt-1">
                     <span className="inline-block px-1.5 py-0.5 text-base font-black leading-none" style={{ color: B, border: `1px solid ${B}` }}>
-                      {item.documentType === DocumentType.MAIN ? topCategoryDisplayName(item.categoryId) ?? "主体方案" : item.documentType === DocumentType.SUB_PLAN ? "配套方案" : dictionaries.label("attachmentTypes", item.attachmentType ?? "") || "配套资料"}
+                      {item.documentType === DocumentType.MAIN ? topCategoryDisplayName(item.categoryId) ?? "主体方案" : item.documentType === DocumentType.SUB_PLAN ? "配套方案" : dictionaries.label("attachmentTypes", item.attachmentType ?? "") || "共享资料"}
                     </span>
                   </div>
                 )}
                 {((item.subHitCount ?? 0) > 0 || (item.attachmentHitCount ?? 0) > 0) && (
                   <div className="flex flex-wrap gap-1 mt-1">
-                    {(item.subHitCount ?? 0) > 0 && <span className="px-1.5 py-0.5 text-base font-black" style={{ color: "var(--dms-deep)", background: "rgba(var(--dms-bright-rgb),.14)" }} {...tipProps("子案/附件命中高级检索条件的主案汇总")}>📄 子案 {item.subHitCount}</span>}
-                    {(item.attachmentHitCount ?? 0) > 0 && <span className="px-1.5 py-0.5 text-base font-black" style={{ color: "var(--dms-deep)", background: "rgba(var(--dms-bright-rgb),.14)" }} {...tipProps("子案/附件命中高级检索条件的主案汇总")}>📎 附件 {item.attachmentHitCount}</span>}
+                    {(item.subHitCount ?? 0) > 0 && <span className="px-1.5 py-0.5 text-base font-black" style={{ color: "var(--dms-deep)", background: "rgba(var(--dms-bright-rgb),.14)" }} {...tipProps("子案/共享资料命中高级检索条件的主案汇总")}>📄 子案 {item.subHitCount}</span>}
+                    {(item.attachmentHitCount ?? 0) > 0 && <span className="px-1.5 py-0.5 text-base font-black" style={{ color: "var(--dms-deep)", background: "rgba(var(--dms-bright-rgb),.14)" }} {...tipProps("子案/共享资料命中高级检索条件的主案汇总")}>📎 共享资料 {item.attachmentHitCount}</span>}
                   </div>
                 )}
               </td>
@@ -3159,7 +3244,7 @@ function F4ReverseModal({ attachment, onClose }: { attachment: DocumentSummary;
   const state = useAttachmentMainPlans(attachment.id);
   const dictionaries = useDictionaries();
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),.5)" }}>
+    <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }}>
       <div className="w-[560px] shadow-2xl" style={{ background: W }}>
         <div className="h-11 px-5 flex items-center justify-between" style={{ background: N, color: W }}>
           <span className="font-black">已挂载主案 · {attachment.documentName}</span>
@@ -3217,7 +3302,7 @@ function LocateTargetModal({ attachment, candidates, onConfirm, onClose }: {
   const dictionaries = useDictionaries();
   const [chosen, setChosen] = useState<string | null>(null);
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),.5)" }}>
+    <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }}>
       <div className="w-[560px] shadow-2xl" style={{ background: W }}>
         <div className="h-11 px-5 flex items-center justify-between" style={{ background: N, color: W }}>
           <span className="font-black truncate">选择定位主案 · {attachment.documentName}</span>
@@ -3271,7 +3356,7 @@ function MovePositionModal({ targetName, scopeLabel, currentPosition, total, onC
     }
   };
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),.5)" }}>
+    <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }}>
       <div className="w-[480px] shadow-2xl" style={{ background: W }}>
         <div className="h-11 px-5 flex items-center justify-between" style={{ background: N, color: W }}>
           <span className="font-black truncate">移动到指定位置 · {targetName}</span>
@@ -3306,9 +3391,6 @@ function MovePositionModal({ targetName, scopeLabel, currentPosition, total, onC
 }
 
 function F4AttachmentLibrary({ compact = false, management = false, canManageOrganization = false, onPreview }: { compact?: boolean; management?: boolean; canManageOrganization?: boolean; onPreview?: (item: DocumentSummary) => void }) {
-  const dictionaries = useDictionaries();
-  const [keyword, setKeyword] = useState("");
-  const [attachmentType, setAttachmentType] = useState("");
   const [page, setPage] = useState(1);
   const [reverse, setReverse] = useState<DocumentSummary | null>(null);
   const [localPreview, setLocalPreview] = useState<DocumentSummary | null>(null);
@@ -3316,11 +3398,9 @@ function F4AttachmentLibrary({ compact = false, management = false, canManageOrg
   const openPreview = onPreview ?? setLocalPreview;
   const [edit, setEdit] = useState<DocumentSummary | null>(null);
   const [remove, setRemove] = useState<DocumentSummary | null>(null);
-  const [upload, setUpload] = useState(false);
-  const [batch, setBatch] = useState(false);
+  // 筛选行与上传/批量导入按钮已上移到管理页顶部页签行(与主体方案一致),
+  // 列表恒为全量倒序,上传弹窗由 AdminPage 持有
   const state = useAttachmentList({
-    keyword: keyword.trim() || undefined,
-    attachmentType: attachmentType ? attachmentType as keyof typeof AttachmentType : undefined,
     sortBy: "updatedAt",
     sortDirection: "desc",
     page,
@@ -3328,19 +3408,6 @@ function F4AttachmentLibrary({ compact = false, management = false, canManageOrg
   });
   return (
     <div className="h-full flex flex-col" style={{ background: W }}>
-      <div className="flex items-center gap-2 p-3 border-b" style={{ borderColor: "rgba(var(--dms-line-rgb),.14)" }}>
-        <Library size={16} style={{ color: B }} />
-        <span className="text-base font-black mr-2" style={{ color: N }}>独立共享资料库</span>
-        <input value={keyword} onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="搜索附件名称"
-          className="h-8 w-60 px-3 text-base font-bold outline-none border" style={{ color: N, borderColor: "rgba(var(--dms-line-rgb),.25)" }} />
-        <select value={attachmentType} onChange={(event) => { setAttachmentType(event.target.value); setPage(1); }}
-          className="h-8 px-2 text-base font-bold border" style={{ color: N, borderColor: "rgba(var(--dms-line-rgb),.25)" }}>
-          <option value="">全部附件类型</option>
-          {dictionaries.options("attachmentTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}
-        </select>
-        <span className="ml-auto text-base font-bold" style={{ color: N }}>共 {state.data?.total ?? 0} 项</span>
-        {management && <><button onClick={() => setUpload(true)} className="px-3 py-1.5 text-base font-black text-white" style={{ background: "var(--dms-primary-gradient)" }}>上传附件</button><button onClick={() => setBatch(true)} className="px-3 py-1.5 text-base font-black border" style={{ color: N, borderColor: B }}>批量导入</button></>}
-      </div>
       <div className="relative flex-1 min-h-0">
         {state.loading && <F4LoadingOverlay />}
         <div className="h-full overflow-auto">
@@ -3369,8 +3436,6 @@ function F4AttachmentLibrary({ compact = false, management = false, canManageOrg
       {fullView && <FilePreviewModal item={fullView} mode="modal" onClose={() => setFullView(null)} />}
       {edit && <EditMetadataModal item={edit} canManageOrganization={canManageOrganization} onClose={() => setEdit(null)} />}
       {remove && <DeleteDocumentModal item={remove} onClose={() => setRemove(null)} onDeleted={() => { setReverse(null); }} />}
-      {upload && <SingleUploadModal kind="attachment" onClose={() => setUpload(false)} />}
-      {batch && <F5BatchImportModal kind="attachment" onClose={() => setBatch(false)} />}
     </div>
   );
 }
@@ -3583,7 +3648,7 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
             )}
             <div className="flex items-center gap-3 px-4 py-2 border-b text-base font-bold whitespace-nowrap" style={{ borderColor: "rgba(var(--dms-line-rgb),.14)", color: N }}>
               <span className="font-black">全局检索结果 · 共 {searchPageState.total} 项</span>
-              <span>主案 {searchStats.main} / 子案 {searchStats.sub} / 附件 {searchStats.attachment}</span>
+              <span>主案 {searchStats.main} / 子案 {searchStats.sub} / 共享资料 {searchStats.attachment}</span>
               {searchResults.truncated && <span style={{ color: "#C0392B" }}>命中超过 1000 条,仅展示前 1000 条</span>}
             </div>
             <div className="flex-1 min-h-0 overflow-auto">
@@ -3663,6 +3728,10 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
   const [remove, setRemove] = useState<DocumentSummary | null>(null);
   const [uploadDocument, setUploadDocument] = useState(false);
   const [batchDocuments, setBatchDocuments] = useState(false);
+  // 共享资料库的上传/批量导入:按钮已上移到顶部页签行(与主体方案一致),
+  // 弹窗状态由本页持有,F4AttachmentLibrary 不再自带工具栏入口
+  const [uploadAttachmentLib, setUploadAttachmentLib] = useState(false);
+  const [batchAttachmentLib, setBatchAttachmentLib] = useState(false);
   const [bindingPlan, setBindingPlan] = useState<DocumentSummary | null>(null);
   const [permission, setPermission] = useState<DocumentSummary | null>(null);
   const [unbind, setUnbind] = useState<AttachmentBindingSummary | null>(null);
@@ -3859,13 +3928,16 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
     <PageFrame title="方案数据管理" current="admin" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending} currentUser={currentUser}>
       {mode === "plans" ? <LeftPanel allCount={allCategoryCount} admin={isAdmin && !!currentUser.permCategoryEdit} selectedCategoryId={categoryId} onSelectCategory={(id) => { resetGlobalSearch(); setCategoryId(id); setPlanPage(1); setSelected(null); setPreview(null); }} /> : <div className="w-[272px] m-3 p-5 flex-shrink-0" style={{ background: W, color: N }}><Library size={22} className="mb-3" /><div className="font-black">共享资料</div></div>}
       <div ref={centerZoom.ref} className="flex-1 flex flex-col m-3 ml-0 min-h-0" style={{ zoom: centerZoom.zoom }}>
-        {isAdmin && <DocumentStatisticsCards />}
+        {/* 统计接口按日志审计管理位鉴权(audit_log_required),前端同步按该权限位显隐,避免无该权限的管理员看到"统计数据加载失败" */}
+        {!!currentUser.permAuditLog && <DocumentStatisticsCards />}
         <GlobalSearchBar ariaLabel="后台全局检索" />
         <div className="flex items-center mb-2" style={{ background: W, border: "1px solid rgba(var(--dms-line-rgb),.18)" }}>
           <button onClick={() => { resetGlobalSearch(); setMode("plans"); }} className="px-4 py-3 text-base font-black" style={{ color: mode === "plans" ? W : N, background: mode === "plans" ? N : W }}>主体方案</button>
           {isAdmin && (<button onClick={() => { resetGlobalSearch(); setMode("attachments"); }} className="px-4 py-3 text-base font-black" style={{ color: mode === "attachments" ? W : N, background: mode === "attachments" ? N : W }}>共享资料库</button>)}
           <div className="ml-auto flex gap-2 mr-3">
-            {isAdmin && mode === "plans" && <><button onClick={() => setUploadDocument(true)} className="px-3 py-1.5 text-base font-black text-white" style={{ background: "var(--dms-primary-gradient)" }}>上传主体方案</button><button onClick={() => setBatchDocuments(true)} className="px-3 py-1.5 text-base font-black border" style={{ color: N, borderColor: B }}>批量导入方案</button></>}
+            {/* 上传/批量导入按方案权限矩阵上传位门控(契约1.31):后端 createRecord 同步校验 */}
+            {mode === "plans" && !!currentUser.permUpload && <><button onClick={() => setUploadDocument(true)} className="px-3 py-1.5 text-base font-black text-white" style={{ background: "var(--dms-primary-gradient)" }}>上传主体方案</button><button onClick={() => setBatchDocuments(true)} className="px-3 py-1.5 text-base font-black border" style={{ color: N, borderColor: B }}>批量导入方案</button></>}
+            {mode === "attachments" && !!currentUser.permUpload && <><button onClick={() => setUploadAttachmentLib(true)} className="px-3 py-1.5 text-base font-black text-white" style={{ background: "var(--dms-primary-gradient)" }}>上传共享资料</button><button onClick={() => setBatchAttachmentLib(true)} className="px-3 py-1.5 text-base font-black border" style={{ color: N, borderColor: B }}>批量导入</button></>}
           </div>
         </div>
         {searchActive ? (
@@ -3879,7 +3951,7 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
                 )}
                 <div className="flex items-center gap-3 px-4 py-2 border-b text-base font-bold whitespace-nowrap" style={{ borderColor: "rgba(var(--dms-line-rgb),.14)", color: N }}>
                   <span className="font-black">全局检索结果 · 共 {searchPageState.total} 项</span>
-                  <span>主案 {searchStats.main} / 子案 {searchStats.sub} / 附件 {searchStats.attachment}</span>
+                  <span>主案 {searchStats.main} / 子案 {searchStats.sub} / 共享资料 {searchStats.attachment}</span>
                   {searchResults.truncated && <span style={{ color: "#C0392B" }}>命中超过 1000 条,仅展示前 1000 条</span>}
                 </div>
                 <div className="flex-1 min-h-0 overflow-auto">
@@ -3926,7 +3998,6 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
                     {selected.allowedActions.includes(AllowedAction.BIND_ATTACHMENT) && <button onClick={() => setBindingPlan(selected)} className="ml-2 my-1.5 px-3 text-base font-black text-white" style={{ background: "var(--dms-primary-gradient)" }}>挂载</button>}
                   </>
                 )}
-                {isAdmin && selected.allowedActions.includes(AllowedAction.CONFIG_PERMISSION) && <button onClick={() => setPermission(selected)} className="my-1.5 ml-2 px-3 text-base font-black border" style={{ color: N, borderColor: B }}>借阅授权</button>}
               </div>
               <div className="relative flex-1 min-h-0">
                 {mounted.loading && <F4LoadingOverlay />}
@@ -3949,6 +4020,8 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
       {uploadDocument && <SingleUploadModal kind="document" mainOnly initialCategoryId={categoryId ?? undefined} onClose={() => setUploadDocument(false)} />}
       {uploadAttachmentTab && <SingleUploadModal kind="attachment" initialAttachmentType={uploadAttachmentTab} onUploaded={(detail) => { void handleAttachmentUploaded(detail); }} onClose={() => setUploadAttachmentTab(null)} />}
       {batchDocuments && <F5BatchImportModal kind="document" onClose={() => setBatchDocuments(false)} />}
+      {uploadAttachmentLib && <SingleUploadModal kind="attachment" onClose={() => setUploadAttachmentLib(false)} />}
+      {batchAttachmentLib && <F5BatchImportModal kind="attachment" onClose={() => setBatchAttachmentLib(false)} />}
       {bindingPlan && (
         <AttachmentBindingModal
           mainPlan={bindingPlan}
@@ -4144,13 +4217,19 @@ export default function App() {
     void handleLogout("长时间未操作,已自动退出登录");
   });
 
-  const navigationItems =
+  const navigationItems = (
     currentUser && dictionaries.status === "success"
       ? getNavigationItems(
           currentUser.allowedModules,
           (module) => dictionaries.label("allowedModules", module),
         )
-      : [];
+      : []
+  ).filter(
+    // 「机构用户」入口按三管理位联合显示(契约1.31):无任何管理位时不渲染该菜单
+    (item) =>
+      item.page !== "directory" ||
+      !!(currentUser?.permOrgEdit || currentUser?.permUserEdit || currentUser?.permRoleEdit),
+  );
   const handleNavigation = (target: AuthenticatedPage) => {
     if (!currentUser) return;
     if (isPageAllowed(target, currentUser.allowedModules)) {
@@ -4266,7 +4345,8 @@ export default function App() {
           <AuditDashboard />
         </PageFrame>
       )}
-      {page === "directory" && currentUser.roleCode === RoleCode.ADMIN && (
+      {page === "directory" && currentUser.roleCode === RoleCode.ADMIN
+        && !!(currentUser.permOrgEdit || currentUser.permUserEdit || currentUser.permRoleEdit) && (
         <PageFrame title="方案数据管理" current="directory" {...pageProps}>
           <DirectoryPage currentUser={currentUser} />
         </PageFrame>

+ 4 - 4
frontend/src/features/attachments/attachmentApi.ts

@@ -33,7 +33,7 @@ export function createAttachmentApi(client: AttachmentHttpClient = httpClient) {
         signal,
       });
       if (!isPageResult(result.data, isDocumentSummary)) {
-        throw contractHttpError(result, "配套资料列表");
+        throw contractHttpError(result, "共享资料列表");
       }
       return result.data;
     },
@@ -41,7 +41,7 @@ export function createAttachmentApi(client: AttachmentHttpClient = httpClient) {
     async detail(id: string, signal?: AbortSignal): Promise<DocumentDetail> {
       const result = await client.get<unknown>(`attachments/${id}`, { signal });
       if (!isDocumentDetail(result.data)) {
-        throw contractHttpError(result, "配套资料详情");
+        throw contractHttpError(result, "共享资料详情");
       }
       return result.data;
     },
@@ -52,7 +52,7 @@ export function createAttachmentApi(client: AttachmentHttpClient = httpClient) {
         { signal },
       );
       if (!isMainPlanBriefResult(result.data)) {
-        throw contractHttpError(result, "附件反向挂载主案列表");
+        throw contractHttpError(result, "共享资料反向挂载主案列表");
       }
       return result.data.items;
     },
@@ -67,7 +67,7 @@ export function createAttachmentApi(client: AttachmentHttpClient = httpClient) {
         { query: { ...query }, signal },
       );
       if (!isPageResult(result.data, isAttachmentBindingSummary)) {
-        throw contractHttpError(result, "主案已挂载附件列表");
+        throw contractHttpError(result, "主案已挂载共享资料列表");
       }
       return result.data;
     },

+ 3 - 3
frontend/src/features/audit/AuditDashboard.tsx

@@ -190,8 +190,8 @@ function RuntimeLogModal({ onClose }: { onClose: () => void }) {
 
   return (
     <div
-      className="fixed inset-0 z-50 flex items-center justify-center p-6"
-      style={{ background: "rgba(var(--dms-deep-rgb),0.45)" }}
+      className="fixed inset-0 z-[80] flex items-center justify-center p-6"
+      style={{ background: "rgba(0,0,0,0.5)" }}
     >
       <div
         className="w-full h-full max-w-5xl max-h-[85vh] rounded-sm shadow-2xl flex flex-col"
@@ -545,7 +545,7 @@ export function AuditDashboard() {
             style={{ color: NAVY, borderColor: "rgba(var(--dms-line-rgb),.14)" }}
           >
           <span className="font-black">操作日志</span>
-            <span className="text-base font-black" style={{ color: BLUE }}>
+            <span className="text-base font-black" style={{ color: "var(--dms-highlight)" }}>
               {logs.data?.total ?? 0} 条
             </span>
             <select

+ 1 - 1
frontend/src/features/auth/ReauthPromptModal.tsx

@@ -52,7 +52,7 @@ export function ReauthPromptModal() {
       role="dialog"
       aria-label="敏感操作二次认证"
       className="fixed inset-0 z-[95] flex items-center justify-center"
-      style={{ background: "rgba(var(--dms-deep-rgb),.55)" }}
+      style={{ background: "rgba(0,0,0,0.5)" }}
       onKeyDown={(event) => {
         if (event.key === "Enter") void confirm();
         if (event.key === "Escape") completeReauthRequest(null);

+ 1 - 1
frontend/src/features/auth/__tests__/appScope.test.ts

@@ -17,7 +17,7 @@ describe("F5 App scope", () => {
     expect(appSource).toContain(">查看</button>");
     expect(appSource).not.toContain("请使用列表中的“预览”或“下载”入口读取真实文件");
     expect(appSource).not.toContain("F5 文件管理基线");
-    expect(appSource).toContain("共 {state.data?.total ?? 0} 项");
+    expect(appSource).not.toContain("共 {state.data?.total ?? 0} 项");
     expect(appSource).not.toContain("列表不请求文件大小");
   });
 });

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

@@ -74,6 +74,7 @@ function installStorage(): void {
 
 function api(overrides: Partial<AuthApi> = {}): AuthApi {
   return {
+    fetchCaptcha: vi.fn().mockResolvedValue({ enabled: false }),
     login: vi.fn().mockResolvedValue({
       accessToken: "jwt",
       tokenType: "Bearer",

+ 11 - 0
frontend/src/features/auth/authApi.ts

@@ -5,9 +5,11 @@ import {
 import { contractDetailError, contractHttpError } from "../../shared/api/contractViolation";
 import type { JsonResult } from "../../shared/api/types";
 import {
+  isCaptchaData,
   isLoginResponseData,
   isReauthResponseData,
   isUserSummary,
+  type CaptchaData,
   type LoginRequest,
   type LoginResponseData,
   type ReauthResponseData,
@@ -30,6 +32,15 @@ interface AuthHttpClient {
 
 export function createAuthApi(client: AuthHttpClient = httpClient) {
   return {
+    /** 登录图片验证码(安全需求4):关闭时返回 enabled=false,登录页隐藏验证码输入。 */
+    async fetchCaptcha(): Promise<CaptchaData> {
+      const result = await client.get<unknown>("auth/captcha", { auth: false });
+      if (!isCaptchaData(result.data)) {
+        throw contractHttpError(result, "验证码");
+      }
+      return result.data;
+    },
+
     async login(request: LoginRequest): Promise<LoginResponseData> {
       const result = await client.post<unknown>("auth/login", request, {
         auth: false,

+ 25 - 0
frontend/src/features/auth/authTypes.ts

@@ -16,6 +16,16 @@ export interface LoginRequest {
   username: string;
   password: string;
   keepSignedIn: boolean;
+  /** 登录图片验证码(安全需求4):后端开启时必填。 */
+  captchaId?: string;
+  captchaCode?: string;
+}
+
+/** 验证码签发响应(安全需求4):关闭时仅 enabled=false。 */
+export interface CaptchaData {
+  enabled: boolean;
+  captchaId?: string;
+  image?: string;
 }
 
 export interface UserSummary {
@@ -40,6 +50,8 @@ export interface UserSummary {
   permAuditLog: boolean;
   /** 角色编辑位(契约1.25,取自生效角色,内置 admin 恒 true)。 */
   permRoleEdit: boolean;
+  /** 上传权限(契约1.31):生效角色方案权限矩阵任一 OWN 行 can_upload;可选以兼容未升级后端。 */
+  permUpload?: boolean;
   /** 用户级管理范围三态(契约1.30):null=全局、"SELF"=跟随本人所在机构(落库哨兵0)、机构ID=固定锚点;内置admin恒null。 */
   managedOrgId: Id | "SELF" | null;
   lastLoginAt: string | null;
@@ -130,6 +142,7 @@ export function isUserSummary(value: unknown): value is UserSummary {
     typeof value.permSystemManage === "boolean" &&
     typeof value.permAuditLog === "boolean" &&
     typeof value.permRoleEdit === "boolean" &&
+    (value.permUpload === undefined || typeof value.permUpload === "boolean") &&
     (value.managedOrgId === null ||
       value.managedOrgId === "SELF" ||
       isId(value.managedOrgId)) &&
@@ -143,6 +156,18 @@ export function isUserSummary(value: unknown): value is UserSummary {
   );
 }
 
+export function isCaptchaData(value: unknown): value is CaptchaData {
+  if (!isRecord(value)) return false;
+  if (typeof value.enabled !== "boolean") return false;
+  if (!value.enabled) return true;
+  return (
+    typeof value.captchaId === "string" &&
+    value.captchaId.length > 0 &&
+    typeof value.image === "string" &&
+    value.image.length > 0
+  );
+}
+
 export function isLoginResponseData(
   value: unknown,
 ): value is LoginResponseData {

+ 43 - 42
frontend/src/features/backup/BackupDashboard.tsx

@@ -1,5 +1,5 @@
 import { useCallback, useEffect, useRef, useState } from "react";
-import { Ban, CopyPlus, DatabaseBackup, Play, Save } from "lucide-react";
+import { Ban, CopyPlus, DatabaseBackup, Play, RefreshCw, Save } from "lucide-react";
 import {
   B,
   DANGER,
@@ -15,6 +15,7 @@ import {
   type OptionEntry,
 } from "../directory/directoryUi";
 import { useConfirm } from "../../shared/ui/ConfirmDialog";
+import { useToast } from "../../shared/ui/toast";
 import { backupApi } from "./backupApi";
 import {
   BACKUP_PHASE_LABELS,
@@ -69,6 +70,8 @@ function progressPercent(job: BackupCurrentJob): number | null {
 /** 备份管理页(契约1.21):配置 / 手动触发 / 进度轮询 / 历史清单。 */
 export function BackupDashboard() {
   const { confirm, dialog } = useConfirm();
+  // 操作反馈/报错改 toast 顶部弹出:本组件嵌在系统管理页底部,页首横幅式提示区不可见
+  const { toast, toastNode } = useToast();
   const [config, setConfig] = useState<BackupConfigView | null>(null);
   const [form, setForm] = useState<ConfigForm | null>(null);
   const [current, setCurrent] = useState<BackupCurrentJob | null>(null);
@@ -76,8 +79,9 @@ export function BackupDashboard() {
     null,
   );
   const [loadError, setLoadError] = useState<string | null>(null);
-  const [actionError, setActionError] = useState<string | null>(null);
-  const [notice, setNotice] = useState<string | null>(null);
+  // 历史区局部错误:rsync 目标不可达时 /backup/jobs 返回 503,只降级历史区块,
+  // 配置表单保持可编辑(修正目标配置即自救路径),不再连坐整页
+  const [historyError, setHistoryError] = useState<string | null>(null);
   const [saving, setSaving] = useState(false);
   const [starting, setStarting] = useState<BackupTypeCode | null>(null);
   const [canceling, setCanceling] = useState(false);
@@ -86,33 +90,33 @@ export function BackupDashboard() {
   const loadHistory = useCallback(async () => {
     try {
       setHistoryList(await backupApi.listHistory());
-    } catch {
-      // 历史刷新失败不打断主流程,下一轮轮询后会重试
+      setHistoryError(null);
+    } catch (error) {
+      // 历史拉取失败(如 rsync 目标不可达 503):仅历史区降级提示,可重试
+      setHistoryError(describeError(error));
     }
   }, []);
 
-  // 初始加载配置与历史(当前任务由轮询首拍拉取)
+  // 初始加载:配置与历史解耦——配置失败才整页报错(服务端异常);
+  // 历史失败只降级历史区,配置表单仍可编辑保存(PUT config 不依赖目标可达)
   useEffect(() => {
     let cancelled = false;
     const load = async () => {
       try {
-        const [loadedConfig, loadedHistory] = await Promise.all([
-          backupApi.getConfig(),
-          backupApi.listHistory(),
-        ]);
+        const loadedConfig = await backupApi.getConfig();
         if (cancelled) return;
         setConfig(loadedConfig);
         setForm(formFromConfig(loadedConfig));
-        setHistoryList(loadedHistory);
       } catch (error) {
         if (!cancelled) setLoadError(describeError(error));
       }
     };
     void load();
+    void loadHistory();
     return () => {
       cancelled = true;
     };
-  }, []);
+  }, [loadHistory]);
 
   // 当前任务轮询:运行结束后自动刷新历史
   useEffect(() => {
@@ -142,8 +146,6 @@ export function BackupDashboard() {
   const handleSaveConfig = async () => {
     if (!form) return;
     setSaving(true);
-    setActionError(null);
-    setNotice(null);
     try {
       const saved = await backupApi.updateConfig({
         target_mode: form.targetMode,
@@ -158,25 +160,25 @@ export function BackupDashboard() {
       });
       setConfig(saved);
       setForm(formFromConfig(saved));
-      setNotice("备份配置已保存");
+      toast("备份配置已保存");
+      // 目标配置修正后历史可能恢复可拉,立即重试形成自救闭环
+      void loadHistory();
     } catch (error) {
-      setActionError(describeError(error));
+      toast(describeError(error), "error");
     } finally {
       setSaving(false);
     }
   };
 
   const handleStart = async (type: BackupTypeCode) => {
-    setActionError(null);
-    setNotice(null);
     setStarting(type);
     try {
       const job = await backupApi.start(type);
       setCurrent({ ...job, running: true });
       runningRef.current = true;
-      setNotice(`已开始${BACKUP_TYPE_LABELS[type]}任务`);
+      toast(`已开始${BACKUP_TYPE_LABELS[type]}任务`);
     } catch (error) {
-      setActionError(describeError(error));
+      toast(describeError(error), "error");
     } finally {
       setStarting(null);
     }
@@ -191,17 +193,15 @@ export function BackupDashboard() {
     });
     if (!okToCancel) return;
     setCanceling(true);
-    setActionError(null);
-    setNotice(null);
     try {
       const canceled = await backupApi.cancel();
       if (canceled) {
-        setNotice("已请求取消,任务将在当前步骤完成后终止");
+        toast("已请求取消,任务将在当前步骤完成后终止");
       } else {
-        setActionError("当前没有正在运行的备份任务");
+        toast("当前没有正在运行的备份任务", "error");
       }
     } catch (error) {
-      setActionError(describeError(error));
+      toast(describeError(error), "error");
     } finally {
       setCanceling(false);
     }
@@ -242,22 +242,7 @@ export function BackupDashboard() {
       style={{ background: PBG }}
     >
       {dialog}
-      {notice && (
-        <div
-          className="mb-3 px-4 py-2 text-base font-bold rounded-sm"
-          style={{ background: W, color: N, border: `1px solid ${B}` }}
-        >
-          {notice}
-        </div>
-      )}
-      {actionError && (
-        <div
-          className="mb-3 px-4 py-2 text-base font-bold rounded-sm"
-          style={{ background: W, color: DANGER, border: `1px solid ${DANGER}` }}
-        >
-          {actionError}
-        </div>
-      )}
+      {toastNode}
 
       {/* 当前任务 */}
       <section
@@ -438,7 +423,23 @@ export function BackupDashboard() {
             备份历史(按时间升序)
           </h2>
         </div>
-        {historyList === null ? (
+        {historyError !== null ? (
+          <div
+            className="text-base font-bold flex items-center gap-3 flex-wrap"
+            style={{ color: DANGER }}
+          >
+            <span>
+              备份历史加载失败:{historyError}。目标不可达时请先在上方「备份配置」中修正备份目标并保存。
+            </span>
+            {smallButton(false, {
+              label: "重试",
+              icon: <RefreshCw size={14} />,
+              onClick: () => {
+                void loadHistory();
+              },
+            })}
+          </div>
+        ) : historyList === null ? (
           <div className="text-base font-bold" style={{ color: N }}>
             正在加载备份历史…
           </div>

+ 10 - 10
frontend/src/features/bindings/AttachmentBindingModal.tsx

@@ -187,7 +187,7 @@ export function AttachmentBindingModal({
         setMounted({
           ids: new Set(),
           loading: false,
-          error: asHttpError(loadError, "已挂载附件加载失败"),
+          error: asHttpError(loadError, "已挂载共享资料加载失败"),
         });
       }
     });
@@ -203,7 +203,7 @@ export function AttachmentBindingModal({
     if (submitting) return;
     const attachmentIds = dedupeAttachmentIds(selected);
     if (attachmentIds.length === 0) {
-      setNotice("请至少选择一个未挂载附件");
+      setNotice("请至少选择一个未挂载共享资料");
       return;
     }
     setSubmitting(true);
@@ -250,7 +250,7 @@ export function AttachmentBindingModal({
   return (
     <div
       className="fixed inset-0 z-[80] flex items-center justify-center p-6"
-      style={{ background: "rgba(var(--dms-deep-rgb),.55)" }}
+      style={{ background: "rgba(0,0,0,0.5)" }}
     >
       <div
         className="w-[980px] max-w-full h-[82vh] flex flex-col shadow-2xl"
@@ -270,7 +270,7 @@ export function AttachmentBindingModal({
             <input
               value={keyword}
               onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
-              placeholder="搜索附件名称、概述或标签"
+              placeholder="搜索共享资料名称、概述或标签"
               className="w-full bg-transparent outline-none text-base font-bold"
             />
           </label>
@@ -279,7 +279,7 @@ export function AttachmentBindingModal({
             onChange={(event) => { setAttachmentType(event.target.value as AttachmentTypeCode | ""); setPage(1); }}
             className="border px-2 text-base font-bold"
           >
-            <option value="">全部附件类型</option>
+            <option value="">全部共享资料类型</option>
             {dictionaries.options("attachmentTypes").map((option) => (
               <option key={option.code} value={option.code}>{option.label}</option>
             ))}
@@ -307,22 +307,22 @@ export function AttachmentBindingModal({
         </div>
 
         <div className="flex-1 overflow-auto">
-          {loading && <div className="p-10 text-center font-bold" style={{ color: NAVY }}><RefreshCw size={16} className="inline mr-2 animate-spin" />正在加载附件和挂载关系…</div>}
+          {loading && <div className="p-10 text-center font-bold" style={{ color: NAVY }}><RefreshCw size={16} className="inline mr-2 animate-spin" />正在加载共享资料和挂载关系…</div>}
           {loadError && !loading && (
             <div className="p-10 text-center font-bold" style={{ color: NAVY }}>
               <AlertCircle size={16} className="inline mr-2" />
-              {loadError.httpStatus === 403 ? "当前账号无权管理附件挂载" : loadError.message}
+              {loadError.httpStatus === 403 ? "当前账号无权管理共享资料挂载" : loadError.message}
               {loadError.requestId && <div className="text-sm mt-2">错误编号:{loadError.requestId}</div>}
             </div>
           )}
           {!loading && !loadError && orderedItems.length === 0 && (
-            <div className="p-10 text-center text-sm font-bold opacity-55" style={{ color: NAVY }}>暂无符合条件的配套资料</div>
+            <div className="p-10 text-center text-sm font-bold opacity-55" style={{ color: NAVY }}>暂无符合条件的共享资料</div>
           )}
           {!loading && !loadError && orderedItems.length > 0 && (
             <table className="w-full border-collapse">
               <thead className="sticky top-0">
                 <tr style={{ background: PALE, color: NAVY }}>
-                  {["选择", "附件名称", "类型", "扩展名", "更新时间", "状态"].map((heading) => <th key={heading} className="text-left px-4 py-3 text-base font-black">{heading}</th>)}
+                  {["选择", "共享资料名称", "类型", "扩展名", "更新时间", "状态"].map((heading) => <th key={heading} className="text-left px-4 py-3 text-base font-black">{heading}</th>)}
                 </tr>
               </thead>
               <tbody>
@@ -368,7 +368,7 @@ export function AttachmentBindingModal({
           {error && <div className="mb-2 text-base font-black"><AlertCircle size={14} className="inline mr-1" />{error.httpStatus === 403 ? "当前账号无挂载管理权限" : error.message}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
           {notice && <div className="mb-2 text-base font-black">{notice}</div>}
           <div className="flex items-center">
-            <span className="text-base font-bold">已选择 {selectedCount} 个未挂载附件;翻页和搜索不会清除选择。</span>
+            <span className="text-base font-bold">已选择 {selectedCount} 个未挂载共享资料;翻页和搜索不会清除选择。</span>
             <div className="ml-auto flex gap-2">
               <button onClick={onClose} disabled={submitting} className="px-4 py-2 text-base font-black border" style={{ borderColor: BLUE }}>取消</button>
               <button onClick={() => void handleSubmit()} disabled={submitting || selected.length === 0 || !!loadError} className="px-4 py-2 text-base font-black text-white disabled:opacity-50" style={{ background: BLUE }}>

+ 1 - 1
frontend/src/features/bindings/SubPlanBindingModal.tsx

@@ -150,7 +150,7 @@ export function SubPlanBindingModal({ mainPlan, onClose, onChanged }: SubPlanBin
   const loadError = candidates.error ?? existing.error;
 
   return (
-    <div className="fixed inset-0 z-[80] flex items-center justify-center p-6" style={{ background: "rgba(var(--dms-deep-rgb),.55)" }}>
+    <div className="fixed inset-0 z-[80] flex items-center justify-center p-6" style={{ background: "rgba(0,0,0,0.5)" }}>
       <div className="w-[880px] max-w-full h-[80vh] flex flex-col shadow-2xl" style={{ background: WHITE }}>
         <div className="h-12 px-5 flex items-center justify-between" style={{ background: NAVY, color: WHITE }}>
           <div>

+ 2 - 2
frontend/src/features/bindings/UnbindAttachmentModal.tsx

@@ -85,7 +85,7 @@ export function UnbindAttachmentModal({
         : error?.message;
 
   return (
-    <div className="fixed inset-0 z-[90] flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),.55)" }}>
+    <div className="fixed inset-0 z-[90] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }}>
       <div className="w-[560px] shadow-2xl" style={{ background: WHITE, color: NAVY }}>
         <div className="h-12 px-5 flex items-center justify-between text-white" style={{ background: NAVY }}>
           <span className="font-black flex items-center gap-2"><Unlink size={17} />解除挂载</span>
@@ -93,7 +93,7 @@ export function UnbindAttachmentModal({
         </div>
         <div className="p-6">
           <p className="font-black mb-4">确认解除“{mainPlan.documentName}”与“{attachment.documentName}”的挂载关系?</p>
-          <div className="text-base font-bold">附件及其文件不会被删除。</div>
+          <div className="text-base font-bold">共享资料及其文件不会被删除。</div>
           {error && <div className="mt-4 p-3 text-base font-black" style={{ background: "var(--dms-canvas)" }}><AlertCircle size={14} className="inline mr-1" />{errorMessage}{error.requestId ? `(错误编号:${error.requestId})` : ""}</div>}
           <div className="flex justify-end gap-2 mt-6">
             <button onClick={onClose} disabled={submitting} className="px-4 py-2 text-base font-black border" style={{ borderColor: BLUE }}>取消</button>

+ 2 - 2
frontend/src/features/bindings/bindingApi.ts

@@ -51,7 +51,7 @@ export function createBindingApi(client: BindingHttpClient = httpClient) {
         { signal, headers: documentPasswordHeader(password) },
       );
       if (!isBindAttachmentsResult(result.data)) {
-        throw contractHttpError(result, "挂载附件");
+        throw contractHttpError(result, "挂载共享资料");
       }
       return { data: result.data, requestId: result.requestId };
     },
@@ -64,7 +64,7 @@ export function createBindingApi(client: BindingHttpClient = httpClient) {
       password?: string,
     ): Promise<BindingResult<UnbindAttachmentResult>> {
       assertId(mainPlanId, "主案ID");
-      assertId(attachmentId, "附件ID");
+      assertId(attachmentId, "共享资料ID");
       if (!Number.isInteger(mainPlanRowVersion) || mainPlanRowVersion < 0) {
         throw new TypeError("主案版本必须是非负整数");
       }

+ 1 - 1
frontend/src/features/bindings/bindingState.ts

@@ -23,7 +23,7 @@ export function retainAvailableSelections(
 export function describeBindResult(result: BindAttachmentsResult): string {
   if (result.createdCount === 0) {
     return result.existingCount > 0
-      ? `所选附件均已挂载(已有 ${result.existingCount} 项)`
+      ? `所选共享资料均已挂载(已有 ${result.existingCount} 项)`
       : "未新增挂载关系";
   }
   const existing = result.existingCount > 0

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

@@ -24,7 +24,7 @@ describe("ui dictionary DTO and labels", () => {
     })).toBe(false);
   });
 
-  it("未知code不回显英文且选项按sortNo和code稳定排序", () => {
+  it("未知code回退显示原英文码且选项按sortNo和code稳定排序", () => {
     const fixture = dictionaryFixture();
     fixture.dictionaries.securityLevels = [
       { code: "SECRET", label: "秘密", sortNo: 20 },
@@ -32,7 +32,7 @@ describe("ui dictionary DTO and labels", () => {
       { code: "INTERNAL", label: "内部", sortNo: 10 },
     ];
     expect(isUiDictionaries(fixture)).toBe(true);
-    expect(dictionaryLabel(fixture, "securityLevels", "UNKNOWN")).toBe("未定义标签");
+    expect(dictionaryLabel(fixture, "securityLevels", "UNKNOWN")).toBe("UNKNOWN");
     expect(dictionaryOptions(fixture, "securityLevels").map((item) => item.code))
       .toEqual(["INTERNAL", "PUBLIC", "SECRET"]);
   });

+ 2 - 1
frontend/src/features/dictionaries/dictionaryLabels.ts

@@ -20,8 +20,9 @@ export function dictionaryLabel(
   code: string | null | undefined,
 ): string {
   if (code === null || code === undefined || code === "") return "未设置";
+  // 字典无匹配时回退显示原英文码(如 DB 放开的审计动作),便于排查而非遮挡。
   return (
     dictionaries.dictionaries[name].find((item) => item.code === code)?.label ??
-    "未定义标签"
+    code
   );
 }

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.