2 Komitmen 6eed0a17f5 ... 1b5f7b3061

Pembuat SHA1 Pesan Tanggal
  weisijie 1b5f7b3061 1. 实现了登录图片验证码(契约 1.32),GET /auth/captcha 签发 132×44 PNG 验证码图片(4 位字符剔除易混淆 0O1I、120 秒有效、一次性消费),DMS_CAPTCHA_ENABLED 缺省启用,开启时登录请求体扩为五键,校验先于用户查询与锁定检查,错误统一返回 400 INVALID_CAPTCHA,前端支持点击刷新与登录失败自动刷新,并修复开关关闭时 Python 版登录仍被拦截的双端不一致; 1 bulan lalu
  weisijie e7346901bc 1. 实现了目录拖动排序功能(契约 1.27),机构树同级拖拽置换、用户列表与角色列表拖动重排,新增 reorder 接口按步长 10 重编号 sort_no(sys_user 加列,Alembic 0031 与 Flyway V16 双引擎幂等迁移,含达梦适配版),全部拖动排序界面移除排序号输入框(库中保留),审计动作新增 REORDER_ORGANIZATION/REORDER_USER/REORDER_ROLE; 1 bulan lalu
100 mengubah file dengan 4953 tambahan dan 246 penghapusan
  1. 59 5
      DMS_API_CONTRACT.md
  2. 13 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. 7 0
      backend-java/src/main/java/com/dms/api/v1/OrganizationsController.java
  6. 7 0
      backend-java/src/main/java/com/dms/api/v1/RolesController.java
  7. 7 0
      backend-java/src/main/java/com/dms/api/v1/UsersController.java
  8. 2 1
      backend-java/src/main/java/com/dms/common/DmsEnums.java
  9. 5 0
      backend-java/src/main/java/com/dms/common/Errors.java
  10. 21 0
      backend-java/src/main/java/com/dms/common/IpAddresses.java
  11. 14 0
      backend-java/src/main/java/com/dms/common/Payloads.java
  12. 49 0
      backend-java/src/main/java/com/dms/common/Reorders.java
  13. 50 0
      backend-java/src/main/java/com/dms/common/SecurityHeadersFilter.java
  14. 52 0
      backend-java/src/main/java/com/dms/config/DmsProperties.java
  15. 9 9
      backend-java/src/main/java/com/dms/mapper/CategoryMapper.java
  16. 13 10
      backend-java/src/main/java/com/dms/mapper/DocumentMapper.java
  17. 15 0
      backend-java/src/main/java/com/dms/mapper/OrganizationMapper.java
  18. 13 0
      backend-java/src/main/java/com/dms/mapper/RoleMapper.java
  19. 6 0
      backend-java/src/main/java/com/dms/mapper/RolePermissionMapper.java
  20. 29 8
      backend-java/src/main/java/com/dms/mapper/UserMapper.java
  21. 3 1
      backend-java/src/main/java/com/dms/model/RolePermissionEntity.java
  22. 5 0
      backend-java/src/main/java/com/dms/model/UserEntity.java
  23. 76 13
      backend-java/src/main/java/com/dms/service/AdminPermission.java
  24. 4 2
      backend-java/src/main/java/com/dms/service/AttachmentBindingService.java
  25. 4 0
      backend-java/src/main/java/com/dms/service/AuditService.java
  26. 19 2
      backend-java/src/main/java/com/dms/service/AuthService.java
  27. 35 1
      backend-java/src/main/java/com/dms/service/AuthorizationService.java
  28. 149 0
      backend-java/src/main/java/com/dms/service/CaptchaService.java
  29. 3 3
      backend-java/src/main/java/com/dms/service/CategoryService.java
  30. 7 0
      backend-java/src/main/java/com/dms/service/DocumentMutationService.java
  31. 55 9
      backend-java/src/main/java/com/dms/service/IpEntryService.java
  32. 44 17
      backend-java/src/main/java/com/dms/service/LoginIpService.java
  33. 86 4
      backend-java/src/main/java/com/dms/service/OrganizationService.java
  34. 2 0
      backend-java/src/main/java/com/dms/service/PortalAuthService.java
  35. 64 2
      backend-java/src/main/java/com/dms/service/RoleService.java
  36. 1 1
      backend-java/src/main/java/com/dms/service/SystemManagementService.java
  37. 155 7
      backend-java/src/main/java/com/dms/service/UserService.java
  38. 10 9
      backend-java/src/main/java/com/dms/service/UserSummaryService.java
  39. 4 3
      backend-java/src/main/java/com/dms/web/SpaController.java
  40. 10 0
      backend-java/src/main/resources/application.yml
  41. 4 4
      backend-java/src/main/resources/db/migration-dm/V12__ui_theme.sql
  42. 354 0
      backend-java/src/main/resources/db/migration-dm/V12_to_V17__merge.sql
  43. 6 6
      backend-java/src/main/resources/db/migration-dm/V13__backup_audit_actions.sql
  44. 354 0
      backend-java/src/main/resources/db/migration-dm/V13_to_V17__merge.sql
  45. 16 16
      backend-java/src/main/resources/db/migration-dm/V14__system_management.sql
  46. 298 0
      backend-java/src/main/resources/db/migration-dm/V14_to_V17__merge.sql
  47. 2 2
      backend-java/src/main/resources/db/migration-dm/V15__role_edit_permission.sql
  48. 280 0
      backend-java/src/main/resources/db/migration-dm/V15_to_V17__merge.sql
  49. 95 0
      backend-java/src/main/resources/db/migration-dm/V16__user_sort_no.sql
  50. 148 0
      backend-java/src/main/resources/db/migration-dm/V16_to_V17__merge.sql
  51. 27 0
      backend-java/src/main/resources/db/migration-dm/V17__user_managed_org.sql
  52. 24 0
      backend-java/src/main/resources/db/migration-dm/V18__role_upload_permission.sql
  53. 10 0
      backend-java/src/main/resources/db/migration-dm/V19__role_anchor_self.sql
  54. 28 0
      backend-java/src/main/resources/db/migration-dm/V20__drop_audit_action_check.sql
  55. 54 0
      backend-java/src/main/resources/db/migration/V16__user_sort_no.sql
  56. 30 0
      backend-java/src/main/resources/db/migration/V17__user_managed_org.sql
  57. 18 0
      backend-java/src/main/resources/db/migration/V18__role_upload_permission.sql
  58. 8 0
      backend-java/src/main/resources/db/migration/V19__role_anchor_self.sql
  59. 21 0
      backend-java/src/main/resources/db/migration/V20__drop_audit_action_check.sql
  60. 85 0
      backend-java/src/test/java/com/dms/common/ReordersTest.java
  61. 136 0
      backend-java/src/test/java/com/dms/service/AdminLoginSwitchTest.java
  62. 153 0
      backend-java/src/test/java/com/dms/service/AdminPermissionUserScopeTest.java
  63. 87 0
      backend-java/src/test/java/com/dms/service/CaptchaServiceTest.java
  64. 87 0
      backend-java/src/test/java/com/dms/service/IpEntryServiceNormalizeTest.java
  65. 67 0
      backend-java/src/test/java/com/dms/service/LoginIpServiceMatchesTest.java
  66. 94 0
      backend-java/src/test/java/com/dms/service/OrganizationTreeScopeTest.java
  67. 84 0
      backend-java/src/test/java/com/dms/service/UserSummaryPermUploadTest.java
  68. 1 1
      backend-java/src/test/java/com/dms/web/SpaControllerTest.java
  69. 13 0
      backend/.env.example
  70. 5 4
      backend/app.py
  71. 51 7
      backend/dms/api/v1/auth.py
  72. 16 1
      backend/dms/api/v1/blueprint.py
  73. 21 9
      backend/dms/api/v1/login_background.py
  74. 8 0
      backend/dms/api/v1/organizations.py
  75. 9 0
      backend/dms/api/v1/roles.py
  76. 8 0
      backend/dms/api/v1/users.py
  77. 4 0
      backend/dms/common/enums.py
  78. 7 0
      backend/dms/common/errors.py
  79. 29 0
      backend/dms/config.py
  80. 3 19
      backend/dms/models/audit_log.py
  81. 8 1
      backend/dms/models/role.py
  82. 9 0
      backend/dms/models/user.py
  83. 15 0
      backend/dms/resources/ui-dictionaries.zh-CN.json
  84. 68 10
      backend/dms/services/admin_permission.py
  85. 4 2
      backend/dms/services/attachment_binding_service.py
  86. 5 1
      backend/dms/services/audit_service.py
  87. 52 0
      backend/dms/services/authorization_service.py
  88. 100 0
      backend/dms/services/captcha_service.py
  89. 8 0
      backend/dms/services/document_mutation_service.py
  90. 5 5
      backend/dms/services/document_query_service.py
  91. 65 15
      backend/dms/services/ip_entry_service.py
  92. 103 3
      backend/dms/services/organization_service.py
  93. 2 0
      backend/dms/services/portal_auth_service.py
  94. 74 1
      backend/dms/services/role_service.py
  95. 1 1
      backend/dms/services/system_management_service.py
  96. 195 21
      backend/dms/services/user_service.py
  97. 166 0
      backend/migrations/versions/0031_user_sort_no.py
  98. 59 0
      backend/migrations/versions/0032_user_managed_org.py
  99. 54 0
      backend/migrations/versions/0033_role_upload_permission.py
  100. 37 0
      backend/migrations/versions/0034_role_anchor_self.py

File diff ditekan karena terlalu besar
+ 59 - 5
DMS_API_CONTRACT.md


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

@@ -22,6 +22,19 @@ DMS_LIBREOFFICE_MAX_CONCURRENCY=2
 # JWT 密钥(与 Python 版一致时两套后端令牌互通)
 DMS_JWT_SECRET=replace-with-a-random-secret
 
+# 页面空闲自动登出(分钟),缺省 5:用户在页面上无鼠标/滚动/键盘等操作持续
+# 达到该时长后前端自动退出登录;设为 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)) {

+ 7 - 0
backend-java/src/main/java/com/dms/api/v1/OrganizationsController.java

@@ -47,6 +47,13 @@ public class OrganizationsController {
                 .body(ApiResponse.ok(organizationService.create(body, request)));
     }
 
