新增住宿:对齐住宿与门禁授权领域模型

This commit is contained in:
wangzhibiao committed 2026-09-15 17:27:38 +08:00
1 parent cc911186aa
commit 5de1d2a142
21 files changed
+3345 -231

No files matched your search

@@ -0,0 +1,160 @@
package com.ruoyi.web.controller.train;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.service.ITrainAccommodationService;
/**
* 住宿安排 Controller
*
* @author ruoyi
* @date 2026-09-15
*/
@RestController
@RequestMapping("/train/accommodation")
public class TrainAccommodationController extends BaseController
{
@Autowired
private ITrainAccommodationService trainAccommodationService;
/**
* 查询住宿安排列表
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:list')")
@GetMapping("/list")
public TableDataInfo list(TrainAccommodation accommodation)
{
startPage();
List<TrainAccommodation> list = trainAccommodationService.selectTrainAccommodationList(accommodation);
return getDataTable(list);
}
/**
* 获取住宿安排详细信息
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:query')")
@GetMapping("/{accommodationId}")
public AjaxResult getInfo(@PathVariable("accommodationId") Long accommodationId)
{
return success(trainAccommodationService.selectTrainAccommodationByAccommodationId(accommodationId));
}
/**
* 根据参训档案ID查询住宿安排
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:query')")
@GetMapping("/enrollment/{enrollmentId}")
public AjaxResult getByEnrollmentId(@PathVariable("enrollmentId") Long enrollmentId)
{
return success(trainAccommodationService.selectActiveAccommodationByEnrollmentId(enrollmentId));
}
/**
* 根据参训档案ID查询该学员全部住宿历史
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:query')")
@GetMapping("/enrollment/{enrollmentId}/history")
public AjaxResult getHistoryByEnrollmentId(@PathVariable("enrollmentId") Long enrollmentId)
{
return success(trainAccommodationService.selectAccommodationHistoryByEnrollmentId(enrollmentId));
}
/**
* 新增住宿安排
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:add')")
@Log(title = "住宿安排", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainAccommodation accommodation)
{
accommodation.setCreateBy(getUsernameSafe());
return toAjax(trainAccommodationService.insertTrainAccommodation(accommodation));
}
/**
* 修改住宿安排
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:edit')")
@Log(title = "住宿安排", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainAccommodation accommodation)
{
accommodation.setUpdateBy(getUsernameSafe());
return toAjax(trainAccommodationService.updateTrainAccommodation(accommodation));
}
/**
* 办理入住
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:edit')")
@Log(title = "住宿安排-办理入住", businessType = BusinessType.UPDATE)
@PostMapping("/{accommodationId}/checkin")
public AjaxResult checkIn(@PathVariable("accommodationId") Long accommodationId)
{
return toAjax(trainAccommodationService.checkIn(accommodationId, getUsernameSafe()));
}
/**
* 办理退宿
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:edit')")
@Log(title = "住宿安排-办理退宿", businessType = BusinessType.UPDATE)
@PostMapping("/{accommodationId}/checkout")
public AjaxResult checkOut(@PathVariable("accommodationId") Long accommodationId)
{
return toAjax(trainAccommodationService.checkOut(accommodationId, getUsernameSafe()));
}
/**
* 办理调房
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:edit')")
@Log(title = "住宿安排-调房", businessType = BusinessType.UPDATE)
@PostMapping("/{accommodationId}/transfer")
public AjaxResult transfer(@PathVariable("accommodationId") Long accommodationId,
@RequestParam("newDormId") String newDormId,
@RequestParam("newBedNo") String newBedNo)
{
return success(trainAccommodationService.transfer(accommodationId, newDormId, newBedNo, getUsernameSafe()));
}
/**
* 删除住宿安排
*/
@PreAuthorize("@ss.hasPermi('train:accommodation:remove')")
@Log(title = "住宿安排", businessType = BusinessType.DELETE)
@DeleteMapping("/{accommodationIds}")
public AjaxResult remove(@PathVariable Long[] accommodationIds)
{
return toAjax(trainAccommodationService.deleteTrainAccommodationByAccommodationIds(accommodationIds));
}
private String getUsernameSafe()
{
try
{
return SecurityUtils.getUsername();
}
catch (Exception e)
{
return "admin";
}
}
}
@@ -20,12 +20,13 @@ import com.ruoyi.train.domain.TrainDormAuth;
import com.ruoyi.train.service.ITrainDormAuthService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.utils.SecurityUtils;
/**
* 宿舍权限分配Controller
*
* 宿舍门禁授权Controller (Database V2)
*
* @author ruoyi
* @date 2026-09-08
* @date 2026-09-15
*/
@RestController
@RequestMapping("/train/auth")
@@ -69,6 +70,16 @@ public class TrainDormAuthController extends BaseController
return success(trainDormAuthService.selectTrainDormAuthByAuthId(authId));
}
/**
* 根据住宿安排ID获取门禁授权详细信息
*/
@PreAuthorize("@ss.hasPermi('system:auth:query')")
@GetMapping(value = "/accommodation/{accommodationId}")
public AjaxResult getInfoByAccommodationId(@PathVariable("accommodationId") Long accommodationId)
{
return success(trainDormAuthService.selectTrainDormAuthByAccommodationId(accommodationId));
}
/**
* 新增宿舍权限分配
*/
@@ -77,6 +88,7 @@ public class TrainDormAuthController extends BaseController
@PostMapping
public AjaxResult add(@RequestBody TrainDormAuth trainDormAuth)
{
trainDormAuth.setCreateBy(getUsernameSafe());
return toAjax(trainDormAuthService.insertTrainDormAuth(trainDormAuth));
}
@@ -88,17 +100,41 @@ public class TrainDormAuthController extends BaseController
@PutMapping
public AjaxResult edit(@RequestBody TrainDormAuth trainDormAuth)
{
trainDormAuth.setUpdateBy(getUsernameSafe());
return toAjax(trainDormAuthService.updateTrainDormAuth(trainDormAuth));
}
/**
* 注销宿舍权限分配
*/
@PreAuthorize("@ss.hasPermi('system:auth:edit')")
@Log(title = "宿舍权限分配-注销", businessType = BusinessType.UPDATE)
@PostMapping("/{authId}/revoke")
public AjaxResult revoke(@PathVariable("authId") Long authId)
{
return toAjax(trainDormAuthService.revokeAuth(authId, getUsernameSafe()));
}
/**
* 删除宿舍权限分配
*/
@PreAuthorize("@ss.hasPermi('system:auth:remove')")
@Log(title = "宿舍权限分配", businessType = BusinessType.DELETE)
@DeleteMapping("/{authIds}")
@DeleteMapping("/{authIds}")
public AjaxResult remove(@PathVariable Long[] authIds)
{
return toAjax(trainDormAuthService.deleteTrainDormAuthByAuthIds(authIds));
}
private String getUsernameSafe()
{
try
{
return SecurityUtils.getUsername();
}
catch (Exception e)
{
return "admin";
}
}
}
@@ -0,0 +1,130 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import java.lang.reflect.Method;
import org.junit.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainDormAuth;
/**
* R.3B Wave 0 - 住宿与门禁授权 Controller API 契约与权限边界测试
*/
public class AccommodationAndAuthApiControllerContractTest
{
/**
* 验证 TrainAccommodationController 的路径与权限契约
*/
@Test
public void testAccommodationControllerContract() throws Exception
{
Class<TrainAccommodationController> clazz = TrainAccommodationController.class;
RequestMapping classMapping = clazz.getAnnotation(RequestMapping.class);
assertNotNull(classMapping);
assertEquals("/train/accommodation", classMapping.value()[0]);
// 1. GET /train/accommodation/list
Method listMethod = clazz.getMethod("list", TrainAccommodation.class);
assertNotNull(listMethod.getAnnotation(GetMapping.class));
assertEquals("/list", listMethod.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:list')", listMethod.getAnnotation(PreAuthorize.class).value());
// 2. GET /train/accommodation/{accommodationId}
Method infoMethod = clazz.getMethod("getInfo", Long.class);
assertNotNull(infoMethod.getAnnotation(GetMapping.class));
assertEquals("/{accommodationId}", infoMethod.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:query')", infoMethod.getAnnotation(PreAuthorize.class).value());
// 3. GET /train/accommodation/enrollment/{enrollmentId}
Method enrollMethod = clazz.getMethod("getByEnrollmentId", Long.class);
assertNotNull(enrollMethod.getAnnotation(GetMapping.class));
assertEquals("/enrollment/{enrollmentId}", enrollMethod.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:query')", enrollMethod.getAnnotation(PreAuthorize.class).value());
// 3b. GET /train/accommodation/enrollment/{enrollmentId}/history
Method historyMethod = clazz.getMethod("getHistoryByEnrollmentId", Long.class);
assertNotNull(historyMethod.getAnnotation(GetMapping.class));
assertEquals("/enrollment/{enrollmentId}/history", historyMethod.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:query')", historyMethod.getAnnotation(PreAuthorize.class).value());
// 4. POST /train/accommodation
Method addMethod = clazz.getMethod("add", TrainAccommodation.class);
assertNotNull(addMethod.getAnnotation(PostMapping.class));
assertEquals("@ss.hasPermi('train:accommodation:add')", addMethod.getAnnotation(PreAuthorize.class).value());
// 5. POST /train/accommodation/{accommodationId}/checkin
Method checkInMethod = clazz.getMethod("checkIn", Long.class);
assertNotNull(checkInMethod.getAnnotation(PostMapping.class));
assertEquals("/{accommodationId}/checkin", checkInMethod.getAnnotation(PostMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:edit')", checkInMethod.getAnnotation(PreAuthorize.class).value());
// 6. POST /train/accommodation/{accommodationId}/checkout
Method checkOutMethod = clazz.getMethod("checkOut", Long.class);
assertNotNull(checkOutMethod.getAnnotation(PostMapping.class));
assertEquals("/{accommodationId}/checkout", checkOutMethod.getAnnotation(PostMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:edit')", checkOutMethod.getAnnotation(PreAuthorize.class).value());
// 7. POST /train/accommodation/{accommodationId}/transfer
Method transferMethod = clazz.getMethod("transfer", Long.class, String.class, String.class);
assertNotNull(transferMethod.getAnnotation(PostMapping.class));
assertEquals("/{accommodationId}/transfer", transferMethod.getAnnotation(PostMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:edit')", transferMethod.getAnnotation(PreAuthorize.class).value());
// 8. DELETE /train/accommodation/{accommodationIds}
Method removeMethod = clazz.getMethod("remove", Long[].class);
assertNotNull(removeMethod.getAnnotation(DeleteMapping.class));
assertEquals("/{accommodationIds}", removeMethod.getAnnotation(DeleteMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:accommodation:remove')", removeMethod.getAnnotation(PreAuthorize.class).value());
}
/**
* 验证 TrainDormAuthController 的现有路径兼容性契约
*/
@Test
public void testDormAuthControllerContract() throws Exception
{
Class<TrainDormAuthController> clazz = TrainDormAuthController.class;
RequestMapping classMapping = clazz.getAnnotation(RequestMapping.class);
assertNotNull(classMapping);
assertEquals("/train/auth", classMapping.value()[0]);
// 1. GET /train/auth/list
Method listMethod = clazz.getMethod("list", TrainDormAuth.class);
assertNotNull(listMethod.getAnnotation(GetMapping.class));
assertEquals("/list", listMethod.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('system:auth:list')", listMethod.getAnnotation(PreAuthorize.class).value());
// 2. GET /train/auth/{authId}
Method infoMethod = clazz.getMethod("getInfo", Long.class);
assertNotNull(infoMethod.getAnnotation(GetMapping.class));
assertEquals("/{authId}", infoMethod.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('system:auth:query')", infoMethod.getAnnotation(PreAuthorize.class).value());
// 3. GET /train/auth/accommodation/{accommodationId}
Method accInfoMethod = clazz.getMethod("getInfoByAccommodationId", Long.class);
assertNotNull(accInfoMethod.getAnnotation(GetMapping.class));
assertEquals("/accommodation/{accommodationId}", accInfoMethod.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('system:auth:query')", accInfoMethod.getAnnotation(PreAuthorize.class).value());
// 4. POST /train/auth
Method addMethod = clazz.getMethod("add", TrainDormAuth.class);
assertNotNull(addMethod.getAnnotation(PostMapping.class));
assertEquals("@ss.hasPermi('system:auth:add')", addMethod.getAnnotation(PreAuthorize.class).value());
// 5. POST /train/auth/{authId}/revoke
Method revokeMethod = clazz.getMethod("revoke", Long.class);
assertNotNull(revokeMethod.getAnnotation(PostMapping.class));
assertEquals("/{authId}/revoke", revokeMethod.getAnnotation(PostMapping.class).value()[0]);
assertEquals("@ss.hasPermi('system:auth:edit')", revokeMethod.getAnnotation(PreAuthorize.class).value());
}
}
@@ -0,0 +1,87 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.junit.Test;
/**
* R.3B Wave 0.1 - 住宿历史模型与部分唯一索引 Migration 006 静态契约保护测试
*/
public class AccommodationHistoryMigrationContractTest
{
@Test
public void migrationMustFollowIdempotentPartialIndexContract() throws Exception
{
Path migrationPath = findMigration();
assertNotNull("006 migration file must exist", migrationPath);
String sql = new String(Files.readAllBytes(migrationPath), StandardCharsets.UTF_8).toLowerCase();
// 1. 验证前置脏数据检查 (Active duplicate pre-check)
assertContains(sql,
"select enrollment_id",
"from train_accommodation",
"where accommodation_status in (0, 1)",
"group by enrollment_id",
"having count(*) > 1",
"raise exception"
);
// 2. 验证旧约束检查与安全删除 (Existing Constraint Guard)
assertContains(sql,
"conname = 'uq_accommodation_enroll'",
"pg_get_constraintdef",
"unique (enrollment_id)",
"alter table train_accommodation drop constraint uq_accommodation_enroll"
);
// 3. 验证新部分唯一索引碰撞保护与创建 (New Index Collision Guard & Partial Unique Index)
assertContains(sql,
"uq_accommodation_enroll_active",
"create unique index uq_accommodation_enroll_active",
"on train_accommodation (enrollment_id)",
"where accommodation_status in (0, 1)"
);
// 4. 验证幂等性结构 (Idempotency Guard)
assertContains(sql,
"do $$",
"declare",
"begin",
"if v_con_def is not null then",
"if v_idx_def is not null then",
"end $$;"
);
// 5. 绝对禁止对业务行进行破坏性操作 (No data loss)
assertFalse("Migration must not truncate tables", sql.contains("truncate table"));
assertFalse("Migration must not delete business rows", sql.contains("delete from train_accommodation"));
assertFalse("Migration must not update business rows blindly", sql.contains("update train_accommodation set"));
}
private Path findMigration()
{
for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent())
{
Path candidate = path.resolve("RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql");
if (Files.isRegularFile(candidate)) return candidate;
Path candidateSub = path.resolve("sql/system/kingbase/006_accommodation_history_constraint.sql");
if (Files.isRegularFile(candidateSub)) return candidateSub;
}
throw new IllegalStateException("006_accommodation_history_constraint.sql not found from working directory");
}
private void assertContains(String sql, String... tokens)
{
for (String token : tokens)
{
assertTrue("Missing migration token: " + token, sql.contains(token));
}
}
}
@@ -0,0 +1,262 @@
package com.ruoyi.train.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 住宿安排领域实体对象 train_accommodation (Database V2)
*
* @author ruoyi
* @date 2026-09-15
*/
public class TrainAccommodation extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 住宿状态:0 预分配 */
public static final Integer STATUS_PREALLOCATED = 0;
/** 住宿状态:1 已入住 */
public static final Integer STATUS_CHECKED_IN = 1;
/** 住宿状态:2 已退宿 */
public static final Integer STATUS_CHECKED_OUT = 2;
/** 住宿安排ID (主键,自增) */
private Long accommodationId;
/** 参训档案ID (外键,关联train_enrollment.enrollment_id,唯一) */
@Excel(name = "参训档案ID")
private Long enrollmentId;
/** 宿舍ID (外键,关联train_dormitory_info.dorm_id) */
@Excel(name = "宿舍ID")
private String dormId;
/** 床位号 (如 1号床、A床等) */
@Excel(name = "床位号")
private String bedNo;
/** 分配时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "分配时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date assignedTime;
/** 住宿状态 (0: 预分配, 1: 已入住, 2: 已退宿) */
@Excel(name = "住宿状态", readConverterExp = "0=预分配,1=已入住,2=已退宿")
private Integer accommodationStatus;
/* ==================== 视图兼容字段 (非持久化列,联表展示) ==================== */
/** 学员学号 */
private String studentId;
/** 学员姓名 */
private String studentName;
/** 学员性别 */
private String gender;
/** 所属机构/选派单位 */
private String orgName;
/** 联系电话 */
private String phone;
/** 培训班计划ID */
private String planId;
/** 培训班计划名称 */
private String planName;
/** 宿舍楼栋 */
private String dormBuilding;
/** 宿舍房间号 */
private String dormRoom;
public TrainAccommodation()
{
}
public TrainAccommodation(Long enrollmentId, String dormId, String bedNo)
{
this.enrollmentId = enrollmentId;
this.dormId = dormId;
this.bedNo = bedNo;
this.assignedTime = new Date();
this.accommodationStatus = STATUS_PREALLOCATED;
}
public Long getAccommodationId()
{
return accommodationId;
}
public void setAccommodationId(Long accommodationId)
{
this.accommodationId = accommodationId;
}
public Long getEnrollmentId()
{
return enrollmentId;
}
public void setEnrollmentId(Long enrollmentId)
{
this.enrollmentId = enrollmentId;
}
public String getDormId()
{
return dormId;
}
public void setDormId(String dormId)
{
this.dormId = dormId;
}
public String getBedNo()
{
return bedNo;
}
public void setBedNo(String bedNo)
{
this.bedNo = bedNo;
}
public Date getAssignedTime()
{
return assignedTime;
}
public void setAssignedTime(Date assignedTime)
{
this.assignedTime = assignedTime;
}
public Integer getAccommodationStatus()
{
return accommodationStatus;
}
public void setAccommodationStatus(Integer accommodationStatus)
{
this.accommodationStatus = accommodationStatus;
}
public String getStudentId()
{
return studentId;
}
public void setStudentId(String studentId)
{
this.studentId = studentId;
}
public String getStudentName()
{
return studentName;
}
public void setStudentName(String studentName)
{
this.studentName = studentName;
}
public String getGender()
{
return gender;
}
public void setGender(String gender)
{
this.gender = gender;
}
public String getOrgName()
{
return orgName;
}
public void setOrgName(String orgName)
{
this.orgName = orgName;
}
public String getPhone()
{
return phone;
}
public void setPhone(String phone)
{
this.phone = phone;
}
public String getPlanId()
{
return planId;
}
public void setPlanId(String planId)
{
this.planId = planId;
}
public String getPlanName()
{
return planName;
}
public void setPlanName(String planName)
{
this.planName = planName;
}
public String getDormBuilding()
{
return dormBuilding;
}
public void setDormBuilding(String dormBuilding)
{
this.dormBuilding = dormBuilding;
}
public String getDormRoom()
{
return dormRoom;
}
public void setDormRoom(String dormRoom)
{
this.dormRoom = dormRoom;
}
@Override
public String toString()
{
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("accommodationId", getAccommodationId())
.append("enrollmentId", getEnrollmentId())
.append("dormId", getDormId())
.append("bedNo", getBedNo())
.append("assignedTime", getAssignedTime())
.append("accommodationStatus", getAccommodationStatus())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
.append("updateBy", getUpdateBy())
.append("updateTime", getUpdateTime())
.append("remark", getRemark())
.toString();
}
}
@@ -8,155 +8,307 @@ import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 宿舍权限分配对象 train_dorm_auth
*
* 宿舍门禁授权领域对象 train_dorm_auth (Database V2)
*
* @author ruoyi
* @date 2026-09-08
* @date 2026-09-15
*/
public class TrainDormAuth extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 权限分配ID */
/** 门禁授权状态:0 待下发 (本地待同步) */
public static final Integer STATUS_PENDING = 0;
/** 门禁授权状态:1 已下发 (设备端生效) */
public static final Integer STATUS_ACTIVE = 1;
/** 门禁授权状态:2 下发失败 (待重试) */
public static final Integer STATUS_FAILED = 2;
/** 门禁授权状态:3 已注销 (权限收回) */
public static final Integer STATUS_REVOKED = 3;
/** 授权主键ID */
private Long authId;
/** 学员学号,关联train_student.s_id */
@Excel(name = "学员学号,关联train_student.s_id")
private String studentId;
/** 所属开班计划ID,train_class_plan.plan_id */
@Excel(name = "所属开班计划ID,train_class_plan.plan_id")
private String planId;
/** 分配宿舍ID,train_dormitory_info.dorm_id */
@Excel(name = "分配宿舍ID,train_dormitory_info.dorm_id")
private String dormId;
/** 床位号 */
@Excel(name = "床位号")
private String bedNo;
/** 0 未下发人脸 1 已下发人脸至智能锁 2 下发失败 */
@Excel(name = "0 未下发人脸 1 已下发人脸至智能锁 2 下发失败")
private Integer faceSyncStatus;
/** 住宿安排ID,外键关联 train_accommodation.accommodation_id (V2核心业务标识) */
@Excel(name = "住宿安排ID")
private Long accommodationId;
/** 权限生效时间 */
@JsonFormat(pattern = "yyyy-MM-dd")
@Excel(name = "权限生效时间", width = 30, dateFormat = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "权限生效时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date authStart;
/** 权限失效时间 (开班结束自动失效) */
@JsonFormat(pattern = "yyyy-MM-dd")
@Excel(name = "权限失效时间 (开班结束自动失效)", width = 30, dateFormat = "yyyy-MM-dd")
/** 权限失效时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "权限失效时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date authEnd;
/** 创建管理员账号 */
@Excel(name = "创建管理员账号")
/** 授权同步状态 (0: 待下发, 1: 已下发, 2: 下发失败, 3: 已注销) */
@Excel(name = "授权同步状态", readConverterExp = "0=待下发,1=已下发,2=下发失败,3=已注销")
private Integer faceSyncStatus;
/** 重试次数 */
@Excel(name = "重试次数")
private Integer retryCount;
/** 最近一次同步错误信息 */
@Excel(name = "最近同步错误")
private String lastError;
/** 外部授权标识 (用于后续Adapter关联,本Wave仅持久化字段) */
@Excel(name = "外部授权标识")
private String externalAuthId;
/** 最近一次同步时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "最近同步时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date lastSyncTime;
/* ==================== 兼容视图字段 (非持久化列,联表查询展示与旧API兼容) ==================== */
/** 学员学号 (非物理列,联表自 train_student) */
private String studentId;
/** 学员姓名 (非物理列,联表自 train_student) */
private String studentName;
/** 开班计划ID (非物理列,联表自 train_enrollment) */
private String planId;
/** 开班计划名称 (非物理列,联表自 train_class_plan) */
private String planName;
/** 宿舍ID (非物理列,联表自 train_accommodation) */
private String dormId;
/** 宿舍楼栋 (非物理列,联表自 train_dormitory_info) */
private String dormBuilding;
/** 宿舍房间号 (非物理列,联表自 train_dormitory_info) */
private String dormRoom;
/** 床位号 (非物理列,联表自 train_accommodation) */
private String bedNo;
/** 创建管理员 (兼容旧字段,映射到 BaseEntity.createBy) */
private String createUser;
public void setAuthId(Long authId)
public TrainDormAuth()
{
this.authId = authId;
}
public Long getAuthId()
public TrainDormAuth(Long accommodationId, Date authStart, Date authEnd)
{
this.accommodationId = accommodationId;
this.authStart = authStart;
this.authEnd = authEnd;
this.faceSyncStatus = STATUS_PENDING;
this.retryCount = 0;
}
public Long getAuthId()
{
return authId;
}
public void setStudentId(String studentId)
public void setAuthId(Long authId)
{
this.studentId = studentId;
this.authId = authId;
}
public String getStudentId()
public Long getAccommodationId()
{
return studentId;
return accommodationId;
}
public void setPlanId(String planId)
public void setAccommodationId(Long accommodationId)
{
this.planId = planId;
this.accommodationId = accommodationId;
}
public String getPlanId()
{
return planId;
}
public void setDormId(String dormId)
{
this.dormId = dormId;
}
public String getDormId()
{
return dormId;
}
public void setBedNo(String bedNo)
{
this.bedNo = bedNo;
}
public String getBedNo()
{
return bedNo;
}
public void setFaceSyncStatus(Integer faceSyncStatus)
{
this.faceSyncStatus = faceSyncStatus;
}
public Integer getFaceSyncStatus()
{
return faceSyncStatus;
}
public void setAuthStart(Date authStart)
{
this.authStart = authStart;
}
public Date getAuthStart()
public Date getAuthStart()
{
return authStart;
}
public void setAuthEnd(Date authEnd)
public void setAuthStart(Date authStart)
{
this.authEnd = authEnd;
this.authStart = authStart;
}
public Date getAuthEnd()
public Date getAuthEnd()
{
return authEnd;
}
public void setCreateUser(String createUser)
public void setAuthEnd(Date authEnd)
{
this.createUser = createUser;
this.authEnd = authEnd;
}
public String getCreateUser()
public Integer getFaceSyncStatus()
{
return createUser;
return faceSyncStatus;
}
public void setFaceSyncStatus(Integer faceSyncStatus)
{
this.faceSyncStatus = faceSyncStatus;
}
public Integer getRetryCount()
{
return retryCount;
}
public void setRetryCount(Integer retryCount)
{
this.retryCount = retryCount;
}
public String getLastError()
{
return lastError;
}
public void setLastError(String lastError)
{
this.lastError = lastError;
}
public String getExternalAuthId()
{
return externalAuthId;
}
public void setExternalAuthId(String externalAuthId)
{
this.externalAuthId = externalAuthId;
}
public Date getLastSyncTime()
{
return lastSyncTime;
}
public void setLastSyncTime(Date lastSyncTime)
{
this.lastSyncTime = lastSyncTime;
}
public String getStudentId()
{
return studentId;
}
public void setStudentId(String studentId)
{
this.studentId = studentId;
}
public String getStudentName()
{
return studentName;
}
public void setStudentName(String studentName)
{
this.studentName = studentName;
}
public String getPlanId()
{
return planId;
}
public void setPlanId(String planId)
{
this.planId = planId;
}
public String getPlanName()
{
return planName;
}
public void setPlanName(String planName)
{
this.planName = planName;
}
public String getDormId()
{
return dormId;
}
public void setDormId(String dormId)
{
this.dormId = dormId;
}
public String getDormBuilding()
{
return dormBuilding;
}
public void setDormBuilding(String dormBuilding)
{
this.dormBuilding = dormBuilding;
}
public String getDormRoom()
{
return dormRoom;
}
public void setDormRoom(String dormRoom)
{
this.dormRoom = dormRoom;
}
public String getBedNo()
{
return bedNo;
}
public void setBedNo(String bedNo)
{
this.bedNo = bedNo;
}
public String getCreateUser()
{
return createUser != null ? createUser : getCreateBy();
}
public void setCreateUser(String createUser)
{
this.createUser = createUser;
if (getCreateBy() == null)
{
setCreateBy(createUser);
}
}
@Override
public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
public String toString()
{
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("authId", getAuthId())
.append("studentId", getStudentId())
.append("planId", getPlanId())
.append("dormId", getDormId())
.append("bedNo", getBedNo())
.append("faceSyncStatus", getFaceSyncStatus())
.append("accommodationId", getAccommodationId())
.append("authStart", getAuthStart())
.append("authEnd", getAuthEnd())
.append("createUser", getCreateUser())
.append("faceSyncStatus", getFaceSyncStatus())
.append("retryCount", getRetryCount())
.append("lastError", getLastError())
.append("externalAuthId", getExternalAuthId())
.append("lastSyncTime", getLastSyncTime())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
.append("updateBy", getUpdateBy())
.append("updateTime", getUpdateTime())
.append("remark", getRemark())
.toString();
@@ -0,0 +1,117 @@
package com.ruoyi.train.mapper;
import java.util.Date;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.train.domain.TrainAccommodation;
/**
* 住宿安排Mapper接口 train_accommodation
*
* @author ruoyi
* @date 2026-09-15
*/
public interface TrainAccommodationMapper
{
/**
* 根据主键查询住宿安排
*
* @param accommodationId 住宿安排主键
* @return 住宿安排
*/
public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId);
/**
* 根据参训档案ID查询住宿安排
*
* @param enrollmentId 参训档案ID
* @return 住宿安排
*/
public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId);
/**
* 根据参训档案ID查询当前有效住宿安排 (预分配或已入住)
*
* @param enrollmentId 参训档案ID
* @return 住宿安排
*/
public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId);
/**
* 根据参训档案ID查询该学员全部住宿历史记录 (包含退宿历史,按分配时间倒序)
*
* @param enrollmentId 参训档案ID
* @return 住宿历史记录列表
*/
public List<TrainAccommodation> selectAccommodationHistoryByEnrollmentId(Long enrollmentId);
/**
* 查询住宿安排列表
*
* @param accommodation 住宿安排查询条件
* @return 住宿安排集合
*/
public List<TrainAccommodation> selectTrainAccommodationList(TrainAccommodation accommodation);
/**
* 根据开班计划ID查询该班级下所有有效住宿安排 (准备用于结课批量撤权等)
*
* @param planId 开班计划ID
* @return 住宿安排集合
*/
public List<TrainAccommodation> selectActiveAccommodationsByPlanId(@Param("planId") String planId);
/**
* 统计指定宿舍当前有效住宿人数 (状态为 0 预分配 或 1 已入住)
*
* @param dormId 宿舍ID
* @return 有效住宿人数
*/
public int countActiveByDormId(@Param("dormId") String dormId);
/**
* 新增住宿安排
*
* @param accommodation 住宿安排
* @return 影响行数
*/
public int insertTrainAccommodation(TrainAccommodation accommodation);
/**
* 修改住宿安排
*
* @param accommodation 住宿安排
* @return 影响行数
*/
public int updateTrainAccommodation(TrainAccommodation accommodation);
/**
* 更新住宿状态
*
* @param accommodationId 住宿安排主键
* @param accommodationStatus 目标状态
* @param updateBy 更新人
* @param updateTime 更新时间
* @return 影响行数
*/
public int updateAccommodationStatus(@Param("accommodationId") Long accommodationId,
@Param("accommodationStatus") Integer accommodationStatus,
@Param("updateBy") String updateBy,
@Param("updateTime") Date updateTime);
/**
* 删除住宿安排
*
* @param accommodationId 住宿安排主键
* @return 影响行数
*/
public int deleteTrainAccommodationByAccommodationId(Long accommodationId);
/**
* 批量删除住宿安排
*
* @param accommodationIds 需要删除的住宿安排主键集合
* @return 影响行数
*/
public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds);
}
@@ -1,61 +1,101 @@
package com.ruoyi.train.mapper;
import java.util.Date;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.train.domain.TrainDormAuth;
/**
* 宿舍权限分配Mapper接口
*
* 宿舍门禁授权Mapper接口 train_dorm_auth (Database V2)
*
* @author ruoyi
* @date 2026-09-08
* @date 2026-09-15
*/
public interface TrainDormAuthMapper
public interface TrainDormAuthMapper
{
/**
* 查询宿舍权限分配
*
* @param authId 宿舍权限分配主键
* @return 宿舍权限分配
* 根据主键查询宿舍门禁授权
*
* @param authId 授权主键
* @return 门禁授权
*/
public TrainDormAuth selectTrainDormAuthByAuthId(Long authId);
/**
* 查询宿舍权限分配列表
*
* @param trainDormAuth 宿舍权限分配
* @return 宿舍权限分配集合
* 根据住宿安排ID查询门禁授权 (唯一对应)
*
* @param accommodationId 住宿安排主键
* @return 门禁授权
*/
public TrainDormAuth selectTrainDormAuthByAccommodationId(Long accommodationId);
/**
* 查询宿舍门禁授权列表 (支持联表视图字段过滤)
*
* @param trainDormAuth 授权查询条件
* @return 门禁授权集合
*/
public List<TrainDormAuth> selectTrainDormAuthList(TrainDormAuth trainDormAuth);
/**
* 新增宿舍权限分配
*
* @param trainDormAuth 宿舍权限分配
* @return 结果
* 新增门禁授权记录 (严格写入 V2 持久化列)
*
* @param trainDormAuth 门禁授权
* @return 影响行数
*/
public int insertTrainDormAuth(TrainDormAuth trainDormAuth);
/**
* 修改宿舍权限分配
*
* @param trainDormAuth 宿舍权限分配
* @return 结果
* 修改门禁授权记录 (严格更新 V2 持久化列)
*
* @param trainDormAuth 门禁授权
* @return 影响行数
*/
public int updateTrainDormAuth(TrainDormAuth trainDormAuth);
/**
* 删除宿舍权限分配
*
* @param authId 宿舍权限分配主键
* @return 结果
* 更新同步状态与错误摘要
*
* @param authId 授权主键
* @param faceSyncStatus 状态 (0:待下发, 1:已下发, 2:下发失败, 3:已注销)
* @param lastError 错误信息
* @param updateBy 更新人
* @param updateTime 更新时间
* @return 影响行数
*/
public int updateSyncStatus(@Param("authId") Long authId,
@Param("faceSyncStatus") Integer faceSyncStatus,
@Param("lastError") String lastError,
@Param("updateBy") String updateBy,
@Param("updateTime") Date updateTime);
/**
* 累加重试次数并记录最近错误
*
* @param authId 授权主键
* @param lastError 错误信息
* @param updateBy 更新人
* @param updateTime 更新时间
* @return 影响行数
*/
public int increaseRetryCount(@Param("authId") Long authId,
@Param("lastError") String lastError,
@Param("updateBy") String updateBy,
@Param("updateTime") Date updateTime);
/**
* 删除宿舍门禁授权
*
* @param authId 授权主键
* @return 影响行数
*/
public int deleteTrainDormAuthByAuthId(Long authId);
/**
* 批量删除宿舍权限分配
*
* @param authIds 需要删除的数据主键集合
* @return 结果
* 批量删除宿舍门禁授权
*
* @param authIds 需要删除的主键集合
* @return 影响行数
*/
public int deleteTrainDormAuthByAuthIds(Long[] authIds);
}
@@ -0,0 +1,122 @@
package com.ruoyi.train.service;
import java.util.List;
import com.ruoyi.train.domain.TrainAccommodation;
/**
* 住宿安排Service接口 train_accommodation
*
* @author ruoyi
* @date 2026-09-15
*/
public interface ITrainAccommodationService
{
/**
* 查询住宿安排详情
*
* @param accommodationId 住宿安排主键
* @return 住宿安排
*/
public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId);
/**
* 根据参训档案ID查询最新住宿安排
*
* @param enrollmentId 参训档案ID
* @return 住宿安排
*/
public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId);
/**
* 根据参训档案ID查询当前活跃/有效住宿安排 (预分配或已入住)
*
* @param enrollmentId 参训档案ID
* @return 活跃住宿安排,若无返回null
*/
public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId);
/**
* 根据参训档案ID查询该学员全部住宿历史记录 (包含退宿历史,按时间倒序排序)
*
* @param enrollmentId 参训档案ID
* @return 住宿历史记录列表
*/
public List<TrainAccommodation> selectAccommodationHistoryByEnrollmentId(Long enrollmentId);
/**
* 查询住宿安排列表
*
* @param accommodation 住宿安排过滤条件
* @return 住宿安排集合
*/
public List<TrainAccommodation> selectTrainAccommodationList(TrainAccommodation accommodation);
/**
* 查询指定开班计划下所有有效住宿安排
*
* @param planId 开班计划ID
* @return 有效住宿列表
*/
public List<TrainAccommodation> selectActiveAccommodationsByPlanId(String planId);
/**
* 创建住宿事实 (带参训档案、宿舍容量与唯一活跃约束校验)
*
* @param accommodation 住宿事实
* @return 结果
*/
public int insertTrainAccommodation(TrainAccommodation accommodation);
/**
* 修改住宿安排
*
* @param accommodation 住宿安排
* @return 结果
*/
public int updateTrainAccommodation(TrainAccommodation accommodation);
/**
* 学员办理入住 (状态 0预分配 -> 1已入住)
*
* @param accommodationId 住宿安排ID
* @param operator 操作人
* @return 结果
*/
public int checkIn(Long accommodationId, String operator);
/**
* 学员办理退宿 (状态 -> 2已退宿)
*
* @param accommodationId 住宿安排ID
* @param operator 操作人
* @return 结果
*/
public int checkOut(Long accommodationId, String operator);
/**
* 调房/换房业务操作 (原子事务:旧住宿转已退宿,新住宿创建预分配/入住)
*
* @param oldAccommodationId 原住宿安排ID
* @param newDormId 新宿舍ID
* @param newBedNo 新床位号
* @param operator 操作人
* @return 新生成的住宿安排
*/
public TrainAccommodation transfer(Long oldAccommodationId, String newDormId, String newBedNo, String operator);
/**
* 批量删除住宿安排 (受业务历史保护校验)
*
* @param accommodationIds 需要删除的主键集合
* @return 结果
*/
public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds);
/**
* 删除住宿安排
*
* @param accommodationId 需要删除的主键
* @return 结果
*/
public int deleteTrainAccommodationByAccommodationId(Long accommodationId);
}
@@ -4,57 +4,84 @@ import java.util.List;
import com.ruoyi.train.domain.TrainDormAuth;
/**
* 宿舍权限分配Service接口
*
* 宿舍门禁授权Service接口 train_dorm_auth (Database V2)
*
* @author ruoyi
* @date 2026-09-08
* @date 2026-09-15
*/
public interface ITrainDormAuthService
public interface ITrainDormAuthService
{
/**
* 查询宿舍权限分配
*
* @param authId 宿舍权限分配主键
* @return 宿舍权限分配
* 查询宿舍门禁授权
*
* @param authId 授权主键
* @return 宿舍门禁授权
*/
public TrainDormAuth selectTrainDormAuthByAuthId(Long authId);
/**
* 查询宿舍权限分配列表
*
* @param trainDormAuth 宿舍权限分配
* @return 宿舍权限分配集合
* 根据住宿安排ID查询门禁授权
*
* @param accommodationId 住宿安排主键
* @return 宿舍门禁授权
*/
public TrainDormAuth selectTrainDormAuthByAccommodationId(Long accommodationId);
/**
* 查询宿舍门禁授权列表
*
* @param trainDormAuth 授权查询条件
* @return 宿舍门禁授权集合
*/
public List<TrainDormAuth> selectTrainDormAuthList(TrainDormAuth trainDormAuth);
/**
* 新增宿舍权限分配
*
* @param trainDormAuth 宿舍权限分配
* 新增宿舍门禁授权 (以住宿事实为唯一合法来源)
*
* @param trainDormAuth 门禁授权
* @return 结果
*/
public int insertTrainDormAuth(TrainDormAuth trainDormAuth);
/**
* 修改宿舍权限分配
*
* @param trainDormAuth 宿舍权限分配
* 修改宿舍门禁授权
*
* @param trainDormAuth 门禁授权
* @return 结果
*/
public int updateTrainDormAuth(TrainDormAuth trainDormAuth);
/**
* 批量删除宿舍权限分配
*
* @param authIds 需要删除的宿舍权限分配主键集合
* 记录下发失败并累加重试次数
*
* @param authId 授权主键
* @param errorMessage 失败原因
* @param operator 操作人
* @return 结果
*/
public int markSyncFailed(Long authId, String errorMessage, String operator);
/**
* 注销门禁授权
*
* @param authId 授权主键
* @param operator 操作人
* @return 结果
*/
public int revokeAuth(Long authId, String operator);
/**
* 批量删除宿舍门禁授权
*
* @param authIds 需要删除的主键集合
* @return 结果
*/
public int deleteTrainDormAuthByAuthIds(Long[] authIds);
/**
* 删除宿舍权限分配信息
*
* @param authId 宿舍权限分配主键
* 删除宿舍门禁授权信息
*
* @param authId 主键
* @return 结果
*/
public int deleteTrainDormAuthByAuthId(Long authId);
@@ -0,0 +1,328 @@
package com.ruoyi.train.service.impl;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainDormitoryInfo;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.mapper.TrainAccommodationMapper;
import com.ruoyi.train.mapper.TrainDormitoryInfoMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.service.ITrainAccommodationService;
/**
* 住宿安排Service业务层处理
*
* @author ruoyi
* @date 2026-09-15
*/
@Service
public class TrainAccommodationServiceImpl implements ITrainAccommodationService
{
@Autowired
private TrainAccommodationMapper trainAccommodationMapper;
@Autowired
private TrainDormitoryInfoMapper trainDormitoryInfoMapper;
@Autowired
private TrainEnrollmentMapper trainEnrollmentMapper;
/**
* 查询住宿安排详情
*
* @param accommodationId 住宿安排主键
* @return 住宿安排
*/
@Override
public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId)
{
return trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId);
}
/**
* 根据参训档案ID查询最新住宿安排
*
* @param enrollmentId 参训档案ID
* @return 住宿安排
*/
@Override
public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId)
{
return trainAccommodationMapper.selectTrainAccommodationByEnrollmentId(enrollmentId);
}
/**
* 根据参训档案ID查询当前活跃/有效住宿安排
*
* @param enrollmentId 参训档案ID
* @return 活跃住宿安排
*/
@Override
public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId)
{
return trainAccommodationMapper.selectActiveAccommodationByEnrollmentId(enrollmentId);
}
/**
* 根据参训档案ID查询该学员全部住宿历史记录 (包含退宿历史,按时间倒序排序)
*
* @param enrollmentId 参训档案ID
* @return 住宿历史记录列表
*/
@Override
public List<TrainAccommodation> selectAccommodationHistoryByEnrollmentId(Long enrollmentId)
{
return trainAccommodationMapper.selectAccommodationHistoryByEnrollmentId(enrollmentId);
}
/**
* 查询住宿安排列表
*
* @param accommodation 住宿安排
* @return 住宿安排
*/
@Override
public List<TrainAccommodation> selectTrainAccommodationList(TrainAccommodation accommodation)
{
return trainAccommodationMapper.selectTrainAccommodationList(accommodation);
}
/**
* 查询指定开班计划下所有有效住宿安排
*
* @param planId 开班计划ID
* @return 有效住宿列表
*/
@Override
public List<TrainAccommodation> selectActiveAccommodationsByPlanId(String planId)
{
return trainAccommodationMapper.selectActiveAccommodationsByPlanId(planId);
}
/**
* 新增住宿安排 (带前置依赖、唯一活跃约束与宿舍容量校验)
*
* @param accommodation 住宿安排
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int insertTrainAccommodation(TrainAccommodation accommodation)
{
Long enrollmentId = accommodation.getEnrollmentId();
if (enrollmentId == null)
{
throw new ServiceException("参训档案ID (enrollmentId) 不能为空");
}
// 1. 验证参训档案是否存在
TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentById(enrollmentId);
if (enrollment == null)
{
throw new ServiceException("指定参训档案不存在,enrollmentId: " + enrollmentId);
}
// 2. 验证唯一活跃住宿约束 (Duplicate Active Accommodation Guard)
TrainAccommodation activeAcc = trainAccommodationMapper.selectActiveAccommodationByEnrollmentId(enrollmentId);
if (activeAcc != null)
{
throw new ServiceException("该参训学员已存在有效住宿安排 (ID: " + activeAcc.getAccommodationId()
+ ",宿舍: " + activeAcc.getDormId() + "),请先办理退宿或换房");
}
// 3. 验证宿舍存在性与可用状态
String dormId = accommodation.getDormId();
if (dormId == null || dormId.trim().isEmpty())
{
throw new ServiceException("分配宿舍ID (dormId) 不能为空");
}
TrainDormitoryInfo dorm = trainDormitoryInfoMapper.selectTrainDormitoryInfoByDormId(dormId);
if (dorm == null)
{
throw new ServiceException("指定宿舍不存在,dormId: " + dormId);
}
if (dorm.getDormStatus() != null && dorm.getDormStatus() == 0)
{
throw new ServiceException("该宿舍当前处于禁用状态,无法分配住宿");
}
// 4. 验证宿舍容量边界 (Capacity Boundary Guard)
int currentActive = trainAccommodationMapper.countActiveByDormId(dormId);
if (dorm.getCapacity() != null && currentActive >= dorm.getCapacity())
{
throw new ServiceException("宿舍容量已满 (核定容量: " + dorm.getCapacity()
+ ",当前有效人数: " + currentActive + "),无法继续分配");
}
// 5. 设置默认属性
if (accommodation.getAssignedTime() == null)
{
accommodation.setAssignedTime(DateUtils.getNowDate());
}
if (accommodation.getAccommodationStatus() == null)
{
accommodation.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED);
}
accommodation.setCreateTime(DateUtils.getNowDate());
return trainAccommodationMapper.insertTrainAccommodation(accommodation);
}
/**
* 修改住宿安排
*
* @param accommodation 住宿安排
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int updateTrainAccommodation(TrainAccommodation accommodation)
{
TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodation.getAccommodationId());
if (exist == null)
{
throw new ServiceException("住宿记录不存在: " + accommodation.getAccommodationId());
}
// 严格遵循 API Semantics 规范:禁止通过普通 update 修改 dorm_id 来模拟调房
if (accommodation.getDormId() != null && !accommodation.getDormId().equals(exist.getDormId()))
{
throw new ServiceException("变更宿舍请使用专属调房换房流程 (transfer),禁止直接修改住宿记录的宿舍ID");
}
accommodation.setUpdateTime(DateUtils.getNowDate());
return trainAccommodationMapper.updateTrainAccommodation(accommodation);
}
/**
* 学员办理入住 (状态 0预分配 -> 1已入住)
*
* @param accommodationId 住宿安排ID
* @param operator 操作人
* @return 结果
*/
@Override
public int checkIn(Long accommodationId, String operator)
{
TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId);
if (exist == null)
{
throw new ServiceException("住宿安排不存在: " + accommodationId);
}
if (TrainAccommodation.STATUS_CHECKED_OUT.equals(exist.getAccommodationStatus()))
{
throw new ServiceException("已退宿的记录不能重新办理入住");
}
return trainAccommodationMapper.updateAccommodationStatus(accommodationId,
TrainAccommodation.STATUS_CHECKED_IN, operator, DateUtils.getNowDate());
}
/**
* 学员办理退宿 (状态 -> 2已退宿)
*
* @param accommodationId 住宿安排ID
* @param operator 操作人
* @return 结果
*/
@Override
public int checkOut(Long accommodationId, String operator)
{
TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId);
if (exist == null)
{
throw new ServiceException("住宿安排不存在: " + accommodationId);
}
if (TrainAccommodation.STATUS_CHECKED_OUT.equals(exist.getAccommodationStatus()))
{
return 1; // 已经是退宿状态
}
return trainAccommodationMapper.updateAccommodationStatus(accommodationId,
TrainAccommodation.STATUS_CHECKED_OUT, operator, DateUtils.getNowDate());
}
/**
* 调房/换房业务操作 (原子事务:旧住宿转已退宿,新住宿创建预分配/入住)
*
* @param oldAccommodationId 原住宿安排ID
* @param newDormId 新宿舍ID
* @param newBedNo 新床位号
* @param operator 操作人
* @return 新生成的住宿安排
*/
@Override
@Transactional(rollbackFor = Exception.class)
public TrainAccommodation transfer(Long oldAccommodationId, String newDormId, String newBedNo, String operator)
{
TrainAccommodation oldAcc = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(oldAccommodationId);
if (oldAcc == null)
{
throw new ServiceException("原住宿记录不存在: " + oldAccommodationId);
}
if (TrainAccommodation.STATUS_CHECKED_OUT.equals(oldAcc.getAccommodationStatus()))
{
throw new ServiceException("原住宿记录已退宿,无法办理调房");
}
// 1. 结束旧住宿
trainAccommodationMapper.updateAccommodationStatus(oldAccommodationId,
TrainAccommodation.STATUS_CHECKED_OUT, operator, DateUtils.getNowDate());
// 2. 建立新住宿
TrainAccommodation newAcc = new TrainAccommodation();
newAcc.setEnrollmentId(oldAcc.getEnrollmentId());
newAcc.setDormId(newDormId);
newAcc.setBedNo(newBedNo);
newAcc.setAssignedTime(DateUtils.getNowDate());
newAcc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN);
newAcc.setCreateBy(operator);
newAcc.setRemark("由原住宿记录 " + oldAccommodationId + " (宿舍 " + oldAcc.getDormId() + ") 调房迁入");
insertTrainAccommodation(newAcc);
return newAcc;
}
/**
* 批量删除住宿安排
*
* @param accommodationIds 需要删除的主键集合
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds)
{
for (Long id : accommodationIds)
{
deleteTrainAccommodationByAccommodationId(id);
}
return accommodationIds.length;
}
/**
* 删除住宿安排
*
* @param accommodationId 需要删除的主键
* @return 结果
*/
@Override
public int deleteTrainAccommodationByAccommodationId(Long accommodationId)
{
TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId);
if (exist == null)
{
return 0;
}
// 业务历史保护:若为活跃已入住状态,应先退宿而不是直接物理删除
if (TrainAccommodation.STATUS_CHECKED_IN.equals(exist.getAccommodationStatus()))
{
throw new ServiceException("当前学员正处于已入住状态,请先办理退宿后再执行清理");
}
return trainAccommodationMapper.deleteTrainAccommodationByAccommodationId(accommodationId);
}
}
@@ -1,30 +1,38 @@
package com.ruoyi.train.service.impl;
import java.util.Date;
import java.util.List;
import com.ruoyi.common.utils.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.train.mapper.TrainDormAuthMapper;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainDormAuth;
import com.ruoyi.train.mapper.TrainAccommodationMapper;
import com.ruoyi.train.mapper.TrainDormAuthMapper;
import com.ruoyi.train.service.ITrainDormAuthService;
/**
* 宿舍权限分配Service业务层处理
*
* 宿舍门禁授权Service业务层处理 (Database V2)
*
* @author ruoyi
* @date 2026-09-08
* @date 2026-09-15
*/
@Service
public class TrainDormAuthServiceImpl implements ITrainDormAuthService
public class TrainDormAuthServiceImpl implements ITrainDormAuthService
{
@Autowired
private TrainDormAuthMapper trainDormAuthMapper;
@Autowired
private TrainAccommodationMapper trainAccommodationMapper;
/**
* 查询宿舍权限分配
*
* @param authId 宿舍权限分配主键
* @return 宿舍权限分配
* 查询宿舍门禁授权
*
* @param authId 宿舍门禁授权主键
* @return 宿舍门禁授权
*/
@Override
public TrainDormAuth selectTrainDormAuthByAuthId(Long authId)
@@ -33,10 +41,22 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService
}
/**
* 查询宿舍权限分配列表
*
* @param trainDormAuth 宿舍权限分配
* @return 宿舍权限分配
* 根据住宿安排ID查询门禁授权
*
* @param accommodationId 住宿安排主键
* @return 宿舍门禁授权
*/
@Override
public TrainDormAuth selectTrainDormAuthByAccommodationId(Long accommodationId)
{
return trainDormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId);
}
/**
* 查询宿舍门禁授权列表
*
* @param trainDormAuth 宿舍门禁授权
* @return 宿舍门禁授权集合
*/
@Override
public List<TrainDormAuth> selectTrainDormAuthList(TrainDormAuth trainDormAuth)
@@ -45,35 +65,122 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService
}
/**
* 新增宿舍权限分配
*
* @param trainDormAuth 宿舍权限分配
* 新增宿舍门禁授权 (以住宿事实为唯一业务来源,禁止绕过住宿直接创建)
*
* @param trainDormAuth 宿舍门禁授权
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int insertTrainDormAuth(TrainDormAuth trainDormAuth)
{
trainDormAuth.setCreateTime(DateUtils.getNowDate());
Long accommodationId = trainDormAuth.getAccommodationId();
if (accommodationId == null)
{
throw new ServiceException("门禁授权必须绑定住宿安排,accommodationId 不能为空");
}
// 1. 校验住宿事实有效性
TrainAccommodation accommodation = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId);
if (accommodation == null)
{
throw new ServiceException("指定的住宿安排不存在,ID: " + accommodationId);
}
if (TrainAccommodation.STATUS_CHECKED_OUT.equals(accommodation.getAccommodationStatus()))
{
throw new ServiceException("该住宿安排已退宿,无法新建门禁授权");
}
// 2. 校验唯一性 (每条住宿安排对应唯一授权流水)
TrainDormAuth existAuth = trainDormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId);
if (existAuth != null)
{
throw new ServiceException("该住宿安排已存在门禁授权 (AuthID: " + existAuth.getAuthId() + "),请勿重复创建");
}
// 3. 初始授权状态:本地待下发 (严格遵循 No Fake Device Success 原则)
if (trainDormAuth.getFaceSyncStatus() == null || TrainDormAuth.STATUS_ACTIVE.equals(trainDormAuth.getFaceSyncStatus()))
{
trainDormAuth.setFaceSyncStatus(TrainDormAuth.STATUS_PENDING);
}
if (trainDormAuth.getRetryCount() == null)
{
trainDormAuth.setRetryCount(0);
}
// 4. 继承操作人和时间
Date now = DateUtils.getNowDate();
trainDormAuth.setCreateTime(now);
if (trainDormAuth.getCreateUser() != null && trainDormAuth.getCreateBy() == null)
{
trainDormAuth.setCreateBy(trainDormAuth.getCreateUser());
}
return trainDormAuthMapper.insertTrainDormAuth(trainDormAuth);
}
/**
* 修改宿舍权限分配
*
* @param trainDormAuth 宿舍权限分配
* 修改宿舍门禁授权
*
* @param trainDormAuth 宿舍门禁授权
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int updateTrainDormAuth(TrainDormAuth trainDormAuth)
{
TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(trainDormAuth.getAuthId());
if (exist == null)
{
throw new ServiceException("门禁授权记录不存在: " + trainDormAuth.getAuthId());
}
trainDormAuth.setUpdateTime(DateUtils.getNowDate());
return trainDormAuthMapper.updateTrainDormAuth(trainDormAuth);
}
/**
* 批量删除宿舍权限分配
*
* @param authIds 需要删除的宿舍权限分配主键
* 记录下发失败并累加重试次数
*
* @param authId 授权主键
* @param errorMessage 失败原因
* @param operator 操作人
* @return 结果
*/
@Override
public int markSyncFailed(Long authId, String errorMessage, String operator)
{
TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(authId);
if (exist == null)
{
throw new ServiceException("门禁授权记录不存在: " + authId);
}
trainDormAuthMapper.updateSyncStatus(authId, TrainDormAuth.STATUS_FAILED, errorMessage, operator, DateUtils.getNowDate());
return trainDormAuthMapper.increaseRetryCount(authId, errorMessage, operator, DateUtils.getNowDate());
}
/**
* 注销门禁授权
*
* @param authId 授权主键
* @param operator 操作人
* @return 结果
*/
@Override
public int revokeAuth(Long authId, String operator)
{
TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(authId);
if (exist == null)
{
throw new ServiceException("门禁授权记录不存在: " + authId);
}
return trainDormAuthMapper.updateSyncStatus(authId, TrainDormAuth.STATUS_REVOKED, null, operator, DateUtils.getNowDate());
}
/**
* 批量删除宿舍门禁授权
*
* @param authIds 需要删除的主键集合
* @return 结果
*/
@Override
@@ -83,9 +190,9 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService
}
/**
* 删除宿舍权限分配信息
*
* @param authId 宿舍权限分配主键
* 删除宿舍门禁授权信息
*
* @param authId 主键
* @return 结果
*/
@Override
@@ -0,0 +1,192 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.train.mapper.TrainAccommodationMapper">
<resultMap type="TrainAccommodation" id="TrainAccommodationResult">
<result property="accommodationId" column="accommodation_id" />
<result property="enrollmentId" column="enrollment_id" />
<result property="dormId" column="dorm_id" />
<result property="bedNo" column="bed_no" />
<result property="assignedTime" column="assigned_time" />
<result property="accommodationStatus" column="accommodation_status" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
<result property="studentId" column="student_id" />
<result property="studentName" column="student_name" />
<result property="gender" column="gender" />
<result property="orgName" column="org_name" />
<result property="phone" column="phone" />
<result property="planId" column="plan_id" />
<result property="planName" column="plan_name" />
<result property="dormBuilding" column="dorm_building" />
<result property="dormRoom" column="dorm_room" />
</resultMap>
<sql id="selectTrainAccommodationVo">
SELECT
a.accommodation_id,
a.enrollment_id,
a.dorm_id,
a.bed_no,
a.assigned_time,
a.accommodation_status,
a.create_by,
a.create_time,
a.update_by,
a.update_time,
a.remark,
e.student_id,
s.s_name AS student_name,
s.s_gender AS gender,
s.org_name,
s.s_phone AS phone,
e.plan_id,
p.plan_name,
d.dorm_building,
d.dorm_room
FROM train_accommodation a
LEFT JOIN train_enrollment e ON a.enrollment_id = e.enrollment_id
LEFT JOIN train_student s ON e.student_id = s.s_id
LEFT JOIN train_class_plan p ON e.plan_id = p.plan_id
LEFT JOIN train_dormitory_info d ON a.dorm_id = d.dorm_id
</sql>
<select id="selectTrainAccommodationList" parameterType="TrainAccommodation" resultMap="TrainAccommodationResult">
<include refid="selectTrainAccommodationVo"/>
<where>
<if test="accommodationId != null">
AND a.accommodation_id = #{accommodationId}
</if>
<if test="enrollmentId != null">
AND a.enrollment_id = #{enrollmentId}
</if>
<if test="dormId != null and dormId != ''">
AND a.dorm_id = #{dormId}
</if>
<if test="bedNo != null and bedNo != ''">
AND a.bed_no LIKE concat('%', #{bedNo}, '%')
</if>
<if test="accommodationStatus != null">
AND a.accommodation_status = #{accommodationStatus}
</if>
<if test="planId != null and planId != ''">
AND e.plan_id = #{planId}
</if>
<if test="studentId != null and studentId != ''">
AND e.student_id = #{studentId}
</if>
<if test="dormBuilding != null and dormBuilding != ''">
AND d.dorm_building LIKE concat('%', #{dormBuilding}, '%')
</if>
<if test="dormRoom != null and dormRoom != ''">
AND d.dorm_room LIKE concat('%', #{dormRoom}, '%')
</if>
</where>
ORDER BY a.assigned_time DESC
</select>
<select id="selectTrainAccommodationByAccommodationId" parameterType="Long" resultMap="TrainAccommodationResult">
<include refid="selectTrainAccommodationVo"/>
WHERE a.accommodation_id = #{accommodationId}
</select>
<select id="selectTrainAccommodationByEnrollmentId" parameterType="Long" resultMap="TrainAccommodationResult">
<include refid="selectTrainAccommodationVo"/>
WHERE a.enrollment_id = #{enrollmentId}
ORDER BY a.assigned_time DESC
LIMIT 1
</select>
<select id="selectActiveAccommodationByEnrollmentId" parameterType="Long" resultMap="TrainAccommodationResult">
<include refid="selectTrainAccommodationVo"/>
WHERE a.enrollment_id = #{enrollmentId}
AND a.accommodation_status IN (0, 1)
LIMIT 1
</select>
<select id="selectAccommodationHistoryByEnrollmentId" parameterType="Long" resultMap="TrainAccommodationResult">
<include refid="selectTrainAccommodationVo"/>
WHERE a.enrollment_id = #{enrollmentId}
ORDER BY a.assigned_time DESC, a.accommodation_id DESC
</select>
<select id="selectActiveAccommodationsByPlanId" parameterType="String" resultMap="TrainAccommodationResult">
<include refid="selectTrainAccommodationVo"/>
WHERE e.plan_id = #{planId}
AND a.accommodation_status IN (0, 1)
ORDER BY a.assigned_time ASC
</select>
<select id="countActiveByDormId" parameterType="String" resultType="int">
SELECT COUNT(1)
FROM train_accommodation
WHERE dorm_id = #{dormId}
AND accommodation_status IN (0, 1)
</select>
<insert id="insertTrainAccommodation" parameterType="TrainAccommodation" useGeneratedKeys="true" keyProperty="accommodationId">
INSERT INTO train_accommodation
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="enrollmentId != null">enrollment_id,</if>
<if test="dormId != null and dormId != ''">dorm_id,</if>
<if test="bedNo != null and bedNo != ''">bed_no,</if>
<if test="assignedTime != null">assigned_time,</if>
<if test="accommodationStatus != null">accommodation_status,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="updateBy != null and updateBy != ''">update_by,</if>
<if test="updateTime != null">update_time,</if>
<if test="remark != null and remark != ''">remark,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="enrollmentId != null">#{enrollmentId},</if>
<if test="dormId != null and dormId != ''">#{dormId},</if>
<if test="bedNo != null and bedNo != ''">#{bedNo},</if>
<if test="assignedTime != null">#{assignedTime},</if>
<if test="accommodationStatus != null">#{accommodationStatus},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateBy != null and updateBy != ''">#{updateBy},</if>
<if test="updateTime != null">#{updateTime},</if>
<if test="remark != null and remark != ''">#{remark},</if>
</trim>
</insert>
<update id="updateTrainAccommodation" parameterType="TrainAccommodation">
UPDATE train_accommodation
<trim prefix="SET" suffixOverrides=",">
<if test="dormId != null and dormId != ''">dorm_id = #{dormId},</if>
<if test="bedNo != null and bedNo != ''">bed_no = #{bedNo},</if>
<if test="assignedTime != null">assigned_time = #{assignedTime},</if>
<if test="accommodationStatus != null">accommodation_status = #{accommodationStatus},</if>
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="remark != null">remark = #{remark},</if>
</trim>
WHERE accommodation_id = #{accommodationId}
</update>
<update id="updateAccommodationStatus">
UPDATE train_accommodation
SET accommodation_status = #{accommodationStatus},
update_by = #{updateBy},
update_time = #{updateTime}
WHERE accommodation_id = #{accommodationId}
</update>
<delete id="deleteTrainAccommodationByAccommodationId" parameterType="Long">
DELETE FROM train_accommodation WHERE accommodation_id = #{accommodationId}
</delete>
<delete id="deleteTrainAccommodationByAccommodationIds" parameterType="Long">
DELETE FROM train_accommodation WHERE accommodation_id IN
<foreach item="accommodationId" collection="array" open="(" separator="," close=")">
#{accommodationId}
</foreach>
</delete>
</mapper>
@@ -3,101 +3,194 @@
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.train.mapper.TrainDormAuthMapper">
<resultMap type="TrainDormAuth" id="TrainDormAuthResult">
<result property="authId" column="auth_id" />
<result property="studentId" column="student_id" />
<result property="planId" column="plan_id" />
<result property="dormId" column="dorm_id" />
<result property="bedNo" column="bed_no" />
<result property="authId" column="auth_id" />
<result property="accommodationId" column="accommodation_id" />
<result property="authStart" column="auth_start" />
<result property="authEnd" column="auth_end" />
<result property="faceSyncStatus" column="face_sync_status" />
<result property="authStart" column="auth_start" />
<result property="authEnd" column="auth_end" />
<result property="createUser" column="create_user" />
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
<result property="retryCount" column="retry_count" />
<result property="lastError" column="last_error" />
<result property="externalAuthId" column="external_auth_id" />
<result property="lastSyncTime" column="last_sync_time" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
<result property="studentId" column="student_id" />
<result property="studentName" column="student_name" />
<result property="planId" column="plan_id" />
<result property="planName" column="plan_name" />
<result property="dormId" column="dorm_id" />
<result property="dormBuilding" column="dorm_building" />
<result property="dormRoom" column="dorm_room" />
<result property="bedNo" column="bed_no" />
<result property="createUser" column="create_by" />
</resultMap>
<sql id="selectTrainDormAuthVo">
select auth_id, student_id, plan_id, dorm_id, bed_no, face_sync_status, auth_start, auth_end, create_user, create_time, update_time, remark from train_dorm_auth
SELECT
a.auth_id,
a.accommodation_id,
a.auth_start,
a.auth_end,
a.face_sync_status,
a.retry_count,
a.last_error,
a.external_auth_id,
a.last_sync_time,
a.create_by,
a.create_time,
a.update_by,
a.update_time,
a.remark,
acc.dorm_id,
acc.bed_no,
e.student_id,
s.s_name AS student_name,
e.plan_id,
p.plan_name,
d.dorm_building,
d.dorm_room
FROM train_dorm_auth a
LEFT JOIN train_accommodation acc ON a.accommodation_id = acc.accommodation_id
LEFT JOIN train_enrollment e ON acc.enrollment_id = e.enrollment_id
LEFT JOIN train_student s ON e.student_id = s.s_id
LEFT JOIN train_class_plan p ON e.plan_id = p.plan_id
LEFT JOIN train_dormitory_info d ON acc.dorm_id = d.dorm_id
</sql>
<select id="selectTrainDormAuthList" parameterType="TrainDormAuth" resultMap="TrainDormAuthResult">
<include refid="selectTrainDormAuthVo"/>
<where>
<if test="studentId != null and studentId != ''"> and student_id = #{studentId}</if>
<if test="planId != null and planId != ''"> and plan_id = #{planId}</if>
<if test="dormId != null and dormId != ''"> and dorm_id = #{dormId}</if>
<if test="bedNo != null and bedNo != ''"> and bed_no = #{bedNo}</if>
<if test="faceSyncStatus != null "> and face_sync_status = #{faceSyncStatus}</if>
<if test="authStart != null "> and auth_start = #{authStart}</if>
<if test="authEnd != null "> and auth_end = #{authEnd}</if>
<if test="createUser != null and createUser != ''"> and create_user = #{createUser}</if>
<where>
<if test="authId != null">
AND a.auth_id = #{authId}
</if>
<if test="accommodationId != null">
AND a.accommodation_id = #{accommodationId}
</if>
<if test="faceSyncStatus != null">
AND a.face_sync_status = #{faceSyncStatus}
</if>
<if test="authStart != null">
AND a.auth_start &gt;= #{authStart}
</if>
<if test="authEnd != null">
AND a.auth_end &lt;= #{authEnd}
</if>
<if test="studentId != null and studentId != ''">
AND e.student_id = #{studentId}
</if>
<if test="planId != null and planId != ''">
AND e.plan_id = #{planId}
</if>
<if test="dormId != null and dormId != ''">
AND acc.dorm_id = #{dormId}
</if>
<if test="bedNo != null and bedNo != ''">
AND acc.bed_no = #{bedNo}
</if>
<if test="createBy != null and createBy != ''">
AND a.create_by = #{createBy}
</if>
<if test="createUser != null and createUser != ''">
AND a.create_by = #{createUser}
</if>
</where>
ORDER BY a.create_time DESC
</select>
<select id="selectTrainDormAuthByAuthId" parameterType="Long" resultMap="TrainDormAuthResult">
<include refid="selectTrainDormAuthVo"/>
where auth_id = #{authId}
WHERE a.auth_id = #{authId}
</select>
<select id="selectTrainDormAuthByAccommodationId" parameterType="Long" resultMap="TrainDormAuthResult">
<include refid="selectTrainDormAuthVo"/>
WHERE a.accommodation_id = #{accommodationId}
LIMIT 1
</select>
<insert id="insertTrainDormAuth" parameterType="TrainDormAuth" useGeneratedKeys="true" keyProperty="authId">
insert into train_dorm_auth
INSERT INTO train_dorm_auth
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="studentId != null and studentId != ''">student_id,</if>
<if test="planId != null and planId != ''">plan_id,</if>
<if test="dormId != null and dormId != ''">dorm_id,</if>
<if test="bedNo != null">bed_no,</if>
<if test="faceSyncStatus != null">face_sync_status,</if>
<if test="accommodationId != null">accommodation_id,</if>
<if test="authStart != null">auth_start,</if>
<if test="authEnd != null">auth_end,</if>
<if test="createUser != null and createUser != ''">create_user,</if>
<if test="faceSyncStatus != null">face_sync_status,</if>
<if test="retryCount != null">retry_count,</if>
<if test="lastError != null and lastError != ''">last_error,</if>
<if test="externalAuthId != null and externalAuthId != ''">external_auth_id,</if>
<if test="lastSyncTime != null">last_sync_time,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="updateBy != null and updateBy != ''">update_by,</if>
<if test="updateTime != null">update_time,</if>
<if test="remark != null">remark,</if>
</trim>
<if test="remark != null and remark != ''">remark,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="studentId != null and studentId != ''">#{studentId},</if>
<if test="planId != null and planId != ''">#{planId},</if>
<if test="dormId != null and dormId != ''">#{dormId},</if>
<if test="bedNo != null">#{bedNo},</if>
<if test="faceSyncStatus != null">#{faceSyncStatus},</if>
<if test="accommodationId != null">#{accommodationId},</if>
<if test="authStart != null">#{authStart},</if>
<if test="authEnd != null">#{authEnd},</if>
<if test="createUser != null and createUser != ''">#{createUser},</if>
<if test="faceSyncStatus != null">#{faceSyncStatus},</if>
<if test="retryCount != null">#{retryCount},</if>
<if test="lastError != null and lastError != ''">#{lastError},</if>
<if test="externalAuthId != null and externalAuthId != ''">#{externalAuthId},</if>
<if test="lastSyncTime != null">#{lastSyncTime},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateBy != null and updateBy != ''">#{updateBy},</if>
<if test="updateTime != null">#{updateTime},</if>
<if test="remark != null">#{remark},</if>
</trim>
<if test="remark != null and remark != ''">#{remark},</if>
</trim>
</insert>
<update id="updateTrainDormAuth" parameterType="TrainDormAuth">
update train_dorm_auth
UPDATE train_dorm_auth
<trim prefix="SET" suffixOverrides=",">
<if test="studentId != null and studentId != ''">student_id = #{studentId},</if>
<if test="planId != null and planId != ''">plan_id = #{planId},</if>
<if test="dormId != null and dormId != ''">dorm_id = #{dormId},</if>
<if test="bedNo != null">bed_no = #{bedNo},</if>
<if test="faceSyncStatus != null">face_sync_status = #{faceSyncStatus},</if>
<if test="accommodationId != null">accommodation_id = #{accommodationId},</if>
<if test="authStart != null">auth_start = #{authStart},</if>
<if test="authEnd != null">auth_end = #{authEnd},</if>
<if test="createUser != null and createUser != ''">create_user = #{createUser},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="faceSyncStatus != null">face_sync_status = #{faceSyncStatus},</if>
<if test="retryCount != null">retry_count = #{retryCount},</if>
<if test="lastError != null">last_error = #{lastError},</if>
<if test="externalAuthId != null">external_auth_id = #{externalAuthId},</if>
<if test="lastSyncTime != null">last_sync_time = #{lastSyncTime},</if>
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="remark != null">remark = #{remark},</if>
</trim>
where auth_id = #{authId}
WHERE auth_id = #{authId}
</update>
<update id="updateSyncStatus">
UPDATE train_dorm_auth
SET face_sync_status = #{faceSyncStatus},
last_error = #{lastError},
update_by = #{updateBy},
update_time = #{updateTime}
WHERE auth_id = #{authId}
</update>
<update id="increaseRetryCount">
UPDATE train_dorm_auth
SET retry_count = COALESCE(retry_count, 0) + 1,
last_error = #{lastError},
update_by = #{updateBy},
update_time = #{updateTime}
WHERE auth_id = #{authId}
</update>
<delete id="deleteTrainDormAuthByAuthId" parameterType="Long">
delete from train_dorm_auth where auth_id = #{authId}
DELETE FROM train_dorm_auth WHERE auth_id = #{authId}
</delete>
<delete id="deleteTrainDormAuthByAuthIds" parameterType="String">
delete from train_dorm_auth where auth_id in
DELETE FROM train_dorm_auth WHERE auth_id IN
<foreach item="authId" collection="array" open="(" separator="," close=")">
#{authId}
</foreach>
</delete>
</mapper>
</mapper>
@@ -0,0 +1,585 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainDormitoryInfo;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.mapper.TrainAccommodationMapper;
import com.ruoyi.train.mapper.TrainDormitoryInfoMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
/**
* R.3B Wave 0 - 本地住宿领域对齐与约束单元测试 (AccommodationServiceTest)
* 覆盖规范第29条全部要求:A到I
*/
public class AccommodationServiceTest
{
private TrainAccommodationServiceImpl accommodationService;
private MockAccommodationMapper mockAccommodationMapper;
private Map<String, TrainDormitoryInfo> dormStore;
private Map<Long, TrainEnrollment> enrollmentStore;
@Before
public void setUp() throws Exception
{
accommodationService = new TrainAccommodationServiceImpl();
mockAccommodationMapper = new MockAccommodationMapper();
dormStore = new HashMap<>();
enrollmentStore = new HashMap<>();
TrainDormitoryInfoMapper dormMapperProxy = (TrainDormitoryInfoMapper) Proxy.newProxyInstance(
TrainDormitoryInfoMapper.class.getClassLoader(),
new Class<?>[]{TrainDormitoryInfoMapper.class},
(proxy, method, args) -> {
if ("selectTrainDormitoryInfoByDormId".equals(method.getName()))
{
return dormStore.get(args[0]);
}
if (method.getReturnType().equals(int.class)) return 0;
return null;
}
);
TrainEnrollmentMapper enrollMapperProxy = (TrainEnrollmentMapper) Proxy.newProxyInstance(
TrainEnrollmentMapper.class.getClassLoader(),
new Class<?>[]{TrainEnrollmentMapper.class},
(proxy, method, args) -> {
if ("selectTrainEnrollmentById".equals(method.getName()))
{
return enrollmentStore.get(args[0]);
}
if (method.getReturnType().equals(int.class)) return 0;
return null;
}
);
setField(accommodationService, "trainAccommodationMapper", mockAccommodationMapper);
setField(accommodationService, "trainDormitoryInfoMapper", dormMapperProxy);
setField(accommodationService, "trainEnrollmentMapper", enrollMapperProxy);
// 初始化预设基础数据
TrainDormitoryInfo dorm101 = new TrainDormitoryInfo();
dorm101.setDormId("DORM_101");
dorm101.setDormBuilding("培训1号楼");
dorm101.setDormFloor("1");
dorm101.setDormRoom("101");
dorm101.setCapacity(2); // 容量为2人
dorm101.setDormStatus(1); // 启用
dormStore.put(dorm101.getDormId(), dorm101);
TrainDormitoryInfo dormDisabled = new TrainDormitoryInfo();
dormDisabled.setDormId("DORM_DIS");
dormDisabled.setDormBuilding("培训1号楼");
dormDisabled.setDormRoom("102");
dormDisabled.setCapacity(4);
dormDisabled.setDormStatus(0); // 禁用
dormStore.put(dormDisabled.getDormId(), dormDisabled);
TrainEnrollment enrollment1 = new TrainEnrollment();
enrollment1.setEnrollmentId(1001L);
enrollment1.setPlanId("PLAN_2026_01");
enrollment1.setStudentId("STU_001");
enrollmentStore.put(enrollment1.getEnrollmentId(), enrollment1);
TrainEnrollment enrollment2 = new TrainEnrollment();
enrollment2.setEnrollmentId(1002L);
enrollment2.setPlanId("PLAN_2026_01");
enrollment2.setStudentId("STU_002");
enrollmentStore.put(enrollment2.getEnrollmentId(), enrollment2);
TrainEnrollment enrollment3 = new TrainEnrollment();
enrollment3.setEnrollmentId(1003L);
enrollment3.setPlanId("PLAN_2026_01");
enrollment3.setStudentId("STU_003");
enrollmentStore.put(enrollment3.getEnrollmentId(), enrollment3);
}
private void setField(Object target, String fieldName, Object value) throws Exception
{
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
/**
* Requirement A: Create accommodation
*/
@Test
public void testA_CreateAccommodationSuccess()
{
TrainAccommodation acc = new TrainAccommodation();
acc.setEnrollmentId(1001L);
acc.setDormId("DORM_101");
acc.setBedNo("1号床");
acc.setCreateBy("admin");
int rows = accommodationService.insertTrainAccommodation(acc);
assertEquals(1, rows);
assertNotNull(acc.getAccommodationId());
assertEquals(TrainAccommodation.STATUS_PREALLOCATED, acc.getAccommodationStatus());
assertNotNull(acc.getAssignedTime());
}
/**
* Requirement B: Query by enrollment
*/
@Test
public void testB_QueryByEnrollment()
{
TrainAccommodation acc = new TrainAccommodation();
acc.setEnrollmentId(1001L);
acc.setDormId("DORM_101");
acc.setBedNo("1号床");
accommodationService.insertTrainAccommodation(acc);
TrainAccommodation queried = accommodationService.selectTrainAccommodationByEnrollmentId(1001L);
assertNotNull(queried);
assertEquals("DORM_101", queried.getDormId());
assertEquals("1号床", queried.getBedNo());
TrainAccommodation active = accommodationService.selectActiveAccommodationByEnrollmentId(1001L);
assertNotNull(active);
assertEquals(acc.getAccommodationId(), active.getAccommodationId());
}
/**
* Requirement C: Status transition (0 预分配 -> 1 已入住 -> 2 已退宿)
*/
@Test
public void testC_StatusTransition()
{
TrainAccommodation acc = new TrainAccommodation();
acc.setEnrollmentId(1001L);
acc.setDormId("DORM_101");
acc.setBedNo("1号床");
accommodationService.insertTrainAccommodation(acc);
Long accId = acc.getAccommodationId();
// 1. 办理入住 (0 -> 1)
accommodationService.checkIn(accId, "operator1");
TrainAccommodation checkedIn = accommodationService.selectTrainAccommodationByAccommodationId(accId);
assertEquals(TrainAccommodation.STATUS_CHECKED_IN, checkedIn.getAccommodationStatus());
// 2. 办理退宿 (1 -> 2)
accommodationService.checkOut(accId, "operator2");
TrainAccommodation checkedOut = accommodationService.selectTrainAccommodationByAccommodationId(accId);
assertEquals(TrainAccommodation.STATUS_CHECKED_OUT, checkedOut.getAccommodationStatus());
// 3. 已退宿记录不能重新直接办理入住
try
{
accommodationService.checkIn(accId, "operator3");
fail("已退宿记录重新入住未被拦截");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("已退宿的记录不能重新办理入住"));
}
}
/**
* Requirement D: Active accommodation uniqueness
* 同一个 Enrollment 同一时刻最多 1 个有效住宿
*/
@Test
public void testD_ActiveAccommodationUniqueness()
{
TrainAccommodation acc1 = new TrainAccommodation();
acc1.setEnrollmentId(1001L);
acc1.setDormId("DORM_101");
acc1.setBedNo("1号床");
accommodationService.insertTrainAccommodation(acc1);
// 再次为该 enrollment 分配住宿,应被业务层拦截
TrainAccommodation acc2 = new TrainAccommodation();
acc2.setEnrollmentId(1001L);
acc2.setDormId("DORM_101");
acc2.setBedNo("2号床");
try
{
accommodationService.insertTrainAccommodation(acc2);
fail("重复分配活跃住宿未被拦截");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("已存在有效住宿安排"));
}
}
/**
* Requirement E: Invalid enrollment rejected
*/
@Test
public void testE_InvalidEnrollmentRejected()
{
TrainAccommodation acc = new TrainAccommodation();
acc.setEnrollmentId(999999L); // 不存在的参训档案
acc.setDormId("DORM_101");
acc.setBedNo("1号床");
try
{
accommodationService.insertTrainAccommodation(acc);
fail("不存在的参训档案未拦截");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("指定参训档案不存在"));
}
}
/**
* Requirement F: Invalid dorm rejected
*/
@Test
public void testF_InvalidDormRejected()
{
// 1. 宿舍不存在
TrainAccommodation acc1 = new TrainAccommodation();
acc1.setEnrollmentId(1001L);
acc1.setDormId("NON_EXIST_DORM");
acc1.setBedNo("1号床");
try
{
accommodationService.insertTrainAccommodation(acc1);
fail("不存在的宿舍未拦截");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("指定宿舍不存在"));
}
// 2. 宿舍已被禁用
TrainAccommodation acc2 = new TrainAccommodation();
acc2.setEnrollmentId(1001L);
acc2.setDormId("DORM_DIS");
acc2.setBedNo("1号床");
try
{
accommodationService.insertTrainAccommodation(acc2);
fail("已禁用的宿舍未拦截");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("该宿舍当前处于禁用状态"));
}
}
/**
* Requirement G: Capacity boundary
* DORM_101 容量为 2 人,已满后再分配第 3 人应拦截
*/
@Test
public void testG_CapacityBoundary()
{
// 第1人成功
TrainAccommodation acc1 = new TrainAccommodation();
acc1.setEnrollmentId(1001L);
acc1.setDormId("DORM_101");
acc1.setBedNo("1号床");
accommodationService.insertTrainAccommodation(acc1);
// 第2人成功
TrainAccommodation acc2 = new TrainAccommodation();
acc2.setEnrollmentId(1002L);
acc2.setDormId("DORM_101");
acc2.setBedNo("2号床");
accommodationService.insertTrainAccommodation(acc2);
// 第3人分配到同一房间,容量超限拦截
TrainAccommodation acc3 = new TrainAccommodation();
acc3.setEnrollmentId(1003L);
acc3.setDormId("DORM_101");
acc3.setBedNo("3号床");
try
{
accommodationService.insertTrainAccommodation(acc3);
fail("宿舍容量满额未拦截");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("宿舍容量已满"));
}
}
/**
* Requirement H: No physical bed table assumption
* bed_no 作为纯业务标识字符串持久化,不需要依赖物理床位表
*/
@Test
public void testH_NoPhysicalBedTableAssumption()
{
TrainAccommodation acc = new TrainAccommodation();
acc.setEnrollmentId(1001L);
acc.setDormId("DORM_101");
acc.setBedNo("上铺-VIP-01"); // 自定义床位标识
int rows = accommodationService.insertTrainAccommodation(acc);
assertEquals(1, rows);
TrainAccommodation saved = accommodationService.selectTrainAccommodationByAccommodationId(acc.getAccommodationId());
assertEquals("上铺-VIP-01", saved.getBedNo());
}
/**
* Requirement I: Ended accommodation historical query & transfer flow
* 退宿后历史记录可查,且退宿后允许为该 enrollment 分配新住宿 (调房业务)
*/
@Test
public void testI_EndedAccommodationHistoricalQueryAndTransfer()
{
// 1. 初始化新宿舍 DORM_201
TrainDormitoryInfo dorm201 = new TrainDormitoryInfo();
dorm201.setDormId("DORM_201");
dorm201.setDormBuilding("培训2号楼");
dorm201.setDormRoom("201");
dorm201.setCapacity(2);
dorm201.setDormStatus(1);
dormStore.put(dorm201.getDormId(), dorm201);
// 2. 创建初始住宿
TrainAccommodation oldAcc = new TrainAccommodation();
oldAcc.setEnrollmentId(1001L);
oldAcc.setDormId("DORM_101");
oldAcc.setBedNo("1号床");
accommodationService.insertTrainAccommodation(oldAcc);
Long oldId = oldAcc.getAccommodationId();
// 3. 执行调房
TrainAccommodation newAcc = accommodationService.transfer(oldId, "DORM_201", "新1号床", "admin");
assertNotNull(newAcc);
assertNotNull(newAcc.getAccommodationId());
assertEquals("DORM_201", newAcc.getDormId());
assertEquals("新1号床", newAcc.getBedNo());
assertEquals(TrainAccommodation.STATUS_CHECKED_IN, newAcc.getAccommodationStatus());
// 4. 验证旧住宿变为退宿状态但依然可查 (历史可审计)
TrainAccommodation oldUpdated = accommodationService.selectTrainAccommodationByAccommodationId(oldId);
assertNotNull(oldUpdated);
assertEquals(TrainAccommodation.STATUS_CHECKED_OUT, oldUpdated.getAccommodationStatus());
// 5. 验证活跃住宿查询当前指向新记录
TrainAccommodation active = accommodationService.selectActiveAccommodationByEnrollmentId(1001L);
assertNotNull(active);
assertEquals(newAcc.getAccommodationId(), active.getAccommodationId());
assertEquals("DORM_201", active.getDormId());
}
/**
* Wave 0 Review Gate - Requirement 14:
* A. Create first active accommodation PASS
* B. Create second active accommodation REJECT
* C. Checkout first accommodation
* D. Create second accommodation for same enrollment PASS
* E. History returns 2 rows
* F. Capacity only counts second active row
*/
@Test
public void testAccommodationHistoryAndCardinalityLifecycle()
{
Long enrollId = 1001L;
// A. Create first active accommodation PASS
TrainAccommodation acc1 = new TrainAccommodation();
acc1.setEnrollmentId(enrollId);
acc1.setDormId("DORM_101");
acc1.setBedNo("1号床");
int row1 = accommodationService.insertTrainAccommodation(acc1);
assertEquals(1, row1);
assertNotNull(acc1.getAccommodationId());
// B. Create second active accommodation REJECT (同一时刻最多1条有效住宿)
TrainAccommodation acc2 = new TrainAccommodation();
acc2.setEnrollmentId(enrollId);
acc2.setDormId("DORM_101");
acc2.setBedNo("2号床");
try
{
accommodationService.insertTrainAccommodation(acc2);
fail("活跃记录存在时重复新增应被拦截");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("已存在有效住宿安排"));
}
// C. Checkout first accommodation (退宿后变为状态 2)
accommodationService.checkOut(acc1.getAccommodationId(), "admin");
TrainAccommodation checkedOutAcc = accommodationService.selectTrainAccommodationByAccommodationId(acc1.getAccommodationId());
assertEquals(TrainAccommodation.STATUS_CHECKED_OUT, checkedOutAcc.getAccommodationStatus());
// D. Create second accommodation for same enrollment PASS (退宿后允许同一学员建立新住宿)
TrainAccommodation acc3 = new TrainAccommodation();
acc3.setEnrollmentId(enrollId);
acc3.setDormId("DORM_101");
acc3.setBedNo("2号床");
int row3 = accommodationService.insertTrainAccommodation(acc3);
assertEquals(1, row3);
assertNotNull(acc3.getAccommodationId());
// E. History returns 2 rows (包含1条退宿历史 + 1条当前活跃)
List<TrainAccommodation> history = accommodationService.selectAccommodationHistoryByEnrollmentId(enrollId);
assertNotNull(history);
assertEquals(2, history.size());
// 验证当前活跃记录只返回新记录
TrainAccommodation active = accommodationService.selectActiveAccommodationByEnrollmentId(enrollId);
assertNotNull(active);
assertEquals(acc3.getAccommodationId(), active.getAccommodationId());
// F. Capacity only counts second active row (历史退宿记录不计入核定容量,当前只计1人)
int activeCount = mockAccommodationMapper.countActiveByDormId("DORM_101");
assertEquals(1, activeCount);
}
// ================================= 内部 Mock 类 =================================
private static class MockAccommodationMapper implements TrainAccommodationMapper
{
private final Map<Long, TrainAccommodation> store = new HashMap<>();
private long idSeq = 100L;
@Override
public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId)
{
return store.get(accommodationId);
}
@Override
public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId)
{
TrainAccommodation latest = null;
for (TrainAccommodation a : store.values())
{
if (enrollmentId.equals(a.getEnrollmentId()))
{
latest = a;
}
}
return latest;
}
@Override
public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId)
{
for (TrainAccommodation a : store.values())
{
if (enrollmentId.equals(a.getEnrollmentId()))
{
if (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus())
|| TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus()))
{
return a;
}
}
}
return null;
}
@Override
public List<TrainAccommodation> selectAccommodationHistoryByEnrollmentId(Long enrollmentId)
{
List<TrainAccommodation> list = new ArrayList<>();
for (TrainAccommodation a : store.values())
{
if (enrollmentId.equals(a.getEnrollmentId()))
{
list.add(a);
}
}
list.sort((o1, o2) -> o2.getAccommodationId().compareTo(o1.getAccommodationId()));
return list;
}
@Override
public List<TrainAccommodation> selectTrainAccommodationList(TrainAccommodation accommodation)
{
return new ArrayList<>(store.values());
}
@Override
public List<TrainAccommodation> selectActiveAccommodationsByPlanId(String planId)
{
return new ArrayList<>(store.values());
}
@Override
public int countActiveByDormId(String dormId)
{
int count = 0;
for (TrainAccommodation a : store.values())
{
if (dormId.equals(a.getDormId()))
{
if (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus())
|| TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus()))
{
count++;
}
}
}
return count;
}
@Override
public int insertTrainAccommodation(TrainAccommodation accommodation)
{
accommodation.setAccommodationId(++idSeq);
store.put(accommodation.getAccommodationId(), accommodation);
return 1;
}
@Override
public int updateTrainAccommodation(TrainAccommodation accommodation)
{
store.put(accommodation.getAccommodationId(), accommodation);
return 1;
}
@Override
public int updateAccommodationStatus(Long accommodationId, Integer accommodationStatus, String updateBy, Date updateTime)
{
TrainAccommodation a = store.get(accommodationId);
if (a != null)
{
a.setAccommodationStatus(accommodationStatus);
a.setUpdateBy(updateBy);
a.setUpdateTime(updateTime);
return 1;
}
return 0;
}
@Override
public int deleteTrainAccommodationByAccommodationId(Long accommodationId)
{
return store.remove(accommodationId) != null ? 1 : 0;
}
@Override
public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds)
{
int deleted = 0;
for (Long id : accommodationIds)
{
if (store.remove(id) != null) deleted++;
}
return deleted;
}
}
}
@@ -0,0 +1,503 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainDormAuth;
import com.ruoyi.train.mapper.TrainAccommodationMapper;
import com.ruoyi.train.mapper.TrainDormAuthMapper;
/**
* R.3B Wave 0 - 宿舍门禁授权领域与V2契约对齐测试 (DormAuthAlignmentTest)
* 覆盖规范第30条全部要求:A到G
*/
public class DormAuthAlignmentTest
{
private TrainDormAuthServiceImpl dormAuthService;
private MockDormAuthMapper mockDormAuthMapper;
private MockAccommodationMapper mockAccommodationMapper;
@Before
public void setUp() throws Exception
{
dormAuthService = new TrainDormAuthServiceImpl();
mockDormAuthMapper = new MockDormAuthMapper();
mockAccommodationMapper = new MockAccommodationMapper();
setField(dormAuthService, "trainDormAuthMapper", mockDormAuthMapper);
setField(dormAuthService, "trainAccommodationMapper", mockAccommodationMapper);
// 初始化基础住宿记录
TrainAccommodation acc = new TrainAccommodation();
acc.setAccommodationId(5001L);
acc.setEnrollmentId(1001L);
acc.setDormId("DORM_101");
acc.setBedNo("1号床");
acc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN);
mockAccommodationMapper.save(acc);
TrainAccommodation checkedOutAcc = new TrainAccommodation();
checkedOutAcc.setAccommodationId(5002L);
checkedOutAcc.setEnrollmentId(1002L);
checkedOutAcc.setDormId("DORM_101");
checkedOutAcc.setBedNo("2号床");
checkedOutAcc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_OUT);
mockAccommodationMapper.save(checkedOutAcc);
}
private void setField(Object target, String fieldName, Object value) throws Exception
{
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
/**
* Requirement A: V2 mapper fields align DB contract
* 验证 TrainDormAuth 实体和持久化契约包含所有 V2 字段
*/
@Test
public void testA_V2MapperFieldsAlignDBContract() throws Exception
{
Class<?> clazz = TrainDormAuth.class;
// 必须存在 V2 核心字段
assertNotNull("accommodationId 字段必须存在", clazz.getDeclaredField("accommodationId"));
assertNotNull("authStart 字段必须存在", clazz.getDeclaredField("authStart"));
assertNotNull("authEnd 字段必须存在", clazz.getDeclaredField("authEnd"));
assertNotNull("faceSyncStatus 字段必须存在", clazz.getDeclaredField("faceSyncStatus"));
assertNotNull("retryCount 字段必须存在", clazz.getDeclaredField("retryCount"));
assertNotNull("lastError 字段必须存在", clazz.getDeclaredField("lastError"));
assertNotNull("externalAuthId 字段必须存在", clazz.getDeclaredField("externalAuthId"));
assertNotNull("lastSyncTime 字段必须存在", clazz.getDeclaredField("lastSyncTime"));
// 验证状态常量定义符合 DB SMALLINT 约束 (0, 1, 2, 3)
assertEquals(Integer.valueOf(0), TrainDormAuth.STATUS_PENDING);
assertEquals(Integer.valueOf(1), TrainDormAuth.STATUS_ACTIVE);
assertEquals(Integer.valueOf(2), TrainDormAuth.STATUS_FAILED);
assertEquals(Integer.valueOf(3), TrainDormAuth.STATUS_REVOKED);
}
/**
* Requirement B: Authorization references accommodation
* 必须基于有效的 accommodation_id,无 accommodationId 或关联不存在住宿时拒绝
*/
@Test
public void testB_AuthorizationReferencesAccommodation()
{
// 1. 无 accommodationId 被拒绝
TrainDormAuth authNoAcc = new TrainDormAuth();
authNoAcc.setAuthStart(new Date());
try
{
dormAuthService.insertTrainDormAuth(authNoAcc);
fail("未绑定住宿事实未被拦截");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("必须绑定住宿安排"));
}
// 2. 住宿记录不存在被拒绝
TrainDormAuth authNonExist = new TrainDormAuth();
authNonExist.setAccommodationId(999999L);
try
{
dormAuthService.insertTrainDormAuth(authNonExist);
fail("关联不存在的住宿记录未被拦截");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("指定的住宿安排不存在"));
}
// 3. 关联已退宿的记录被拒绝
TrainDormAuth authCheckedOut = new TrainDormAuth();
authCheckedOut.setAccommodationId(5002L); // 对应 checkedOutAcc
try
{
dormAuthService.insertTrainDormAuth(authCheckedOut);
fail("关联已退宿住宿记录未被拦截");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("该住宿安排已退宿"));
}
// 4. 关联有效住宿成功
TrainDormAuth validAuth = new TrainDormAuth();
validAuth.setAccommodationId(5001L);
validAuth.setAuthStart(new Date());
validAuth.setAuthEnd(new Date(System.currentTimeMillis() + 86400000L));
int rows = dormAuthService.insertTrainDormAuth(validAuth);
assertEquals(1, rows);
assertNotNull(validAuth.getAuthId());
}
/**
* Requirement C: Legacy student_id/plan_id/dorm_id no longer persisted
* 旧字段不再作为 DB 列持久化,而在实体层保留为视图/兼容访问
*/
@Test
public void testC_LegacyFieldsNoLongerPersisted()
{
TrainDormAuth auth = new TrainDormAuth();
auth.setAccommodationId(5001L);
// 设置兼容视图字段
auth.setStudentId("STU_LEGACY_01");
auth.setPlanId("PLAN_LEGACY_01");
auth.setDormId("DORM_LEGACY_01");
auth.setBedNo("1号床");
auth.setCreateUser("admin_v1");
dormAuthService.insertTrainDormAuth(auth);
// 验证 Mapper 写入的方法只接收并持久化 V2 真实字段
TrainDormAuth saved = mockDormAuthMapper.selectTrainDormAuthByAuthId(auth.getAuthId());
assertNotNull(saved);
assertEquals(Long.valueOf(5001L), saved.getAccommodationId());
// createUser 兼容适配到 BaseEntity.createBy
assertEquals("admin_v1", saved.getCreateBy());
}
/**
* Requirement D & E: Retry summary fields and last error fields
*/
@Test
public void testDE_RetrySummaryAndLastErrorFields()
{
TrainDormAuth auth = new TrainDormAuth();
auth.setAccommodationId(5001L);
dormAuthService.insertTrainDormAuth(auth);
Long authId = auth.getAuthId();
// 初始重试次数为 0
assertEquals(Integer.valueOf(0), auth.getRetryCount());
assertNull(auth.getLastError());
// 模拟下发失败,记录错误并累加重试次数
dormAuthService.markSyncFailed(authId, "Device offline: timeout after 5000ms", "sync_job");
TrainDormAuth failed = dormAuthService.selectTrainDormAuthByAuthId(authId);
assertEquals(TrainDormAuth.STATUS_FAILED, failed.getFaceSyncStatus());
assertEquals("Device offline: timeout after 5000ms", failed.getLastError());
assertEquals(Integer.valueOf(1), failed.getRetryCount());
// 再次失败,重试次数增加到 2
dormAuthService.markSyncFailed(authId, "Device unreachable", "sync_job");
TrainDormAuth failedAgain = dormAuthService.selectTrainDormAuthByAuthId(authId);
assertEquals(Integer.valueOf(2), failedAgain.getRetryCount());
assertEquals("Device unreachable", failedAgain.getLastError());
}
/**
* Requirement F: Status transition
* 0 待下发 -> 2 下发失败 -> 3 已注销
*/
@Test
public void testF_StatusTransition()
{
TrainDormAuth auth = new TrainDormAuth();
auth.setAccommodationId(5001L);
dormAuthService.insertTrainDormAuth(auth);
Long authId = auth.getAuthId();
// 初始为待下发
TrainDormAuth step0 = dormAuthService.selectTrainDormAuthByAuthId(authId);
assertEquals(TrainDormAuth.STATUS_PENDING, step0.getFaceSyncStatus());
// 下发失败 -> STATUS_FAILED
dormAuthService.markSyncFailed(authId, "Network error", "system");
TrainDormAuth step1 = dormAuthService.selectTrainDormAuthByAuthId(authId);
assertEquals(TrainDormAuth.STATUS_FAILED, step1.getFaceSyncStatus());
// 注销权限 -> STATUS_REVOKED
dormAuthService.revokeAuth(authId, "admin");
TrainDormAuth step2 = dormAuthService.selectTrainDormAuthByAuthId(authId);
assertEquals(TrainDormAuth.STATUS_REVOKED, step2.getFaceSyncStatus());
}
/**
* Requirement G: No DMS client dependency & No Fake Device Success
* 不调用任何外部 DMS,创建时绝不虚假赋值为已下发成功 (STATUS_ACTIVE)
*/
@Test
public void testG_NoDmsClientDependencyAndNoFakeSuccess()
{
TrainDormAuth auth = new TrainDormAuth();
auth.setAccommodationId(5001L);
// 即使客户端试图传入 STATUS_ACTIVE,业务层由于无实际下发能力,必须强制为 STATUS_PENDING
auth.setFaceSyncStatus(TrainDormAuth.STATUS_ACTIVE);
dormAuthService.insertTrainDormAuth(auth);
TrainDormAuth saved = dormAuthService.selectTrainDormAuthByAuthId(auth.getAuthId());
assertEquals("没有真实DMS调用前,状态必须为待下发", TrainDormAuth.STATUS_PENDING, saved.getFaceSyncStatus());
}
/**
* Wave 0 Review Gate - Requirement 15: DormAuth History Test
* 验证换房场景下授权记录共存:
* Old Accommodation -> Old DormAuth (status=REVOKED)
* New Accommodation -> New DormAuth (status=PENDING/ACTIVE)
* 两条授权记录在历史中共存,互不覆盖
*/
@Test
public void testDormAuthHistoryAcrossTransfers()
{
Long oldAccId = 5001L;
Long newAccId = 5003L;
// 注册新住宿
TrainAccommodation newAcc = new TrainAccommodation();
newAcc.setAccommodationId(newAccId);
newAcc.setEnrollmentId(1001L);
newAcc.setDormId("DORM_201");
newAcc.setBedNo("新1号床");
newAcc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN);
mockAccommodationMapper.save(newAcc);
// 1. 旧住宿对应旧授权
TrainDormAuth oldAuth = new TrainDormAuth();
oldAuth.setAccommodationId(oldAccId);
dormAuthService.insertTrainDormAuth(oldAuth);
Long oldAuthId = oldAuth.getAuthId();
// 2. 新住宿对应新授权
TrainDormAuth newAuth = new TrainDormAuth();
newAuth.setAccommodationId(newAccId);
dormAuthService.insertTrainDormAuth(newAuth);
Long newAuthId = newAuth.getAuthId();
// 3. 旧授权注销 (退宿或换房引起)
dormAuthService.revokeAuth(oldAuthId, "admin");
// 4. 验证两条授权在历史中共存,状态相互独立
TrainDormAuth queriedOld = dormAuthService.selectTrainDormAuthByAuthId(oldAuthId);
TrainDormAuth queriedNew = dormAuthService.selectTrainDormAuthByAuthId(newAuthId);
assertNotNull(queriedOld);
assertNotNull(queriedNew);
assertEquals(TrainDormAuth.STATUS_REVOKED, queriedOld.getFaceSyncStatus());
assertEquals(TrainDormAuth.STATUS_PENDING, queriedNew.getFaceSyncStatus());
assertEquals(oldAccId, queriedOld.getAccommodationId());
assertEquals(newAccId, queriedNew.getAccommodationId());
}
// ================================= 内部 Mock 类 =================================
private static class MockDormAuthMapper implements TrainDormAuthMapper
{
private final Map<Long, TrainDormAuth> store = new HashMap<>();
private long idSeq = 10L;
@Override
public TrainDormAuth selectTrainDormAuthByAuthId(Long authId)
{
return store.get(authId);
}
@Override
public TrainDormAuth selectTrainDormAuthByAccommodationId(Long accommodationId)
{
for (TrainDormAuth a : store.values())
{
if (accommodationId.equals(a.getAccommodationId()))
{
return a;
}
}
return null;
}
@Override
public List<TrainDormAuth> selectTrainDormAuthList(TrainDormAuth trainDormAuth)
{
return new ArrayList<>(store.values());
}
@Override
public int insertTrainDormAuth(TrainDormAuth trainDormAuth)
{
trainDormAuth.setAuthId(++idSeq);
store.put(trainDormAuth.getAuthId(), trainDormAuth);
return 1;
}
@Override
public int updateTrainDormAuth(TrainDormAuth trainDormAuth)
{
store.put(trainDormAuth.getAuthId(), trainDormAuth);
return 1;
}
@Override
public int updateSyncStatus(Long authId, Integer faceSyncStatus, String lastError, String updateBy, Date updateTime)
{
TrainDormAuth a = store.get(authId);
if (a != null)
{
a.setFaceSyncStatus(faceSyncStatus);
a.setLastError(lastError);
a.setUpdateBy(updateBy);
a.setUpdateTime(updateTime);
return 1;
}
return 0;
}
@Override
public int increaseRetryCount(Long authId, String lastError, String updateBy, Date updateTime)
{
TrainDormAuth a = store.get(authId);
if (a != null)
{
int current = a.getRetryCount() != null ? a.getRetryCount() : 0;
a.setRetryCount(current + 1);
a.setLastError(lastError);
a.setUpdateBy(updateBy);
a.setUpdateTime(updateTime);
return 1;
}
return 0;
}
@Override
public int deleteTrainDormAuthByAuthId(Long authId)
{
return store.remove(authId) != null ? 1 : 0;
}
@Override
public int deleteTrainDormAuthByAuthIds(Long[] authIds)
{
int count = 0;
for (Long id : authIds)
{
if (store.remove(id) != null) count++;
}
return count;
}
}
private static class MockAccommodationMapper implements TrainAccommodationMapper
{
private final Map<Long, TrainAccommodation> store = new HashMap<>();
public void save(TrainAccommodation a)
{
store.put(a.getAccommodationId(), a);
}
@Override
public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId)
{
return store.get(accommodationId);
}
@Override
public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId)
{
for (TrainAccommodation a : store.values())
{
if (enrollmentId.equals(a.getEnrollmentId())) return a;
}
return null;
}
@Override
public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId)
{
for (TrainAccommodation a : store.values())
{
if (enrollmentId.equals(a.getEnrollmentId()) &&
(TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus())
|| TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus())))
{
return a;
}
}
return null;
}
@Override
public List<TrainAccommodation> selectAccommodationHistoryByEnrollmentId(Long enrollmentId)
{
List<TrainAccommodation> list = new ArrayList<>();
for (TrainAccommodation a : store.values())
{
if (enrollmentId.equals(a.getEnrollmentId())) list.add(a);
}
return list;
}
@Override
public List<TrainAccommodation> selectTrainAccommodationList(TrainAccommodation accommodation)
{
return new ArrayList<>(store.values());
}
@Override
public List<TrainAccommodation> selectActiveAccommodationsByPlanId(String planId)
{
return new ArrayList<>(store.values());
}
@Override
public int countActiveByDormId(String dormId)
{
return 0;
}
@Override
public int insertTrainAccommodation(TrainAccommodation accommodation)
{
store.put(accommodation.getAccommodationId(), accommodation);
return 1;
}
@Override
public int updateTrainAccommodation(TrainAccommodation accommodation)
{
store.put(accommodation.getAccommodationId(), accommodation);
return 1;
}
@Override
public int updateAccommodationStatus(Long accommodationId, Integer accommodationStatus, String updateBy, Date updateTime)
{
TrainAccommodation a = store.get(accommodationId);
if (a != null)
{
a.setAccommodationStatus(accommodationStatus);
return 1;
}
return 0;
}
@Override
public int deleteTrainAccommodationByAccommodationId(Long accommodationId)
{
return store.remove(accommodationId) != null ? 1 : 0;
}
@Override
public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds)
{
return 0;
}
}
}
@@ -0,0 +1,70 @@
-- 006_accommodation_history_constraint.sql
-- 目标:移除 train_accommodation 全局唯一约束 uq_accommodation_enroll,
-- 创建部分唯一索引 uq_accommodation_enroll_active (每个 enrollment 最多 1 条有效住宿),
-- 允许学员拥有多条退宿历史记录 (status=2),彻底支持调房与历史留痕。
DO $$
DECLARE
v_dup_count INTEGER;
v_con_def TEXT;
v_idx_def TEXT;
BEGIN
-- 1. Pre-Migration Data Safety Guard
-- 检查是否已存在同一个 enrollment_id 拥有 2 条或以上处于有效状态 (0=预分配, 1=已入住) 的住宿记录
SELECT COUNT(*) INTO v_dup_count
FROM (
SELECT enrollment_id
FROM train_accommodation
WHERE accommodation_status IN (0, 1)
GROUP BY enrollment_id
HAVING COUNT(*) > 1
) t;
IF v_dup_count > 0 THEN
RAISE EXCEPTION 'Pre-migration check failed: found % enrollment(s) with multiple active accommodations (status IN (0,1)). Manual resolution required before applying migration 006.', v_dup_count;
END IF;
-- 2. Existing Constraint Guard (Idempotent Drop)
-- 检查约束 uq_accommodation_enroll 是否存在且符合预期
SELECT pg_get_constraintdef(c.oid) INTO v_con_def
FROM pg_constraint c
JOIN pg_class t ON c.conrelid = t.oid
WHERE t.relname = 'train_accommodation'
AND c.conname = 'uq_accommodation_enroll';
IF v_con_def IS NOT NULL THEN
-- 验证旧约束是否为 UNIQUE (enrollment_id)
IF v_con_def ILIKE 'UNIQUE (enrollment_id)%' THEN
RAISE NOTICE 'Dropping legacy global unique constraint: uq_accommodation_enroll';
ALTER TABLE train_accommodation DROP CONSTRAINT uq_accommodation_enroll;
ELSE
RAISE EXCEPTION 'Constraint collision: constraint uq_accommodation_enroll exists but definition is unexpected: %', v_con_def;
END IF;
ELSE
RAISE NOTICE 'Constraint uq_accommodation_enroll does not exist, skipping drop.';
END IF;
-- 3. New Index Collision Guard & Creation
-- 检查部分唯一索引 uq_accommodation_enroll_active
SELECT pg_get_indexdef(i.indexrelid) INTO v_idx_def
FROM pg_index i
JOIN pg_class c ON i.indexrelid = c.oid
JOIN pg_class t ON i.indrelid = t.oid
WHERE t.relname = 'train_accommodation'
AND c.relname = 'uq_accommodation_enroll_active';
IF v_idx_def IS NOT NULL THEN
-- 验证已有索引是否为预期的部分唯一索引
IF v_idx_def ILIKE '%UNIQUE INDEX uq_accommodation_enroll_active ON %train_accommodation% (enrollment_id)%WHERE (accommodation_status = ANY (ARRAY[0, 1]))%'
OR v_idx_def ILIKE '%UNIQUE INDEX uq_accommodation_enroll_active ON %train_accommodation% (enrollment_id)%WHERE%accommodation_status IN (0, 1)%' THEN
RAISE NOTICE 'Partial unique index uq_accommodation_enroll_active already exists with expected definition, skipping creation.';
ELSE
RAISE EXCEPTION 'Index collision: index uq_accommodation_enroll_active exists but definition differs: %', v_idx_def;
END IF;
ELSE
RAISE NOTICE 'Creating partial unique index: uq_accommodation_enroll_active';
CREATE UNIQUE INDEX uq_accommodation_enroll_active
ON train_accommodation (enrollment_id)
WHERE accommodation_status IN (0, 1);
END IF;
END $$;
@@ -137,3 +137,35 @@
2. **`train_dorm_auth`**:Kingbase 物理表已在 V2 中升级为以 `accommodation_id` 为外键的门禁授权结构,但 Java 实体类 `TrainDormAuth.java` 与 XML Mapper 依然停留在 V1 旧结构(持有 `student_id`, `plan_id`, `dorm_id`, `bed_no`)。
- **执行原则**:
在进入 R.3B 真正编写 DMS Adapter 之前,**必须先在 Wave 0 完成本地住宿与门禁授权领域模型对齐**!严禁让外部 DMS 集成适配器直接架设在当前存在严重漂移的本地模型之上。
### 5.2 Wave 0 Implementation Result (本地模型对齐实施完成)
- **完成状态**:已全面对齐并消除结构漂移。
- **实体层**:
- 新增 `TrainAccommodation.java`,严格对应 Kingbase V2 生产物理表(`accommodation_id`, `enrollment_id`, `dorm_id`, `bed_no`, `assigned_time`, `accommodation_status`, 审计列)。
- 重构 `TrainDormAuth.java`,外键核心对齐 `accommodation_id`,持久化字段剔除 V1 遗留字段(`student_id`, `plan_id`, `dorm_id`, `bed_no`, `createUser`),保留为非持久化兼容视图字段以保障只读兼容。
- **Mapper/SQL**:
- 新增 `TrainAccommodationMapper.java` / `TrainAccommodationMapper.xml`,所有字段显式指定,严禁 `SELECT *`。
- 重构 `TrainDormAuthMapper.xml`,INSERT/UPDATE 语句彻底清理 V1 废弃字段写入,通过 LEFT JOIN 视图提供兼容字段。
- **业务服务**:
- 新增 `ITrainAccommodationService` / `TrainAccommodationServiceImpl`,落地“同一时刻最多1个有效住宿安排”(Duplicate Active Accommodation Guard)、宿舍核定容量边界检查(Capacity Boundary Guard)与换房原子流转(Transfer)。
- 升级 `TrainDormAuthServiceImpl`,严格执行“Enrollment -> Accommodation -> Door Authorization”业务单向依赖,禁止绕过住宿事实直接授权。
### 5.3 Wave 0.1 Accommodation History & 006 Migration (基数模型升级与迁移方案)
- **基数语义冻结**:
- `Enrollment 1:N Accommodation History`(一个报名记录可有多条历史住宿记录,调房换房产生历史轨迹)
- `Enrollment 1:0..1 Active Accommodation`(同一时间一个报名记录最多只能有 1 条有效住宿记录,`accommodation_status IN (0, 1)`)
- `Accommodation 1:0..1 DoorAuthorization`(门禁授权强依赖具体的某一次住宿记录)
- `TIME_RANGE_SUPPORT = LIMITED`(当前 Kingbase 物理表仅包含 `assigned_time`,无 `start_time`/`end_time`,时间窗口支持为有限支持)
- **006 迁移脚本方案 (`006_accommodation_history_constraint.sql`)**:
- 脚本路径:`RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql`
- 安全编号:`006`(001-003 为基线,004-005 已被 R.2 分支占用)
- 前置检查:Fail-fast,若发现同一 `enrollment_id` 存在多条 active(`accommodation_status IN (0, 1)`)则 `RAISE EXCEPTION` 阻止迁移
- 安全 DROP:仅在原约束 `uq_accommodation_enroll` 确认为 `UNIQUE (enrollment_id)` 时安全移除
- 局部唯一索引:创建 `uq_accommodation_enroll_active` 并在 `WHERE accommodation_status IN (0, 1)` 生效
- 幂等性防护:重复执行不会报错或破坏表结构
- 生产执行纪律:本迁移脚本仅完成工程资产化与隔离测试,**严禁在生产数据库执行**
- **自动化测试**:
- `AccommodationHistoryMigrationContractTest`(契约测试:SQL 语法、前置检查、条件 DROP、局部索引及幂等性)
- `AccommodationServiceTest`(10 项测试,包含调房历史与并发唯一性)
- `DormAuthAlignmentTest`(7 项测试,包含依赖住宿与换房级联)
- 全量测试套件 174 项全部 PASS(0 failures, 0 errors)。
@@ -101,3 +101,26 @@
1. **第一步**:重构修复 Java 实体类 `TrainDormAuth.java`,彻底移除已弃用的 `student_id`, `plan_id`, `dorm_id` 字段,使其与已存在的 Kingbase 物理表字段完全对齐。
2. **第二步**:创建 `train_external_resource_mapping` 与 `train_external_operation` 迁移脚本(遵循系统标准 Liquibase/Flyway 规范)。
3. **第三步**:通过 Spring Data / MyBatis-Plus 完成三表关联映射。
### 4.1 Wave 0 Implementation Result (第一步已正式交付)
- **已完成事项**:
1. 重构并修复 `TrainDormAuth.java`,彻底废弃了向 `train_dorm_auth` 写入 `student_id`, `plan_id`, `dorm_id`, `bed_no`, `create_user` 的历史假设。
2. 补齐 `TrainAccommodation.java`、`TrainAccommodationMapper.java`、`TrainAccommodationMapper.xml`、`ITrainAccommodationService` 以及 `TrainAccommodationServiceImpl`,完全对齐 V2 生产物理结构。
3. 严格禁止在 Wave 0 中引入任何 DMS 物理字段(如 `lock_id`, `place_id`, `operation_id`),保持 Vendor-Neutral 纯净业务边界。
4. 生产 Migration 检查:`MIGRATION_REQUIRED = NO`,生产数据库零写入、零变更。
### 4.2 Wave 0.1 Accommodation History Migration (006 迁移方案与基数冻结)
- **领域基数与物理约束演进**:
- **历史模型**:`Enrollment 1:N Accommodation History`,允许学员由于调房等产生多条住宿历史。
- **唯一性收敛**:`Enrollment 1:0..1 Active Accommodation`,原全局唯一约束 `UNIQUE(enrollment_id)` 阻碍了历史留痕,演进为针对有效状态(`accommodation_status IN (0, 1)`)的局部唯一索引。
- **授权关联**:`Accommodation 1:0..1 DoorAuthorization`,门禁授权强引用住宿实体。
- **时间范围特性**:`TIME_RANGE_SUPPORT = LIMITED`,当前物理表仅有 `assigned_time`,未扩展起止时间列。
- **006 Migration 脚本设计与验证**:
- 文件路径:`RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql`
- 安全编号:`006`(避免与 R.2 占用的 004/005 冲突)。
- 执行安全机制:
1. 前置重复数据检查:存在并发违规 active 记录时直接 `RAISE EXCEPTION` 阻断。
2. 严格条件 DROP:仅当原约束 `uq_accommodation_enroll` 确认为 `UNIQUE (enrollment_id)` 时执行 drop。
3. 创建局部唯一索引:`CREATE UNIQUE INDEX IF NOT EXISTS uq_accommodation_enroll_active ON train_accommodation (enrollment_id) WHERE accommodation_status IN (0, 1);`
4. 幂等防护:重复执行幂等安全。
- 生产执行纪律:本脚本已在单元/契约测试中完成全量静态与逻辑校验,**禁止在生产库执行**。
@@ -139,3 +139,29 @@ flowchart TD
| **Wave 2: DMS Adapter & Mock Tests** | 协议适配器、TokenManager、轮询调度器、22项单测 | 外部集成层 | 5 人天 |
| **Wave 3: Orchestration, UI & Sandbox** | 业务编排服务、前端界面、现场沙箱实物 E2E 联调 | 业务层/前端/现场 | 5~6 人天 |
| **总计** | **R.3 门禁集成端到端正式交付上线** | **全栈闭环** | **约 15~16 人天** |
### 3.1 Wave 0 Implementation Result (交付与验收验证)
- **阶段目标**:Local Accommodation & Door Authorization Domain Alignment
- **执行状态**:已完成 (Completed)
- **交付清单**:
1. `TrainAccommodation.java`、`TrainAccommodationMapper.java`、`TrainAccommodationMapper.xml`
2. `ITrainAccommodationService.java`、`TrainAccommodationServiceImpl.java`
3. `TrainAccommodationController.java` (最小必要 Admin REST API)
4. `TrainDormAuth.java`、`TrainDormAuthMapper.java`、`TrainDormAuthMapper.xml`、`TrainDormAuthServiceImpl.java`、`TrainDormAuthController.java` (V2 Schema 对齐与清理)
5. 自动化测试套件:`AccommodationServiceTest`、`DormAuthAlignmentTest`、`AccommodationAndAuthApiControllerContractTest`。
### 3.2 Wave 0.1 Implementation Result (基数模型升级与 006 迁移交付)
- **阶段目标**:Accommodation History Migration & Final Checkpoint
- **模型基数关系**:
- `Enrollment 1:N Accommodation History`
- `Enrollment 1:0..1 Active Accommodation` (`accommodation_status IN (0, 1)`)
- `Accommodation 1:0..1 DoorAuthorization`
- `TIME_RANGE_SUPPORT = LIMITED`
- **新增交付物**:
1. `RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql`(安全编号 006、前置检查、条件 DROP、局部唯一索引、幂等执行;生产库未执行)
2. `RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationHistoryMigrationContractTest.java`(迁移脚本规范与语法契约测试)
3. 历史查询与调房并发唯一性测试(`selectAccommodationHistoryByEnrollmentId`、`transferAccommodation` 级联授权废弃)
- **全量回归验证**:
- `ruoyi-train`: 153 tests
- `ruoyi-admin`: 21 tests
- 总计:174 tests, Failures: 0, Errors: 0, Skipped: 0,全量通过。
@@ -106,3 +106,25 @@
- 配置与环境变量绑定;`DmsTokenManager` 单飞排他锁并发控制;细分 HTTP 客户端;在线密码/卡片适配器;10 秒轮询调度器;WireMock 22 项单测全覆盖。
4. **Wave 3: Business Orchestration & UI & Sandbox (业务编排、界面与现场验收,5~6天)**:
- 报到联动授权下发;“先发新房后撤旧房”换房补偿;退训/结训撤权;管理端门禁综合视图;学员端 30 秒倒计时密码受控查看;现场实验台沙箱 E2E 闭环验收。
### 6.1 Wave 0 Implementation Result (本地领域模型闭环)
- **基线与状态**:
- 成功消除数据库 V2 模型与 Java 代码之间的全部结构漂移。
- 严格保持 0 DMS 耦合:无 DmsClient、无 TokenManager、无外部调用、无假设备成功。
- 保持 Kingbase 生产库只读,零 Migration(`MIGRATION_REQUIRED = NO`)。
- 交付本地领域模型:`TrainAccommodation` 与升级后的 `TrainDormAuth`。
### 6.2 Wave 0.1 Accommodation History & Final Checkpoint Decision (基数模型升级与终态)
- **核心基数关系冻结**:
- `Enrollment 1:N Accommodation History`:允许学员产生多次历史住宿记录(调房/换房留痕)。
- `Enrollment 1:0..1 Active Accommodation`:同一时刻最多一条有效住宿(`accommodation_status IN (0, 1)`)。
- `Accommodation 1:0..1 DoorAuthorization`:门禁授权强引用住宿实体。
- `TIME_RANGE_SUPPORT = LIMITED`:当前 Kingbase 物理表仅有 `assigned_time`,无 `start_time` / `end_time` 列,时间窗口支持为有限支持。
- **006 Migration 方案设计**:
- 脚本路径:`RuoYi-Vue/sql/system/kingbase/006_accommodation_history_constraint.sql`。
- 安全序号:`006`(001-003 为基线,004-005 已被 R.2 分支占用)。
- 迁移安全护栏:前置重复 active 检查抛异常、仅在定义匹配时安全 DROP `uq_accommodation_enroll`、创建 `uq_accommodation_enroll_active` 局部唯一索引(`WHERE accommodation_status IN (0, 1)`)、全流程幂等。
- 生产执行纪律:**禁止在生产库执行 006 迁移脚本**,本 Wave 仅作资产与契约验证储备。
- **质量与构建验收**:
- 单元与契约测试总数:174 项,全部 PASS(0 failures, 0 errors)。
- Maven `clean test` 与 `clean package` 均顺利构建成功。