diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java
index 7506c37fa..d53566a0a 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainAccommodationMapper.java
@@ -69,6 +69,18 @@ public interface TrainAccommodationMapper
*/
public int countActiveByDormId(@Param("dormId") String dormId);
+ /**
+ * 统计指定宿舍和床位当前处于有效状态 (0预分配, 1已入住) 的住宿记录数
+ *
+ * @param dormId 宿舍ID
+ * @param bedNo 床位号
+ * @param excludeAccommodationId 排除的自身住宿ID (更新时排除自身)
+ * @return 冲突占用数
+ */
+ public int countActiveByDormIdAndBedNo(@Param("dormId") String dormId,
+ @Param("bedNo") String bedNo,
+ @Param("excludeAccommodationId") Long excludeAccommodationId);
+
/**
* 新增住宿安排
*
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java
index cad9e4dae..6234b2898 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java
@@ -169,7 +169,17 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
+ ",当前有效人数: " + currentActive + "),无法继续分配");
}
- // 5. 设置默认属性
+ // 5. 验证床位唯一占用约束 (Active Bed Uniqueness Guard: MAJ-06)
+ if (accommodation.getBedNo() != null && !accommodation.getBedNo().trim().isEmpty())
+ {
+ int bedOccupied = trainAccommodationMapper.countActiveByDormIdAndBedNo(dormId, accommodation.getBedNo().trim(), null);
+ if (bedOccupied > 0)
+ {
+ throw new ServiceException("BED_ALREADY_OCCUPIED: 该床位当前已被占用 (宿舍: " + dormId + ",床位: " + accommodation.getBedNo() + ")");
+ }
+ }
+
+ // 6. 设置默认属性
if (accommodation.getAssignedTime() == null)
{
accommodation.setAssignedTime(DateUtils.getNowDate());
@@ -215,6 +225,18 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
throw new ServiceException("变更宿舍请使用专属调房换房流程 (transfer),禁止直接修改住宿记录的宿舍ID");
}
+ // 验证床位唯一占用约束 (更新时排除自身)
+ String targetDormId = accommodation.getDormId() != null ? accommodation.getDormId() : exist.getDormId();
+ String targetBedNo = accommodation.getBedNo() != null ? accommodation.getBedNo().trim() : (exist.getBedNo() != null ? exist.getBedNo().trim() : null);
+ if (targetBedNo != null && !targetBedNo.isEmpty())
+ {
+ int bedOccupied = trainAccommodationMapper.countActiveByDormIdAndBedNo(targetDormId, targetBedNo, exist.getAccommodationId());
+ if (bedOccupied > 0)
+ {
+ throw new ServiceException("BED_ALREADY_OCCUPIED: 该床位当前已被占用 (宿舍: " + targetDormId + ",床位: " + targetBedNo + ")");
+ }
+ }
+
accommodation.setUpdateTime(DateUtils.getNowDate());
return trainAccommodationMapper.updateTrainAccommodation(accommodation);
}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml
index 494b10e6d..c8ca7b33c 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml
+++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAccommodationMapper.xml
@@ -129,6 +129,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
AND accommodation_status IN (0, 1)
+
+
INSERT INTO train_accommodation
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java
index d11f6c250..d0661e7e3 100644
--- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java
@@ -609,6 +609,31 @@ public class AccommodationServiceTest
return count;
}
+ @Override
+ public int countActiveByDormIdAndBedNo(String dormId, String bedNo, Long excludeAccommodationId)
+ {
+ if (dormId == null || bedNo == null || bedNo.trim().isEmpty()) {
+ return 0;
+ }
+ int count = 0;
+ for (TrainAccommodation a : store.values())
+ {
+ if (excludeAccommodationId != null && excludeAccommodationId.equals(a.getAccommodationId()))
+ {
+ continue;
+ }
+ if (dormId.equals(a.getDormId()) && bedNo.equals(a.getBedNo()))
+ {
+ if (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus())
+ || TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus()))
+ {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+
@Override
public int insertTrainAccommodation(TrainAccommodation accommodation)
{
diff --git a/RuoYi-Vue/sql/system/kingbase/015_dms_package_a_hardening.sql b/RuoYi-Vue/sql/system/kingbase/015_dms_package_a_hardening.sql
new file mode 100644
index 000000000..3695a67d6
--- /dev/null
+++ b/RuoYi-Vue/sql/system/kingbase/015_dms_package_a_hardening.sql
@@ -0,0 +1,119 @@
+-- ============================================================================
+-- Migration: 015_dms_package_a_hardening.sql
+-- Description: Package A.1 DMS 一致性加固与最小权限拆分
+-- 1. 权限最小化拆分:新增 2194 (train:access:credential),从 train_admin 移除 2192 (train:access:manage)
+-- 2. 床位唯一性约束:检测无重复记录时创建有效床位部分唯一索引
+-- 3. 幂等与断言校验
+-- ============================================================================
+
+\set ON_ERROR_STOP on
+
+BEGIN;
+
+-- 1. 注册 2194 (train:access:credential) 门禁凭据下发与注销专用权限
+INSERT INTO public.sys_menu
+ (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache,
+ menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
+SELECT 2194, '门禁凭据操作', 2060, 4, '#', '', '', 1, 0,
+ 'F', '0', '0', 'train:access:credential', '#', 'admin', CURRENT_TIMESTAMP, '', NULL, '门禁凭据下发与注销权限'
+WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2194);
+
+-- 2. 超级管理员角色 (role_id = 1) 授予 2194
+INSERT INTO public.sys_role_menu (role_id, menu_id)
+SELECT 1, 2194
+WHERE NOT EXISTS (SELECT 1 FROM public.sys_role_menu WHERE role_id = 1 AND menu_id = 2194);
+
+-- 3. 培训管理员角色 (role_id = 3) 调整权限 (MIN-09):
+-- 授予 2194 (train:access:credential)
+INSERT INTO public.sys_role_menu (role_id, menu_id)
+SELECT 3, 2194
+WHERE NOT EXISTS (SELECT 1 FROM public.sys_role_menu WHERE role_id = 3 AND menu_id = 2194);
+
+-- 从 train_admin (role_id = 3) 回收 2192 (train:access:manage,硬件映射与维护权限)
+DELETE FROM public.sys_role_menu
+WHERE role_id = 3 AND menu_id = 2192;
+
+-- 从 train_admin (role_id = 3) 回收 2263 (train:accommodation:remove,物理删除住宿历史权限)
+DELETE FROM public.sys_role_menu
+WHERE role_id = 3 AND menu_id = 2263;
+
+-- 4. 有效床位唯一性约束 (Section 17)
+-- 先只读检查是否存在已有的活跃床位重复数据
+DO $$
+DECLARE
+ v_dup_groups INTEGER;
+BEGIN
+ SELECT COUNT(*) INTO v_dup_groups FROM (
+ SELECT dorm_id, bed_no
+ FROM train_accommodation
+ WHERE accommodation_status IN (0, 1)
+ AND bed_no IS NOT NULL
+ GROUP BY dorm_id, bed_no
+ HAVING COUNT(*) > 1
+ ) t;
+
+ IF v_dup_groups = 0 THEN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE c.relname = 'idx_train_acc_dorm_bed_active_uniq'
+ ) THEN
+ CREATE UNIQUE INDEX idx_train_acc_dorm_bed_active_uniq
+ ON train_accommodation (dorm_id, bed_no)
+ WHERE accommodation_status IN (0, 1) AND bed_no IS NOT NULL;
+ RAISE NOTICE 'Created partial unique index idx_train_acc_dorm_bed_active_uniq successfully.';
+ END IF;
+ ELSE
+ RAISE NOTICE 'ACTIVE_BED_DUPLICATE_GROUP_COUNT = %, skipping unique index creation to prevent business data destruction.', v_dup_groups;
+ END IF;
+END $$;
+
+-- 5. 状态机约束兼容性检查 (train_external_operation)
+-- 确保 operation_status 允许 DISPATCHING 与 AMBIGUOUS
+DO $$
+DECLARE
+ v_conname TEXT;
+BEGIN
+ SELECT conname INTO v_conname
+ FROM pg_constraint
+ WHERE conrelid = 'train_external_operation'::regclass
+ AND contype = 'c'
+ AND pg_get_constraintdef(oid) LIKE '%operation_status%';
+
+ IF v_conname IS NOT NULL THEN
+ EXECUTE 'ALTER TABLE train_external_operation DROP CONSTRAINT ' || quote_ident(v_conname);
+ EXECUTE 'ALTER TABLE train_external_operation ADD CONSTRAINT chk_external_operation_status ' ||
+ 'CHECK (operation_status IN (''CREATED'',''DISPATCHING'',''REQUESTED'',''ACCEPTED'',''PENDING'',''RETRY_WAIT'',''AMBIGUOUS'',''SUCCESS'',''FAILED'',''TIMEOUT''))';
+ END IF;
+END $$;
+
+-- 6. 后置断言验证
+DO $$
+BEGIN
+ -- 断言 1: 2194 已正确注册
+ IF (SELECT count(*) FROM public.sys_menu WHERE menu_id = 2194 AND perms = 'train:access:credential' AND status = '0') <> 1 THEN
+ RAISE EXCEPTION 'Assertion failed: menu 2194 train:access:credential not found or invalid';
+ END IF;
+
+ -- 断言 2: train_admin (role_id = 3) 拥有 2194 (train:access:credential)
+ IF (SELECT count(*) FROM public.sys_role_menu WHERE role_id = 3 AND menu_id = 2194) <> 1 THEN
+ RAISE EXCEPTION 'Assertion failed: role 3 not granted menu 2194';
+ END IF;
+
+ -- 断言 3: train_admin (role_id = 3) 不得拥有 2192 (train:access:manage)
+ IF (SELECT count(*) FROM public.sys_role_menu WHERE role_id = 3 AND menu_id = 2192) <> 0 THEN
+ RAISE EXCEPTION 'Assertion failed: role 3 still has menu 2192 (train:access:manage)';
+ END IF;
+
+ -- 断言 4: train_admin (role_id = 3) 不得拥有 2263 (train:accommodation:remove)
+ IF (SELECT count(*) FROM public.sys_role_menu WHERE role_id = 3 AND menu_id = 2263) <> 0 THEN
+ RAISE EXCEPTION 'Assertion failed: role 3 has forbidden menu 2263 (train:accommodation:remove)';
+ END IF;
+
+ -- 断言 5: train_admin 拥有 2264 (train:accommodation:add)
+ IF (SELECT count(*) FROM public.sys_role_menu WHERE role_id = 3 AND menu_id = 2264) <> 1 THEN
+ RAISE EXCEPTION 'Assertion failed: role 3 missing menu 2264 (train:accommodation:add)';
+ END IF;
+END $$;
+
+COMMIT;