+    /** 对齐 reorder_organizations_route(契约1.27):机构树同级拖动重排。 */
+    @AuthRequired(requireAdmin = true)
+    @PutMapping("/reorder")
+    public ApiResponse<Object> reorder(@RequestBody Object body, HttpServletRequest request) {
+        return ApiResponse.ok(organizationService.reorder(body, request));
+    }
+
     @AuthRequired(requireAdmin = true)
     @PutMapping("/{id}")
     public ApiResponse<Object> update(@PathVariable String id, @RequestBody Object body,

+ 7 - 0
backend-java/src/main/java/com/dms/api/v1/RolesController.java

@@ -48,6 +48,13 @@ public class RolesController {
                 .body(ApiResponse.ok(roleService.create(body, request)));
     }
 
+    /** 对齐 reorder_roles_route(契约1.27):角色列表拖动重排。 */
+    @AuthRequired(requireAdmin = true, manageRoleEdit = true)
+    @PutMapping("/reorder")
+    public ApiResponse<Object> reorder(@RequestBody Object body, HttpServletRequest request) {
+        return ApiResponse.ok(roleService.reorder(body, request));
+    }
+
     @AuthRequired(requireAdmin = true, manageRoleEdit = true)
     @PutMapping("/{id}")
     public ApiResponse<Object> update(@PathVariable String id, @RequestBody Object body,

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

@@ -82,6 +82,13 @@ public class UsersController {
                 .body(ApiResponse.ok(userService.create(body, request)));
     }
 
+    /** 对齐 reorder_users_route(契约1.27):用户列表拖动重排。 */
+    @AuthRequired(requireAdmin = true)
+    @PutMapping("/reorder")
+    public ApiResponse<Object> reorder(@RequestBody Object body, HttpServletRequest request) {
+        return ApiResponse.ok(userService.reorder(body, request));
+    }
+
     @AuthRequired(requireAdmin = true)
     @PutMapping("/{id}")
     public ApiResponse<Object> update(@PathVariable String id, @RequestBody Object body,

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

@@ -58,7 +58,8 @@ public final class DmsEnums {
             "DOCUMENT_PASSWORD_REMOVE", "CREATE_IP_ENTRY", "EDIT_IP_ENTRY",
             "DELETE_IP_ENTRY", "ASSIGN_ORG_IP",
             "UPLOAD_PERMISSION_VOUCHER", "DELETE_PERMISSION_VOUCHER", "BACKUP_CREATE",
-            "PREVIEW_CACHE_CLEAR", "FORCE_OFFLINE", "BACKUP_TEST_TARGET");
+            "PREVIEW_CACHE_CLEAR", "FORCE_OFFLINE", "BACKUP_TEST_TARGET",
+            "REORDER_ORGANIZATION", "REORDER_USER", "REORDER_ROLE");
 
     public static final Set<String> AUDIT_TARGETS = Set.of(
             "AUTH", "CATEGORY", "DOCUMENT", "ATTACHMENT", "PERMISSION",

+ 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, "用户名或密码错误");

+ 21 - 0
backend-java/src/main/java/com/dms/common/IpAddresses.java

@@ -0,0 +1,21 @@
+package com.dms.common;
+
+/** IP 字节序列工具(契约1.28,IP条目范围解析与登录IP匹配共用)。 */
+public final class IpAddresses {
+
+    private IpAddresses() {
+    }
+
+    /** 无符号逐字节比较;长度兜底比较保证非等长输入也有确定结果。 */
+    public static int unsignedCompare(byte[] left, byte[] right) {
+        int limit = Math.min(left.length, right.length);
+        for (int i = 0; i < limit; i++) {
+            int a = left[i] & 0xFF;
+            int b = right[i] & 0xFF;
+            if (a != b) {
+                return Integer.compare(a, b);
+            }
+        }
+        return Integer.compare(left.length, right.length);
+    }
+}

+ 14 - 0
backend-java/src/main/java/com/dms/common/Payloads.java

@@ -1,6 +1,7 @@
 package com.dms.common;
 
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -35,6 +36,19 @@ public final class Payloads {
         return map;
     }
 
+    /** 对齐 _exact_payload 的 optional 语义:必填全在场、允许集内无多余键。 */
+    public static Map<String, Object> exactPayload(Object body, Set<String> expected,
+                                                   Set<String> optional) {
+        Map<String, Object> map = jsonBody(body);
+        Set<String> allowed = new HashSet<>(expected);
+        allowed.addAll(optional);
+        if (!map.keySet().containsAll(expected) || !allowed.containsAll(map.keySet())) {
+            throw Errors.invalidArgument(
+                    "请求体字段必须且只能包含:" + String.join("、", new TreeSet<>(allowed)));
+        }
+        return map;
+    }
+
     /** 对齐 _required_text:非空、strip、长度上限。 */
     public static String requiredText(Object value, String field, int maxLength) {
         if (!(value instanceof String text) || text.strip().isEmpty()) {

+ 49 - 0
backend-java/src/main/java/com/dms/common/Reorders.java

@@ -0,0 +1,49 @@
+package com.dms.common;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** 拖动重排(契约1.27,机构/用户/角色 reorder 共用):入参解析与槽位置换。 */
+public final class Reorders {
+
+    private Reorders() {
+    }
+
+    /** orderedIds 入参解析(对齐 Python):正整数字符串数组、无重复、至少2个。 */
+    public static List<Long> parseOrderedIds(Object rawIds) {
+        if (!(rawIds instanceof List<?> rawList)) {
+            throw Errors.invalidArgument("orderedIds必须是正整数形式的字符串ID数组");
+        }
+        List<Long> orderedIds = new ArrayList<>();
+        Set<Long> seen = new HashSet<>();
+        for (int index = 0; index < rawList.size(); index++) {
+            Long id = Payloads.stringId(rawList.get(index), "orderedIds[" + index + "]", false);
+            if (!seen.add(id)) {
+                throw Errors.invalidArgument("orderedIds存在重复ID");
+            }
+            orderedIds.add(id);
+        }
+        if (orderedIds.size() < 2) {
+            throw Errors.invalidArgument("orderedIds必须是至少包含2个ID的数组");
+        }
+        return orderedIds;
+    }
+
+    /**
+     * 槽位置换:被提交ID按原槽位顺序排好,依次填入目标顺序;
+     * 未提交ID保持原槽位、相对次序不变(步长10全量重编号由调用方处理)。
+     */
+    public static List<Long> slotSwap(List<Long> allIds, List<Long> orderedIds) {
+        List<Long> slotIds = new ArrayList<>(orderedIds);
+        slotIds.sort(Comparator.comparingInt(allIds::indexOf));
+        List<Long> newOrder = new ArrayList<>(allIds);
+        for (int position = 0; position < slotIds.size(); position++) {
+            newOrder.set(allIds.indexOf(slotIds.get(position)), orderedIds.get(position));
+        }
+        return newOrder;
+    }
+}
+

+ 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);
+    }
+}

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

@@ -26,6 +26,8 @@ public class DmsProperties {
     private final Path storageRoot;
     private final String jwtSecret;
     private final long jwtExpiresSeconds;
+    /** 页面空闲自动登出(契约1.29):分钟数,0 表示关闭。 */
+    private final int idleLogoutMinutes;
     /** 契约1.21:信封加密 KEK(base64 编码 32 字节),未配置为 null(明文落盘)。 */
     private final byte[] storageKek;
     private final long maxUploadSize;
@@ -44,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;
@@ -63,6 +69,9 @@ public class DmsProperties {
         this.jwtSecret = environment.getProperty("DMS_JWT_SECRET", "");
         // 对齐 config.py:DMS_JWT_EXPIRES_SECONDS 缺省 3600(1 小时)。
         this.jwtExpiresSeconds = positiveLong(environment, "DMS_JWT_EXPIRES_SECONDS", 3600L);
+        // 对齐 config.py(契约1.29):DMS_IDLE_LOGOUT_MINUTES 缺省 5,0 表示关闭。
+        this.idleLogoutMinutes =
+                (int) nonNegativeLong(environment, "DMS_IDLE_LOGOUT_MINUTES", 5L);
         // 对齐 config.py load_kek:DMS_STORAGE_KEK 为 base64 编码的 32 字节密钥,
         // 留空=不明文加密;非法值启动即失败。
         this.storageKek = decodeStorageKek(
@@ -132,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(
@@ -214,6 +233,24 @@ public class DmsProperties {
         return value;
     }
 
+    /** 非负整数(0 有语义:表示关闭该能力,如空闲登出)。 */
+    private static long nonNegativeLong(Environment environment, String name, long defaultValue) {
+        String raw = environment.getProperty(name, "").trim();
+        if (raw.isEmpty()) {
+            return defaultValue;
+        }
+        long value;
+        try {
+            value = Long.parseLong(raw);
+        } catch (NumberFormatException ex) {
+            throw new IllegalStateException(name + "必须是非负整数");
+        }
+        if (value < 0) {
+            throw new IllegalStateException(name + "必须是非负整数");
+        }
+        return value;
+    }
+
     /** 对齐 Python 侧 load_kek:base64 解码并校验 32 字节长度,空值返回 null。 */
     private static byte[] decodeStorageKek(String raw) {
         if (raw.isEmpty()) {
@@ -251,6 +288,11 @@ public class DmsProperties {
         return jwtExpiresSeconds;
     }
 
+    /** 页面空闲自动登出分钟数(契约1.29);0 表示关闭。 */
+    public int idleLogoutMinutes() {
+        return idleLogoutMinutes;
+    }
+
     /** 信封加密 KEK;未配置返回 null(明文落盘)。 */
     public byte[] storageKek() {
         return storageKek;
@@ -306,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;

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

@@ -94,29 +94,29 @@ public interface CategoryMapper {
             + "GROUP BY category_id")
     List<Map<String, Object>> countActiveMainGroupByCategory();
 
-    /** 候选分类(含后代)下有效主案是否存在子方案。 */
-    @Select("<script>SELECT EXISTS(SELECT 1 FROM doc_document WHERE document_type = 'SUB_PLAN' "
+    /** 候选分类(含后代)下有效主案是否存在子方案。COUNT 判存在:达梦不支持 SELECT EXISTS 标量(MySQL 专有)。 */
+    @Select("<script>SELECT COUNT(*) FROM doc_document WHERE document_type = 'SUB_PLAN' "
             + "AND is_deleted = 0 AND parent_document_id IN ("
             + "SELECT id FROM doc_document WHERE document_type = 'MAIN' AND is_deleted = 0 "
             + "AND category_id IN <foreach item='item' collection='categoryIds' open='(' "
             + "separator=',' close=')'>#{item}</foreach>))</script>")
-    boolean existsSubPlanUnderCategories(@Param("categoryIds") List<Long> categoryIds);
+    int existsSubPlanUnderCategories(@Param("categoryIds") List<Long> categoryIds);
 
-    /** 候选分类下有效主案是否存在附件挂载。 */
-    @Select("<script>SELECT EXISTS(SELECT 1 FROM doc_attachment_binding WHERE is_deleted = 0 "
+    /** 候选分类下有效主案是否存在附件挂载。COUNT 判存在:达梦不支持 SELECT EXISTS 标量(MySQL 专有)。 */
+    @Select("<script>SELECT COUNT(*) FROM doc_attachment_binding WHERE is_deleted = 0 "
             + "AND main_document_id IN ("
             + "SELECT id FROM doc_document WHERE document_type = 'MAIN' AND is_deleted = 0 "
             + "AND category_id IN <foreach item='item' collection='categoryIds' open='(' "
             + "separator=',' close=')'>#{item}</foreach>))</script>")
-    boolean existsBindingUnderCategories(@Param("categoryIds") List<Long> categoryIds);
+    int existsBindingUnderCategories(@Param("categoryIds") List<Long> categoryIds);
 
-    /** 候选分类下有效主案是否存在子案跨主案挂载(doc_sub_plan_binding,阶段10)。 */
-    @Select("<script>SELECT EXISTS(SELECT 1 FROM doc_sub_plan_binding WHERE is_deleted = 0 "
+    /** 候选分类下有效主案是否存在子案跨主案挂载(doc_sub_plan_binding,阶段10)。COUNT 判存在:达梦不支持 SELECT EXISTS 标量。 */
+    @Select("<script>SELECT COUNT(*) FROM doc_sub_plan_binding WHERE is_deleted = 0 "
             + "AND main_document_id IN ("
             + "SELECT id FROM doc_document WHERE document_type = 'MAIN' AND is_deleted = 0 "
             + "AND category_id IN <foreach item='item' collection='categoryIds' open='(' "
             + "separator=',' close=')'>#{item}</foreach>))</script>")
-    boolean existsSubPlanMountUnderCategories(@Param("categoryIds") List<Long> categoryIds);
+    int existsSubPlanMountUnderCategories(@Param("categoryIds") List<Long> categoryIds);
 
     // ------------------------------------------------------------ 文档域(阶段5.5)
 

+ 13 - 10
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>"
@@ -392,8 +393,10 @@ public interface DocumentMapper {
      * scope 为 OWN/OTHER;集合为空时调用方传 [-1] 占位(对齐 Python
      * in_(空集)恒假、not_in(空集)恒真语义);canView=1 时矩阵行自身即查看来源。
      * 借阅语义(0026):ACL 仅剩面向用户的限时查看,now 由应用层按 UTC 传入。
+     * 达梦(Oracle 兼容)不允许 EXISTS 作 SELECT 投影标量(MySQL 专有写法),
+     * 故用 COUNT(*) 判存在、恒真用 1=1,MySQL 与达梦双兼容。
      */
-    @Select("<script>SELECT EXISTS(SELECT 1 FROM doc_document d "
+    @Select("<script>SELECT COUNT(*) FROM doc_document d "
             + "WHERE d.is_deleted = 0 AND d.document_status = 'PUBLISHED' "
             + "AND d.document_type = 'MAIN' AND d.security_level = #{securityLevel} "
             + "<choose>"
@@ -405,13 +408,13 @@ public interface DocumentMapper {
             + "AND (EXISTS(SELECT 1 FROM doc_permission p WHERE p.document_id = d.id "
             + "AND p.is_deleted = 0 AND p.can_view = 1 AND p.subject_id = #{userId} "
             + "AND p.expires_at &gt; #{now})"
-            + "<if test='canView'>OR TRUE</if>))</script>")
-    boolean existsManageHit(@Param("securityLevel") String securityLevel,
-                            @Param("scope") String scope,
-                            @Param("ownIds") List<Long> ownIds,
-                            @Param("userId") long userId,
-                            @Param("canView") boolean canView,
-                            @Param("now") LocalDateTime now);
+            + "<if test='canView'>OR 1=1</if>)</script>")
+    int existsManageHit(@Param("securityLevel") String securityLevel,
+                        @Param("scope") String scope,
+                        @Param("ownIds") List<Long> ownIds,
+                        @Param("userId") long userId,
+                        @Param("canView") boolean canView,
+                        @Param("now") LocalDateTime now);
 
     /** _current_version:挂载唯一键冲突兜底的当前版本读取。 */
     @Select("SELECT row_version FROM doc_document WHERE id = #{id} AND is_deleted = 0")

+ 15 - 0
backend-java/src/main/java/com/dms/mapper/OrganizationMapper.java

@@ -106,4 +106,19 @@ public interface OrganizationMapper {
             + "<foreach item='item' collection='ids' open='(' separator=',' close=')'>#{item}"
             + "</foreach></script>")
     List<Long> listValidIds(@Param("ids") Collection<Long> ids);
+
+    // ------------------------------------------------------------ 拖动重排(契约1.27)
+
+    /** 重排:同级范围锁定(parentId 为 NULL 表示顶层,对齐 reorder_organizations)。 */
+    @Select("<script>SELECT * FROM sys_organization WHERE is_deleted = 0 "
+            + "<choose><when test='parentId != null'>AND parent_id = #{parentId}</when>"
+            + "<otherwise>AND parent_id IS NULL</otherwise></choose> "
+            + "ORDER BY sort_no ASC, id ASC FOR UPDATE</script>")
+    List<OrganizationEntity> listSiblingsForUpdate(@Param("parentId") Long parentId);
+
+    /** 重排:变更行按目标排序号落库(row_version+1,对齐 Python 属性赋值语义)。 */
+    @Update("UPDATE sys_organization SET sort_no = #{sortNo}, updated_by = #{updatedBy}, "
+            + "updated_at = #{updatedAt}, row_version = row_version + 1 WHERE id = #{id}")
+    int updateSort(@Param("id") long id, @Param("sortNo") int sortNo,
+                   @Param("updatedBy") Long updatedBy, @Param("updatedAt") LocalDateTime updatedAt);
 }

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

@@ -90,4 +90,17 @@ public interface RoleMapper {
             + "<if test='status != null'> AND status = #{status}</if>"
             + "</script>")
     long countByFilters(@Param("keyword") String keyword, @Param("status") String status);
+
+    // ------------------------------------------------------------ 拖动重排(契约1.27)
+
+    /** 重排:全体未删角色按 sort_no,id 锁定(对齐 reorder_roles 的 with_for_update)。 */
+    @Select("SELECT * FROM sys_role WHERE is_deleted = 0 "
+            + "ORDER BY sort_no ASC, id ASC FOR UPDATE")
+    List<RoleEntity> listAllForUpdate();
+
+    /** 重排:变更行按目标排序号落库(row_version+1,对齐 Python 属性赋值语义)。 */
+    @Update("UPDATE sys_role SET sort_no = #{sortNo}, updated_by = #{updatedBy}, "
+            + "updated_at = #{updatedAt}, row_version = row_version + 1 WHERE id = #{id}")
+    int updateSort(@Param("id") long id, @Param("sortNo") int sortNo,
+                   @Param("updatedBy") Long updatedBy, @Param("updatedAt") LocalDateTime updatedAt);
 }

+ 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}, "

+ 29 - 8
backend-java/src/main/java/com/dms/mapper/UserMapper.java

@@ -46,20 +46,24 @@ public interface UserMapper {
     /** 用户维护:完整落库(auth/row_version 走 DB 默认值 0)。 */
     @Insert("INSERT INTO sys_user (username, password_hash, real_name, organization_id, "
             + "organization_name, role_code, role_id, security_level, status, "
-            + "created_by, updated_by) VALUES (#{username}, #{passwordHash}, #{realName}, "
-            + "#{organizationId}, #{organizationName}, #{roleCode}, #{roleId}, #{securityLevel}, "
-            + "#{status}, #{createdBy}, #{updatedBy})")
+            + "managed_org_id, created_by, updated_by) VALUES (#{username}, #{passwordHash}, "
+            + "#{realName}, #{organizationId}, #{organizationName}, #{roleCode}, #{roleId}, "
+            + "#{securityLevel}, #{status}, #{managedOrgId}, #{createdBy}, #{updatedBy})")
     @Options(useGeneratedKeys = true, keyProperty = "id")
     int insertFull(UserEntity user);
 
-    /** 用户维护:全字段更新,authBump=0/1 控制 auth_version 是否递增。 */
+    /** 用户维护:全字段更新,authBump=0/1 控制 auth_version 是否递增;
+     * managedOrgProvided=0/1 控制管理范围是否覆盖(契约1.30 可选字段,未提交保持原值)。 */
     @Update("UPDATE sys_user SET real_name = #{user.realName}, organization_id = #{user.organizationId}, "
             + "organization_name = #{user.organizationName}, role_code = #{user.roleCode}, "
             + "role_id = #{user.roleId}, security_level = #{user.securityLevel}, status = #{user.status}, "
+            + "managed_org_id = CASE WHEN #{managedOrgProvided} = 1 "
+            + "THEN #{user.managedOrgId} ELSE managed_org_id END, "
             + "updated_by = #{user.updatedBy}, updated_at = #{user.updatedAt}, "
             + "auth_version = auth_version + #{authBump}, "
             + "row_version = row_version + 1 WHERE id = #{user.id}")
-    int updateFull(@Param("user") UserEntity user, @Param("authBump") int authBump);
+    int updateFull(@Param("user") UserEntity user, @Param("authBump") int authBump,
+                   @Param("managedOrgProvided") int managedOrgProvided);
 
     /** 软删 + 停用 + 使旧 Token 失效。 */
     @Update("UPDATE sys_user SET is_deleted = 1, deleted_at = #{now}, updated_by = #{actorId}, "
@@ -87,20 +91,23 @@ 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>"
             + "<if test='keyword != null'> AND (username LIKE CONCAT('%', #{keyword}, '%') "
             + "OR real_name LIKE CONCAT('%', #{keyword}, '%'))</if>"
             + "<if test='status != null'> AND status = #{status}</if>"
-            + " ORDER BY real_name ASC, id ASC LIMIT #{limit} OFFSET #{offset}</script>")
-    List<UserEntity> pageByFilters(@Param("organizationIds") List<Long> organizationIds,
+            + " ORDER BY sort_no ASC, id ASC LIMIT #{limit} OFFSET #{offset}</script>")
+    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>"
@@ -108,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);
 
@@ -201,4 +209,17 @@ public interface UserMapper {
     /** 实时解析生效角色:取未删用户的显式角色(对齐 _effective_role)。 */
     @Select("SELECT role_id FROM sys_user WHERE id = #{id} AND is_deleted = 0 LIMIT 1")
     Long findRoleIdActive(@Param("id") long id);
+
+    // ------------------------------------------------------------ 拖动重排(契约1.27)
+
+    /** 重排:全体未删用户按 sort_no,id 锁定(对齐 reorder_users 的 with_for_update)。 */
+    @Select("SELECT * FROM sys_user WHERE is_deleted = 0 "
+            + "ORDER BY sort_no ASC, id ASC FOR UPDATE")
+    List<UserEntity> listAllForUpdate();
+
+    /** 重排:变更行按目标排序号落库(row_version+1,对齐 Python 属性赋值语义)。 */
+    @Update("UPDATE sys_user SET sort_no = #{sortNo}, updated_by = #{updatedBy}, "
+            + "updated_at = #{updatedAt}, row_version = row_version + 1 WHERE id = #{id}")
+    int updateSort(@Param("id") long id, @Param("sortNo") int sortNo,
+                   @Param("updatedBy") Long updatedBy, @Param("updatedAt") LocalDateTime updatedAt);
 }

+ 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;

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

@@ -32,4 +32,9 @@ public class UserEntity {
     private String externalId;
     /** 界面主题偏好(账号级漫游,契约1.20):blue/red/green,存量与缺省为 blue。 */
     private String uiTheme;
+    /** 拖动排序号(契约1.27):拖拽重排按步长10全量重编号,排序号输入已废止。 */
+    private Integer sortNo;
+    /** 用户级管理范围(契约1.30):null=全局、0=跟随本人所在机构(SELF 哨兵)、
+     * 机构ID=固定锚点;仅角色锚点为 SELF 时生效(实际范围以角色设置为准)。 */
+    private Long managedOrgId;
 }

+ 76 - 13
backend-java/src/main/java/com/dms/service/AdminPermission.java

@@ -35,8 +35,9 @@ public class AdminPermission {
     public static final String SUPER_ADMIN_USERNAME = "admin";
 
     /**
-     * managed_org_id 哨兵值 0 = "SELF"(锚点跟随用户所在部门):
-     * 0 不是合法机构ID,机构/用户编辑范围按各用户自身 organization_id 收敛。
+     * managed_org_id 哨兵值 0 = "SELF"(跟随设置):
+     * 0 不是合法机构ID。角色列语义=「跟随用户的管理范围设置」(契约1.30);
+     * 用户列语义=「跟随本人所在机构」,范围按各用户自身 organization_id 收敛。
      */
     public static final long MANAGED_ORG_SELF_ANCHOR = 0L;
     public static final String MANAGED_ORG_SELF = "SELF";
@@ -58,12 +59,13 @@ public class AdminPermission {
     }
 
     /**
-     * 生效角色:显式角色(活跃且启用)优先,否则回退默认角色(活跃且启用)。
-     * 对齐 effective_manage_role(findActiveEnabledById / findDefaultActive)。
+     * 按显式角色ID解析生效角色快照(活跃且启用优先,回退默认角色)。
+     * 与 effectiveManageRole 同语义,用于尚未落库的目标用户
+     * (创建/编辑用户时的防提权校验)。
      */
-    public RoleEntity effectiveManageRole(UserEntity user) {
-        if (user.getRoleId() != null) {
-            RoleEntity role = roleMapper.findActiveEnabledById(user.getRoleId());
+    public RoleEntity effectiveRoleSnapshot(Long roleId) {
+        if (roleId != null) {
+            RoleEntity role = roleMapper.findActiveEnabledById(roleId);
             if (role != null) {
                 return role;
             }
@@ -71,6 +73,14 @@ public class AdminPermission {
         return roleMapper.findDefaultActive();
     }
 
+    /**
+     * 生效角色:显式角色(活跃且启用)优先,否则回退默认角色(活跃且启用)。
+     * 对齐 effective_manage_role(findActiveEnabledById / findDefaultActive)。
+     */
+    public RoleEntity effectiveManageRole(UserEntity user) {
+        return effectiveRoleSnapshot(user.getRoleId());
+    }
+
     public boolean canEditCategories(UserEntity user) {
         if (isSuperAdmin(user)) {
             return true;
@@ -184,9 +194,11 @@ public class AdminPermission {
 
     /**
      * 操作者可管理的机构ID集合;null 表示不受范围约束(全局)。
-     * super_admin 与生效角色锚点为 NULL 的管理员不受约束;锚点为 SELF
-     * 哨兵(0)时按用户自身所在部门收敛(无部门则无可管理范围);其余
-     * 按锚点部门子树(仅沿启用边)收敛。
+     * 实际范围以生效角色锚点为准(契约1.30):super_admin 与锚点 NULL 的
+     * 管理员不受约束;角色锚点为 SELF 哨兵(0)时回落用户自身管理范围
+     * (sys_user.managed_org_id)——NULL=全局、SELF(0)=用户所在机构
+     * (无部门则无可管理范围)、机构ID=固定锚点;其余按锚点部门子树
+     * (仅沿启用边)收敛。
      */
     public Set<Long> managedScopeIds(UserEntity user) {
         if (isSuperAdmin(user)) {
@@ -198,10 +210,18 @@ public class AdminPermission {
             return null;
         }
         if (anchor == MANAGED_ORG_SELF_ANCHOR) {
-            if (user.getOrganizationId() == null) {
-                return new HashSet<>();
+            // 角色「跟随用户设置」:取用户自身管理范围(契约1.30)
+            anchor = user.getManagedOrgId();
+            if (anchor == null) {
+                return null;
+            }
+            if (anchor == MANAGED_ORG_SELF_ANCHOR) {
+                // 用户范围「跟随所在机构」:按用户所在部门收敛
+                if (user.getOrganizationId() == null) {
+                    return new HashSet<>();
+                }
+                anchor = user.getOrganizationId();
             }
-            anchor = user.getOrganizationId();
         }
         List<OrganizationEntity> organizations = organizationMapper.listAllNotDeleted();
         Map<Long, List<Long>> children = new HashMap<>();
@@ -240,6 +260,49 @@ public class AdminPermission {
         }
     }
 
+    /**
+     * 防提权(契约1.30修订):目标用户最终生效管理范围不得超出操作者。
+     *
+     * <p>仅当目标为 ADMIN 且生效角色具备受锚点约束的管理位
+     * (机构/用户编辑位)时校验——无管理位的角色(含默认角色)不构成提权。
+     * 目标生效锚点按「角色锚点优先,角色 SELF 回落用户级三态」解析:
+     * 解析为全局(角色锚点或用户级为 NULL)或范围外机构即拒绝;
+     * 用户级 SELF 收敛到目标所在机构子树(其机构已由既有校验限制在
+     * 操作者范围内,树传递性恒安全)。</p>
+     */
+    public void ensureUserScopeInManagedScope(UserEntity actor, String roleCode,
+                                              RoleEntity effectiveRole,
+                                              Long managedOrgId, Long organizationId) {
+        Set<Long> scope = managedScopeIds(actor);
+        if (scope == null || !"ADMIN".equals(roleCode)) {
+            return;
+        }
+        if (effectiveRole == null || !(Boolean.TRUE.equals(effectiveRole.getManageOrgEdit())
+                || Boolean.TRUE.equals(effectiveRole.getManageUserEdit()))) {
+            return;
+        }
+        Long anchor = effectiveRole.getManagedOrgId();
+        if (anchor == null) {
+            throw Errors.forbidden("目标超出管理范围");
+        }
+        if (anchor == MANAGED_ORG_SELF_ANCHOR) {
+            if (managedOrgId == null) {
+                throw Errors.forbidden("目标超出管理范围");
+            }
+            if (managedOrgId == MANAGED_ORG_SELF_ANCHOR) {
+                if (organizationId == null) {
+                    return;
+                }
+                anchor = organizationId;
+            } else {
+                anchor = managedOrgId;
+            }
+        }
+        if (!scope.contains(anchor)) {
+            throw Errors.forbidden("目标超出管理范围");
+        }
+    }
+
     /** 锚点机构存在性校验用:按 id 查未删除机构。 */
     public OrganizationEntity findNotDeletedOrganization(long id) {
         return organizationMapper.findByIdNotDeleted(id);

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

@@ -188,7 +188,8 @@ public class AttachmentBindingService {
         AuthContext context = AuthContext.get();
         return deadlockRetry.execute(() -> {
             DocumentEntity main = lockedMain(mainDocumentId, context);
-            authorizationService.requirePlanAction(main, context, "BIND_ATTACHMENT");
+            // 挂载重排=调整主案挂载区顺序:按主案 EDIT 判定(移动=编辑,契约1.18修订)
+            authorizationService.requirePlanAction(main, context, "EDIT");
             checkVersion(main, expectedVersion);
             List<AttachmentBindingEntity> bindings =
                     attachmentBindingMapper.listActiveByMainForUpdate(main.getId());
@@ -263,7 +264,8 @@ public class AttachmentBindingService {
         AuthContext context = AuthContext.get();
         return deadlockRetry.execute(() -> {
             DocumentEntity main = lockedMain(mainDocumentId, context);
-            authorizationService.requirePlanAction(main, context, "BIND_ATTACHMENT");
+            // 挂载移动与重排同权限口径:按主案 EDIT 判定(移动=编辑,契约1.18修订)
+            authorizationService.requirePlanAction(main, context, "EDIT");
             checkVersion(main, expectedVersion);
             List<AttachmentBindingEntity> bindings =
                     attachmentBindingMapper.listActiveByMainForUpdate(main.getId());

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

@@ -74,6 +74,10 @@ public class AuditService {
         public static final String PREVIEW_CACHE_CLEAR = "PREVIEW_CACHE_CLEAR";
         public static final String FORCE_OFFLINE = "FORCE_OFFLINE";
         public static final String BACKUP_TEST_TARGET = "BACKUP_TEST_TARGET";
+        // 机构/用户/角色拖动排序(契约1.27)
+        public static final String REORDER_ORGANIZATION = "REORDER_ORGANIZATION";
+        public static final String REORDER_USER = "REORDER_USER";
+        public static final String REORDER_ROLE = "REORDER_ROLE";
     }
 
     public static final String RESULT_SUCCESS = "SUCCESS";

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

@@ -2,11 +2,13 @@ package com.dms.service;
 
 import com.dms.common.DmsException;
 import com.dms.common.Errors;
+import com.dms.config.DmsProperties;
 import com.dms.mapper.UserMapper;
 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;
@@ -26,23 +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) {
+                       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");
@@ -88,6 +103,8 @@ public class AuthService {
         data.put("accessToken", issued.token());
         data.put("tokenType", "Bearer");
         data.put("expiresIn", issued.expiresInSeconds());
+        // 页面空闲自动登出(契约1.29):分钟数,0 表示关闭
+        data.put("idleLogoutMinutes", dmsProperties.idleLogoutMinutes());
         data.put("user", userSummaryService.userSummary(user,
                 userSummaryService.allowedModulesForUser(user)));
         return data;

+ 35 - 1
backend-java/src/main/java/com/dms/service/AuthorizationService.java

@@ -514,7 +514,7 @@ public class AuthorizationService {
         for (RolePermissionEntity row : candidates) {
             if (documentMapper.existsManageHit(row.getSecurityLevel(), row.getDeptScope(),
                     placeholder(ownIds), context.userId(),
-                    Boolean.TRUE.equals(row.getCanView()), now)) {
+                    Boolean.TRUE.equals(row.getCanView()), now) > 0) {
                 return true;
             }
         }
@@ -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));
+    }
+}

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

@@ -400,9 +400,9 @@ public class CategoryService {
             Set<Long> checkCategoryIds = new HashSet<>(descendantIds);
             checkCategoryIds.add(category.getId());
             List<Long> checkIds = new ArrayList<>(checkCategoryIds);
-            boolean hasSubPlan = categoryMapper.existsSubPlanUnderCategories(checkIds);
-            boolean hasAttachment = categoryMapper.existsBindingUnderCategories(checkIds);
-            boolean hasSubPlanMount = categoryMapper.existsSubPlanMountUnderCategories(checkIds);
+            boolean hasSubPlan = categoryMapper.existsSubPlanUnderCategories(checkIds) > 0;
+            boolean hasAttachment = categoryMapper.existsBindingUnderCategories(checkIds) > 0;
+            boolean hasSubPlanMount = categoryMapper.existsSubPlanMountUnderCategories(checkIds) > 0;
             if (hasSubPlan || hasAttachment || hasSubPlanMount) {
                 Map<String, Object> details = new LinkedHashMap<>();
                 details.put("categoryId", String.valueOf(category.getId()));

+ 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,

+ 55 - 9
backend-java/src/main/java/com/dms/service/IpEntryService.java

@@ -2,6 +2,7 @@ package com.dms.service;
 
 import com.dms.common.DmsException;
 import com.dms.common.Errors;
+import com.dms.common.IpAddresses;
 import com.dms.common.Page;
 import com.dms.common.Payloads;
 import com.dms.mapper.IpEntryMapper;
@@ -262,7 +263,8 @@ public class IpEntryService {
     // ------------------------------------------------------------ IP 规范化
 
     /**
-     * 对齐 normalize_ip_value:精确 IP 或 CIDR(主机位必须为 0)。
+     * 对齐 normalize_ip_value:精确 IP、CIDR(主机位必须为 0)或「起IP-止IP」范围
+     * (两端同版本且起≤止)。
      *
      * <p>IPv4 手动解析以完全对齐 Python ipaddress(拒绝前导零且不做 DNS 查询);
      * IPv6 走 InetAddress 字面量解析(Java 输出全展开形式,与 Python 压缩形式存在
@@ -280,10 +282,16 @@ public class IpEntryService {
             throw Errors.invalidArgument("ipValue长度不能超过" + IP_VALUE_MAX_LENGTH);
         }
         int slash = stripped.indexOf('/');
-        if (slash >= 0) {
-            return normalizeCidr(stripped, slash);
+        String normalized = slash >= 0
+                ? normalizeCidr(stripped, slash)
+                : stripped.indexOf('-') >= 0
+                        ? normalizeRange(stripped, stripped.indexOf('-'))
+                        : normalizeAddress(stripped);
+        // IPv6 等形式的规范化展开可能长于输入文本,统一复查落库长度
+        if (normalized.length() > IP_VALUE_MAX_LENGTH) {
+            throw Errors.invalidArgument("ipValue长度不能超过" + IP_VALUE_MAX_LENGTH);
         }
-        return normalizeAddress(stripped);
+        return normalized;
     }
 
     private static String normalizeCidr(String text, int slashIndex) {
@@ -295,7 +303,7 @@ public class IpEntryService {
             if (!hostBitsZero(bytes, prefix)) {
                 throw hostBitsError(text);
             }
-            return ipv6Text(bytes) + "/" + prefix;
+            return inetText(bytes) + "/" + prefix;
         }
         int[] octets = parseIpv4(addressPart, text);
         int prefix = parsePrefix(prefixPart, 32, text);
@@ -307,9 +315,38 @@ public class IpEntryService {
         return octets[0] + "." + octets[1] + "." + octets[2] + "." + octets[3] + "/" + prefix;
     }
 
+    /** 对齐范围分支:「起IP-止IP」两端同版本且起≤止,规范化为 起文本-止文本。 */
+    private static String normalizeRange(String text, int dashIndex) {
+        String startPart = text.substring(0, dashIndex);
+        String endPart = text.substring(dashIndex + 1);
+        if (startPart.isEmpty() || endPart.isEmpty() || endPart.indexOf('-') >= 0) {
+            throw invalidIp(text);
+        }
+        boolean startV6 = startPart.indexOf(':') >= 0;
+        if (startV6 != (endPart.indexOf(':') >= 0)) {
+            throw rangeVersionError(text);
+        }
+        byte[] start = startV6
+                ? parseIpv6(startPart, text)
+                : ipv4ToBytes(parseIpv4(startPart, text));
+        byte[] end = startV6
+                ? parseIpv6(endPart, text)
+                : ipv4ToBytes(parseIpv4(endPart, text));
+        if (IpAddresses.unsignedCompare(start, end) > 0) {
+            throw rangeOrderError(text);
+        }
+        return inetText(start) + "-" + inetText(end);
+    }
+
+    private static byte[] ipv4ToBytes(int[] octets) {
+        return new byte[]{
+                (byte) octets[0], (byte) octets[1], (byte) octets[2], (byte) octets[3]
+        };
+    }
+
     private static String normalizeAddress(String text) {
         if (text.indexOf(':') >= 0) {
-            return ipv6Text(parseIpv6(text, text));
+            return inetText(parseIpv6(text, text));
         }
         int[] octets = parseIpv4(text, text);
         return octets[0] + "." + octets[1] + "." + octets[2] + "." + octets[3];
@@ -376,22 +413,31 @@ public class IpEntryService {
         return true;
     }
 
-    private static String ipv6Text(byte[] bytes) {
+    /** 字节序列渲染为地址文本(4字节为IPv4点分、16字节为IPv6全展开形式)。 */
+    private static String inetText(byte[] bytes) {
         try {
             return InetAddress.getByAddress(bytes).getHostAddress();
         } catch (Exception ex) {
-            throw new IllegalStateException("IPv6 地址渲染失败", ex);
+            throw new IllegalStateException("IP 地址渲染失败", ex);
         }
     }
 
     private static DmsException invalidIp(String text) {
-        return Errors.invalidArgument("IP地址或网段格式非法:" + text);
+        return Errors.invalidArgument("IP地址、网段或IP范围格式非法:" + text);
     }
 
     private static DmsException hostBitsError(String text) {
         return Errors.invalidArgument("网段要求主机位为0,请输入如192.168.1.0/24的形式:" + text);
     }
 
+    private static DmsException rangeVersionError(String text) {
+        return Errors.invalidArgument("IP范围两端必须同为IPv4或IPv6:" + text);
+    }
+
+    private static DmsException rangeOrderError(String text) {
+        return Errors.invalidArgument("IP范围起始不能大于结束:" + text);
+    }
+
     private static boolean isDigits(String value) {
         for (int i = 0; i < value.length(); i++) {
             char c = value.charAt(i);

+ 44 - 17
backend-java/src/main/java/com/dms/service/LoginIpService.java

@@ -1,6 +1,7 @@
 package com.dms.service;
 
 import com.dms.common.Errors;
+import com.dms.common.IpAddresses;
 import com.dms.mapper.IpEntryMapper;
 import com.dms.model.UserEntity;
 import jakarta.servlet.http.HttpServletRequest;
@@ -21,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 =
@@ -41,31 +46,53 @@ public class LoginIpService {
             throw Errors.loginIpForbidden();
         }
         for (String value : ipValues) {
-            try {
-                if (value.contains("/")) {
-                    String[] parts = value.split("/", 2);
-                    InetAddress network = InetAddress.getByName(parts[0]);
-                    int prefix = Integer.parseInt(parts[1]);
-                    if (networkInetVersion(address) == networkInetVersion(network)
-                            && sameNetwork(address, network, prefix)) {
-                        return;
-                    }
-                } else if (address.equals(InetAddress.getByName(value))) {
-                    return;
-                }
-            } catch (UnknownHostException | NumberFormatException ex) {
-                // 存量数据异常按不匹配处理(安全优先),不中断登录流程判断
+            if (matchesValue(address, value)) {
+                return;
             }
         }
         throw Errors.loginIpForbidden();
     }
 
-    private int networkInetVersion(InetAddress address) {
+    /**
+     * 对齐 _ip_matches:单条 ip_value(精确IP/CIDR网段/IP范围)是否命中来源地址;
+     * 存量数据格式异常按不匹配处理(安全优先),不中断登录流程判断。
+     * 包级私有便于单测。
+     */
+    static boolean matchesValue(InetAddress address, String value) {
+        try {
+            if (value.contains("/")) {
+                String[] parts = value.split("/", 2);
+                InetAddress network = InetAddress.getByName(parts[0]);
+                int prefix = Integer.parseInt(parts[1]);
+                return networkInetVersion(address) == networkInetVersion(network)
+                        && sameNetwork(address, network, prefix);
+            }
+            if (value.contains("-")) {
+                String[] parts = value.split("-", -1);
+                if (parts.length != 2 || parts[0].isEmpty() || parts[1].isEmpty()) {
+                    return false;
+                }
+                InetAddress start = InetAddress.getByName(parts[0]);
+                InetAddress end = InetAddress.getByName(parts[1]);
+                return networkInetVersion(start) == networkInetVersion(end)
+                        && networkInetVersion(address) == networkInetVersion(start)
+                        && IpAddresses.unsignedCompare(
+                                address.getAddress(), start.getAddress()) >= 0
+                        && IpAddresses.unsignedCompare(
+                                address.getAddress(), end.getAddress()) <= 0;
+            }
+            return address.equals(InetAddress.getByName(value));
+        } catch (UnknownHostException | NumberFormatException ex) {
+            return false;
+        }
+    }
+
+    private static int networkInetVersion(InetAddress address) {
         return address.getAddress().length == 4 ? 4 : 6;
     }
 
     /** 前缀位匹配(地址族已由调用方保证一致)。 */
-    private boolean sameNetwork(InetAddress address, InetAddress network, int prefixLength) {
+    private static boolean sameNetwork(InetAddress address, InetAddress network, int prefixLength) {
         byte[] addressBytes = address.getAddress();
         byte[] networkBytes = network.getAddress();
         int totalBits = addressBytes.length * 8;

+ 86 - 4
backend-java/src/main/java/com/dms/service/OrganizationService.java

@@ -3,8 +3,10 @@ package com.dms.service;
 import com.dms.common.DmsException;
 import com.dms.common.Errors;
 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;
@@ -35,39 +37,59 @@ public class OrganizationService {
             "orgCode", "orgName", "parentId", "sortNo", "status");
     private static final Set<String> UPDATE_FIELDS = Set.of(
             "orgCode", "orgName", "parentId", "sortNo", "status", "rowVersion");
+    private static final Set<String> REORDER_FIELDS = Set.of("parentId", "orderedIds");
 
     private final OrganizationMapper organizationMapper;
     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;
     }
 
     // ------------------------------------------------------------ 树查询
 
-    /** 对齐 organization_tree:status 默认 ENABLED,keyword 匹配名称/编码,祖先链补全。 */
+    /**
+     * 对齐 organization_tree:不传 status 返回全部(含停用,管理界面可见可重新启用),
+     * 传值时按 ENABLED/DISABLED 过滤并补全祖先链;keyword 匹配名称/编码。
+     */
     @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);
         }
 
         String normalizedStatus = status == null || status.isEmpty()
-                ? "ENABLED" : Payloads.statusValue(status);
+                ? null : Payloads.statusValue(status);
 
         Set<Long> baseIds = new HashSet<>();
         for (OrganizationEntity item : organizations) {
-            if (normalizedStatus.equals(item.getStatus())) {
+            if (normalizedStatus == null || normalizedStatus.equals(item.getStatus())) {
                 baseIds.add(item.getId());
             }
         }
@@ -336,6 +358,66 @@ public class OrganizationService {
         return result;
     }
 
+    /**
+     * 对齐 reorder_organizations(契约1.27):同级范围槽位置换,步长10重编号。
+     * parentId 为 null 表示顶层;锚点管理员仅可重排管理范围子树(复用托管范围校验)。
+     */
+    @Transactional
+    public Map<String, Object> reorder(Object payload, HttpServletRequest request) {
+        Map<String, Object> body = Payloads.exactPayload(payload, REORDER_FIELDS);
+        Long parentId = Payloads.stringId(body.get("parentId"), "parentId", true);
+        List<Long> orderedIds = Reorders.parseOrderedIds(body.get("orderedIds"));
+        Long actorId = AuthContext.get().userId();
+        LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+
+        UserEntity actor = adminPermission.requireOrganizationEditor();
+        if (parentId != null && organizationMapper.findByIdNotDeleted(parentId) == null) {
+            throw notFound("上级机构不存在");
+        }
+        adminPermission.ensureOrgInManagedScope(actor, parentId);
+        List<OrganizationEntity> siblings = organizationMapper.listSiblingsForUpdate(parentId);
+        Set<Long> siblingIds = new HashSet<>();
+        List<Long> allIds = new ArrayList<>();
+        Map<Long, OrganizationEntity> rowById = new HashMap<>();
+        for (OrganizationEntity item : siblings) {
+            siblingIds.add(item.getId());
+            allIds.add(item.getId());
+            rowById.put(item.getId(), item);
+        }
+        for (Long itemId : orderedIds) {
+            if (!siblingIds.contains(itemId)) {
+                throw Errors.invalidArgument("orderedIds包含不在同级范围内的机构");
+            }
+        }
+        // 槽位置换:被提交ID按原槽位顺序排好,依次填入目标顺序
+        List<Long> newOrder = Reorders.slotSwap(allIds, orderedIds);
+        Map<Long, Integer> targetSortById = new HashMap<>();
+        for (int index = 0; index < newOrder.size(); index++) {
+            targetSortById.put(newOrder.get(index), (index + 1) * 10);
+        }
+        int reorderedCount = 0;
+        for (OrganizationEntity item : siblings) {
+            Integer target = targetSortById.get(item.getId());
+            if (target != null && item.getSortNo() != null
+                    && item.getSortNo().intValue() == target.intValue()) {
+                continue;
+            }
+            organizationMapper.updateSort(item.getId(), target, actorId, now);
+            reorderedCount++;
+        }
+        OrganizationEntity first = rowById.get(orderedIds.get(0));
+        Map<String, Object> detail = Payloads.orderedMap();
+        detail.put("scope", parentId != null ? String.valueOf(parentId) : "ROOT");
+        detail.put("orderedIds", orderedIds.stream().map(String::valueOf).toList());
+        detail.put("reorderedCount", reorderedCount);
+        audit(request, AuditService.Action.REORDER_ORGANIZATION,
+                first.getId(), first.getOrgName(), detail);
+
+        Map<String, Object> result = Payloads.orderedMap();
+        result.put("reorderedCount", reorderedCount);
+        return result;
+    }
+
     // ------------------------------------------------------------ 内部工具
 
     private OrganizationEntity validParent(Long parentId) {

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

@@ -269,6 +269,8 @@ public class PortalAuthService {
         data.put("accessToken", issued.token());
         data.put("tokenType", "Bearer");
         data.put("expiresIn", issued.expiresInSeconds());
+        // 页面空闲自动登出(契约1.29):分钟数,0 表示关闭
+        data.put("idleLogoutMinutes", dmsProperties.idleLogoutMinutes());
         data.put("user", userSummaryService.userSummary(user,
                 userSummaryService.allowedModulesForUser(user)));
         return data;

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

@@ -4,6 +4,7 @@ import com.dms.common.DmsException;
 import com.dms.common.Errors;
 import com.dms.common.Page;
 import com.dms.common.Payloads;
+import com.dms.common.Reorders;
 import com.dms.mapper.RoleMapper;
 import com.dms.mapper.RolePermissionMapper;
 import com.dms.mapper.UserMapper;
@@ -46,10 +47,12 @@ public class RoleService {
             "rowVersion");
     private static final Set<String> ROLE_PERMISSION_FIELDS = Set.of("matrix", "rowVersion");
     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(
@@ -405,6 +408,58 @@ public class RoleService {
         return result;
     }
 
+    /**
+     * 对齐 reorder_roles(契约1.27):全体角色槽位置换,步长10重编号 sort_no。
+     * orderedIds 允许为当前分页视图的子集,被提交角色按原槽位次序互换
+     * (未提交角色相对次序不变);权限由路由层 manageRoleEdit 把关。
+     */
+    @Transactional
+    public Map<String, Object> reorder(Object payload, HttpServletRequest request) {
+        Map<String, Object> body = Payloads.exactPayload(payload, ROLE_REORDER_FIELDS);
+        List<Long> orderedIds = Reorders.parseOrderedIds(body.get("orderedIds"));
+        Long actorId = AuthContext.get().userId();
+        LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+
+        List<RoleEntity> roles = roleMapper.listAllForUpdate();
+        Map<Long, RoleEntity> rowById = new HashMap<>();
+        List<Long> allIds = new ArrayList<>();
+        for (RoleEntity item : roles) {
+            rowById.put(item.getId(), item);
+            allIds.add(item.getId());
+        }
+        for (Long roleId : orderedIds) {
+            if (!rowById.containsKey(roleId)) {
+                throw Errors.invalidArgument("orderedIds包含无效角色");
+            }
+        }
+        // 槽位置换:被提交ID按原槽位顺序排好,依次填入目标顺序
+        List<Long> newOrder = Reorders.slotSwap(allIds, orderedIds);
+        Map<Long, Integer> targetSortById = new HashMap<>();
+        for (int index = 0; index < newOrder.size(); index++) {
+            targetSortById.put(newOrder.get(index), (index + 1) * 10);
+        }
+        int reorderedCount = 0;
+        for (RoleEntity item : roles) {
+            Integer target = targetSortById.get(item.getId());
+            if (target != null && item.getSortNo() != null
+                    && item.getSortNo().intValue() == target.intValue()) {
+                continue;
+            }
+            roleMapper.updateSort(item.getId(), target, actorId, now);
+            reorderedCount++;
+        }
+        RoleEntity first = rowById.get(orderedIds.get(0));
+        Map<String, Object> detail = Payloads.orderedMap();
+        detail.put("orderedIds", orderedIds.stream().map(String::valueOf).toList());
+        detail.put("reorderedCount", reorderedCount);
+        audit(request, AuditService.Action.REORDER_ROLE,
+                first.getId(), first.getRoleName(), detail);
+
+        Map<String, Object> result = Payloads.orderedMap();
+        result.put("reorderedCount", reorderedCount);
+        return result;
+    }
+
     // ------------------------------------------------------------ 内部工具
 
     private void requireRole(long roleId) {
@@ -518,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());
@@ -559,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;
@@ -567,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());
@@ -586,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"));

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

@@ -44,7 +44,7 @@ import org.springframework.stereotype.Service;
 @Service
 public class SystemManagementService {
 
-    public static final String API_CONTRACT_VERSION = "1.26.0";
+    public static final String API_CONTRACT_VERSION = "1.30.0";
     public static final String BACKEND_KIND = "JAVA";
 
     /** 对齐 Python isoformat(timespec="milliseconds") + "Z"。 */

+ 155 - 7
backend-java/src/main/java/com/dms/service/UserService.java

@@ -4,6 +4,8 @@ import com.dms.common.DmsException;
 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;
@@ -40,8 +42,11 @@ public class UserService {
     private static final Set<String> UPDATE_FIELDS = Set.of(
             "realName", "organizationId", "roleCode", "roleId",
             "securityLevel", "status", "rowVersion");
+    /** 用户级管理范围(契约1.30)为可选字段:create 未提交缺省落 SELF(哨兵0),update 未提交保持原值。 */
+    private static final Set<String> OPTIONAL_MANAGED_ORG_FIELDS = Set.of("managedOrgId");
     private static final Set<String> RESET_PASSWORD_FIELDS = Set.of("newPassword", "rowVersion");
     private static final Set<String> UI_THEME_FIELDS = Set.of("uiTheme");
+    private static final Set<String> REORDER_FIELDS = Set.of("orderedIds");
     private static final Set<String> UI_THEMES = Set.of("blue", "red", "green");
 
     private final UserMapper userMapper;
@@ -51,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;
@@ -63,6 +69,7 @@ public class UserService {
         this.auditService = auditService;
         this.adminPermission = adminPermission;
         this.reauth = reauth;
+        this.dmsProperties = dmsProperties;
     }
 
     // ------------------------------------------------------------ 列表查询
@@ -90,21 +97,31 @@ public class UserService {
                     } else {
                         organizationIds = new ArrayList<>(organizationIds);
                         organizationIds.retainAll(scope);
+                        if (organizationIds.isEmpty()) {
+                            // 显式请求的机构不在管理范围内:明确报无权限,
+                            // 不再静默交集返回空页(避免前端误显示"该机构无用户")
+                            throw Errors.forbidden("无权限查看该机构的用户");
+                        }
                     }
                 }
             }
         }
         if (organizationIds != null && organizationIds.isEmpty()) {
-            // 请求范围与管理范围无交集:直接空页,避免 IN () 非法 SQL
+            // 管理范围为空集(锚点SELF且无部门等)且未显式请求机构:
+            // 直接空页,避免 IN () 非法 SQL
             return Page.result(new ArrayList<>(), pageRequest, 0);
         }
         String keywordValue = keyword != null && !keyword.isEmpty() ? keyword.strip() : null;
         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) {
@@ -118,7 +135,8 @@ public class UserService {
     /** 对齐 create_user。 */
     @Transactional
     public Map<String, Object> create(Object payload, HttpServletRequest request) {
-        Map<String, Object> body = Payloads.exactPayload(payload, CREATE_FIELDS);
+        Map<String, Object> body = Payloads.exactPayload(
+                payload, CREATE_FIELDS, OPTIONAL_MANAGED_ORG_FIELDS);
         String username = Payloads.requiredText(body.get("username"), "username", 64);
         String realName = Payloads.requiredText(body.get("realName"), "realName", 64);
         String initialPassword = requiredPassword(body.get("initialPassword"));
@@ -128,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();
@@ -137,6 +161,23 @@ public class UserService {
             }
             OrganizationEntity organization = resolveOrganization(organizationId);
             RoleEntity role = resolveRole(roleId);
+            // 缺省落 SELF(哨兵0):显式赋值不依赖数据库缺省,
+            // 保证未提交该字段的新建用户与迁移回填的存量行语义一致。
+            // 显式提交 null(全局)时 optionalManagedOrg 返回 null,
+            // 用 if/else 赋值避免三元与 long 常量混用触发自动拆箱 NPE
+            Long managedOrgId;
+            if (body.containsKey("managedOrgId")) {
+                managedOrgId = optionalManagedOrg(body.get("managedOrgId"));
+            } else {
+                managedOrgId = AdminPermission.MANAGED_ORG_SELF_ANCHOR;
+            }
+            // 防提权(契约1.30修订):目标 ADMIN 的生效管理范围不得超出操作者
+            adminPermission.ensureUserScopeInManagedScope(
+                    actor, roleCode,
+                    adminPermission.effectiveRoleSnapshot(
+                            role == null ? null : role.getId()),
+                    managedOrgId,
+                    organization == null ? null : organization.getId());
 
             UserEntity user = new UserEntity();
             user.setUsername(username);
@@ -148,6 +189,7 @@ public class UserService {
             user.setRoleId(role == null ? null : role.getId());
             user.setSecurityLevel(securityLevel);
             user.setStatus(status);
+            user.setManagedOrgId(managedOrgId);
             user.setCreatedBy(actorId);
             user.setUpdatedBy(actorId);
             user.setAuthVersion(0);
@@ -169,6 +211,7 @@ public class UserService {
             detail.put("roleName", role == null ? null : role.getRoleName());
             detail.put("securityLevel", user.getSecurityLevel());
             detail.put("status", user.getStatus());
+            detail.put("managedOrgId", managedOrgValue(user.getManagedOrgId()));
             audit(request, AuditService.Action.CREATE_USER,
                     user.getId(), user.getRealName(), detail);
             return userSummaryService.userSummary(created);
@@ -180,7 +223,8 @@ public class UserService {
     /** 对齐 update_user:角色/分配/停用联动 auth_version 与最后管理员守卫。 */
     @Transactional
     public Map<String, Object> update(long userId, Object payload, HttpServletRequest request) {
-        Map<String, Object> body = Payloads.exactPayload(payload, UPDATE_FIELDS);
+        Map<String, Object> body = Payloads.exactPayload(
+                payload, UPDATE_FIELDS, OPTIONAL_MANAGED_ORG_FIELDS);
         String realName = Payloads.requiredText(body.get("realName"), "realName", 64);
         Long organizationId = bodyStringId(body.get("organizationId"), "organizationId");
         String newRoleCode = Payloads.roleCodeValue(body.get("roleCode"));
@@ -218,6 +262,23 @@ public class UserService {
         RoleEntity role = resolveRole(newRoleId);
         Long resolvedRoleId = role == null ? null : role.getId();
         boolean roleAssignmentChanges = !Objects.equals(resolvedRoleId, user.getRoleId());
+        // 可选字段(契约1.30):未提交保持原值;调整不改变认证身份,
+        // 不触发二次认证、不失效旧 Token(与角色锚点同口径)
+        boolean managedOrgProvided = body.containsKey("managedOrgId");
+        Long newManagedOrgId = null;
+        if (managedOrgProvided) {
+            newManagedOrgId = optionalManagedOrg(body.get("managedOrgId"));
+        }
+        // 防提权(契约1.30修订):目标 ADMIN 最终生效管理范围不得超出操作者;
+        // 未提交 managedOrgId 时按保持原值判定。校验置于 reauth 之前,
+        // 避免换角色场景先抛 REAUTH_REQUIRED 掩盖越权拒绝。
+        Long finalManagedOrgId = managedOrgProvided
+                ? newManagedOrgId : user.getManagedOrgId();
+        adminPermission.ensureUserScopeInManagedScope(
+                actorUser, newRoleCode,
+                adminPermission.effectiveRoleSnapshot(resolvedRoleId),
+                finalManagedOrgId,
+                organization == null ? null : organization.getId());
 
         // 敏感字段(密级/角色/状态)实际变化:强制操作人二次认证(契约1.21)
         boolean securityLevelChanges = !newSecurityLevel.equals(user.getSecurityLevel());
@@ -232,13 +293,16 @@ public class UserService {
         user.setRoleId(resolvedRoleId);
         user.setSecurityLevel(newSecurityLevel);
         user.setStatus(newStatus);
+        if (managedOrgProvided) {
+            user.setManagedOrgId(newManagedOrgId);
+        }
         user.setUpdatedBy(actorId);
         user.setUpdatedAt(now);
         user.setRowVersion(user.getRowVersion() + 1);
 
         boolean authBump = roleChanges || roleAssignmentChanges
                 || (statusChanges && "DISABLED".equals(newStatus));
-        userMapper.updateFull(user, authBump ? 1 : 0);
+        userMapper.updateFull(user, authBump ? 1 : 0, managedOrgProvided ? 1 : 0);
         if (authBump) {
             user.setAuthVersion(user.getAuthVersion() + 1);
         }
@@ -346,6 +410,59 @@ public class UserService {
         return userSummaryService.userSummary(user);
     }
 
+    /**
+     * 对齐 reorder_users(契约1.27):全体用户槽位置换,步长10重编号 sort_no。
+     * orderedIds 允许为当前分页视图的子集,被提交用户按原槽位次序互换
+     * (未提交用户相对次序不变),与分类同级重排的置换语义一致。
+     */
+    @Transactional
+    public Map<String, Object> reorder(Object payload, HttpServletRequest request) {
+        Map<String, Object> body = Payloads.exactPayload(payload, REORDER_FIELDS);
+        adminPermission.requireUserEditor();
+        List<Long> orderedIds = Reorders.parseOrderedIds(body.get("orderedIds"));
+        Long actorId = AuthContext.get().userId();
+        LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+
+        List<UserEntity> users = userMapper.listAllForUpdate();
+        Map<Long, UserEntity> rowById = new HashMap<>();
+        List<Long> allIds = new ArrayList<>();
+        for (UserEntity item : users) {
+            rowById.put(item.getId(), item);
+            allIds.add(item.getId());
+        }
+        for (Long userId : orderedIds) {
+            if (!rowById.containsKey(userId)) {
+                throw notFound("用户不存在");
+            }
+        }
+        // 槽位置换:被提交ID按原槽位顺序排好,依次填入目标顺序
+        List<Long> newOrder = Reorders.slotSwap(allIds, orderedIds);
+        Map<Long, Integer> targetSortById = new HashMap<>();
+        for (int index = 0; index < newOrder.size(); index++) {
+            targetSortById.put(newOrder.get(index), (index + 1) * 10);
+        }
+        int reorderedCount = 0;
+        for (UserEntity item : users) {
+            Integer target = targetSortById.get(item.getId());
+            if (target != null && item.getSortNo() != null
+                    && item.getSortNo().intValue() == target.intValue()) {
+                continue;
+            }
+            userMapper.updateSort(item.getId(), target, actorId, now);
+            reorderedCount++;
+        }
+        UserEntity first = rowById.get(orderedIds.get(0));
+        Map<String, Object> detail = Payloads.orderedMap();
+        detail.put("orderedIds", orderedIds.stream().map(String::valueOf).toList());
+        detail.put("reorderedCount", reorderedCount);
+        audit(request, AuditService.Action.REORDER_USER,
+                first.getId(), first.getRealName(), detail);
+
+        Map<String, Object> result = Payloads.orderedMap();
+        result.put("reorderedCount", reorderedCount);
+        return result;
+    }
+
     // ------------------------------------------------------------ 内部工具
 
     /** 对齐 _descendant_ids:仅沿 ENABLED 组织边展开(含根自身)。 */
@@ -421,6 +538,36 @@ public class UserService {
         return Payloads.stringId(value, name, false);
     }
 
+    /** 对齐 _optional_managed_org:null/空串=全局、"SELF"=哨兵0、机构ID=固定锚点(须未删除)。 */
+    private Long optionalManagedOrg(Object value) {
+        if (value == null || (value instanceof String text && text.isEmpty())) {
+            return null;
+        }
+        if (AdminPermission.MANAGED_ORG_SELF.equals(value)) {
+            return AdminPermission.MANAGED_ORG_SELF_ANCHOR;
+        }
+        if (!(value instanceof String digits) || !digits.matches("\\d+")
+                || Long.parseLong(digits) <= 0) {
+            throw Errors.invalidArgument("managedOrgId必须是SELF、机构ID或null");
+        }
+        long orgId = Long.parseLong(digits);
+        if (organizationMapper.findByIdNotDeleted(orgId) == null) {
+            throw Errors.invalidArgument("managedOrgId必须指向有效机构");
+        }
+        return orgId;
+    }
+
+    /** 对齐 _managed_org_value:null→null、哨兵0→"SELF"、其余→机构ID字符串。 */
+    private static String managedOrgValue(Long value) {
+        if (value == null) {
+            return null;
+        }
+        if (value == AdminPermission.MANAGED_ORG_SELF_ANCHOR) {
+            return AdminPermission.MANAGED_ORG_SELF;
+        }
+        return String.valueOf(value);
+    }
+
     /** 对齐 _required_password(不 strip;长度 8~64 + 至少三类字符)。 */
     private static String requiredPassword(Object value) {
         if (!(value instanceof String text) || text.isEmpty()) {
@@ -472,6 +619,7 @@ public class UserService {
         snapshot.put("roleId", serializeId(user.getRoleId()));
         snapshot.put("securityLevel", user.getSecurityLevel());
         snapshot.put("status", user.getStatus());
+        snapshot.put("managedOrgId", managedOrgValue(user.getManagedOrgId()));
         snapshot.put("rowVersion", user.getRowVersion());
         return snapshot;
     }

+ 10 - 9
backend-java/src/main/java/com/dms/service/UserSummaryService.java

@@ -77,7 +77,12 @@ public class UserSummaryService {
                 && Boolean.TRUE.equals(manageRole.getManageAuditLog())));
         result.put("permRoleEdit", superAdmin || (manageRole != null
                 && Boolean.TRUE.equals(manageRole.getManageRoleEdit())));
-        result.put("managedOrgId", managedOrgValue(superAdmin, manageRole));
+        // 上传权限(契约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()));
         result.put("lastLoginAt", formatUtc(user.getLastLoginAt()));
         result.put("lastLoginIp", user.getLastLoginIp());
         result.put("uiTheme", user.getUiTheme());
@@ -131,19 +136,15 @@ public class UserSummaryService {
         return id == null ? null : String.valueOf(id);
     }
 
-    /** 锚点序列化:超管恒全局;SELF 哨兵(0)还原为 "SELF",其余按机构ID。 */
-    private static String managedOrgValue(boolean superAdmin, RoleEntity manageRole) {
+    /** 用户级管理范围序列化(契约1.30):超管恒全局;SELF 哨兵(0)还原为 "SELF",其余按机构ID。 */
+    private static String managedOrgValue(boolean superAdmin, Long value) {
         if (superAdmin) {
             return null;
         }
-        if (manageRole == null) {
-            return null;
-        }
-        Long anchor = manageRole.getManagedOrgId();
-        if (anchor != null && anchor == AdminPermission.MANAGED_ORG_SELF_ANCHOR) {
+        if (value != null && value == AdminPermission.MANAGED_ORG_SELF_ANCHOR) {
             return AdminPermission.MANAGED_ORG_SELF;
         }
-        return serializeId(anchor);
+        return serializeId(value);
     }
 
     /** 组织有效时取实时名称,否则回退用户表快照名。 */

+ 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'
+    )
+);

+ 95 - 0
backend-java/src/main/resources/db/migration-dm/V16__user_sort_no.sql

@@ -0,0 +1,95 @@
+-- V16__user_sort_no 的达梦版(DM8):MySQL 原版
+-- ../migration/V16__user_sort_no.sql 的等价迁移。
+-- 用途:达梦不在 Flyway 支持列表、内置迁移默认关闭(见 application.yml),本脚本
+--   供 DISQL / DM 管理工具手工执行或外部迁移工具引用;目录与 db/migration 平级,
+--   不参与 Flyway 扫描(放 db/migration 子目录会与 MySQL 版冲突为重复 V16)。
+-- 语义与 MySQL 版完全一致:
+--   1. sys_user 加 sort_no INT NOT NULL DEFAULT 0;
+--   2. 重建 sys_audit_log 动作 CHECK 白名单(45 项,含三个 REORDER_*)。
+-- 幂等:列/约束均先探测后执行,重复运行无副作用;与 Python 版 Alembic 0031
+--   双引擎互冲安全(谁先跑,后者跳过或重建同集合)。
+-- 语法说明:达梦无 MySQL 的 SET @var / IF() / PREPARE 动态 SQL,改用 DMSQL 匿名块 +
+--   EXECUTE IMMEDIATE;系统字典用 Oracle 兼容视图 USER_TAB_COLUMNS / USER_CONSTRAINTS
+--   (未加引号建表时名称大写存储)。
+
+-- 1. sys_user.sort_no 加列(已存在则跳过)
+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. 删除旧动作 CHECK 约束(不存在则跳过;按达梦大写存储名探测与删除)
+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;
+/
+
+-- 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'
+    )
+);

+ 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;
+/

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

@@ -0,0 +1,27 @@
+-- V17__user_managed_org 的达梦版(DM8):MySQL 原版
+-- ../migration/V17__user_managed_org.sql 的等价迁移。
+-- 用途:达梦不在 Flyway 支持列表、内置迁移默认关闭(见 application.yml),本脚本
+--   供 DISQL / DM 管理工具手工执行或外部迁移工具引用;目录与 db/migration 平级,
+--   不参与 Flyway 扫描(放 db/migration 子目录会与 MySQL 版冲突为重复 V17)。
+-- 语义与 MySQL 版完全一致:
+--   1. sys_user 加 managed_org_id BIGINT NULL DEFAULT 0(契约1.30 用户级管理范围);
+--   2. 存量回填哨兵 0(跟随所在机构),仅随本次加列执行,双引擎互冲安全。
+-- 语法说明:达梦无 SET @var / IF() / PREPARE,改用 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_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;
+/

+ 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;
+/

+ 54 - 0
backend-java/src/main/resources/db/migration/V16__user_sort_no.sql

@@ -0,0 +1,54 @@
+-- 0031_user_sort_no 的 Flyway 等价迁移(Java 版同步,契约1.27)。
+-- 变更要点:
+--   1. sys_user 新增 sort_no 整型排序号(默认 0;机构/角色已有同名列,仅用户缺),
+--      用户列表排序由 real_name 改为 sort_no,id,拖拽重排按步长10重编号;
+--   2. 重建 sys_audit_log 的 audit_action_type CHECK 白名单(V14 的 42 项 + 3 = 45 项:
+--      REORDER_ORGANIZATION / REORDER_USER / REORDER_ROLE)。
+-- 达梦兼容:DM 将约束名渲染为大写,探测按 UPPER() 比较(同 V14)。
+-- 幂等:加列先探测、CHECK drop-if-exists 后重建,
+--   与 Python 版 Alembic 0031 并行执行互不冲突。
+
+-- 1. sys_user.sort_no 加列(V15 探测模式)
+SET @column_exists = (
+    SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'sys_user'
+      AND COLUMN_NAME = 'sort_no'
+);
+SET @ddl = IF(@column_exists = 0,
+    'ALTER TABLE sys_user ADD COLUMN sort_no INT NOT NULL DEFAULT 0',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 2. 删除旧动作 CHECK 约束(不存在则跳过;探测按 UPPER() 兼容达梦大写形态)
+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;
+
+-- 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'));

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

@@ -0,0 +1,30 @@
+-- 0032_user_managed_org 的 Flyway 等价迁移(Java 版同步,契约1.30)。
+-- 变更要点:
+--   1. sys_user 新增 managed_org_id(BIGINT NULL DEFAULT 0)三态:
+--      NULL=全局、0=跟随该用户所在机构(SELF 哨兵)、机构ID=固定锚点;
+--   2. 角色锚点 "SELF"(0)的回落目标由「用户所在机构」改为「用户的管理范围」;
+--   3. 存量回填哨兵 0,与升级前行为一致;
+--   4. 幂等与双引擎互冲安全:加列先探测;回填仅在本次真正执行加列时进行
+--      (@column_exists 在加列前取样),防止 Alembic 先跑后 V17 把显式 NULL 覆盖回 0。
+
+-- 1. sys_user.managed_org_id 加列(V16 探测模式)
+SET @column_exists = (
+    SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'sys_user'
+      AND COLUMN_NAME = 'managed_org_id'
+);
+SET @ddl = IF(@column_exists = 0,
+    'ALTER TABLE sys_user ADD COLUMN managed_org_id BIGINT NULL DEFAULT 0',
+    'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 2. 存量回填哨兵 0(仅本次执行了加列时;@column_exists 已在加列前取样)
+SET @backfill = IF(@column_exists = 0,
+    'UPDATE sys_user SET managed_org_id = 0 WHERE managed_org_id IS NULL',
+    'SELECT 1');
+PREPARE stmt FROM @backfill;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 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;

+ 85 - 0
backend-java/src/test/java/com/dms/common/ReordersTest.java

@@ -0,0 +1,85 @@
+package com.dms.common;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 拖动重排共用工具单测(契约1.27,机构/用户/角色 reorder 共用):
+ * 入参解析校验对齐 Python(正整数字符串、无重复、至少2个),
+ * 槽位置换保证分页子集提交时未提交ID相对次序不变。
+ */
+class ReordersTest {
+
+    private static DmsException invalid(Runnable runnable) {
+        return assertThrows(DmsException.class, runnable::run);
+    }
+
+    // ------------------------------------------------------------ parseOrderedIds
+
+    @Test
+    void 解析合法的字符串ID数组() {
+        assertEquals(List.of(3L, 1L, 2L),
+                Reorders.parseOrderedIds(List.of("3", "1", "2")));
+    }
+
+    @Test
+    void 非数组入参被拒绝() {
+        DmsException ex = invalid(() -> Reorders.parseOrderedIds("3,1"));
+        assertTrue(ex.getMessage().contains("orderedIds必须是正整数形式的字符串ID数组"));
+    }
+
+    @Test
+    void 非字符串或非正整数项被拒绝() {
+        assertTrue(invalid(() -> Reorders.parseOrderedIds(List.of("3", 4)))
+                .getMessage().contains("正整数形式的字符串ID"));
+        assertTrue(invalid(() -> Reorders.parseOrderedIds(List.of("3", "abc")))
+                .getMessage().contains("正整数形式的字符串ID"));
+        assertTrue(invalid(() -> Reorders.parseOrderedIds(List.of("3", "0")))
+                .getMessage().contains("正整数形式的字符串ID"));
+        assertTrue(invalid(() -> Reorders.parseOrderedIds(List.of("3", "-1")))
+                .getMessage().contains("正整数形式的字符串ID"));
+    }
+
+    @Test
+    void 重复ID被拒绝() {
+        DmsException ex = invalid(() -> Reorders.parseOrderedIds(List.of("3", "3")));
+        assertTrue(ex.getMessage().contains("orderedIds存在重复ID"));
+    }
+
+    @Test
+    void 少于两个ID被拒绝() {
+        DmsException ex = invalid(() -> Reorders.parseOrderedIds(List.of("3")));
+        assertTrue(ex.getMessage().contains("至少包含2个ID"));
+    }
+
+    // ------------------------------------------------------------ slotSwap
+
+    @Test
+    void 全量提交按目标顺序置换() {
+        assertEquals(List.of(3L, 1L, 2L), Reorders.slotSwap(
+                List.of(1L, 2L, 3L), List.of(3L, 1L, 2L)));
+    }
+
+    @Test
+    void 相邻两元素互换() {
+        assertEquals(List.of(2L, 1L), Reorders.slotSwap(
+                List.of(1L, 2L), List.of(2L, 1L)));
+    }
+
+    @Test
+    void 子集提交未提交ID保持原槽位次序() {
+        // 分页语义:仅提交第1、3行,2、4行不动
+        assertEquals(List.of(3L, 2L, 1L, 4L), Reorders.slotSwap(
+                List.of(1L, 2L, 3L, 4L), List.of(3L, 1L)));
+    }
+
+    @Test
+    void 有序提交结果与原序一致() {
+        assertEquals(List.of(1L, 2L, 3L), Reorders.slotSwap(
+                List.of(1L, 2L, 3L), List.of(1L, 2L)));
+    }
+}

+ 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();
+    }
+}

+ 153 - 0
backend-java/src/test/java/com/dms/service/AdminPermissionUserScopeTest.java

@@ -0,0 +1,153 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.dms.common.DmsException;
+import com.dms.mapper.OrganizationMapper;
+import com.dms.mapper.RoleMapper;
+import com.dms.mapper.UserMapper;
+import com.dms.model.OrganizationEntity;
+import com.dms.model.RoleEntity;
+import com.dms.model.UserEntity;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 用户级防提权单测(契约1.30修订,对齐 test_admin_permissions.py):
+ * 目标 ADMIN 最终生效管理范围不得超出操作者。
+ * 仅当目标 ADMIN 且生效角色具备机构/用户编辑位时校验。
+ */
+class AdminPermissionUserScopeTest {
+
+    private static final long OPS = 10L;
+    private static final long OPS_CHILD = 11L;
+    private static final long COMMS = 20L;
+
+    private final UserMapper userMapper = mock(UserMapper.class);
+    private final OrganizationMapper organizationMapper = mock(OrganizationMapper.class);
+    private final RoleMapper roleMapper = mock(RoleMapper.class);
+    private final AdminPermission adminPermission = new AdminPermission(
+            userMapper, organizationMapper, roleMapper);
+
+    private UserEntity actor;
+
+    @BeforeEach
+    void setUpScope() {
+        // 操作者:角色锚点 OPS(manageUserEdit),生效子树 {OPS, OPS_CHILD}
+        RoleEntity actorRole = new RoleEntity();
+        actorRole.setManageUserEdit(true);
+        actorRole.setManagedOrgId(OPS);
+        when(roleMapper.findActiveEnabledById(77L)).thenReturn(actorRole);
+
+        actor = new UserEntity();
+        actor.setUsername("scoped");
+        actor.setRoleCode("ADMIN");
+        actor.setRoleId(77L);
+
+        when(organizationMapper.listAllNotDeleted())
+                .thenReturn(List.of(org(OPS), org(OPS_CHILD), org(COMMS)));
+    }
+
+    private static OrganizationEntity org(long id) {
+        OrganizationEntity entity = new OrganizationEntity();
+        entity.setId(id);
+        entity.setParentId(id == OPS_CHILD ? OPS : null);
+        entity.setStatus("ENABLED");
+        return entity;
+    }
+
+    /** 目标生效角色:anchor=null 全局 / 0 SELF / 其余固定锚点。 */
+    private static RoleEntity targetRole(Long anchor, boolean manageUserEdit) {
+        RoleEntity role = new RoleEntity();
+        role.setManageOrgEdit(false);
+        role.setManageUserEdit(manageUserEdit);
+        role.setManagedOrgId(anchor);
+        return role;
+    }
+
+    private void check(RoleEntity effectiveRole, Long managedOrgId, Long organizationId) {
+        adminPermission.ensureUserScopeInManagedScope(
+                actor, "ADMIN", effectiveRole, managedOrgId, organizationId);
+    }
+
+    @Test
+    void 生效角色锚点全局的目标被拒绝() {
+        assertThatThrownBy(() -> check(targetRole(null, true), 0L, OPS))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("目标超出管理范围");
+    }
+
+    @Test
+    void 角色SELF且用户级全局的目标被拒绝() {
+        assertThatThrownBy(() -> check(targetRole(0L, true), null, OPS))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("目标超出管理范围");
+    }
+
+    @Test
+    void 角色SELF且用户级范围外机构被拒绝() {
+        assertThatThrownBy(() -> check(targetRole(0L, true), COMMS, OPS))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("目标超出管理范围");
+    }
+
+    @Test
+    void 角色SELF且用户级SELF但所在机构范围外被拒绝() {
+        assertThatThrownBy(() -> check(targetRole(0L, true), 0L, COMMS))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("目标超出管理范围");
+    }
+
+    @Test
+    void 生效角色锚点范围外机构被拒绝() {
+        assertThatThrownBy(() -> check(targetRole(COMMS, true), 0L, OPS))
+                .isInstanceOf(DmsException.class)
+                .hasMessageContaining("目标超出管理范围");
+    }
+
+    @Test
+    void 角色SELF且用户级SELF按所在机构放行() {
+        assertThatCode(() -> check(targetRole(0L, true), 0L, OPS))
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    void 用户级SELF且无部门的目标被放行() {
+        // organizationId=null:既有校验已限制无部门场景,树传递性恒安全
+        assertThatCode(() -> check(targetRole(0L, true), 0L, null))
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    void 角色固定锚点在子树内放行且用户级不参与() {
+        // 角色锚点 OPS_CHILD(范围内)优先,用户级 COMMS 不影响判定
+        assertThatCode(() -> check(targetRole(OPS_CHILD, true), COMMS, COMMS))
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    void 无管理位角色不校验() {
+        // 默认角色锚点=NULL 且管理位全0:不构成提权
+        assertThatCode(() -> check(targetRole(null, false), null, null))
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    void USER类别目标不校验() {
+        assertThatCode(() -> adminPermission.ensureUserScopeInManagedScope(
+                actor, "USER", targetRole(null, true), null, null))
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    void 操作者全局时不校验() {
+        // 角色(含默认角色)锚点=NULL 的操作者为全局管理员
+        when(roleMapper.findActiveEnabledById(77L)).thenReturn(targetRole(null, true));
+        assertThatCode(() -> check(targetRole(null, true), null, null))
+                .doesNotThrowAnyException();
+    }
+}

+ 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");
+    }
+}

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

@@ -0,0 +1,87 @@
+package com.dms.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.dms.common.DmsException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * IP 条目规范化单测(契约1.28,对齐 test_ip_entries.py normalize 用例):
+ * 精确IP / CIDR / 起止范围三分支,IPv4 手写解析与 Python ipaddress 语义对齐。
+ */
+class IpEntryServiceNormalizeTest {
+
+    private static String normalize(Object value) {
+        return IpEntryService.normalizeIpValue(value);
+    }
+
+    private static DmsException invalid(Object value) {
+        return assertThrows(DmsException.class, () -> normalize(value));
+    }
+
+    @Test
+    void 精确IP与CIDR规范化不变() {
+        assertEquals("192.168.1.5", normalize(" 192.168.1.5 "));
+        assertEquals("192.168.1.0/24", normalize("192.168.1.0/24"));
+        assertEquals("10.0.0.0/8", normalize("10.0.0.0/8"));
+    }
+
+    @Test
+    void 范围规范化与strip() {
+        assertEquals(
+                "192.168.1.1-192.168.1.254",
+                normalize(" 192.168.1.1-192.168.1.254 "));
+        assertEquals("10.0.0.5-10.0.0.5", normalize("10.0.0.5-10.0.0.5"));
+    }
+
+    @Test
+    void IPv6范围为全展开输出() {
+        // Java 侧 IPv6 输出全展开形式(与 Python 压缩形式的已知展示差异)
+        assertEquals(
+                "2001:db8:0:0:0:0:0:1-2001:db8:0:0:0:0:0:10",
+                normalize("2001:db8::1-2001:db8::10"));
+    }
+
+    @Test
+    void 倒序范围被拒绝() {
+        assertTrue(invalid("192.168.1.254-192.168.1.1")
+                .getMessage().contains("起始不能大于结束"));
+    }
+
+    @Test
+    void 混合版本范围被拒绝() {
+        assertTrue(invalid("::1-192.168.1.1")
+                .getMessage().contains("同为IPv4或IPv6"));
+    }
+
+    @Test
+    void CIDR主机位提示保持不变() {
+        assertTrue(invalid("192.168.1.5/24").getMessage().contains("主机位"));
+    }
+
+    @Test
+    void 非法范围与非法输入统一格式非法() {
+        for (String bad : new String[]{
+                "1.1.1.1-",
+                "-1.1.1.1",
+                "1.1.1.1-2.2.2.2-3.3.3.3",
+                "1.1.1.1-999.2.2.2",
+                "abc",
+                "999.1.1.1",
+                "192.168.1.0/33",
+                "010.1.1.1"}) {
+            assertTrue(
+                    invalid(bad).getMessage().contains("格式非法"),
+                    "expected format error for: " + bad);
+        }
+    }
+
+    @Test
+    void 非字符串与空文本被拒绝() {
+        assertTrue(invalid(123).getMessage().contains("必须是字符串"));
+        assertTrue(invalid(null).getMessage().contains("必须是字符串"));
+        assertTrue(invalid("   ").getMessage().contains("不能为空"));
+    }
+}

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

@@ -0,0 +1,67 @@
+package com.dms.service;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.InetAddress;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 登录 IP 匹配单测(契约1.28,对齐 _ip_matches):
+ * 精确IP / CIDR / 起止范围三分支与脏数据安全降级(非法字面量不触发 DNS)。
+ */
+class LoginIpServiceMatchesTest {
+
+    private static boolean matches(String sourceIp, String value) throws Exception {
+        return LoginIpService.matchesValue(InetAddress.getByName(sourceIp), value);
+    }
+
+    @Test
+    void 精确IP匹配() throws Exception {
+        assertTrue(matches("10.10.10.10", "10.10.10.10"));
+        assertFalse(matches("10.10.10.11", "10.10.10.10"));
+    }
+
+    @Test
+    void CIDR网段匹配() throws Exception {
+        assertTrue(matches("192.168.10.55", "192.168.10.0/24"));
+        assertFalse(matches("192.168.11.55", "192.168.10.0/24"));
+    }
+
+    @Test
+    void 范围匹配含两端边界() throws Exception {
+        assertTrue(matches("172.16.20.10", "172.16.20.10-172.16.20.99"));
+        assertTrue(matches("172.16.20.55", "172.16.20.10-172.16.20.99"));
+        assertTrue(matches("172.16.20.99", "172.16.20.10-172.16.20.99"));
+        assertFalse(matches("172.16.20.100", "172.16.20.10-172.16.20.99"));
+        assertFalse(matches("172.16.20.9", "172.16.20.10-172.16.20.99"));
+    }
+
+    @Test
+    void 范围版本不一致时拒绝() throws Exception {
+        assertFalse(matches("::1", "172.16.20.10-172.16.20.99"));
+    }
+
+    @Test
+    void 脏数据按不匹配处理() throws Exception {
+        // 均为非法字面量,InetAddress 直接抛 UnknownHostException,不触发 DNS 查询
+        assertFalse(matches("10.10.10.10", "1.2.3.4.5"));
+        assertFalse(matches("10.10.10.10", "1.1.1.1-2.2.2.2-3.3.3.3"));
+        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", "无权限访问"));
         }
     }
 

+ 13 - 0
backend/.env.example

@@ -29,6 +29,19 @@ DMS_JWT_SECRET=replace-with-a-random-secret
 # 访问令牌有效期(秒),缺省 3600(1 小时);保持登录(7 天)不受此项影响
 DMS_JWT_EXPIRES_SECONDS=3600
 
+# 页面空闲自动登出(分钟),缺省 5:用户在页面上无鼠标/滚动/键盘等操作持续
+# 达到该时长后前端自动退出登录;设为 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)

+ 51 - 7
backend/dms/api/v1/auth.py

@@ -4,7 +4,7 @@ from __future__ import annotations
 
 from datetime import datetime, timezone
 
-from flask import request
+from flask import current_app, request
 from sqlalchemy import select
 
 from dms.api.v1.blueprint import api_v1
@@ -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):
@@ -121,6 +163,8 @@ def login():
             "accessToken": token,
             "tokenType": "Bearer",
             "expiresIn": expires_in,
+            # 页面空闲自动登出(契约1.29):分钟数,0 表示关闭
+            "idleLogoutMinutes": current_app.config["DMS_IDLE_LOGOUT_MINUTES"],
             "user": user_summary(
                 user, allowed_modules=allowed_modules_for_user(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")

+ 8 - 0
backend/dms/api/v1/organizations.py

@@ -14,6 +14,7 @@ from dms.services.organization_service import (
     organization_tree,
     parse_delete_row_version,
     parse_organization_id,
+    reorder_organizations,
     update_organization,
 )
 from dms.services.ip_entry_service import (
@@ -50,6 +51,13 @@ def create_organization_route():
     return success_response(create_organization(_json_body()), status=201)
 
 
+@api_v1.put("/organizations/reorder")
+@bearer_auth_required
+@admin_required
+def reorder_organizations_route():
+    return success_response(reorder_organizations(_json_body()))
+
+
 @api_v1.put("/organizations/<id>")
 @bearer_auth_required
 @admin_required

+ 9 - 0
backend/dms/api/v1/roles.py

@@ -20,6 +20,7 @@ from dms.services.role_service import (
     list_role_permissions,
     list_role_users,
     list_roles,
+    reorder_roles,
     update_role,
     update_role_permissions,
     update_role_users,
@@ -80,6 +81,14 @@ def create_role_route():
     return success_response(create_role(_json_body()), status=201)
 
 
+@api_v1.put("/roles/reorder")
+@bearer_auth_required
+@admin_required
+@role_edit_required
+def reorder_roles_route():
+    return success_response(reorder_roles(_json_body()))
+
+
 @api_v1.put("/roles/<id>")
 @bearer_auth_required
 @admin_required

+ 8 - 0
backend/dms/api/v1/users.py

@@ -19,6 +19,7 @@ from dms.services.user_service import (
     delete_user,
     list_users,
     parse_string_id,
+    reorder_users,
     reset_user_password,
     update_ui_theme,
     update_user,
@@ -117,6 +118,13 @@ def create_user_route():
     return success_response(create_user(_json_body()), status=201)
 
 
+@api_v1.put("/users/reorder")
+@bearer_auth_required
+@admin_required
+def reorder_users_route():
+    return success_response(reorder_users(_json_body()))
+
+
 @api_v1.put("/users/<id>")
 @bearer_auth_required
 @admin_required

+ 4 - 0
backend/dms/common/enums.py

@@ -138,6 +138,10 @@ class AuditAction(StrEnum):
     PREVIEW_CACHE_CLEAR = "PREVIEW_CACHE_CLEAR"
     FORCE_OFFLINE = "FORCE_OFFLINE"
     BACKUP_TEST_TARGET = "BACKUP_TEST_TARGET"
+    # 机构/用户/角色拖动排序(契约1.27)
+    REORDER_ORGANIZATION = "REORDER_ORGANIZATION"
+    REORDER_USER = "REORDER_USER"
+    REORDER_ROLE = "REORDER_ROLE"
 
 
 class AuditTarget(StrEnum):

+ 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

+ 29 - 0
backend/dms/config.py

@@ -46,6 +46,20 @@ def _positive_int(name: str, default: int) -> int:
     return value
 
 
+def _non_negative_int(name: str, default: int) -> int:
+    """非负整数(0 有语义:表示关闭该能力,如空闲登出)。"""
+    raw_value = os.environ.get(name)
+    if raw_value is None or not raw_value.strip():
+        return default
+    try:
+        value = int(raw_value)
+    except ValueError as exc:
+        raise ValueError(f"{name}必须是非负整数") from exc
+    if value < 0:
+        raise ValueError(f"{name}必须是非负整数")
+    return value
+
+
 def resolve_backup_config_path() -> Path:
     """备份配置文件路径:环境变量覆盖,缺省为工作目录(与 Java 版口径一致)。"""
     value = os.environ.get("DMS_BACKUP_CONFIG_PATH", "").strip()
@@ -154,6 +168,10 @@ def load_dms_config() -> dict[str, Any]:
             "DMS_JWT_EXPIRES_SECONDS", 3600
         ),
         "DMS_JWT_KEEP_SIGNED_IN_EXPIRES_SECONDS": 604800,
+        # 页面空闲自动登出(契约1.29):分钟数,0 表示关闭
+        "DMS_IDLE_LOGOUT_MINUTES": _non_negative_int(
+            "DMS_IDLE_LOGOUT_MINUTES", 5
+        ),
         # 契约1.21:应用层信封加密 KEK(base64 编码 32 字节);未配置则明文落盘
         "DMS_STORAGE_KEK_BYTES": load_kek(
             os.environ.get("DMS_STORAGE_KEK", "")
@@ -184,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 - 19
backend/dms/models/audit_log.py

@@ -13,26 +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', "
-            "'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,

+ 9 - 0
backend/dms/models/user.py

@@ -128,6 +128,15 @@ class User(BusinessTableMixin, db.Model):
         nullable=False,
         server_default=text("'blue'"),
     )
+    # 拖动排序号(契约1.27):拖拽重排按步长10全量重编号,排序号输入已废止
+    sort_no: Mapped[int] = mapped_column(nullable=False, server_default=text("0"))
+    # 用户级管理范围(契约1.30):NULL=全局、0=跟随本人所在机构(SELF 哨兵)、
+    # 机构ID=固定锚点;仅角色锚点为 SELF 时生效(实际范围以角色设置为准)
+    managed_org_id: Mapped[int | None] = mapped_column(
+        BigInteger,
+        nullable=True,
+        server_default=text("0"),
+    )
     active_marker: Mapped[int | None] = mapped_column(
         Integer(),
         Computed("IF(is_deleted = 0, 1, NULL)", persisted=False),

+ 15 - 0
backend/dms/resources/ui-dictionaries.zh-CN.json

@@ -444,6 +444,21 @@
         "code": "BACKUP_TEST_TARGET",
         "label": "备份目标测试",
         "sortNo": 440
+      },
+      {
+        "code": "REORDER_ORGANIZATION",
+        "label": "调整机构排序",
+        "sortNo": 450
+      },
+      {
+        "code": "REORDER_USER",
+        "label": "调整用户排序",
+        "sortNo": 460
+      },
+      {
+        "code": "REORDER_ROLE",
+        "label": "调整角色排序",
+        "sortNo": 470
       }
     ],
     "auditTargets": [

+ 68 - 10
backend/dms/services/admin_permission.py

@@ -26,10 +26,14 @@ MANAGED_ORG_SELF_ANCHOR = 0
 MANAGED_ORG_SELF = "SELF"
 
 
-def effective_manage_role(session, user: User) -> Role | None:
-    """生效角色:显式角色(活跃且启用)优先,否则回退默认角色(活跃且启用)。"""
-    if user.role_id is not None:
-        role = session.get(Role, user.role_id)
+def effective_role_snapshot(session, role_id: int | None) -> Role | None:
+    """按显式角色ID解析生效角色快照(活跃且启用优先,回退默认角色)。
+
+    与 effective_manage_role 同语义,用于尚未落库的目标用户
+    (创建/编辑用户时的防提权校验)。
+    """
+    if role_id is not None:
+        role = session.get(Role, role_id)
         if (
             role is not None
             and not role.is_deleted
@@ -45,6 +49,11 @@ def effective_manage_role(session, user: User) -> Role | None:
     )
 
 
+def effective_manage_role(session, user: User) -> Role | None:
+    """生效角色:显式角色(活跃且启用)优先,否则回退默认角色(活跃且启用)。"""
+    return effective_role_snapshot(session, user.role_id)
+
+
 def is_super_admin(user: User) -> bool:
     """内置 admin 账号恒为超管,不受三管理位与管理范围约束。"""
     return user.username == SUPER_ADMIN_USERNAME
@@ -150,9 +159,11 @@ def require_role_editor(session) -> User:
 def managed_scope_ids(session, user: User) -> set[int] | None:
     """操作者可管理的机构ID集合;None 表示不受范围约束(全局)。
 
-    super_admin 与生效角色锚点为 NULL 的管理员不受约束;锚点为 SELF
-    哨兵(0)时按用户自身所在部门收敛(无部门则无可管理范围);其余
-    按锚点部门子树(仅沿启用边)收敛。
+    实际范围以生效角色锚点为准(契约1.30):super_admin 与锚点 NULL 的
+    管理员不受约束;角色锚点为 SELF 哨兵(0)时回落用户级管理范围
+    (sys_user.managed_org_id)——NULL=全局、SELF(0)=用户所在机构
+    (无部门则无可管理范围)、机构ID=固定锚点;其余按锚点部门子树
+    (仅沿启用边)收敛。
     """
     if is_super_admin(user):
         return None
@@ -161,9 +172,15 @@ def managed_scope_ids(session, user: User) -> set[int] | None:
     if anchor is None:
         return None
     if anchor == MANAGED_ORG_SELF_ANCHOR:
-        if user.organization_id is None:
-            return set()
-        anchor = user.organization_id
+        # 角色「跟随用户设置」:取用户自身管理范围(契约1.30)
+        anchor = user.managed_org_id
+        if anchor is None:
+            return None
+        if anchor == MANAGED_ORG_SELF_ANCHOR:
+            # 用户范围「跟随所在机构」:按用户所在部门收敛
+            if user.organization_id is None:
+                return set()
+            anchor = user.organization_id
     organizations = session.scalars(
         select(Organization).where(Organization.is_deleted.is_(False))
     ).all()
@@ -192,3 +209,44 @@ def ensure_org_in_managed_scope(session, actor: User, org_id: int | None) -> Non
         return
     if org_id is None or org_id not in scope:
         raise ForbiddenError("目标超出管理范围")
+
+
+def ensure_user_scope_in_managed_scope(
+    session,
+    actor: User,
+    *,
+    role_code: str,
+    effective_role: Role | None,
+    managed_org_id: int | None,
+    organization_id: int | None,
+) -> None:
+    """防提权(契约1.30修订):目标用户最终生效管理范围不得超出操作者。
+
+    仅当目标为 ADMIN 且生效角色具备受锚点约束的管理位
+    (机构/用户编辑位)时校验——无管理位的角色(含默认角色)不构成提权。
+    目标生效锚点按「角色锚点优先,角色 SELF 回落用户级三态」解析:
+    解析为全局(角色锚点或用户级为 NULL)或范围外机构即拒绝;
+    用户级 SELF 收敛到目标所在机构子树(其机构已由既有校验限制在
+    操作者范围内,树传递性恒安全)。
+    """
+    scope = managed_scope_ids(session, actor)
+    if scope is None or role_code != RoleCode.ADMIN.value:
+        return
+    if effective_role is None or not (
+        effective_role.manage_org_edit or effective_role.manage_user_edit
+    ):
+        return
+    anchor = effective_role.managed_org_id
+    if anchor is None:
+        raise ForbiddenError("目标超出管理范围")
+    if anchor == MANAGED_ORG_SELF_ANCHOR:
+        if managed_org_id is None:
+            raise ForbiddenError("目标超出管理范围")
+        if managed_org_id == MANAGED_ORG_SELF_ANCHOR:
+            if organization_id is None:
+                return
+            anchor = organization_id
+        else:
+            anchor = managed_org_id
+    if anchor not in scope:
+        raise ForbiddenError("目标超出管理范围")

+ 4 - 2
backend/dms/services/attachment_binding_service.py

@@ -358,7 +358,8 @@ def reorder_main_plan_attachments(
     def operation() -> dict[str, int]:
         with transaction() as session:
             main = _locked_main(session, main_document_id)
-            require_plan_action(main, context, AllowedAction.BIND_ATTACHMENT)
+            # 挂载重排=调整主案挂载区顺序:按主案 EDIT 判定(移动=编辑,契约1.18修订)
+            require_plan_action(main, context, AllowedAction.EDIT)
             _check_version(main, expected_version)
             bindings = session.scalars(
                 select(AttachmentBinding)
@@ -492,7 +493,8 @@ def move_main_plan_attachment(
     def operation() -> dict[str, int]:
         with transaction() as session:
             main = _locked_main(session, main_document_id)
-            require_plan_action(main, context, AllowedAction.BIND_ATTACHMENT)
+            # 挂载移动与重排同权限口径:按主案 EDIT 判定(移动=编辑,契约1.18修订)
+            require_plan_action(main, context, AllowedAction.EDIT)
             _check_version(main, expected_version)
             bindings = session.scalars(
                 select(AttachmentBinding)

+ 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:

+ 65 - 15
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"}
@@ -62,7 +63,8 @@ def _exact_payload(payload: Any, expected: set[str]) -> dict[str, Any]:
 
 
 def normalize_ip_value(value: Any) -> str:
-    """规范化精确IP或CIDR网段文本;CIDR要求主机位为0(strict=True)。"""
+    """规范化精确IP、CIDR网段或IP范围文本;CIDR要求主机位为0(strict=True),
+    范围形式为「起IP-止IP」(两端同为IPv4或IPv6且起≤止)。"""
     if not isinstance(value, str):
         raise InvalidArgumentError("ipValue必须是字符串")
     text = value.strip()
@@ -72,16 +74,44 @@ def normalize_ip_value(value: Any) -> str:
         raise InvalidArgumentError(
             f"ipValue长度不能超过{IP_VALUE_MAX_LENGTH}"
         )
+    normalized = _normalize_ip_text(text)
+    # IPv6等形式的规范化展开可能长于输入文本,统一复查落库长度
+    if len(normalized) > IP_VALUE_MAX_LENGTH:
+        raise InvalidArgumentError(
+            f"ipValue长度不能超过{IP_VALUE_MAX_LENGTH}"
+        )
+    return normalized
+
+
+def _normalize_ip_text(text: str) -> str:
+    """按「含/为网段、含-为范围、否则精确IP」三分支解析规范化。"""
     try:
         if "/" in text:
             return str(ipaddress.ip_network(text, strict=True))
+        if "-" in text:
+            parts = text.split("-")
+            if len(parts) != 2:
+                raise ValueError(text)
+            start = ipaddress.ip_address(parts[0])
+            end = ipaddress.ip_address(parts[1])
+            if start.version != end.version:
+                raise InvalidArgumentError(
+                    f"IP范围两端必须同为IPv4或IPv6:{text}"
+                )
+            if start > end:
+                raise InvalidArgumentError(
+                    f"IP范围起始不能大于结束:{text}"
+                )
+            return f"{start}-{end}"
         return str(ipaddress.ip_address(text))
     except ValueError as exc:
         if "host bits" in str(exc):
             raise InvalidArgumentError(
                 f"网段要求主机位为0,请输入如192.168.1.0/24的形式:{text}"
             ) from exc
-        raise InvalidArgumentError(f"IP地址或网段格式非法:{text}") from exc
+        raise InvalidArgumentError(
+            f"IP地址、网段或IP范围格式非法:{text}"
+        ) from exc
 
 
 def _remark(value: Any) -> str:
@@ -388,9 +418,37 @@ def update_org_ip_assignments(
 # ─── 登录IP校验 ────────────────────────────────────────────────────────────
 
 
+def _ip_matches(
+    address: ipaddress.IPv4Address | ipaddress.IPv6Address, value: str
+) -> bool:
+    """单条ip_value(精确IP/CIDR网段/IP范围)是否命中来源地址;
+    存量数据格式异常按不匹配处理(安全优先),不中断登录流程判断。"""
+    try:
+        if "/" in value:
+            network = ipaddress.ip_network(value)
+            return address.version == network.version and address in network
+        if "-" in value:
+            parts = value.split("-")
+            if len(parts) != 2:
+                return False
+            start = ipaddress.ip_address(parts[0])
+            end = ipaddress.ip_address(parts[1])
+            return (
+                address.version == start.version == end.version
+                and start <= address <= end
+            )
+        return address == ipaddress.ip_address(value)
+    except ValueError:
+        return False
+
+
 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
@@ -416,16 +474,8 @@ def verify_login_ip(user: User) -> None:
     except ValueError as exc:
         raise LoginIpForbiddenError() from exc
     for value in ip_values:
-        try:
-            if "/" in value:
-                network = ipaddress.ip_network(value)
-                if address.version == network.version and address in network:
-                    return
-            elif address == ipaddress.ip_address(value):
-                return
-        except ValueError:
-            # 存量数据异常时按不匹配处理(安全优先),不中断登录流程判断
-            continue
+        if _ip_matches(address, value):
+            return
     raise LoginIpForbiddenError()
 
 

+ 103 - 3
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,17 +169,28 @@ 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 返回全部(含停用,管理界面可见可重新启用);传值时按状态过滤
     if status:
         try:
-            normalized_status = EnabledStatus(status).value
+            normalized_status: str | None = EnabledStatus(status).value
         except ValueError as exc:
             raise InvalidArgumentError("status必须是ENABLED或DISABLED") from exc
     else:
-        normalized_status = EnabledStatus.ENABLED.value
+        normalized_status = None
 
-    base_ids = {item.id for item in organizations if item.status == normalized_status}
+    base_ids = {
+        item.id
+        for item in organizations
+        if normalized_status is None or item.status == normalized_status
+    }
     if keyword and keyword.strip():
         needle = keyword.strip().casefold()
         base_ids = {
@@ -538,3 +550,91 @@ def parse_delete_row_version(value: str | None) -> int:
     if value is None or not value.isdecimal():
         raise InvalidArgumentError("rowVersion必须是非负整数")
     return int(value)
+
+
+REORDER_FIELDS = {"parentId", "orderedIds"}
+
+
+def reorder_organizations(payload: Any) -> dict[str, object]:
+    """机构树同级拖动重排(契约1.27):parentId为null表示顶层,否则为该父级直接子级。
+
+    orderedIds 为拖动后的目标顺序,同级槽位置换后按步长10重编号 sort_no;
+    范围校验(锚点管理员仅可重排管理范围子树)复用 ensure_org_in_managed_scope。
+    """
+    body = _exact_payload(payload, REORDER_FIELDS)
+    parent_id = _string_id(body["parentId"], field="parentId", nullable=True)
+    ordered_ids = body["orderedIds"]
+    if not isinstance(ordered_ids, list) or any(
+        not isinstance(item, str) or not item.isdecimal() or int(item) <= 0
+        for item in ordered_ids
+    ):
+        raise InvalidArgumentError("orderedIds必须是正整数形式的字符串ID数组")
+    if len(set(ordered_ids)) != len(ordered_ids):
+        raise InvalidArgumentError("orderedIds存在重复ID")
+    if len(ordered_ids) < 2:
+        raise InvalidArgumentError("orderedIds必须是至少包含2个ID的数组")
+    actor_id = get_auth_context().user_id
+
+    with transaction() as session:
+        actor = require_organization_editor(session)
+        if parent_id is not None:
+            parent = session.scalar(
+                select(Organization).where(
+                    Organization.id == parent_id,
+                    Organization.is_deleted.is_(False),
+                )
+            )
+            if parent is None:
+                raise ResourceNotFoundError("上级机构不存在")
+        ensure_org_in_managed_scope(session, actor, parent_id)
+        siblings = session.scalars(
+            select(Organization)
+            .where(
+                Organization.is_deleted.is_(False),
+                Organization.parent_id.is_(None)
+                if parent_id is None
+                else Organization.parent_id == parent_id,
+            )
+            .order_by(Organization.sort_no.asc(), Organization.id.asc())
+            .with_for_update()
+        ).all()
+        sibling_ids = {item.id for item in siblings}
+        if any(item_id not in sibling_ids for item_id in ordered_ids):
+            raise InvalidArgumentError("orderedIds包含不在同级范围内的机构")
+        all_ids = [item.id for item in siblings]
+        ordered = [int(item) for item in ordered_ids]
+        # 槽位置换:被提交ID按原槽位顺序排好,依次填入目标顺序
+        slot_ids = sorted(ordered, key=all_ids.index)
+        new_order = list(all_ids)
+        for position, item_id in enumerate(slot_ids):
+            new_order[all_ids.index(item_id)] = ordered[position]
+        row_by_id = {item.id: item for item in siblings}
+        now = _utc_now()
+        reordered_count = 0
+        for index, item_id in enumerate(new_order):
+            row = row_by_id[item_id]
+            target = (index + 1) * 10
+            if row.sort_no == target:
+                continue
+            row.sort_no = target
+            row.updated_by = actor_id
+            row.updated_at = now
+            row.row_version += 1
+            reordered_count += 1
+        first = row_by_id[ordered[0]]
+        session.add(
+            business_audit(
+                action=AuditAction.REORDER_ORGANIZATION,
+                target=AuditTarget.ORGANIZATION,
+                target_id=first.id,
+                target_name=first.org_name,
+                detail={
+                    "scope": serialize_id(parent_id)
+                    if parent_id is not None
+                    else "ROOT",
+                    "orderedIds": list(ordered_ids),
+                    "reorderedCount": reordered_count,
+                },
+            )
+        )
+    return {"reorderedCount": reordered_count}

+ 2 - 0
backend/dms/services/portal_auth_service.py

@@ -319,6 +319,8 @@ def setup_portal_password(
         "accessToken": token,
         "tokenType": "Bearer",
         "expiresIn": expires_in,
+        # 页面空闲自动登出(契约1.29):分钟数,0 表示关闭
+        "idleLogoutMinutes": current_app.config["DMS_IDLE_LOGOUT_MINUTES"],
         # 门户登录同样按文档权限动态计算模块可见性。
         "user": user_summary(user, allowed_modules=allowed_modules_for_user(user)),
     }

+ 74 - 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)
 
@@ -694,3 +699,71 @@ def update_role_users(role_id: int, payload: Any) -> dict[str, object]:
         "userIds": [serialize_id(v) for v in sorted(target_ids)],
         "rowVersion": role.row_version,
     }
+
+
+ROLE_REORDER_FIELDS = {"orderedIds"}
+
+
+def reorder_roles(payload: Any) -> dict[str, object]:
+    """角色列表拖动重排(契约1.27):全体角色槽位置换,步长10重编号 sort_no。
+
+    orderedIds 允许为当前分页视图的子集,被提交角色按原槽位次序互换
+    (未提交角色相对次序不变),与分类同级重排的置换语义一致;
+    权限由路由层 role_edit_required 把关。
+    """
+    body = _exact_payload(payload, ROLE_REORDER_FIELDS)
+    ordered_ids = body["orderedIds"]
+    if not isinstance(ordered_ids, list) or any(
+        not isinstance(item, str) or not item.isdecimal() or int(item) <= 0
+        for item in ordered_ids
+    ):
+        raise InvalidArgumentError("orderedIds必须是正整数形式的字符串ID数组")
+    if len(set(ordered_ids)) != len(ordered_ids):
+        raise InvalidArgumentError("orderedIds存在重复ID")
+    if len(ordered_ids) < 2:
+        raise InvalidArgumentError("orderedIds必须是至少包含2个ID的数组")
+    actor_id = get_auth_context().user_id
+
+    with transaction() as session:
+        rows = session.scalars(
+            select(Role)
+            .where(Role.is_deleted.is_(False))
+            .order_by(Role.sort_no.asc(), Role.id.asc())
+            .with_for_update()
+        ).all()
+        row_by_id = {row.id: row for row in rows}
+        all_ids = [row.id for row in rows]
+        ordered = [int(item) for item in ordered_ids]
+        if any(role_id not in row_by_id for role_id in ordered):
+            raise InvalidArgumentError("orderedIds包含无效角色")
+        # 槽位置换:被提交ID按原槽位顺序排好,依次填入目标顺序
+        slot_ids = sorted(ordered, key=all_ids.index)
+        new_order = list(all_ids)
+        for position, role_id in enumerate(slot_ids):
+            new_order[all_ids.index(role_id)] = ordered[position]
+        now = _utc_now()
+        reordered_count = 0
+        for index, role_id in enumerate(new_order):
+            row = row_by_id[role_id]
+            target = (index + 1) * 10
+            if row.sort_no == target:
+                continue
+            row.sort_no = target
+            row.updated_by = actor_id
+            row.updated_at = now
+            row.row_version += 1
+            reordered_count += 1
+        first = row_by_id[ordered[0]]
+        session.add(
+            business_audit(
+                action=AuditAction.REORDER_ROLE,
+                target=AuditTarget.ROLE,
+                target_id=first.id,
+                target_name=first.role_name,
+                detail={
+                    "orderedIds": list(ordered_ids),
+                    "reorderedCount": reordered_count,
+                },
+            )
+        )
+    return {"reorderedCount": reordered_count}

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

@@ -29,7 +29,7 @@ from dms.storage.paths import STORAGE_SUBDIRECTORIES
 
 logger = logging.getLogger(__name__)
 
-API_CONTRACT_VERSION = "1.26.0"
+API_CONTRACT_VERSION = "1.30.0"
 BACKEND_KIND = "PYTHON"
 
 try:

+ 195 - 21
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
 
@@ -39,12 +40,15 @@ from dms.services.admin_permission import (
     can_manage_system,
     can_view_audit_log,
     effective_manage_role,
+    effective_role_snapshot,
     ensure_org_in_managed_scope,
+    ensure_user_scope_in_managed_scope,
     is_super_admin,
     managed_scope_ids,
     require_user_editor,
 )
 from dms.services.audit_service import business_audit
+from dms.services.authorization_service import has_any_upload_permission
 
 
 ALLOWED_MODULES = {
@@ -77,6 +81,9 @@ UPDATE_FIELDS = {
     "rowVersion",
 }
 RESET_PASSWORD_FIELDS = {"newPassword", "rowVersion"}
+# 用户级管理范围(契约1.30)为可选字段:create 未提交缺省落 SELF(哨兵0),
+# update 未提交保持原值,兼容不带该字段的存量调用方
+OPTIONAL_MANAGED_ORG_FIELDS = {"managedOrgId"}
 
 
 def _utc_now() -> datetime:
@@ -226,20 +233,20 @@ def user_summary(
                 else False
             )
         ),
-        "managedOrgId": (
-            None
+        # 上传权限(契约1.31):生效角色方案权限矩阵任一 OWN 行 can_upload=True
+        "permUpload": (
+            True
             if super_admin
-            else (
-                MANAGED_ORG_SELF
-                if manage_role is not None
-                and manage_role.managed_org_id == MANAGED_ORG_SELF_ANCHOR
-                else (
-                    serialize_id(manage_role.managed_org_id)
-                    if manage_role is not None
-                    else None
-                )
+            else has_any_upload_permission(
+                manage_role.id if manage_role is not None else None
             )
         ),
+        # 用户级管理范围(契约1.30):null=全局、"SELF"=跟随本人所在机构(哨兵0)、
+        # 机构ID=固定锚点;实际生效范围以角色锚点为准,角色为 SELF 时才回落本设置。
+        # 内置 admin 恒全局。
+        "managedOrgId": (
+            None if super_admin else _managed_org_value(user.managed_org_id)
+        ),
         "lastLoginAt": _format_utc(user.last_login_at),
         "lastLoginIp": user.last_login_ip,
         "uiTheme": user.ui_theme,
@@ -289,19 +296,25 @@ 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)
-    if actor is not None:
-        scope = managed_scope_ids(db.session, actor)
-        if scope is not None:
-            filters.append(User.organization_id.in_(scope))
+    scope = managed_scope_ids(db.session, actor) if actor is not None else None
+    if scope is not None:
+        filters.append(User.organization_id.in_(scope))
     if organization_id is not None:
         organization_ids = (
             _descendant_ids(organization_id)
             if include_descendants
             else {organization_id}
         )
+        # 显式请求的机构不在管理范围内:明确报无权限,
+        # 不再静默交集返回空页(避免前端误显示"该机构无用户")
+        if scope is not None and not (scope & organization_ids):
+            raise ForbiddenError("无权限查看该机构的用户")
         filters.append(User.organization_id.in_(organization_ids))
     if keyword:
         pattern = f"%{keyword.strip()}%"
@@ -317,7 +330,7 @@ def list_users(
     users = db.session.scalars(
         select(User)
         .where(*filters)
-        .order_by(User.real_name.asc(), User.id.asc())
+        .order_by(User.sort_no.asc(), User.id.asc())
         .offset(page_request.offset)
         .limit(page_request.page_size)
     ).all()
@@ -389,10 +402,16 @@ def _row_version(value: Any) -> int:
     return value
 
 
-def _exact_payload(payload: Any, expected: set[str]) -> dict[str, Any]:
-    if not isinstance(payload, dict) or set(payload) != expected:
+def _exact_payload(
+    payload: Any,
+    expected: set[str],
+    *,
+    optional: set[str] | None = None,
+) -> dict[str, Any]:
+    allowed = expected | (optional or set())
+    if not isinstance(payload, dict) or not expected <= set(payload) <= allowed:
         raise InvalidArgumentError(
-            "请求体字段必须且只能包含:" + "、".join(sorted(expected))
+            "请求体字段必须且只能包含:" + "、".join(sorted(allowed))
         )
     return payload
 
@@ -432,6 +451,39 @@ def _resolve_role(session, role_id: int | None) -> tuple[int | None, str | None]
     return role.id, role.role_name
 
 
+def _optional_managed_org(session, value: Any) -> int | None:
+    """解析用户级管理范围三态(契约1.30)。
+
+    null/空串=全局、"SELF"=跟随本人所在机构(哨兵0)、机构ID=固定锚点;
+    锚点须指向未删除机构(与角色锚点同口径,不要求启用、不限操作者范围)。
+    """
+    if value is None or value == "":
+        return None
+    if value == MANAGED_ORG_SELF:
+        return MANAGED_ORG_SELF_ANCHOR
+    if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
+        raise InvalidArgumentError("managedOrgId必须是SELF、机构ID或null")
+    org_id = int(value)
+    exists = session.scalar(
+        select(Organization.id).where(
+            Organization.id == org_id,
+            Organization.is_deleted.is_(False),
+        )
+    )
+    if exists is None:
+        raise InvalidArgumentError("managedOrgId必须指向有效机构")
+    return org_id
+
+
+def _managed_org_value(value: int | None) -> str | None:
+    """用户级管理范围序列化:NULL→null、哨兵0→"SELF"、其余→机构ID字符串。"""
+    if value is None:
+        return None
+    if value == MANAGED_ORG_SELF_ANCHOR:
+        return MANAGED_ORG_SELF
+    return serialize_id(value)
+
+
 def _ensure_not_self(actor: Any, target_user_id: int, *, action: str) -> None:
     if actor.user_id == target_user_id:
         raise UserSelfProtectionError(
@@ -480,7 +532,9 @@ def _ensure_not_last_active_admin(session, user: User, *, reason: str) -> None:
 
 
 def create_user(payload: Any) -> dict[str, object]:
-    body = _exact_payload(payload, CREATE_FIELDS)
+    body = _exact_payload(
+        payload, CREATE_FIELDS, optional=OPTIONAL_MANAGED_ORG_FIELDS
+    )
     username = _required_text(body["username"], field="username", max_length=64)
     real_name = _required_text(body["realName"], field="realName", max_length=64)
     initial_password = _required_password(body["initialPassword"])
@@ -490,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:
@@ -507,6 +568,22 @@ def create_user(payload: Any) -> dict[str, object]:
                 raise ConflictError("用户名已存在")
             org_id, org_name = _resolve_organization(session, organization_id)
             resolved_role_id, resolved_role_name = _resolve_role(session, role_id)
+            # 缺省落 SELF(哨兵0):显式赋值不依赖数据库缺省,
+            # 保证未提交该字段的新建用户与迁移回填的存量行语义一致
+            managed_org_id = (
+                _optional_managed_org(session, body["managedOrgId"])
+                if "managedOrgId" in body
+                else MANAGED_ORG_SELF_ANCHOR
+            )
+            # 防提权(契约1.30修订):目标 ADMIN 的生效管理范围不得超出操作者
+            ensure_user_scope_in_managed_scope(
+                session,
+                actor,
+                role_code=role_code,
+                effective_role=effective_role_snapshot(session, resolved_role_id),
+                managed_org_id=managed_org_id,
+                organization_id=org_id,
+            )
             user = User(
                 username=username,
                 password_hash=hash_password(initial_password),
@@ -517,6 +594,7 @@ def create_user(payload: Any) -> dict[str, object]:
                 role_id=resolved_role_id,
                 security_level=security_level,
                 status=status,
+                managed_org_id=managed_org_id,
                 created_by=actor_id,
                 updated_by=actor_id,
             )
@@ -540,6 +618,7 @@ def create_user(payload: Any) -> dict[str, object]:
                         "roleName": resolved_role_name,
                         "securityLevel": user.security_level,
                         "status": user.status,
+                        "managedOrgId": _managed_org_value(user.managed_org_id),
                     },
                 )
             )
@@ -549,7 +628,9 @@ def create_user(payload: Any) -> dict[str, object]:
 
 
 def update_user(user_id: int, payload: Any) -> dict[str, object]:
-    body = _exact_payload(payload, UPDATE_FIELDS)
+    body = _exact_payload(
+        payload, UPDATE_FIELDS, optional=OPTIONAL_MANAGED_ORG_FIELDS
+    )
     real_name = _required_text(body["realName"], field="realName", max_length=64)
     organization_id = parse_string_id(
         body["organizationId"], name="organizationId"
@@ -592,6 +673,7 @@ def update_user(user_id: int, payload: Any) -> dict[str, object]:
             "roleId": serialize_id(user.role_id),
             "securityLevel": user.security_level,
             "status": user.status,
+            "managedOrgId": _managed_org_value(user.managed_org_id),
             "rowVersion": user.row_version,
         }
 
@@ -613,6 +695,27 @@ def update_user(user_id: int, payload: Any) -> dict[str, object]:
         ensure_org_in_managed_scope(session, actor_user, org_id)
         resolved_role_id, resolved_role_name = _resolve_role(session, new_role_id)
         role_assignment_changes = resolved_role_id != user.role_id
+        # 可选字段(契约1.30):未提交保持原值;调整不改变认证身份,
+        # 不触发二次认证、不失效旧 Token(与角色锚点同口径)
+        new_managed_org_id = (
+            _optional_managed_org(session, body["managedOrgId"])
+            if "managedOrgId" in body
+            else None
+        )
+        # 防提权(契约1.30修订):目标 ADMIN 最终生效管理范围不得超出操作者;
+        # 未提交 managedOrgId 时按保持原值判定。校验置于 reauth 之前,
+        # 避免换角色场景先抛 REAUTH_REQUIRED 掩盖越权拒绝。
+        final_managed_org_id = (
+            new_managed_org_id if "managedOrgId" in body else user.managed_org_id
+        )
+        ensure_user_scope_in_managed_scope(
+            session,
+            actor_user,
+            role_code=new_role_code,
+            effective_role=effective_role_snapshot(session, resolved_role_id),
+            managed_org_id=final_managed_org_id,
+            organization_id=org_id,
+        )
 
         # 敏感字段(密级/角色/状态)实际变化:强制操作人二次认证(契约1.21)
         if (
@@ -630,6 +733,8 @@ def update_user(user_id: int, payload: Any) -> dict[str, object]:
         user.role_id = resolved_role_id
         user.security_level = new_security_level
         user.status = new_status
+        if "managedOrgId" in body:
+            user.managed_org_id = new_managed_org_id
         user.updated_by = actor_id
         user.updated_at = now
         user.row_version += 1
@@ -652,6 +757,7 @@ def update_user(user_id: int, payload: Any) -> dict[str, object]:
             "roleId": serialize_id(user.role_id),
             "securityLevel": user.security_level,
             "status": user.status,
+            "managedOrgId": _managed_org_value(user.managed_org_id),
             "rowVersion": user.row_version,
         }
         session.add(
@@ -780,6 +886,74 @@ def reset_user_password(user_id: int, payload: Any) -> dict[str, object]:
 UI_THEMES = ("blue", "red", "green")
 
 
+REORDER_FIELDS = {"orderedIds"}
+
+
+def reorder_users(payload: Any) -> dict[str, object]:
+    """用户列表拖动重排(契约1.27):全体用户槽位置换,步长10重编号 sort_no。
+
+    orderedIds 允许为当前分页视图的子集,被提交用户按原槽位次序互换
+    (未提交用户相对次序不变),与分类同级重排的置换语义一致。
+    """
+    body = _exact_payload(payload, REORDER_FIELDS)
+    ordered_ids = body["orderedIds"]
+    if not isinstance(ordered_ids, list) or any(
+        not isinstance(item, str) or not item.isdecimal() or int(item) <= 0
+        for item in ordered_ids
+    ):
+        raise InvalidArgumentError("orderedIds必须是正整数形式的字符串ID数组")
+    if len(set(ordered_ids)) != len(ordered_ids):
+        raise InvalidArgumentError("orderedIds存在重复ID")
+    if len(ordered_ids) < 2:
+        raise InvalidArgumentError("orderedIds必须是至少包含2个ID的数组")
+    actor_id = get_auth_context().user_id
+    now = _utc_now()
+
+    with transaction() as session:
+        require_user_editor(session)
+        rows = session.scalars(
+            select(User)
+            .where(User.is_deleted.is_(False))
+            .order_by(User.sort_no.asc(), User.id.asc())
+            .with_for_update()
+        ).all()
+        row_by_id = {row.id: row for row in rows}
+        all_ids = [row.id for row in rows]
+        ordered = [int(item) for item in ordered_ids]
+        if any(user_id not in row_by_id for user_id in ordered):
+            raise ResourceNotFoundError("用户不存在")
+        # 槽位置换:被提交ID按原槽位顺序排好,依次填入目标顺序
+        slot_ids = sorted(ordered, key=all_ids.index)
+        new_order = list(all_ids)
+        for position, user_id in enumerate(slot_ids):
+            new_order[all_ids.index(user_id)] = ordered[position]
+        reordered_count = 0
+        for index, user_id in enumerate(new_order):
+            row = row_by_id[user_id]
+            target = (index + 1) * 10
+            if row.sort_no == target:
+                continue
+            row.sort_no = target
+            row.updated_by = actor_id
+            row.updated_at = now
+            row.row_version += 1
+            reordered_count += 1
+        first = row_by_id[ordered[0]]
+        session.add(
+            business_audit(
+                action=AuditAction.REORDER_USER,
+                target=AuditTarget.USER,
+                target_id=first.id,
+                target_name=first.real_name,
+                detail={
+                    "orderedIds": list(ordered_ids),
+                    "reorderedCount": reordered_count,
+                },
+            )
+        )
+    return {"reorderedCount": reordered_count}
+
+
 def update_ui_theme(payload: Any) -> dict[str, object]:
     """本人界面主题偏好(契约1.20):白名单校验,仅操作本人行。
 

+ 166 - 0
backend/migrations/versions/0031_user_sort_no.py

@@ -0,0 +1,166 @@
+"""机构/用户/角色拖动排序(契约1.27):sys_user 加排序号列、审计白名单扩展。
+
+Revision ID: 0031_user_sort_no
+Revises: 0030_role_edit_permission
+Create Date: 2026-09-11
+
+变更要点:
+1. sys_user 新增 sort_no 整型排序号(默认 0;机构/角色已有同名列,仅用户缺),
+   用户列表排序由 real_name 改为 sort_no,id,拖拽重排按步长10重编号;
+2. 重建 sys_audit_log 的 audit_action_type CHECK 白名单(0029 的 42 项 + 3 = 45 项:
+   REORDER_ORGANIZATION / REORDER_USER / REORDER_ROLE);
+3. 达梦兼容:约束名大写探测小写比较后 drop(同 0028/0029 做法);
+4. 升级幂等:加列先探测、CHECK drop-if-exists 后重建(与 Flyway V16 并行互不冲突);
+5. 降级拒绝已存在三个重排审计动作记录的库。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0031_user_sort_no"
+down_revision: str | None = "0030_role_edit_permission"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+# 0029 的 V10 全集(42 项)+ 本期新增动作
+AUDIT_ACTIONS_V11 = (
+    "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",
+)
+AUDIT_ACTIONS_V10 = AUDIT_ACTIONS_V11[:-3]
+
+ACTION_CONSTRAINT = "audit_action_type"
+
+REORDER_ACTIONS = (
+    "REORDER_ORGANIZATION",
+    "REORDER_USER",
+    "REORDER_ROLE",
+)
+
+
+def _check_sql(values: tuple[str, ...]) -> str:
+    # 安全性:values 为本文件内硬编码的枚举常量,非用户输入,无注入风险
+    inline = ", ".join(f"'{v}'" for v in values)
+    return f"action_type IN ({inline})"
+
+
+def _existing_check_names() -> set[str]:
+    # 达梦返回的约束名为大写,统一小写比较(同 0028/0029)。
+    return {
+        item["name"].lower()
+        for item in sa.inspect(op.get_bind()).get_check_constraints("sys_audit_log")
+    }
+
+
+def _drop_checks(names: set[str]) -> None:
+    if f"ck_sys_audit_log_{ACTION_CONSTRAINT}" in names:
+        op.drop_constraint(ACTION_CONSTRAINT, "sys_audit_log", type_="check")
+
+
+def _existing_user_columns() -> set[str]:
+    inspector = sa.inspect(op.get_bind())
+    return {
+        column["name"].lower()
+        for column in inspector.get_columns("sys_user")
+    }
+
+
+def _has_reorder_audit_records() -> bool:
+    placeholders = ", ".join(f":action_{i}" for i in range(len(REORDER_ACTIONS)))
+    params = {
+        f"action_{i}": action for i, action in enumerate(REORDER_ACTIONS)
+    }
+    result = op.get_bind().execute(
+        sa.text(
+            f"SELECT COUNT(*) FROM sys_audit_log "
+            f"WHERE action_type IN ({placeholders})"
+        ),
+        params,
+    )
+    return bool(result.scalar())
+
+
+def upgrade() -> None:
+    # 1. sys_user.sort_no 加列(先探测后执行,幂等)
+    if "sort_no" not in _existing_user_columns():
+        op.add_column(
+            "sys_user",
+            sa.Column(
+                "sort_no",
+                sa.Integer(),
+                nullable=False,
+                server_default=sa.text("0"),
+            ),
+        )
+
+    # 2. 审计动作白名单重建(drop-if-exists + 重建)
+    existing = _existing_check_names()
+    _drop_checks(existing)
+    op.create_check_constraint(
+        ACTION_CONSTRAINT,
+        "sys_audit_log",
+        _check_sql(AUDIT_ACTIONS_V11),
+    )
+
+
+def downgrade() -> None:
+    if _has_reorder_audit_records():
+        raise RuntimeError("已存在机构/用户/角色重排审计记录,拒绝有损降级")
+
+    if "sort_no" in _existing_user_columns():
+        op.drop_column("sys_user", "sort_no")
+
+    existing = _existing_check_names()
+    _drop_checks(existing)
+    op.create_check_constraint(
+        ACTION_CONSTRAINT,
+        "sys_audit_log",
+        _check_sql(AUDIT_ACTIONS_V10),
+    )

+ 59 - 0
backend/migrations/versions/0032_user_managed_org.py

@@ -0,0 +1,59 @@
+"""用户级管理范围(契约1.30):sys_user 加 managed_org_id 列。
+
+Revision ID: 0032_user_managed_org
+Revises: 0031_user_sort_no
+Create Date: 2026-09-12
+
+变更要点:
+1. sys_user 新增 managed_org_id(BIGINT NULL DEFAULT 0)三态:
+   NULL=全局、0=跟随该用户所在机构(SELF 哨兵)、机构ID=固定锚点;
+2. 角色锚点 "SELF"(0)的回落目标由「用户所在机构」改为「用户的管理范围」:
+   实际生效范围仍以角色锚点为准,仅角色为 SELF 时取用户设置(契约1.30);
+3. 存量回填哨兵 0(跟随所在机构),与升级前「角色 SELF → user.organization_id」
+   行为完全一致,无感切换;
+4. 幂等与双引擎互冲安全:加列先探测;回填仅在本次真正执行加列时进行,
+   防止 Alembic/Flyway 先后执行时把已显式设为 NULL(全局)的行覆盖回 0。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0032_user_managed_org"
+down_revision: str | None = "0031_user_sort_no"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _existing_user_columns() -> set[str]:
+    # 达梦返回的列名为大写,统一小写比较(同 0031)。
+    return {
+        column["name"].lower()
+        for column in sa.inspect(op.get_bind()).get_columns("sys_user")
+    }
+
+
+def upgrade() -> None:
+    if "managed_org_id" not in _existing_user_columns():
+        op.add_column(
+            "sys_user",
+            sa.Column(
+                "managed_org_id",
+                sa.BigInteger(),
+                nullable=True,
+                server_default=sa.text("0"),
+            ),
+        )
+        # 存量回填哨兵 0:仅随本次加列执行(见模块注释第 4 点)
+        op.get_bind().execute(
+            sa.text("UPDATE sys_user SET managed_org_id = 0 "
+                    "WHERE managed_org_id IS NULL")
+        )
+
+
+def downgrade() -> None:
+    if "managed_org_id" in _existing_user_columns():
+        op.drop_column("sys_user", "managed_org_id")

+ 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,无法还原,拒绝降级"
+    )

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini