批量上传人脸:只上传头像、下发到人脸服务;不授权门锁;
This commit is contained in:
1 parent
6d2ad20723
commit
e683d4da59
29 files changed
+4257
-101
No files matched your search
+37
@@ -37,6 +37,7 @@ import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.ruoyi.common.config.RuoYiConfig;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.spring.SpringUtils;
|
||||
import com.ruoyi.train.domain.TrainTeacher;
|
||||
import com.ruoyi.train.domain.query.StudentQuery;
|
||||
@@ -260,6 +261,42 @@ public class TrainFaceApiController {
|
||||
return doDelete("/employees");
|
||||
}
|
||||
|
||||
/**
|
||||
* 4.8 POST /recognize 人脸识别
|
||||
*/
|
||||
@Operation(summary = "人脸识别", description = "调用人脸识别设备 POST /recognize,按 image_path 或 image_base64(支持 data URL 与纯 Base64)识别图中人脸,threshold 为相似度阈值,默认 0.44。")
|
||||
@PostMapping("/recognize")
|
||||
public AjaxResult recognize(@RequestBody Map<String, Object> payload)
|
||||
{
|
||||
log.info("recognize 收到请求 payload={}", payload);
|
||||
|
||||
if (!faceEnabled)
|
||||
{
|
||||
return AjaxResult.error("人脸服务未启用,无法进行人脸识别");
|
||||
}
|
||||
String imagePath = payload.get("image_path") == null ? null : payload.get("image_path").toString();
|
||||
String imageBase64 = payload.get("image_base64") == null ? null : payload.get("image_base64").toString();
|
||||
if (StringUtils.isEmpty(imagePath) && StringUtils.isEmpty(imageBase64))
|
||||
{
|
||||
return AjaxResult.error("参数 image_path 与 image_base64 至少提供一个");
|
||||
}
|
||||
Map<String, Object> body = new HashMap<>(3);
|
||||
if (StringUtils.isNotEmpty(imagePath))
|
||||
{
|
||||
body.put("image_path", imagePath);
|
||||
}
|
||||
if (StringUtils.isNotEmpty(imageBase64))
|
||||
{
|
||||
body.put("image_base64", imageBase64);
|
||||
}
|
||||
Object threshold = payload.get("threshold");
|
||||
if (threshold != null)
|
||||
{
|
||||
body.put("threshold", threshold);
|
||||
}
|
||||
return doPost("/recognize", body);
|
||||
}
|
||||
|
||||
// ===================== 内部:真实调用人脸设备 =====================
|
||||
|
||||
private AjaxResult doPost(String path, Map<String, Object> body)
|
||||
|
||||
+176
-74
@@ -460,6 +460,41 @@ public class TrainRegisterApiController {
|
||||
return AjaxResult.success(bindingAdminService.toStudentAdminVO(detail));
|
||||
}
|
||||
|
||||
/**
|
||||
* 2.1 按手机号查询学员信息(报到机)
|
||||
* 手机号即 train_student.s_phone,精确匹配后返回与 /student/{sId} 完全一致的学员视图。
|
||||
* 手机号同时用于学员登录账号,正常应唯一;若历史数据存在同号多人,按学员列表顺序取第一条并记录告警日志。
|
||||
*/
|
||||
@Operation(summary = "按手机号查询学员信息", description = "按 train_student.s_phone 精确匹配手机号,命中则返回学员信息(结构同 /student/{sId}),未命中返回“学员不存在”。")
|
||||
@GetMapping("/student/phone/{phone}")
|
||||
public AjaxResult getInfoByPhone(@PathVariable("phone") String phone)
|
||||
{
|
||||
String mobile = phone == null ? null : phone.trim();
|
||||
if (StringUtils.isEmpty(mobile))
|
||||
{
|
||||
return AjaxResult.error("手机号不能为空");
|
||||
}
|
||||
|
||||
StudentQuery query = new StudentQuery();
|
||||
query.setsPhone(mobile);
|
||||
List<StudentItemVO> matched = studentService.selectStudentList(query);
|
||||
if (matched == null || matched.isEmpty())
|
||||
{
|
||||
return AjaxResult.error("学员不存在,手机号:" + mobile);
|
||||
}
|
||||
if (matched.size() > 1)
|
||||
{
|
||||
log.warn("手机号 {} 匹配到 {} 名学员,按列表首条返回 sId={}", mobile, matched.size(), matched.get(0).getsId());
|
||||
}
|
||||
|
||||
StudentDetailVO detail = studentService.selectStudentDetail(matched.get(0).getsId());
|
||||
if (detail == null)
|
||||
{
|
||||
return AjaxResult.error("学员不存在,手机号:" + mobile);
|
||||
}
|
||||
return AjaxResult.success(bindingAdminService.toStudentAdminVO(detail));
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. 获取学员基础信息(宿舍/教室/开班/)
|
||||
*/
|
||||
@@ -820,89 +855,156 @@ public class TrainRegisterApiController {
|
||||
log.info("已为学员 sId={} 在门锁 roomId={} 创建人脸授权", sId, dorm.getDormRoom());
|
||||
}
|
||||
}
|
||||
return AjaxResult.success(data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 往宿舍门锁下发人脸:sId → train_enrollment(enrollmentId) → train_accommodation(dormId)
|
||||
// → train_dormitory_info(deviceSn) → lock_device(deviceId)
|
||||
/*TrainStudent stud = trainStudentMapper.selectTrainStudentBySId(sId);
|
||||
log.info("学员信息 stud={}", stud );
|
||||
// 步骤1/2:根据 sId 取当前有效住宿记录(含 enrollmentId、dormId;enrollmentId 即 train_enrollment 报到记录主键)
|
||||
TrainAccommodation acc = accommodationService.selectActiveAccommodationByStudentId(sId);
|
||||
if (acc != null && StringUtils.isNotEmpty(acc.getDormId()))
|
||||
/**
|
||||
* 6.1 人脸识别后授权门锁(报到机)
|
||||
* 接收 JSON:sId(学员ID)、faceImage(base64 人脸图片,可带 data URI 前缀)。
|
||||
* 处理:先调用人脸识别接口判断该学员人脸是否已存在于人脸服务;
|
||||
* <ul>
|
||||
* <li>识别结果的 employee_id 等于 sId:仅执行宿舍门锁授权;</li>
|
||||
* <li>识别不到人脸或 employee_id 不等于 sId:转由上传人脸流程处理(写头像 + 下发设备 + 报到授权)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Operation(summary = "人脸识别后授权门锁", description = "先识别 faceImage,命中 sId 时仅授权宿舍门锁;未命中时按上传人脸流程补录并授权。")
|
||||
@PostMapping("/student/face-auth")
|
||||
public AjaxResult authorizeByFace(@RequestBody Map<String, Object> body)
|
||||
{
|
||||
Object sIdObj = body.get("sId");
|
||||
Object faceObj = body.get("faceImage");
|
||||
if (sIdObj == null || faceObj == null)
|
||||
{
|
||||
// 回查 train_enrollment 报到信息
|
||||
TrainEnrollment enrollment = enrollmentService.selectTrainEnrollmentById(acc.getEnrollmentId());
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
Date enrollmentDate = new Date();
|
||||
enrollment.setJoinTime(enrollmentDate);
|
||||
enrollmentService.updateTrainEnrollment(enrollment);
|
||||
log.info("学员 sId={} 报到记录 enrollmentId={}, dormId={}", sId,
|
||||
enrollment != null ? enrollment.getEnrollmentId() : null, acc.getDormId());
|
||||
// 步骤3:宿舍 → 门锁设备序列号
|
||||
TrainDormitoryInfo dorm = dormitoryInfoService.selectTrainDormitoryInfoByDormId(acc.getDormId());
|
||||
if (dorm != null && StringUtils.isNotEmpty(dorm.getLockDeviceSn()))
|
||||
{
|
||||
// 步骤4:门锁 SN → lock_device 的 deviceId
|
||||
LockDevice lockDevice = lockDeviceMapper.selectDeviceBySn(dorm.getLockDeviceSn());
|
||||
if (lockDevice != null)
|
||||
{
|
||||
//data.put("dormDeviceId", lockDevice.getDeviceId());
|
||||
//data.put("dormDeviceSn", lockDevice.getDeviceSn());
|
||||
log.info("宿舍门锁下发人脸目标 deviceId={}, deviceSn={}, sId={}",
|
||||
lockDevice.getDeviceId(), lockDevice.getDeviceSn(), sId);
|
||||
// 宿舍门锁下发人脸:提取人脸特征 → 创建门锁授权
|
||||
try
|
||||
{
|
||||
String feature = this.studentService.extractStudent(sId, lockDevice.getDeviceId());
|
||||
log.warn("学员 feature={}", feature);
|
||||
if (StringUtils.isEmpty(feature))
|
||||
{
|
||||
log.warn("特征提取失败,请确认已上传人脸图片且门锁已配置人脸算法版本");
|
||||
}
|
||||
else if (stud == null || StringUtils.isEmpty(stud.getPersonNo()))
|
||||
{
|
||||
log.warn("学员 sId={} 主档缺失或未配置 personNo,跳过门锁授权创建", sId);
|
||||
}
|
||||
else
|
||||
{
|
||||
LockAuth auth = new LockAuth();
|
||||
auth.setAuthType("01"); // 01=人脸
|
||||
auth.setPersonNo(stud.getPersonNo());
|
||||
auth.setPersonName(stud.getsName());
|
||||
auth.setDeviceId(lockDevice.getDeviceId());
|
||||
auth.setAuthValue(feature); // 人脸特征
|
||||
// 截断到整秒:避免 MySQL DATETIME 对毫秒四舍五入,导致落库授权时间与下发帧相差 1 秒而触发 Payload mismatch
|
||||
Date now = new Date(System.currentTimeMillis() / 1000 * 1000);
|
||||
auth.setBeginTime(now);
|
||||
auth.setEndTime(new Date(now.getTime() + 14L * 24 * 60 * 60 * 1000)); // 默认两周有效
|
||||
log.info("学员 auth={}",auth);
|
||||
//lockAuthService.insertAuth(auth);
|
||||
return AjaxResult.error("参数缺失:sId 与 faceImage 均为必填");
|
||||
}
|
||||
String sId = sIdObj.toString();
|
||||
if (StringUtils.isEmpty(sId))
|
||||
{
|
||||
return AjaxResult.error("sId 不能为空");
|
||||
}
|
||||
|
||||
boolean faceRegistered = isFaceRegistered(sId, faceObj.toString());
|
||||
if (!faceRegistered)
|
||||
{
|
||||
log.info("人脸识别未命中 sId={},转入人脸上传播链路", sId);
|
||||
return uploadStudentFace(body);
|
||||
}
|
||||
log.info("人脸识别命中 sId={},仅执行宿舍门锁授权", sId);
|
||||
Map<String, Object> data = authorizeDormLock(sId);
|
||||
data.put("faceRecognized", Boolean.TRUE);
|
||||
return AjaxResult.success(data);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("宿舍门锁人脸下发失败 sId={}, deviceId={}, msg={}", sId, lockDevice.getDeviceId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
log.warn("宿舍门锁 SN={} 在 lock_device 中未找到对应设备", dorm.getLockDeviceSn());
|
||||
}
|
||||
}
|
||||
else
|
||||
/**
|
||||
* 授权学员宿舍门锁(报到机)。
|
||||
* 步骤与上传人脸后的报到授权保持一致:
|
||||
* 1) 回查 train_enrollment 报到记录并置为已报到(列表为空则跳过);
|
||||
* 2) 按 create_time 降序取第一条住宿记录作为报到判定依据;
|
||||
* 3) 存在宿舍时生成门锁业务流水号并调用门禁入住接口 /openapi/iot/stay/checkin。
|
||||
*
|
||||
* @param sId 学员ID
|
||||
* @return 授权结果(lockCheckin / lockAuthId / joinTime / sId)
|
||||
*/
|
||||
private Map<String, Object> authorizeDormLock(String sId)
|
||||
{
|
||||
Map<String, Object> data = new HashMap<>(4);
|
||||
// 回查 train_enrollment 报到信息(已加入班级即有记录,更新报到状态即可),取第一条记录办理报到
|
||||
List<EnrollmentItemVO> enrollmentList = enrollmentService.selectEnrollmentsByStudentId(sId);
|
||||
TrainEnrollment enrollment = null;
|
||||
Date enrollmentDate = null;
|
||||
if (enrollmentList != null && !enrollmentList.isEmpty())
|
||||
{
|
||||
enrollment = enrollmentService.selectTrainEnrollmentById(enrollmentList.get(0).getEnrollmentId());
|
||||
if (enrollment != null)
|
||||
{
|
||||
log.warn("宿舍 dormId={} 未配置门锁序列号", acc.getDormId());
|
||||
enrollment.setEnrollmentStatus(1);
|
||||
enrollmentDate = new Date();
|
||||
enrollment.setJoinTime(enrollmentDate);
|
||||
enrollmentService.updateTrainEnrollment(enrollment);
|
||||
}
|
||||
}
|
||||
else
|
||||
// 报到判定依据:按 create_time 降序排序后的第一条住宿记录
|
||||
TrainAccommodation acc = accommodationService.selectActiveAccommodationByStudentId(sId);
|
||||
log.info("授权门锁 sId={} 报到记录 enrollmentId={}, dormId={}", sId,
|
||||
enrollment != null ? enrollment.getEnrollmentId() : null,
|
||||
acc != null ? acc.getDormId() : null);
|
||||
if (acc != null && StringUtils.isNotEmpty(acc.getDormId()))
|
||||
{
|
||||
log.info("学员 sId={} 暂无有效住宿记录,跳过宿舍门锁下发", sId);
|
||||
}*/
|
||||
// 步骤3:宿舍 → 门锁设备序列号
|
||||
TrainDormitoryInfo dorm = dormitoryInfoService.selectTrainDormitoryInfoByDormId(acc.getDormId());
|
||||
log.info("授权门锁 sId={} 宿舍信息 dorm={}", sId, dorm);
|
||||
if (dorm != null)
|
||||
{
|
||||
// 调用对外开放房间门禁服务的入住接口(/openapi/iot/stay/checkin,逻辑统一由住宿 Service 实现)
|
||||
String lockApiBizNo = accommodationService.generateLockApiBizNo();
|
||||
acc.setLockApiBizNo(lockApiBizNo);
|
||||
accommodationService.updateTrainAccommodation(acc);
|
||||
log.info("学员 sId={} 入住门锁 bizNo={}", sId, lockApiBizNo);
|
||||
LockStayCheckinResult lockCheckinResult = accommodationService.callStayCheckin(lockApiBizNo, enrollment, dorm);
|
||||
data.put("lockCheckin", lockCheckinResult);
|
||||
data.put("lockAuthId", "");
|
||||
log.info("已为学员 sId={} 在门锁 roomId={} 创建人脸授权", sId, dorm.getDormRoom());
|
||||
}
|
||||
}
|
||||
data.put("sId", sId);
|
||||
data.put("joinTime", enrollmentDate);
|
||||
return data;
|
||||
}
|
||||
|
||||
return AjaxResult.success(data);
|
||||
/**
|
||||
* 调用人脸识别接口判断该学员人脸是否已存在于人脸服务。
|
||||
* 识别结果中任一 face 的 employee_id 与 sId 相同即视为已存在。
|
||||
*
|
||||
* @param sId 学员ID
|
||||
* @param faceImage base64 人脸图片(支持 data URL)
|
||||
* @return true=人脸服务中已存在该学员
|
||||
*/
|
||||
private boolean isFaceRegistered(String sId, String faceImage)
|
||||
{
|
||||
try
|
||||
{
|
||||
TrainFaceApiController faceController = SpringUtils.getBean(TrainFaceApiController.class);
|
||||
Map<String, Object> payload = new HashMap<>(1);
|
||||
payload.put("image_base64", faceImage);
|
||||
AjaxResult recResult = faceController.recognize(payload);
|
||||
if (recResult == null || !recResult.isSuccess())
|
||||
{
|
||||
log.warn("人脸识别未成功 sId={} msg={}", sId, recResult == null ? "null" : recResult.get(AjaxResult.MSG_TAG));
|
||||
return false;
|
||||
}
|
||||
Object recData = recResult.get(AjaxResult.DATA_TAG);
|
||||
if (!(recData instanceof Map))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Object inner = ((Map<?, ?>) recData).get("data");
|
||||
if (!(inner instanceof Map))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Object faces = ((Map<?, ?>) inner).get("faces");
|
||||
if (!(faces instanceof List))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (Object face : (List<?>) faces)
|
||||
{
|
||||
if (face instanceof Map)
|
||||
{
|
||||
Object employeeId = ((Map<?, ?>) face).get("employee_id");
|
||||
if (employeeId != null && sId.equals(employeeId.toString()))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.warn("人脸识别异常 sId={}", sId, ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.ruoyi.web.controller.train;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
|
||||
import com.ruoyi.web.service.train.TrainBatchAvatarAuthService;
|
||||
|
||||
/**
|
||||
* 班级名单「批量上传头像 / 批量授权」接口。
|
||||
*
|
||||
* <p>与既有单个授权链路(/api/lock/v1/stay/checkin)相互独立:
|
||||
* 头像导入只更新 train_student.avatar;批量授权由后台任务逐人执行,
|
||||
* 接口立即返回受理结果,页面不需要等待授权完成。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/train/batch-auth")
|
||||
public class TrainBatchAvatarAuthController extends BaseController
|
||||
{
|
||||
@Autowired
|
||||
private TrainBatchAvatarAuthService batchAvatarAuthService;
|
||||
|
||||
/**
|
||||
* 上传「手机号.png」命名的头像压缩包,按手机号匹配本班人员并写入 avatar。
|
||||
*
|
||||
* @param planId 培训班ID
|
||||
* @param file 头像 ZIP 包
|
||||
* @return data: { total, successCount, failureCount, successList, failureList }
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:enrollment:import')")
|
||||
@Log(title = "班级-批量上传头像", businessType = BusinessType.IMPORT)
|
||||
@PostMapping("/avatar-zip")
|
||||
public AjaxResult uploadAvatarZip(@RequestParam("planId") String planId,
|
||||
@RequestParam(value = "file", required = false) MultipartFile file)
|
||||
{
|
||||
Map<String, Object> data = batchAvatarAuthService.importAvatarsFromZip(planId, file);
|
||||
Integer successCount = (Integer) data.get("successCount");
|
||||
Integer failureCount = (Integer) data.get("failureCount");
|
||||
String message = "头像上传完成:成功 " + successCount + " 条,失败 " + failureCount + " 条";
|
||||
return AjaxResult.success(message, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询本班「已有头像但门锁未授权」的人员,供批量授权勾选。
|
||||
*
|
||||
* @param planId 培训班ID
|
||||
* @return 候选人员列表
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:accommodation:edit')")
|
||||
@GetMapping("/candidates")
|
||||
public AjaxResult listCandidates(@RequestParam("planId") String planId)
|
||||
{
|
||||
List<EnrollmentItemVO> list = batchAvatarAuthService.listAuthCandidates(planId);
|
||||
return AjaxResult.success((Object) list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交批量授权任务:立即返回受理人数,循环授权在后台线程池执行,
|
||||
* 单人完成后把 lock_result 回写为 2(已授权)。
|
||||
*
|
||||
* @param body { planId: 培训班ID, studentIds: [学员主档ID] }
|
||||
* @return 受理人数
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:accommodation:edit')")
|
||||
@Log(title = "班级-批量授权", businessType = BusinessType.UPDATE)
|
||||
@PostMapping("/authorize")
|
||||
public AjaxResult authorize(@RequestBody Map<String, Object> body)
|
||||
{
|
||||
String planId = body.get("planId") == null ? null : body.get("planId").toString();
|
||||
List<String> studentIds = new ArrayList<>();
|
||||
Object idsObj = body.get("studentIds");
|
||||
if (idsObj instanceof List)
|
||||
{
|
||||
for (Object idObj : (List<?>) idsObj)
|
||||
{
|
||||
if (idObj != null)
|
||||
{
|
||||
studentIds.add(idObj.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
int count = batchAvatarAuthService.authorizeBatchAsync(planId, studentIds, getUsername());
|
||||
return AjaxResult.success("已发起授权任务(" + count + " 人),请稍后在列表中查看授权结果", count);
|
||||
}
|
||||
}
|
||||
+33
@@ -28,6 +28,7 @@ import com.ruoyi.train.domain.vo.BatchImportResultVO;
|
||||
import com.ruoyi.train.domain.vo.EnrollmentDetailVO;
|
||||
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
|
||||
import com.ruoyi.train.service.EnrollmentAppService;
|
||||
import com.ruoyi.train.service.IEnrollmentBatchImportService;
|
||||
|
||||
/**
|
||||
* 参训人员全生命周期管理 Controller (API Contract Module B 第 3 部分 / ISSUE-03-01 / P0)。
|
||||
@@ -42,6 +43,9 @@ public class TrainEnrollmentController extends BaseController
|
||||
{
|
||||
private final EnrollmentAppService enrollmentAppService;
|
||||
|
||||
@Autowired
|
||||
private IEnrollmentBatchImportService enrollmentBatchImportService;
|
||||
|
||||
@Autowired
|
||||
public TrainEnrollmentController(EnrollmentAppService enrollmentAppService)
|
||||
{
|
||||
@@ -100,6 +104,35 @@ public class TrainEnrollmentController extends BaseController
|
||||
enrollmentAppService.exportImportTemplate(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学员批量导入预检(10 列学员模板)。
|
||||
*
|
||||
* <p>只解析文件并校验第 1 列学员姓名、第 2 列联系电话是否为空,不写入任何业务数据。</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:enrollment:import')")
|
||||
@PostMapping("/plans/{planId}/enrollments/import/precheck")
|
||||
public AjaxResult precheckImport(@PathVariable String planId, @RequestParam("file") MultipartFile file)
|
||||
{
|
||||
return success(enrollmentBatchImportService.precheck(planId, file));
|
||||
}
|
||||
|
||||
/**
|
||||
* 学员批量导入(10 列学员模板)。
|
||||
*
|
||||
* <p>逐行独立事务处理:学员建档、账号开通、参训关联、宿舍匹配与床位分配,
|
||||
* 单行失败只回滚该行,结果按成功/跳过/失败/忽略分类返回。</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:enrollment:import')")
|
||||
@Log(title = "批量导入参训学员", businessType = BusinessType.IMPORT)
|
||||
@PostMapping("/plans/{planId}/enrollments/import")
|
||||
public AjaxResult importEnrollments(@PathVariable String planId,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "ignoreInvalid", required = false, defaultValue = "false") Boolean ignoreInvalid)
|
||||
{
|
||||
return success(enrollmentBatchImportService.importEnrollments(
|
||||
planId, file, Boolean.TRUE.equals(ignoreInvalid), SecurityUtils.getUsername()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取参训档案详情 (P0)。
|
||||
*/
|
||||
|
||||
+19
@@ -1,6 +1,7 @@
|
||||
package com.ruoyi.web.controller.train;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
@@ -124,6 +125,24 @@ public class TrainStudentController extends BaseController
|
||||
return success(bindingAdminService.updateStudentWithAccount(dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅修改学员头像 (PUT /train/student/{sId}/avatar)。
|
||||
*
|
||||
* <p>供班级名单「点击头像」弹窗使用:只更新 train_student.avatar,
|
||||
* 不触发姓名校验、学员其他字段与账号创建/同步逻辑;
|
||||
* body 中 avatar 传空串表示移除头像。</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:student:edit')")
|
||||
@Log(title = "学员主档-头像", businessType = BusinessType.UPDATE)
|
||||
@PutMapping("/{sId}/avatar")
|
||||
public AjaxResult updateAvatar(@PathVariable("sId") String sId,
|
||||
@RequestBody(required = false) Map<String, Object> body)
|
||||
{
|
||||
Object avatarObj = body == null ? null : body.get("avatar");
|
||||
String avatar = avatarObj == null ? "" : avatarObj.toString();
|
||||
return toAjax(trainStudentService.updateStudentAvatar(sId, avatar));
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载学员批量导入模板 (POST /train/student/importTemplate)。
|
||||
*/
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.ruoyi.web.service.train;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.system.service.ISysUserService;
|
||||
import com.ruoyi.train.domain.TrainStudent;
|
||||
import com.ruoyi.train.mapper.TrainStudentMapper;
|
||||
import com.ruoyi.train.service.ITrainingIdentityService;
|
||||
import com.ruoyi.train.service.StudentAccountProvisioner;
|
||||
|
||||
/**
|
||||
* 学员登录账号开通实现(admin 模块)。
|
||||
*
|
||||
* <p>复用现有账号绑定能力,仅在手机号账号不存在时新建;已存在则跳过,
|
||||
* 不修改 {@link TrainAccountBindingAdminService} 的任何既有逻辑。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@Service
|
||||
public class StudentAccountProvisionerImpl implements StudentAccountProvisioner
|
||||
{
|
||||
private final TrainStudentMapper studentMapper;
|
||||
|
||||
private final ISysUserService userService;
|
||||
|
||||
private final ITrainingIdentityService identityService;
|
||||
|
||||
private final TrainAccountBindingAdminService bindingAdminService;
|
||||
|
||||
public StudentAccountProvisionerImpl(TrainStudentMapper studentMapper,
|
||||
ISysUserService userService,
|
||||
ITrainingIdentityService identityService,
|
||||
TrainAccountBindingAdminService bindingAdminService)
|
||||
{
|
||||
this.studentMapper = studentMapper;
|
||||
this.userService = userService;
|
||||
this.identityService = identityService;
|
||||
this.bindingAdminService = bindingAdminService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long ensureAccount(String sId, String phone, String name)
|
||||
{
|
||||
if (StringUtils.isEmpty(phone))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
TrainStudent student = studentMapper.selectTrainStudentBySId(sId);
|
||||
if (student == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (student.getUserId() != null)
|
||||
{
|
||||
return student.getUserId();
|
||||
}
|
||||
|
||||
SysUser existed = userService.selectUserByUserName(phone);
|
||||
if (existed != null)
|
||||
{
|
||||
// 账号已存在:已绑定其他学员时按要求跳过,未绑定则直接关联当前学员
|
||||
TrainStudent bound = studentMapper.selectTrainStudentByUserId(existed.getUserId());
|
||||
if (bound == null)
|
||||
{
|
||||
identityService.bindStudentUser(sId, existed.getUserId());
|
||||
return existed.getUserId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return bindingAdminService.ensureStudentAccount(sId, phone, name);
|
||||
}
|
||||
}
|
||||
+556
@@ -0,0 +1,556 @@
|
||||
package com.ruoyi.web.service.train;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.ruoyi.common.config.RuoYiConfig;
|
||||
import com.ruoyi.common.constant.Constants;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.DateUtils;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.spring.SpringUtils;
|
||||
import com.ruoyi.train.domain.TrainAccommodation;
|
||||
import com.ruoyi.train.domain.TrainDormitoryInfo;
|
||||
import com.ruoyi.train.domain.TrainEnrollment;
|
||||
import com.ruoyi.train.domain.TrainStudent;
|
||||
import com.ruoyi.train.domain.query.EnrollmentQuery;
|
||||
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
|
||||
import com.ruoyi.train.integration.lock.client.TrainLockStayApiClient;
|
||||
import com.ruoyi.train.integration.lock.dto.LockStayCheckinResult;
|
||||
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudentMapper;
|
||||
import com.ruoyi.train.service.ITrainAccommodationService;
|
||||
import com.ruoyi.train.service.ITrainDormitoryInfoService;
|
||||
import com.ruoyi.train.service.ITrainStudentService;
|
||||
import com.ruoyi.web.controller.api.TrainFaceApiController;
|
||||
|
||||
/**
|
||||
* 班级名单「批量上传头像/授权」业务服务。
|
||||
*
|
||||
* <p>职责:</p>
|
||||
* <ol>
|
||||
* <li>解析「手机号.png / 手机号.jpg」命名的 ZIP 包,按本班参训人员手机号匹配并写入 train_student.avatar;</li>
|
||||
* <li>查询本班「已有头像但门锁未授权」的候选人员;</li>
|
||||
* <li>提交异步批量授权任务:逐人调用门禁开放接口,成功后把 lock_result 回写为 2(已授权)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>批量授权为独立实现(逐人组装参数后直接调用门禁开放客户端),
|
||||
* 不复用既有的单个入住方法 callStayCheckin,避免影响原授权链路的行为。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@Service
|
||||
public class TrainBatchAvatarAuthService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(TrainBatchAvatarAuthService.class);
|
||||
|
||||
/** 单张头像最大字节数(5MB) */
|
||||
private static final long MAX_AVATAR_BYTES = 5L * 1024 * 1024;
|
||||
|
||||
/** 门锁授权有效期(天),与单个入住授权的默认区间保持一致 */
|
||||
private static final int AUTH_VALID_DAYS = 15;
|
||||
|
||||
/** 允许的图片扩展名 */
|
||||
private static final Set<String> ALLOWED_EXTENSIONS =
|
||||
new HashSet<>(Arrays.asList("png", "jpg", "jpeg"));
|
||||
|
||||
/** 无登录上下文时的操作人标识 */
|
||||
private static final String SYSTEM_OPERATOR = "system";
|
||||
|
||||
@Autowired
|
||||
private TrainEnrollmentMapper trainEnrollmentMapper;
|
||||
|
||||
@Autowired
|
||||
private TrainStudentMapper trainStudentMapper;
|
||||
|
||||
@Autowired
|
||||
private ITrainStudentService trainStudentService;
|
||||
|
||||
@Autowired
|
||||
private ITrainAccommodationService trainAccommodationService;
|
||||
|
||||
@Autowired
|
||||
private ITrainDormitoryInfoService trainDormitoryInfoService;
|
||||
|
||||
@Autowired
|
||||
private TrainLockStayApiClient trainLockStayApiClient;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("threadPoolTaskExecutor")
|
||||
private TaskExecutor taskExecutor;
|
||||
|
||||
// ==================== 一、批量上传头像 ====================
|
||||
|
||||
/**
|
||||
* 解析 ZIP 头像包并按手机号匹配本班人员写入 avatar。
|
||||
*
|
||||
* <p>压缩包内文件命名规则:{@code 手机号.png}(也支持 jpg/jpeg),可按目录分组;
|
||||
* 手机号取文件名中第一个点号之前的部分。</p>
|
||||
*
|
||||
* @param planId 培训班ID
|
||||
* @param file 头像压缩包
|
||||
* @return 含成功/失败明细的统计结果
|
||||
*/
|
||||
public Map<String, Object> importAvatarsFromZip(String planId, MultipartFile file)
|
||||
{
|
||||
if (StringUtils.isEmpty(planId))
|
||||
{
|
||||
throw new ServiceException("培训班ID不能为空");
|
||||
}
|
||||
if (file == null || file.isEmpty())
|
||||
{
|
||||
throw new ServiceException("请上传头像压缩包");
|
||||
}
|
||||
|
||||
// 1. 本班参训人员:手机号 -> 人员(同一手机号重复时保留首个)
|
||||
EnrollmentQuery query = new EnrollmentQuery();
|
||||
query.setPlanId(planId);
|
||||
List<EnrollmentItemVO> enrollments = trainEnrollmentMapper.selectEnrollmentItemList(query);
|
||||
Map<String, EnrollmentItemVO> phoneIndex = new HashMap<>();
|
||||
for (EnrollmentItemVO item : enrollments)
|
||||
{
|
||||
String phone = normalizePhone(item.getPhone());
|
||||
if (phone != null && !phoneIndex.containsKey(phone))
|
||||
{
|
||||
phoneIndex.put(phone, item);
|
||||
}
|
||||
}
|
||||
if (phoneIndex.isEmpty())
|
||||
{
|
||||
throw new ServiceException("本班暂无可用手机号的参训人员,无法按手机号匹配头像");
|
||||
}
|
||||
|
||||
List<Map<String, Object>> successList = new ArrayList<>();
|
||||
List<Map<String, Object>> failureList = new ArrayList<>();
|
||||
int total = 0;
|
||||
String operator = currentOperator();
|
||||
|
||||
try (ZipInputStream zip = new ZipInputStream(file.getInputStream()))
|
||||
{
|
||||
ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null)
|
||||
{
|
||||
if (entry.isDirectory())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String fileName = extractFileName(entry.getName());
|
||||
if (fileName.isEmpty() || fileName.startsWith("."))
|
||||
{
|
||||
// 忽略隐藏文件与压缩工具生成的元数据
|
||||
continue;
|
||||
}
|
||||
total++;
|
||||
|
||||
int dot = fileName.lastIndexOf('.');
|
||||
if (dot <= 0 || dot == fileName.length() - 1)
|
||||
{
|
||||
addFailure(failureList, fileName, null, "文件名缺少扩展名,应为「手机号.png」");
|
||||
continue;
|
||||
}
|
||||
String ext = fileName.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||
if (!ALLOWED_EXTENSIONS.contains(ext))
|
||||
{
|
||||
addFailure(failureList, fileName, null, "仅支持 png / jpg / jpeg 格式");
|
||||
continue;
|
||||
}
|
||||
String phone = normalizePhone(fileName.substring(0, dot));
|
||||
if (phone == null)
|
||||
{
|
||||
addFailure(failureList, fileName, null, "文件名不是有效手机号");
|
||||
continue;
|
||||
}
|
||||
EnrollmentItemVO item = phoneIndex.get(phone);
|
||||
if (item == null)
|
||||
{
|
||||
addFailure(failureList, fileName, phone, "本班无匹配该手机号的参训人员");
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] bytes = readEntry(zip);
|
||||
if (bytes.length == 0)
|
||||
{
|
||||
addFailure(failureList, fileName, phone, "图片内容为空");
|
||||
continue;
|
||||
}
|
||||
if (bytes.length > MAX_AVATAR_BYTES)
|
||||
{
|
||||
addFailure(failureList, fileName, phone, "图片超过 5MB");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
String avatarPath = saveAvatarFile(bytes, ext);
|
||||
int rows = trainStudentService.updateStudentAvatar(item.getStudentId(), avatarPath);
|
||||
if (rows <= 0)
|
||||
{
|
||||
addFailure(failureList, fileName, phone, "头像写入失败:学员档案不存在");
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> ok = new LinkedHashMap<>();
|
||||
ok.put("studentId", item.getStudentId());
|
||||
ok.put("studentName", item.getStudentName());
|
||||
ok.put("phone", phone);
|
||||
ok.put("avatar", avatarPath);
|
||||
// 头像已入库,随后同步人脸到人脸识别设备(以 sId 作为 employee_id),口径同报到机单张上传
|
||||
syncFaceToDevice(item.getStudentId(), ok);
|
||||
successList.add(ok);
|
||||
}
|
||||
catch (IOException ioEx)
|
||||
{
|
||||
log.error("[批量头像] 头像落盘失败, phone={}", phone, ioEx);
|
||||
addFailure(failureList, fileName, phone, "头像保存失败:" + ioEx.getMessage());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.error("[批量头像] 头像写入异常, phone={}", phone, ex);
|
||||
addFailure(failureList, fileName, phone, "头像写入异常:" + ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException ioEx)
|
||||
{
|
||||
log.error("[批量头像] 压缩包解析失败, planId={}", planId, ioEx);
|
||||
throw new ServiceException("压缩包解析失败:" + ioEx.getMessage());
|
||||
}
|
||||
|
||||
log.info("[批量头像] 处理完成 planId={}, total={}, success={}, failure={}, operator={}",
|
||||
planId, total, successList.size(), failureList.size(), operator);
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("total", total);
|
||||
data.put("successCount", successList.size());
|
||||
data.put("failureCount", failureList.size());
|
||||
data.put("successList", successList);
|
||||
data.put("failureList", failureList);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把单个学员的头像同步到人脸识别设备。
|
||||
*
|
||||
* <p>处理口径与报到机单张上传({@code TrainRegisterApiController#uploadStudentFace})一致:
|
||||
* 头像先写入 train_student,再以 sId 作为 employee_id 调用设备接口,
|
||||
* 由设备侧读取该头像转 Base64 后下发。设备同步与头像入库相互独立,
|
||||
* 同步结果写入 {@code faceSync} / {@code faceMsg},不改变原本的上传成功判定。</p>
|
||||
*
|
||||
* @param sId 学员ID,作为设备侧 employee_id
|
||||
* @param row 本次上传的成功记录,用于回写设备同步状态
|
||||
*/
|
||||
private void syncFaceToDevice(String sId, Map<String, Object> row)
|
||||
{
|
||||
row.put("faceSync", "skipped");
|
||||
try
|
||||
{
|
||||
TrainFaceApiController faceController = SpringUtils.getBean(TrainFaceApiController.class);
|
||||
Map<String, Object> facePayload = new HashMap<>(1);
|
||||
facePayload.put("employee_id", sId);
|
||||
log.info("[批量头像] 同步人脸到设备 sId={}", sId);
|
||||
AjaxResult faceResult = faceController.addEmployeeFace(facePayload);
|
||||
if (faceResult != null && faceResult.isSuccess())
|
||||
{
|
||||
row.put("faceSync", "success");
|
||||
}
|
||||
else
|
||||
{
|
||||
row.put("faceSync", "failed");
|
||||
row.put("faceMsg", faceResult == null ? "设备无响应" : (String) faceResult.get(AjaxResult.MSG_TAG));
|
||||
log.warn("[批量头像] 人脸设备同步未成功 sId={} msg={}", sId,
|
||||
faceResult == null ? "null" : faceResult.get(AjaxResult.MSG_TAG));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
row.put("faceSync", "failed");
|
||||
row.put("faceMsg", ex.getMessage());
|
||||
log.warn("[批量头像] 人脸设备同步异常 sId={}", sId, ex);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 二、已有头像但未授权的候选人员 ====================
|
||||
|
||||
/**
|
||||
* 查询本班「已有头像但门锁未授权」的人员,供批量授权勾选。
|
||||
*
|
||||
* @param planId 培训班ID
|
||||
* @return 候选人员列表
|
||||
*/
|
||||
public List<EnrollmentItemVO> listAuthCandidates(String planId)
|
||||
{
|
||||
if (StringUtils.isEmpty(planId))
|
||||
{
|
||||
throw new ServiceException("培训班ID不能为空");
|
||||
}
|
||||
return trainEnrollmentMapper.selectAuthCandidateList(planId);
|
||||
}
|
||||
|
||||
// ==================== 三、批量授权(异步任务) ====================
|
||||
|
||||
/**
|
||||
* 提交异步批量授权任务,立即返回受理人数,页面无需等待执行结果。
|
||||
*
|
||||
* <p>循环授权在独立线程池任务中执行;每个人授权完成后按单个授权口径
|
||||
* 把 train_accommodation.lock_result 回写为 2(已授权)。</p>
|
||||
*
|
||||
* @param planId 培训班ID
|
||||
* @param studentIds 学员主档ID集合
|
||||
* @param operator 操作人
|
||||
* @return 本次受理的人数
|
||||
*/
|
||||
public int authorizeBatchAsync(String planId, List<String> studentIds, String operator)
|
||||
{
|
||||
if (StringUtils.isEmpty(planId))
|
||||
{
|
||||
throw new ServiceException("培训班ID不能为空");
|
||||
}
|
||||
List<String> ids = new ArrayList<>();
|
||||
if (studentIds != null)
|
||||
{
|
||||
for (String id : studentIds)
|
||||
{
|
||||
if (StringUtils.isNotEmpty(id))
|
||||
{
|
||||
String trimmed = id.trim();
|
||||
if (!ids.contains(trimmed))
|
||||
{
|
||||
ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ids.isEmpty())
|
||||
{
|
||||
throw new ServiceException("请先选择需要授权的人员");
|
||||
}
|
||||
|
||||
final String finalPlanId = planId;
|
||||
final String finalOperator = StringUtils.isNotEmpty(operator) ? operator : SYSTEM_OPERATOR;
|
||||
taskExecutor.execute(() -> runBatchAuthorize(finalPlanId, ids, finalOperator));
|
||||
return ids.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量授权任务主体:逐人授权,单个人失败不中断整体任务。
|
||||
*
|
||||
* @param planId 培训班ID
|
||||
* @param ids 学员主档ID集合
|
||||
* @param operator 操作人
|
||||
*/
|
||||
private void runBatchAuthorize(String planId, List<String> ids, String operator)
|
||||
{
|
||||
log.info("[批量授权] 任务开始 planId={}, count={}, operator={}", planId, ids.size(), operator);
|
||||
int success = 0;
|
||||
int failure = 0;
|
||||
for (String studentId : ids)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (authorizeOne(planId, studentId, operator))
|
||||
{
|
||||
success++;
|
||||
}
|
||||
else
|
||||
{
|
||||
failure++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure++;
|
||||
log.error("[批量授权] 单人授权异常 planId={}, studentId={}", planId, studentId, ex);
|
||||
}
|
||||
}
|
||||
log.info("[批量授权] 任务结束 planId={}, success={}, failure={}", planId, success, failure);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个人员门锁授权(批量任务专用的独立实现)。
|
||||
*
|
||||
* <p>与单个入住方法 callStayCheckin 的区别:本方法按 planId + studentId 自行定位
|
||||
* 参训档案、有效住宿与宿舍,不复用原方法,且在本方法内直接完成 lock_result 回写。</p>
|
||||
*
|
||||
* @param planId 培训班ID
|
||||
* @param studentId 学员主档ID
|
||||
* @param operator 操作人
|
||||
* @return true 表示授权成功
|
||||
*/
|
||||
private boolean authorizeOne(String planId, String studentId, String operator)
|
||||
{
|
||||
TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentByPlanIdAndStudentId(planId, studentId);
|
||||
if (enrollment == null)
|
||||
{
|
||||
log.warn("[批量授权] 跳过:本班无该学员参训档案 planId={}, studentId={}", planId, studentId);
|
||||
return false;
|
||||
}
|
||||
TrainAccommodation accommodation =
|
||||
trainAccommodationService.selectActiveAccommodationByEnrollmentId(enrollment.getEnrollmentId());
|
||||
if (accommodation == null)
|
||||
{
|
||||
log.warn("[批量授权] 跳过:学员无有效住宿安排 studentId={}, enrollmentId={}",
|
||||
studentId, enrollment.getEnrollmentId());
|
||||
return false;
|
||||
}
|
||||
TrainStudent student = trainStudentMapper.selectTrainStudentBySId(studentId);
|
||||
if (student == null)
|
||||
{
|
||||
log.warn("[批量授权] 跳过:学员档案不存在 studentId={}", studentId);
|
||||
return false;
|
||||
}
|
||||
if (StringUtils.isEmpty(student.getAvatar()) || student.getAvatar().trim().isEmpty())
|
||||
{
|
||||
log.warn("[批量授权] 跳过:学员缺少头像,无法下发人脸授权 studentId={}", studentId);
|
||||
return false;
|
||||
}
|
||||
TrainDormitoryInfo dorm =
|
||||
trainDormitoryInfoService.selectTrainDormitoryInfoByDormId(accommodation.getDormId());
|
||||
if (dorm == null || StringUtils.isEmpty(dorm.getDormRoom()))
|
||||
{
|
||||
log.warn("[批量授权] 跳过:宿舍或房间号缺失,dormId={}, studentId={}",
|
||||
accommodation.getDormId(), studentId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 门锁业务流水号:已有则复用,缺失时生成并回写,保证回调能按 bizNo 定位住宿记录
|
||||
String bizNo = trainAccommodationService.generateLockApiBizNo();
|
||||
trainAccommodationService.updateLockApiBizNo(accommodation.getAccommodationId(), bizNo, operator);
|
||||
|
||||
|
||||
Date beginTime = DateUtils.getNowDate();
|
||||
Date endTime = Date.from(beginTime.toInstant().plus(AUTH_VALID_DAYS, ChronoUnit.DAYS));
|
||||
LockStayCheckinResult result = trainLockStayApiClient.checkinWithResult(bizNo, student.getsId(),
|
||||
student.getsName(), dorm.getDormRoom(), beginTime, endTime, student.getAvatar());
|
||||
if (result == null || !result.isSuccess())
|
||||
{
|
||||
log.warn("[批量授权] 授权失败 studentId={}, bizNo={}, status={}, msg={}", studentId, bizNo,
|
||||
result == null ? null : result.getStatus(),
|
||||
result == null ? null : result.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 参照单个授权口径:受理成功后把住宿记录的门锁授权结果置为 2(已授权)
|
||||
trainAccommodationService.updateLockResult(accommodation.getAccommodationId(),
|
||||
TrainAccommodation.LOCK_RESULT_AUTHORIZED, operator);
|
||||
log.info("[批量授权] 授权成功 studentId={}, accommodationId={}, bizNo={}",
|
||||
studentId, accommodation.getAccommodationId(), bizNo);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== 内部工具 ====================
|
||||
|
||||
/** 取当前登录用户名,无登录上下文时回落 system */
|
||||
private String currentOperator()
|
||||
{
|
||||
try
|
||||
{
|
||||
String username = SecurityUtils.getUsername();
|
||||
return StringUtils.isNotEmpty(username) ? username : SYSTEM_OPERATOR;
|
||||
}
|
||||
catch (Exception ignored)
|
||||
{
|
||||
return SYSTEM_OPERATOR;
|
||||
}
|
||||
}
|
||||
|
||||
/** 取压缩包内的纯文件名(兼容 / 与 \ 两种分隔符) */
|
||||
private String extractFileName(String entryName)
|
||||
{
|
||||
if (entryName == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
String name = entryName.trim();
|
||||
int slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\'));
|
||||
if (slash >= 0)
|
||||
{
|
||||
name = name.substring(slash + 1);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号归一化:仅保留数字,长度 6~20 视为有效,其余返回 null。
|
||||
* 文件名与学员手机号两侧都做同样归一化,提升带分隔符场景的匹配成功率。
|
||||
*/
|
||||
private String normalizePhone(String value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String digits = value.replaceAll("[^0-9]", "");
|
||||
if (digits.length() < 6 || digits.length() > 20)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
/** 读取当前 ZIP entry 的全部字节 */
|
||||
private byte[] readEntry(InputStream input) throws IOException
|
||||
{
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
byte[] chunk = new byte[8192];
|
||||
int len;
|
||||
while ((len = input.read(chunk)) > 0)
|
||||
{
|
||||
buffer.write(chunk, 0, len);
|
||||
}
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 头像落盘:写入 profile/upload/yyyy/MM/dd 目录,返回入库用的相对路径
|
||||
* (形如 /profile/upload/2026/11/12/xxx.png,与单个上传口径一致)。
|
||||
*/
|
||||
private String saveAvatarFile(byte[] bytes, String ext) throws IOException
|
||||
{
|
||||
String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date());
|
||||
File dir = new File(RuoYiConfig.getProfile() + File.separator + "upload" + File.separator + datePath);
|
||||
if (!dir.exists() && !dir.mkdirs())
|
||||
{
|
||||
throw new IOException("创建头像存储目录失败:" + dir.getAbsolutePath());
|
||||
}
|
||||
String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
|
||||
Files.write(new File(dir, fileName).toPath(), bytes);
|
||||
return Constants.RESOURCE_PREFIX + "/upload/" + datePath + "/" + fileName;
|
||||
}
|
||||
|
||||
/** 追加一条失败明细 */
|
||||
private void addFailure(List<Map<String, Object>> failureList, String fileName, String phone, String reason)
|
||||
{
|
||||
Map<String, Object> failure = new LinkedHashMap<>();
|
||||
failure.put("fileName", fileName);
|
||||
failure.put("phone", phone);
|
||||
failure.put("reason", reason);
|
||||
failureList.add(failure);
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ iot:
|
||||
#- http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
|
||||
# 调用超时(毫秒)
|
||||
timeout: 10000
|
||||
lock_endpoint: http://192.168.1.203:8080
|
||||
lock_endpoint: http://192.168.1.202:8085
|
||||
#lock_endpoint: http://192.168.1.202:8085
|
||||
api_key: RiviwlSWGDVjY7ktsRsn718R6oDNmrQy
|
||||
avatar_profile: http://192.168.1.106:8096
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package com.ruoyi.train.domain.dto;
|
||||
|
||||
/**
|
||||
* 参训学员批量导入单行原始数据(对应 10 列学员导入模板)。
|
||||
*
|
||||
* <p>列顺序:学员姓名 / 联系电话 / 身份证号 / 性别 / 出生日期 / 所属单位 / 学号 / 房间号 / 门禁卡号 / 备注。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class EnrollmentImportRowDTO
|
||||
{
|
||||
/** Excel 实际行号(从 1 开始,含表头偏移)。 */
|
||||
private int rowNum;
|
||||
|
||||
private String studentName;
|
||||
|
||||
private String phone;
|
||||
|
||||
private String idCard;
|
||||
|
||||
private String gender;
|
||||
|
||||
private String birth;
|
||||
|
||||
private String deptName;
|
||||
|
||||
private String personNo;
|
||||
|
||||
private String roomNo;
|
||||
|
||||
private String cardNo;
|
||||
|
||||
private String remark;
|
||||
|
||||
public int getRowNum()
|
||||
{
|
||||
return rowNum;
|
||||
}
|
||||
|
||||
public void setRowNum(int rowNum)
|
||||
{
|
||||
this.rowNum = rowNum;
|
||||
}
|
||||
|
||||
public String getStudentName()
|
||||
{
|
||||
return studentName;
|
||||
}
|
||||
|
||||
public void setStudentName(String studentName)
|
||||
{
|
||||
this.studentName = studentName;
|
||||
}
|
||||
|
||||
public String getPhone()
|
||||
{
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone)
|
||||
{
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getIdCard()
|
||||
{
|
||||
return idCard;
|
||||
}
|
||||
|
||||
public void setIdCard(String idCard)
|
||||
{
|
||||
this.idCard = idCard;
|
||||
}
|
||||
|
||||
public String getGender()
|
||||
{
|
||||
return gender;
|
||||
}
|
||||
|
||||
public void setGender(String gender)
|
||||
{
|
||||
this.gender = gender;
|
||||
}
|
||||
|
||||
public String getBirth()
|
||||
{
|
||||
return birth;
|
||||
}
|
||||
|
||||
public void setBirth(String birth)
|
||||
{
|
||||
this.birth = birth;
|
||||
}
|
||||
|
||||
public String getDeptName()
|
||||
{
|
||||
return deptName;
|
||||
}
|
||||
|
||||
public void setDeptName(String deptName)
|
||||
{
|
||||
this.deptName = deptName;
|
||||
}
|
||||
|
||||
public String getPersonNo()
|
||||
{
|
||||
return personNo;
|
||||
}
|
||||
|
||||
public void setPersonNo(String personNo)
|
||||
{
|
||||
this.personNo = personNo;
|
||||
}
|
||||
|
||||
public String getRoomNo()
|
||||
{
|
||||
return roomNo;
|
||||
}
|
||||
|
||||
public void setRoomNo(String roomNo)
|
||||
{
|
||||
this.roomNo = roomNo;
|
||||
}
|
||||
|
||||
public String getCardNo()
|
||||
{
|
||||
return cardNo;
|
||||
}
|
||||
|
||||
public void setCardNo(String cardNo)
|
||||
{
|
||||
this.cardNo = cardNo;
|
||||
}
|
||||
|
||||
public String getRemark()
|
||||
{
|
||||
return remark;
|
||||
}
|
||||
|
||||
public void setRemark(String remark)
|
||||
{
|
||||
this.remark = remark;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.ruoyi.train.domain.vo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 参训学员批量导入预检结果(只读校验,不落库)。
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class EnrollmentImportPrecheckVO
|
||||
{
|
||||
/** 解析到的数据行总数。 */
|
||||
private int total;
|
||||
|
||||
/** 缺少必填项(学员姓名、联系电话)的行数。 */
|
||||
private int invalidCount;
|
||||
|
||||
private List<EnrollmentImportRowResultVO> invalidRows = new ArrayList<>();
|
||||
|
||||
public void appendInvalid(EnrollmentImportRowResultVO row)
|
||||
{
|
||||
if (row == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
invalidRows.add(row);
|
||||
invalidCount++;
|
||||
}
|
||||
|
||||
public int getTotal()
|
||||
{
|
||||
return total;
|
||||
}
|
||||
|
||||
public void setTotal(int total)
|
||||
{
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
public int getInvalidCount()
|
||||
{
|
||||
return invalidCount;
|
||||
}
|
||||
|
||||
public void setInvalidCount(int invalidCount)
|
||||
{
|
||||
this.invalidCount = invalidCount;
|
||||
}
|
||||
|
||||
public List<EnrollmentImportRowResultVO> getInvalidRows()
|
||||
{
|
||||
return invalidRows;
|
||||
}
|
||||
|
||||
public void setInvalidRows(List<EnrollmentImportRowResultVO> invalidRows)
|
||||
{
|
||||
this.invalidRows = invalidRows == null ? new ArrayList<>() : invalidRows;
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package com.ruoyi.train.domain.vo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 参训学员批量导入汇总结果。
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class EnrollmentImportResultVO
|
||||
{
|
||||
/** 解析到的数据行总数。 */
|
||||
private int total;
|
||||
|
||||
private int successCount;
|
||||
|
||||
private int skipCount;
|
||||
|
||||
private int failureCount;
|
||||
|
||||
private int ignoredCount;
|
||||
|
||||
private List<EnrollmentImportRowResultVO> rows = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 追加一行结果并同步累计计数。
|
||||
*
|
||||
* @param row 单行结果
|
||||
*/
|
||||
public void append(EnrollmentImportRowResultVO row)
|
||||
{
|
||||
if (row == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
rows.add(row);
|
||||
total++;
|
||||
if (EnrollmentImportRowResultVO.RESULT_SUCCESS.equals(row.getResult()))
|
||||
{
|
||||
successCount++;
|
||||
}
|
||||
else if (EnrollmentImportRowResultVO.RESULT_SKIPPED.equals(row.getResult()))
|
||||
{
|
||||
skipCount++;
|
||||
}
|
||||
else if (EnrollmentImportRowResultVO.RESULT_IGNORED.equals(row.getResult()))
|
||||
{
|
||||
ignoredCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
failureCount++;
|
||||
}
|
||||
}
|
||||
|
||||
public int getTotal()
|
||||
{
|
||||
return total;
|
||||
}
|
||||
|
||||
public void setTotal(int total)
|
||||
{
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
public int getSuccessCount()
|
||||
{
|
||||
return successCount;
|
||||
}
|
||||
|
||||
public void setSuccessCount(int successCount)
|
||||
{
|
||||
this.successCount = successCount;
|
||||
}
|
||||
|
||||
public int getSkipCount()
|
||||
{
|
||||
return skipCount;
|
||||
}
|
||||
|
||||
public void setSkipCount(int skipCount)
|
||||
{
|
||||
this.skipCount = skipCount;
|
||||
}
|
||||
|
||||
public int getFailureCount()
|
||||
{
|
||||
return failureCount;
|
||||
}
|
||||
|
||||
public void setFailureCount(int failureCount)
|
||||
{
|
||||
this.failureCount = failureCount;
|
||||
}
|
||||
|
||||
public int getIgnoredCount()
|
||||
{
|
||||
return ignoredCount;
|
||||
}
|
||||
|
||||
public void setIgnoredCount(int ignoredCount)
|
||||
{
|
||||
this.ignoredCount = ignoredCount;
|
||||
}
|
||||
|
||||
public List<EnrollmentImportRowResultVO> getRows()
|
||||
{
|
||||
return rows;
|
||||
}
|
||||
|
||||
public void setRows(List<EnrollmentImportRowResultVO> rows)
|
||||
{
|
||||
this.rows = rows == null ? new ArrayList<>() : rows;
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package com.ruoyi.train.domain.vo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 参训学员批量导入的单行处理结果。
|
||||
*
|
||||
* <p>result 取值固定,便于前端分类提示:
|
||||
* SUCCESS 成功、SKIPPED 跳过、FAILED 失败、IGNORED 忽略、INVALID 预检不合法。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class EnrollmentImportRowResultVO
|
||||
{
|
||||
public static final String RESULT_SUCCESS = "SUCCESS";
|
||||
|
||||
public static final String RESULT_SKIPPED = "SKIPPED";
|
||||
|
||||
public static final String RESULT_FAILED = "FAILED";
|
||||
|
||||
public static final String RESULT_IGNORED = "IGNORED";
|
||||
|
||||
public static final String RESULT_INVALID = "INVALID";
|
||||
|
||||
/** Excel 实际行号。 */
|
||||
private int rowNum;
|
||||
|
||||
private String studentName;
|
||||
|
||||
private String phone;
|
||||
|
||||
/** SUCCESS / SKIPPED / FAILED / IGNORED / INVALID。 */
|
||||
private String result;
|
||||
|
||||
/** 结果说明,失败与跳过必填。 */
|
||||
private String reason;
|
||||
|
||||
private String sId;
|
||||
|
||||
private Long enrollmentId;
|
||||
|
||||
/** 是否为本次新建的学员主档。 */
|
||||
private boolean newStudent;
|
||||
|
||||
/** 是否本次新建了登录账号。 */
|
||||
private boolean accountCreated;
|
||||
|
||||
private String dormId;
|
||||
|
||||
private String bedNo;
|
||||
|
||||
/** 非阻断性提示,例如账号已存在、宿舍被自动创建。 */
|
||||
private List<String> warnings = new ArrayList<>();
|
||||
|
||||
public static EnrollmentImportRowResultVO of(int rowNum, String studentName, String phone, String result, String reason)
|
||||
{
|
||||
EnrollmentImportRowResultVO vo = new EnrollmentImportRowResultVO();
|
||||
vo.setRowNum(rowNum);
|
||||
vo.setStudentName(studentName);
|
||||
vo.setPhone(phone);
|
||||
vo.setResult(result);
|
||||
vo.setReason(reason);
|
||||
return vo;
|
||||
}
|
||||
|
||||
public static EnrollmentImportRowResultVO success(int rowNum, String studentName, String phone)
|
||||
{
|
||||
return of(rowNum, studentName, phone, RESULT_SUCCESS, null);
|
||||
}
|
||||
|
||||
public static EnrollmentImportRowResultVO skipped(int rowNum, String studentName, String phone, String reason)
|
||||
{
|
||||
return of(rowNum, studentName, phone, RESULT_SKIPPED, reason);
|
||||
}
|
||||
|
||||
public static EnrollmentImportRowResultVO failed(int rowNum, String studentName, String phone, String reason)
|
||||
{
|
||||
return of(rowNum, studentName, phone, RESULT_FAILED, reason);
|
||||
}
|
||||
|
||||
public static EnrollmentImportRowResultVO ignored(int rowNum, String studentName, String phone, String reason)
|
||||
{
|
||||
return of(rowNum, studentName, phone, RESULT_IGNORED, reason);
|
||||
}
|
||||
|
||||
public static EnrollmentImportRowResultVO invalid(int rowNum, String studentName, String phone, String reason)
|
||||
{
|
||||
return of(rowNum, studentName, phone, RESULT_INVALID, reason);
|
||||
}
|
||||
|
||||
public int getRowNum()
|
||||
{
|
||||
return rowNum;
|
||||
}
|
||||
|
||||
public void setRowNum(int rowNum)
|
||||
{
|
||||
this.rowNum = rowNum;
|
||||
}
|
||||
|
||||
public String getStudentName()
|
||||
{
|
||||
return studentName;
|
||||
}
|
||||
|
||||
public void setStudentName(String studentName)
|
||||
{
|
||||
this.studentName = studentName;
|
||||
}
|
||||
|
||||
public String getPhone()
|
||||
{
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone)
|
||||
{
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getResult()
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
public void setResult(String result)
|
||||
{
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
public String getReason()
|
||||
{
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason)
|
||||
{
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public String getsId()
|
||||
{
|
||||
return sId;
|
||||
}
|
||||
|
||||
public void setsId(String sId)
|
||||
{
|
||||
this.sId = sId;
|
||||
}
|
||||
|
||||
public Long getEnrollmentId()
|
||||
{
|
||||
return enrollmentId;
|
||||
}
|
||||
|
||||
public void setEnrollmentId(Long enrollmentId)
|
||||
{
|
||||
this.enrollmentId = enrollmentId;
|
||||
}
|
||||
|
||||
public boolean isNewStudent()
|
||||
{
|
||||
return newStudent;
|
||||
}
|
||||
|
||||
public void setNewStudent(boolean newStudent)
|
||||
{
|
||||
this.newStudent = newStudent;
|
||||
}
|
||||
|
||||
public boolean isAccountCreated()
|
||||
{
|
||||
return accountCreated;
|
||||
}
|
||||
|
||||
public void setAccountCreated(boolean accountCreated)
|
||||
{
|
||||
this.accountCreated = accountCreated;
|
||||
}
|
||||
|
||||
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 List<String> getWarnings()
|
||||
{
|
||||
return warnings;
|
||||
}
|
||||
|
||||
public void setWarnings(List<String> warnings)
|
||||
{
|
||||
this.warnings = warnings == null ? new ArrayList<>() : warnings;
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.ruoyi.train.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import com.ruoyi.train.domain.TrainDormitoryInfo;
|
||||
import com.ruoyi.train.domain.TrainStudent;
|
||||
|
||||
/**
|
||||
* 参训学员批量导入专用只读查询 Mapper。
|
||||
*
|
||||
* <p>为避免影响既有学员、宿舍、住宿分配流程,本接口只提供导入必需的两个查询,
|
||||
* 不复用也不修改任何已有 Mapper 方法。写入操作仍调用既有 Mapper 的插入方法。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public interface TrainEnrollmentImportMapper
|
||||
{
|
||||
/**
|
||||
* 按联系电话匹配学员主档(未删除),用于判定是否为复训老学员。
|
||||
*
|
||||
* @param sPhone 联系电话
|
||||
* @return 学员主档,不存在返回 null
|
||||
*/
|
||||
TrainStudent selectStudentByPhone(@Param("sPhone") String sPhone);
|
||||
|
||||
/**
|
||||
* 按房间号匹配宿舍(未删除)。
|
||||
*
|
||||
* @param dormRoom 房间号
|
||||
* @return 宿舍信息,不存在返回 null
|
||||
*/
|
||||
TrainDormitoryInfo selectDormitoryByRoomNo(@Param("dormRoom") String dormRoom);
|
||||
}
|
||||
@@ -66,6 +66,17 @@ public interface TrainEnrollmentMapper
|
||||
*/
|
||||
List<EnrollmentItemVO> selectEnrollmentItemList(EnrollmentQuery query);
|
||||
|
||||
/**
|
||||
* 查询指定班级「已有头像但门锁未授权」的人员(批量授权候选)。
|
||||
*
|
||||
* <p>取数为当前有效住宿(0 预分配 / 1 已入住),且 avatar 非空、lock_result 不等于 2(已授权)。
|
||||
* 供班级名单「批量上传头像/授权」使用,不改变既有列表查询逻辑。</p>
|
||||
*
|
||||
* @param planId 培训班ID
|
||||
* @return 候选人员列表(含 avatar、dormRoom、lockApiBizNo、lockResult)
|
||||
*/
|
||||
List<EnrollmentItemVO> selectAuthCandidateList(@Param("planId") String planId);
|
||||
|
||||
/**
|
||||
* 统计指定培训计划下的参训人数(排除已放弃3的学员统计,或统计全部)。
|
||||
*
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.ruoyi.train.service;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.ruoyi.train.domain.vo.EnrollmentImportPrecheckVO;
|
||||
import com.ruoyi.train.domain.vo.EnrollmentImportResultVO;
|
||||
|
||||
/**
|
||||
* 参训学员批量导入服务(10 列学员导入模板)。
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public interface IEnrollmentBatchImportService
|
||||
{
|
||||
/**
|
||||
* 预检:只解析与校验第 1、2 列必填项,不写库。
|
||||
*
|
||||
* @param planId 开班计划ID(班级ID)
|
||||
* @param file 上传的 xlsx 文件
|
||||
* @return 预检结果
|
||||
*/
|
||||
EnrollmentImportPrecheckVO precheck(String planId, MultipartFile file);
|
||||
|
||||
/**
|
||||
* 执行导入:逐行独立事务处理。
|
||||
*
|
||||
* @param planId 开班计划ID(班级ID)
|
||||
* @param file 上传的 xlsx 文件
|
||||
* @param ignoreInvalid 是否忽略缺少必填项的行
|
||||
* @param operator 操作人
|
||||
* @return 导入明细结果
|
||||
*/
|
||||
EnrollmentImportResultVO importEnrollments(String planId, MultipartFile file, boolean ignoreInvalid, String operator);
|
||||
}
|
||||
@@ -43,6 +43,18 @@ public interface ITrainStudentService
|
||||
*/
|
||||
String updateTrainStudent(StudentUpdateDTO dto);
|
||||
|
||||
/**
|
||||
* 仅更新学员头像(不触发姓名校验、账号创建/同步等其他逻辑)。
|
||||
*
|
||||
* <p>与 {@link #updateTrainStudent(StudentUpdateDTO)} 的差异:不对空串做归一化,
|
||||
* 传空串即显式清空 avatar(Mapper 动态 SQL 对 null 会跳过该字段,无法清空)。</p>
|
||||
*
|
||||
* @param sId 学员主档ID
|
||||
* @param avatar 头像相对路径;空串表示移除头像
|
||||
* @return 影响行数
|
||||
*/
|
||||
int updateStudentAvatar(String sId, String avatar);
|
||||
|
||||
/**
|
||||
* 安全删除学员档案(ZERO-REFERENCE SAFE DELETE)
|
||||
*
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.ruoyi.train.service;
|
||||
|
||||
/**
|
||||
* 学员登录账号开通能力。
|
||||
*
|
||||
* <p>由于 sys_user 相关服务位于 system/admin 模块,ruoyi-train 无法直接依赖,
|
||||
* 这里只定义能力接口,由 admin 模块提供实现,保证导入流程与现有账号绑定逻辑一致。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public interface StudentAccountProvisioner
|
||||
{
|
||||
/**
|
||||
* 确保学员已绑定登录账号(账号名为手机号)。
|
||||
*
|
||||
* @param sId 学员ID
|
||||
* @param phone 手机号(账号名)
|
||||
* @param name 学员姓名(昵称)
|
||||
* @return 绑定的用户ID;账号已存在且无需新建时返回 null
|
||||
*/
|
||||
Long ensureAccount(String sId, String phone, String name);
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellType;
|
||||
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||
import org.apache.poi.ss.usermodel.DateUtil;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.train.domain.dto.EnrollmentImportRowDTO;
|
||||
import com.ruoyi.train.domain.vo.EnrollmentImportPrecheckVO;
|
||||
import com.ruoyi.train.domain.vo.EnrollmentImportResultVO;
|
||||
import com.ruoyi.train.domain.vo.EnrollmentImportRowResultVO;
|
||||
import com.ruoyi.train.service.IEnrollmentBatchImportService;
|
||||
|
||||
/**
|
||||
* 参训学员批量导入服务实现。
|
||||
*
|
||||
* <p>文件按本页面下载的 10 列学员模板解析;预检只读不落库,正式导入逐行独立事务处理,
|
||||
* 单行失败只回滚该行并记录原因,不影响其它行。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@Service
|
||||
public class EnrollmentBatchImportServiceImpl implements IEnrollmentBatchImportService
|
||||
{
|
||||
/** 上传文件大小上限 5MB。 */
|
||||
private static final long MAX_FILE_SIZE = 5L * 1024 * 1024;
|
||||
|
||||
/** 单次最多处理行数。 */
|
||||
private static final int MAX_ROW_COUNT = 500;
|
||||
|
||||
private static final DataFormatter CELL_FORMATTER = new DataFormatter();
|
||||
|
||||
private final EnrollmentImportRowProcessor rowProcessor;
|
||||
|
||||
public EnrollmentBatchImportServiceImpl(EnrollmentImportRowProcessor rowProcessor)
|
||||
{
|
||||
this.rowProcessor = rowProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EnrollmentImportPrecheckVO precheck(String planId, MultipartFile file)
|
||||
{
|
||||
List<EnrollmentImportRowDTO> rows = parseRows(file);
|
||||
EnrollmentImportPrecheckVO result = new EnrollmentImportPrecheckVO();
|
||||
result.setTotal(rows.size());
|
||||
for (EnrollmentImportRowDTO row : rows)
|
||||
{
|
||||
String reason = invalidReason(row);
|
||||
if (reason != null)
|
||||
{
|
||||
result.appendInvalid(EnrollmentImportRowResultVO.invalid(
|
||||
row.getRowNum(), row.getStudentName(), row.getPhone(), reason));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EnrollmentImportResultVO importEnrollments(String planId, MultipartFile file, boolean ignoreInvalid,
|
||||
String operator)
|
||||
{
|
||||
if (StringUtils.isEmpty(planId))
|
||||
{
|
||||
throw new ServiceException("班级ID不能为空");
|
||||
}
|
||||
List<EnrollmentImportRowDTO> rows = parseRows(file);
|
||||
|
||||
if (!ignoreInvalid)
|
||||
{
|
||||
int invalidCount = 0;
|
||||
for (EnrollmentImportRowDTO row : rows)
|
||||
{
|
||||
if (invalidReason(row) != null)
|
||||
{
|
||||
invalidCount++;
|
||||
}
|
||||
}
|
||||
if (invalidCount > 0)
|
||||
{
|
||||
throw new ServiceException("存在 " + invalidCount
|
||||
+ " 行缺少学员姓名或联系电话,请完善后导入或选择忽略不合法记录");
|
||||
}
|
||||
}
|
||||
|
||||
EnrollmentImportResultVO result = new EnrollmentImportResultVO();
|
||||
for (EnrollmentImportRowDTO row : rows)
|
||||
{
|
||||
String reason = invalidReason(row);
|
||||
if (reason != null)
|
||||
{
|
||||
result.append(EnrollmentImportRowResultVO.ignored(
|
||||
row.getRowNum(), row.getStudentName(), row.getPhone(), reason));
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
result.append(rowProcessor.process(row, planId, operator));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
result.append(EnrollmentImportRowResultVO.failed(
|
||||
row.getRowNum(), row.getStudentName(), row.getPhone(), resolveMessage(e)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验文件并解析出数据行(跳过表头与全空行)。
|
||||
*/
|
||||
private List<EnrollmentImportRowDTO> parseRows(MultipartFile file)
|
||||
{
|
||||
validateFile(file);
|
||||
List<EnrollmentImportRowDTO> rows = new ArrayList<>();
|
||||
try (InputStream inputStream = file.getInputStream();
|
||||
Workbook workbook = WorkbookFactory.create(inputStream))
|
||||
{
|
||||
if (workbook.getNumberOfSheets() == 0)
|
||||
{
|
||||
throw new ServiceException("Excel 中没有可用的工作表");
|
||||
}
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
Row header = sheet.getRow(sheet.getFirstRowNum());
|
||||
String firstName = getCellString(header == null ? null : header.getCell(0));
|
||||
String firstPhone = getCellString(header == null ? null : header.getCell(1));
|
||||
if (!firstName.contains("学员姓名") || !firstPhone.contains("联系电话"))
|
||||
{
|
||||
throw new ServiceException("模板不匹配,请使用本页面下载的学员导入模板");
|
||||
}
|
||||
|
||||
for (int index = sheet.getFirstRowNum() + 1; index <= sheet.getLastRowNum(); index++)
|
||||
{
|
||||
Row row = sheet.getRow(index);
|
||||
if (row == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
EnrollmentImportRowDTO dto = readRow(row, index + 1);
|
||||
if (isBlankRow(dto))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
rows.add(dto);
|
||||
if (rows.size() > MAX_ROW_COUNT)
|
||||
{
|
||||
throw new ServiceException("单次最多导入 " + MAX_ROW_COUNT + " 行,请拆分后重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
throw e;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new ServiceException("Excel 解析失败:" + e.getMessage());
|
||||
}
|
||||
if (rows.isEmpty())
|
||||
{
|
||||
throw new ServiceException("未读取到有效数据行");
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private EnrollmentImportRowDTO readRow(Row row, int rowNum)
|
||||
{
|
||||
EnrollmentImportRowDTO dto = new EnrollmentImportRowDTO();
|
||||
dto.setRowNum(rowNum);
|
||||
dto.setStudentName(getCellString(row.getCell(0)));
|
||||
dto.setPhone(getCellString(row.getCell(1)));
|
||||
dto.setIdCard(getCellString(row.getCell(2)));
|
||||
dto.setGender(getCellString(row.getCell(3)));
|
||||
dto.setBirth(getCellString(row.getCell(4)));
|
||||
dto.setDeptName(getCellString(row.getCell(5)));
|
||||
dto.setPersonNo(getCellString(row.getCell(6)));
|
||||
dto.setRoomNo(getCellString(row.getCell(7)));
|
||||
dto.setCardNo(getCellString(row.getCell(8)));
|
||||
dto.setRemark(getCellString(row.getCell(9)));
|
||||
return dto;
|
||||
}
|
||||
|
||||
private String getCellString(Cell cell)
|
||||
{
|
||||
if (cell == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
CellType cellType = cell.getCellType();
|
||||
if (cellType == CellType.FORMULA)
|
||||
{
|
||||
cellType = cell.getCachedFormulaResultType();
|
||||
}
|
||||
if (cellType == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell))
|
||||
{
|
||||
return new SimpleDateFormat("yyyy-MM-dd").format(cell.getDateCellValue());
|
||||
}
|
||||
return CELL_FORMATTER.formatCellValue(cell).trim();
|
||||
}
|
||||
|
||||
private boolean isBlankRow(EnrollmentImportRowDTO row)
|
||||
{
|
||||
return StringUtils.isEmpty(row.getStudentName())
|
||||
&& StringUtils.isEmpty(row.getPhone())
|
||||
&& StringUtils.isEmpty(row.getIdCard())
|
||||
&& StringUtils.isEmpty(row.getRoomNo());
|
||||
}
|
||||
|
||||
/**
|
||||
* 必填项校验:第 1 列学员姓名、第 2 列联系电话。
|
||||
*
|
||||
* @return 不合法的原因,合法返回 null
|
||||
*/
|
||||
private String invalidReason(EnrollmentImportRowDTO row)
|
||||
{
|
||||
boolean blankName = StringUtils.isEmpty(row.getStudentName());
|
||||
boolean blankPhone = StringUtils.isEmpty(row.getPhone());
|
||||
if (blankName && blankPhone)
|
||||
{
|
||||
return "学员姓名、联系电话不能为空";
|
||||
}
|
||||
if (blankName)
|
||||
{
|
||||
return "学员姓名不能为空";
|
||||
}
|
||||
if (blankPhone)
|
||||
{
|
||||
return "联系电话不能为空";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void validateFile(MultipartFile file)
|
||||
{
|
||||
if (file == null || file.isEmpty())
|
||||
{
|
||||
throw new ServiceException("请选择要导入的 Excel 文件");
|
||||
}
|
||||
String fileName = file.getOriginalFilename();
|
||||
if (fileName == null || !fileName.toLowerCase(Locale.ROOT).endsWith(".xlsx"))
|
||||
{
|
||||
throw new ServiceException("仅支持 .xlsx 格式的学员导入模板");
|
||||
}
|
||||
if (file.getSize() > MAX_FILE_SIZE)
|
||||
{
|
||||
throw new ServiceException("文件大小不能超过 5MB");
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveMessage(Exception e)
|
||||
{
|
||||
String message = e.getMessage();
|
||||
if (StringUtils.isEmpty(message))
|
||||
{
|
||||
message = e.getClass().getSimpleName();
|
||||
}
|
||||
return message.length() > 200 ? message.substring(0, 200) : message;
|
||||
}
|
||||
}
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.uuid.IdUtils;
|
||||
import com.ruoyi.train.domain.TrainAccommodation;
|
||||
import com.ruoyi.train.domain.TrainDormitoryInfo;
|
||||
import com.ruoyi.train.domain.TrainEnrollment;
|
||||
import com.ruoyi.train.domain.TrainStudent;
|
||||
import com.ruoyi.train.domain.dto.EnrollmentImportRowDTO;
|
||||
import com.ruoyi.train.domain.vo.EnrollmentImportRowResultVO;
|
||||
import com.ruoyi.train.mapper.TrainAccommodationMapper;
|
||||
import com.ruoyi.train.mapper.TrainDormitoryInfoMapper;
|
||||
import com.ruoyi.train.mapper.TrainEnrollmentImportMapper;
|
||||
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudentMapper;
|
||||
import com.ruoyi.train.security.StudentIdentityProtection;
|
||||
import com.ruoyi.train.service.StudentAccountProvisioner;
|
||||
|
||||
/**
|
||||
* 参训学员批量导入单行处理器。
|
||||
*
|
||||
* <p>每一行在一个独立事务中处理:学员建档、账号开通、参训关联、宿舍匹配、床位分配,
|
||||
* 任一步失败都会回滚整行,不影响其它行。该类必须由外部 Bean 调用,保证事务代理生效。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@Service
|
||||
public class EnrollmentImportRowProcessor
|
||||
{
|
||||
/** 单间宿舍最多可分配的床位数。 */
|
||||
private static final int MAX_BED_NO = 4;
|
||||
|
||||
/** 床位号后缀,统一形如「1号床」。 */
|
||||
private static final String BED_SUFFIX = "号床";
|
||||
|
||||
private final TrainEnrollmentImportMapper importMapper;
|
||||
|
||||
private final TrainStudentMapper studentMapper;
|
||||
|
||||
private final TrainEnrollmentMapper enrollmentMapper;
|
||||
|
||||
private final TrainDormitoryInfoMapper dormitoryMapper;
|
||||
|
||||
private final TrainAccommodationMapper accommodationMapper;
|
||||
|
||||
private final StudentIdentityProtection identityProtection;
|
||||
|
||||
private final StudentAccountProvisioner accountProvisioner;
|
||||
|
||||
public EnrollmentImportRowProcessor(TrainEnrollmentImportMapper importMapper,
|
||||
TrainStudentMapper studentMapper,
|
||||
TrainEnrollmentMapper enrollmentMapper,
|
||||
TrainDormitoryInfoMapper dormitoryMapper,
|
||||
TrainAccommodationMapper accommodationMapper,
|
||||
StudentIdentityProtection identityProtection,
|
||||
StudentAccountProvisioner accountProvisioner)
|
||||
{
|
||||
this.importMapper = importMapper;
|
||||
this.studentMapper = studentMapper;
|
||||
this.enrollmentMapper = enrollmentMapper;
|
||||
this.dormitoryMapper = dormitoryMapper;
|
||||
this.accommodationMapper = accommodationMapper;
|
||||
this.identityProtection = identityProtection;
|
||||
this.accountProvisioner = accountProvisioner;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单行导入数据。
|
||||
*
|
||||
* @param row 行数据(学员姓名、联系电话已通过必填校验)
|
||||
* @param planId 开班计划ID(即班级ID)
|
||||
* @param operator 操作人
|
||||
* @return 单行结果
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public EnrollmentImportRowResultVO process(EnrollmentImportRowDTO row, String planId, String operator)
|
||||
{
|
||||
String name = trim(row.getStudentName());
|
||||
String phone = trim(row.getPhone());
|
||||
EnrollmentImportRowResultVO result = EnrollmentImportRowResultVO.success(row.getRowNum(), name, phone);
|
||||
|
||||
// 1. 以联系电话匹配学员主档:命中视为复训老学员,不重复建档
|
||||
TrainStudent student = importMapper.selectStudentByPhone(phone);
|
||||
if (student == null)
|
||||
{
|
||||
student = buildStudent(row, operator);
|
||||
studentMapper.insertTrainStudent(student);
|
||||
result.setNewStudent(true);
|
||||
}
|
||||
result.setsId(student.getsId());
|
||||
|
||||
// 2. 账号同步:以手机号为账号,已存在则跳过
|
||||
syncAccount(student, row, result);
|
||||
|
||||
// 3. 班级关联:同班同手机号重复导入直接跳过
|
||||
TrainEnrollment existed = enrollmentMapper.selectTrainEnrollmentByPlanIdAndStudentId(planId, student.getsId());
|
||||
if (existed != null)
|
||||
{
|
||||
result.setResult(EnrollmentImportRowResultVO.RESULT_SKIPPED);
|
||||
result.setReason("该学员已在本班参训名单中");
|
||||
result.setEnrollmentId(existed.getEnrollmentId());
|
||||
return result;
|
||||
}
|
||||
|
||||
String roomNo = trim(row.getRoomNo());
|
||||
TrainEnrollment enrollment = new TrainEnrollment();
|
||||
enrollment.setPlanId(planId);
|
||||
enrollment.setStudentId(student.getsId());
|
||||
enrollment.setSourceType(1);
|
||||
enrollment.setEnrollmentStatus(0);
|
||||
enrollment.setAccommodationRequired(StringUtils.isEmpty(roomNo) ? 0 : 1);
|
||||
enrollment.setCreateBy(operator);
|
||||
enrollment.setCreateTime(new Date());
|
||||
enrollmentMapper.insertTrainEnrollment(enrollment);
|
||||
|
||||
TrainEnrollment created = enrollmentMapper.selectTrainEnrollmentByPlanIdAndStudentId(planId, student.getsId());
|
||||
if (created == null || created.getEnrollmentId() == null)
|
||||
{
|
||||
throw new ServiceException("参训记录写入后回读失败");
|
||||
}
|
||||
result.setEnrollmentId(created.getEnrollmentId());
|
||||
|
||||
if (StringUtils.isEmpty(roomNo))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 4、5. 宿舍匹配与床位分配
|
||||
assignAccommodation(result, roomNo, created.getEnrollmentId(), operator);
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
// 仅宿舍分配失败:保留学员、账号与参训记录,不回滚,转人工分配
|
||||
result.setReason("宿舍未分配成功,需手动分配;");
|
||||
result.getWarnings().add(e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 宿舍匹配与床位分配。
|
||||
*
|
||||
* <p>业务校验失败(房间禁用、床位已满等)抛出 {@link ServiceException},
|
||||
* 由 {@link #process} 降级为「宿舍未分配成功,需手动分配」,不回滚其它已写入数据。</p>
|
||||
*/
|
||||
private void assignAccommodation(EnrollmentImportRowResultVO result, String roomNo, Long enrollmentId,
|
||||
String operator)
|
||||
{
|
||||
TrainDormitoryInfo dormitory = importMapper.selectDormitoryByRoomNo(roomNo);
|
||||
if (dormitory == null)
|
||||
{
|
||||
dormitory = buildDormitory(roomNo, operator);
|
||||
dormitoryMapper.insertTrainDormitoryInfo(dormitory);
|
||||
result.getWarnings().add("房间号 " + roomNo + " 不存在,已自动创建宿舍");
|
||||
}
|
||||
else if (dormitory.getDormStatus() != null && dormitory.getDormStatus() != 1)
|
||||
{
|
||||
throw new ServiceException("房间 " + roomNo + " 已被禁用,无法分配住宿");
|
||||
}
|
||||
|
||||
// 行级排他锁,避免并发导入抢占同一床位
|
||||
dormitoryMapper.selectTrainDormitoryInfoForUpdate(dormitory.getDormId());
|
||||
|
||||
// 1号床起,已占用则顺延
|
||||
String bedNo = allocateBed(dormitory, roomNo);
|
||||
TrainAccommodation accommodation = new TrainAccommodation();
|
||||
accommodation.setEnrollmentId(enrollmentId);
|
||||
accommodation.setDormId(dormitory.getDormId());
|
||||
accommodation.setBedNo(bedNo);
|
||||
accommodation.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED);
|
||||
accommodation.setAssignedTime(new Date());
|
||||
accommodation.setCreateBy(operator);
|
||||
accommodation.setCreateTime(new Date());
|
||||
accommodationMapper.insertTrainAccommodation(accommodation);
|
||||
|
||||
result.setDormId(dormitory.getDormId());
|
||||
result.setBedNo(bedNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步学员登录账号;手机号对应的账号已存在时跳过创建。
|
||||
*/
|
||||
private void syncAccount(TrainStudent student, EnrollmentImportRowDTO row, EnrollmentImportRowResultVO result)
|
||||
{
|
||||
if (student.getUserId() != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Long userId = accountProvisioner.ensureAccount(student.getsId(), trim(row.getPhone()), trim(row.getStudentName()));
|
||||
if (userId == null)
|
||||
{
|
||||
result.getWarnings().add("手机号对应的登录账号已存在,已跳过新建账号");
|
||||
}
|
||||
else
|
||||
{
|
||||
result.setAccountCreated(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装学员主档。身份证允许为空;备注写入 train_student.remark。
|
||||
*/
|
||||
private TrainStudent buildStudent(EnrollmentImportRowDTO row, String operator)
|
||||
{
|
||||
TrainStudent student = new TrainStudent();
|
||||
student.setsId(IdUtils.fastSimpleUUID());
|
||||
student.setsName(trim(row.getStudentName()));
|
||||
student.setsPhone(trim(row.getPhone()));
|
||||
student.setsGender(toStudentGender(row.getGender()));
|
||||
student.setsBirth(parseBirth(row.getBirth()));
|
||||
student.setDeptName(trim(row.getDeptName()));
|
||||
|
||||
String personNo = trim(row.getPersonNo());
|
||||
student.setPersonNo(StringUtils.isEmpty(personNo) ? generatePersonNo() : personNo);
|
||||
|
||||
// card_no 非空约束且无默认值,空值写入空串
|
||||
String cardNo = trim(row.getCardNo());
|
||||
student.setCardNo(StringUtils.isEmpty(cardNo) ? "" : cardNo);
|
||||
|
||||
student.setRemark(trim(row.getRemark()));
|
||||
student.setSourceType(1);
|
||||
student.setStatus(1);
|
||||
student.setDelFlag("0");
|
||||
student.setCreateBy(operator);
|
||||
student.setCreateTime(new Date());
|
||||
|
||||
String idCard = trim(row.getIdCard());
|
||||
if (StringUtils.isNotEmpty(idCard))
|
||||
{
|
||||
String normalized = idCard.toUpperCase(Locale.ROOT);
|
||||
student.setIdCardCiphertext(identityProtection.encrypt(normalized));
|
||||
student.setIdCardHash(identityProtection.deterministicHash(normalized));
|
||||
student.setIdCardLast4(normalized.substring(Math.max(0, normalized.length() - 4)));
|
||||
}
|
||||
return student;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按房间号创建宿舍:4 位房间号取第 1 位为楼栋、第 2 位为楼层;
|
||||
* 不足 4 位时楼栋与楼层均取首位数字,整体作为房间号存储。
|
||||
*/
|
||||
private TrainDormitoryInfo buildDormitory(String roomNo, String operator)
|
||||
{
|
||||
String digits = roomNo.replaceAll("[^0-9]", "");
|
||||
String building = "0";
|
||||
String floor = "0";
|
||||
if (StringUtils.isNotEmpty(digits))
|
||||
{
|
||||
building = digits.substring(0, 1);
|
||||
floor = digits.length() >= 4 ? digits.substring(1, 2) : digits.substring(0, 1);
|
||||
}
|
||||
|
||||
TrainDormitoryInfo dormitory = new TrainDormitoryInfo();
|
||||
dormitory.setDormId(IdUtils.fastSimpleUUID());
|
||||
dormitory.setDormBuilding(building);
|
||||
dormitory.setDormFloor(floor);
|
||||
dormitory.setDormRoom(roomNo);
|
||||
dormitory.setCapacity(MAX_BED_NO);
|
||||
dormitory.setGenderType(0);
|
||||
dormitory.setDormStatus(1);
|
||||
dormitory.setDelFlag("0");
|
||||
dormitory.setCreateBy(operator);
|
||||
dormitory.setCreateTime(new Date());
|
||||
return dormitory;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 1 号床开始查找空闲床位,全部占用时抛出业务异常(整行回滚)。
|
||||
*/
|
||||
private String allocateBed(TrainDormitoryInfo dormitory, String roomNo)
|
||||
{
|
||||
int limit = MAX_BED_NO;
|
||||
if (dormitory.getCapacity() != null && dormitory.getCapacity() > 0 && dormitory.getCapacity() < MAX_BED_NO)
|
||||
{
|
||||
limit = dormitory.getCapacity();
|
||||
}
|
||||
for (int bed = 1; bed <= limit; bed++)
|
||||
{
|
||||
String bedNo = bed + BED_SUFFIX;
|
||||
if (accommodationMapper.countActiveByDormIdAndBedNo(dormitory.getDormId(), bedNo, null) == 0)
|
||||
{
|
||||
return bedNo;
|
||||
}
|
||||
}
|
||||
throw new ServiceException("房间 " + roomNo + " 床位已满(" + limit + " 个床位均已占用)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 性别归一化:男=0、女=1、其它或空=2。
|
||||
*/
|
||||
private String toStudentGender(String gender)
|
||||
{
|
||||
String value = trim(gender);
|
||||
if ("男".equals(value) || "0".equals(value))
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
if ("女".equals(value) || "1".equals(value))
|
||||
{
|
||||
return "1";
|
||||
}
|
||||
return "2";
|
||||
}
|
||||
|
||||
/**
|
||||
* 出生日期解析,支持 yyyy-MM-dd / yyyy/MM/dd / yyyy.MM.dd;无法识别时返回 null(不阻断导入)。
|
||||
*/
|
||||
private Date parseBirth(String value)
|
||||
{
|
||||
String text = trim(value);
|
||||
if (StringUtils.isEmpty(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String[] patterns = { "yyyy-MM-dd", "yyyy/MM/dd", "yyyy.MM.dd" };
|
||||
for (String pattern : patterns)
|
||||
{
|
||||
SimpleDateFormat format = new SimpleDateFormat(pattern);
|
||||
format.setLenient(false);
|
||||
try
|
||||
{
|
||||
return format.parse(text);
|
||||
}
|
||||
catch (ParseException ignored)
|
||||
{
|
||||
// 继续尝试下一种格式
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 学号兜底生成,仅在模板未填写学号时使用。
|
||||
*/
|
||||
private String generatePersonNo()
|
||||
{
|
||||
return "AA" + IdUtils.fastSimpleUUID().substring(0, 6).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String trim(String value)
|
||||
{
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
+36
@@ -374,6 +374,42 @@ public class TrainStudentServiceImpl implements ITrainStudentService
|
||||
return student.getsId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅更新学员头像。
|
||||
*
|
||||
* <p>与 updateTrainStudent 不同,此处刻意不做 trimToNull:Mapper 动态 SQL 的条件是
|
||||
* {@code avatar != null},若把空串归一化为 null 就再也无法清空头像。</p>
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int updateStudentAvatar(String sId, String avatar)
|
||||
{
|
||||
if (StringUtils.isEmpty(sId))
|
||||
{
|
||||
throw new ServiceException("学员ID不能为空");
|
||||
}
|
||||
TrainStudent existing = trainStudentMapper.selectTrainStudentBySId(sId);
|
||||
if (existing == null)
|
||||
{
|
||||
throw new ServiceException(StudentErrorCode.STUDENT_NOT_FOUND_MESSAGE,
|
||||
StudentErrorCode.STUDENT_NOT_FOUND);
|
||||
}
|
||||
TrainStudent student = new TrainStudent();
|
||||
student.setsId(existing.getsId());
|
||||
// 空串原样落库(移除头像);null 仍表示“不修改该字段”
|
||||
student.setAvatar(avatar == null ? null : avatar.trim());
|
||||
student.setUpdateTime(DateUtils.getNowDate());
|
||||
try
|
||||
{
|
||||
student.setUpdateBy(SecurityUtils.getUsername());
|
||||
}
|
||||
catch (Exception ignored)
|
||||
{
|
||||
// 非 Web 单元测试无登录上下文;不使用任何请求数据作为审计用户兜底。
|
||||
}
|
||||
return trainStudentMapper.updateTrainStudent(student);
|
||||
}
|
||||
|
||||
private StudentItemVO toItemVO(TrainStudent student)
|
||||
{
|
||||
StudentItemVO item = new StudentItemVO();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?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.TrainEnrollmentImportMapper">
|
||||
|
||||
<resultMap type="TrainStudent" id="TrainStudentPhoneResult">
|
||||
<result property="id" column="id" />
|
||||
<result property="sId" column="s_id" />
|
||||
<result property="sName" column="s_name" />
|
||||
<result property="personNo" column="person_no" />
|
||||
<result property="sGender" column="s_gender" />
|
||||
<result property="sBirth" column="s_birth" />
|
||||
<result property="userId" column="user_id" />
|
||||
<result property="deptName" column="dept_name" />
|
||||
<result property="cardNo" column="card_no" />
|
||||
<result property="sPhone" column="s_phone" />
|
||||
<result property="idCardHash" column="id_card_hash" />
|
||||
<result property="sourceType" column="source_type" />
|
||||
<result property="status" column="status" />
|
||||
<result property="delFlag" column="del_flag" />
|
||||
<result property="remark" column="remark" />
|
||||
</resultMap>
|
||||
|
||||
<resultMap type="TrainDormitoryInfo" id="TrainDormitoryRoomResult">
|
||||
<result property="id" column="id" />
|
||||
<result property="dormId" column="dorm_id" />
|
||||
<result property="dormBuilding" column="dorm_building" />
|
||||
<result property="dormFloor" column="dorm_floor" />
|
||||
<result property="dormRoom" column="dorm_room" />
|
||||
<result property="capacity" column="capacity" />
|
||||
<result property="genderType" column="gender_type" />
|
||||
<result property="dormStatus" column="dorm_status" />
|
||||
<result property="delFlag" column="del_flag" />
|
||||
</resultMap>
|
||||
|
||||
<select id="selectStudentByPhone" parameterType="String" resultMap="TrainStudentPhoneResult">
|
||||
select id, s_id, s_name, person_no, s_gender, s_birth, user_id, dept_name,
|
||||
card_no, s_phone, id_card_hash, source_type, status, del_flag, remark
|
||||
from train_student
|
||||
where s_phone = #{sPhone}
|
||||
and del_flag = '0'
|
||||
order by id desc
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="selectDormitoryByRoomNo" parameterType="String" resultMap="TrainDormitoryRoomResult">
|
||||
select id, dorm_id, dorm_building, dorm_floor, dorm_room, capacity, gender_type, dorm_status, del_flag
|
||||
from train_dormitory_info
|
||||
where dorm_room = #{dormRoom}
|
||||
and del_flag = '0'
|
||||
order by id asc
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -121,6 +121,49 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
order by e.enrollment_id asc
|
||||
</select>
|
||||
|
||||
<!-- 批量授权候选:本班「已有头像但门锁未授权」的参训人员。
|
||||
取数与 selectEnrollmentItemList 保持一致(当前有效住宿 0/1、学员住宿 occupant_type='student'),
|
||||
额外要求 avatar 非空且 lock_result 不等于 2(已授权)。 -->
|
||||
<select id="selectAuthCandidateList" parameterType="String"
|
||||
resultType="com.ruoyi.train.domain.vo.EnrollmentItemVO">
|
||||
select e.enrollment_id as enrollmentId,
|
||||
e.plan_id as planId,
|
||||
e.student_id as studentId,
|
||||
s.s_name as studentName,
|
||||
s.person_no as personNo,
|
||||
s.s_gender as gender,
|
||||
s.s_phone as phone,
|
||||
s.avatar as avatar,
|
||||
e.enrollment_status as enrollmentStatus,
|
||||
e.accommodation_required as accommodationRequired,
|
||||
d.dorm_building as dormBuilding,
|
||||
d.dorm_room as dormRoom,
|
||||
a.bed_no as bedNo,
|
||||
a.accommodation_status as accommodationStatus,
|
||||
a.lock_api_biz_no as lockApiBizNo,
|
||||
a.lock_result as lockResult
|
||||
from train_enrollment e
|
||||
left join train_student s on e.student_id = s.s_id
|
||||
left join train_accommodation a on a.accommodation_id = (
|
||||
select a2.accommodation_id
|
||||
from train_accommodation a2
|
||||
where a2.enrollment_id = e.enrollment_id
|
||||
and a2.occupant_type = 'student'
|
||||
and a2.accommodation_status in (0, 1)
|
||||
order by a2.assigned_time desc,
|
||||
a2.accommodation_id desc
|
||||
limit 1
|
||||
)
|
||||
left join train_dormitory_info d on a.dorm_id = d.dorm_id and d.del_flag = '0'
|
||||
where e.plan_id = #{planId}
|
||||
and s.avatar is not null
|
||||
and trim(s.avatar) <> ''
|
||||
<!-- 无有效住宿记录时无法下发门锁授权,直接从候选中排除,避免勾选后必然失败 -->
|
||||
and a.accommodation_id is not null
|
||||
and (a.lock_result is null or a.lock_result <> 2)
|
||||
order by e.enrollment_id asc
|
||||
</select>
|
||||
|
||||
<select id="countEnrollmentsByPlanId" parameterType="String" resultType="int">
|
||||
select count(1) from train_enrollment where plan_id = #{planId}
|
||||
</select>
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,31 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 批量上传头像压缩包(内部文件名:手机号.png),按手机号匹配本班人员写入 avatar
|
||||
export function uploadAvatarZip(planId, data) {
|
||||
return request({
|
||||
url: '/train/batch-auth/avatar-zip',
|
||||
method: 'post',
|
||||
params: { planId },
|
||||
data: data,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120000
|
||||
})
|
||||
}
|
||||
|
||||
// 查询本班「已有头像但门锁未授权」的候选人员
|
||||
export function listAuthCandidates(planId) {
|
||||
return request({
|
||||
url: '/train/batch-auth/candidates',
|
||||
method: 'get',
|
||||
params: { planId }
|
||||
})
|
||||
}
|
||||
|
||||
// 提交批量授权任务(后台异步执行,逐人通过门禁入住 checkin 接口下发,接口立即返回受理结果)
|
||||
export function authorizeBatch(planId, studentIds) {
|
||||
return request({
|
||||
url: '/train/batch-auth/authorize',
|
||||
method: 'post',
|
||||
data: { planId: planId, studentIds: studentIds }
|
||||
})
|
||||
}
|
||||
@@ -30,6 +30,30 @@ export function importPlanEnrollments(planId, data) {
|
||||
})
|
||||
}
|
||||
|
||||
// 3.3.1 学员批量导入预检(10 列学员模板,仅解析校验不落库)
|
||||
export function precheckEnrollmentImport(planId, data) {
|
||||
return request({
|
||||
url: `/train/plans/${planId}/enrollments/import/precheck`,
|
||||
method: 'post',
|
||||
data: data,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3.3.2 学员批量导入(10 列学员模板,逐行独立事务)
|
||||
export function importEnrollmentsV2(planId, data) {
|
||||
return request({
|
||||
url: `/train/plans/${planId}/enrollments/import`,
|
||||
method: 'post',
|
||||
data: data,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3.4 获取参训档案详情 【P0】
|
||||
export function getEnrollmentDetail(enrollmentId) {
|
||||
return request({
|
||||
|
||||
@@ -35,6 +35,15 @@ export function updateStudent(data) {
|
||||
})
|
||||
}
|
||||
|
||||
// 仅修改学员头像(avatar 传空串表示移除头像,仅更新 train_student.avatar)
|
||||
export function updateStudentAvatar(sId, avatar) {
|
||||
return request({
|
||||
url: '/train/student/' + sId + '/avatar',
|
||||
method: 'put',
|
||||
data: { avatar: avatar || '' }
|
||||
})
|
||||
}
|
||||
|
||||
// 生成唯一学号(AA + 6位数字)
|
||||
export function genPersonNo() {
|
||||
return request({
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -253,6 +253,13 @@
|
||||
<div class="avatar-edit-actions">
|
||||
<el-button size="mini" type="primary" icon="el-icon-picture" @click="handleSelectAvatar">选择图片</el-button>
|
||||
<el-button size="mini" icon="el-icon-camera" :loading="cameraStarting" @click="openCamera">拍照</el-button>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="danger"
|
||||
icon="el-icon-delete"
|
||||
:disabled="!createForm.avatar"
|
||||
@click="handleRemoveAvatar"
|
||||
>移除头像</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
@@ -627,7 +634,7 @@
|
||||
|
||||
<script>
|
||||
import request from '@/utils/request'
|
||||
import { addStudent, getStudent, listStudent, delStudent, getStudentBindableUsers, bindStudentUser, unbindStudentUser, updateStudent, genPersonNo, importStudentData } from '@/api/train/student'
|
||||
import { addStudent, getStudent, listStudent, delStudent, getStudentBindableUsers, bindStudentUser, unbindStudentUser, updateStudent, updateStudentAvatar, genPersonNo, importStudentData } from '@/api/train/student'
|
||||
import {deptTreeSelect} from "@/api/system/user";
|
||||
import Treeselect from "@riophae/vue-treeselect";
|
||||
import "@riophae/vue-treeselect/dist/vue-treeselect.css";
|
||||
@@ -1419,6 +1426,18 @@ export default {
|
||||
this.$refs.createForm.clearValidate('avatar')
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 移除头像:表单与预览同时置空,头像组件回落默认占位图。
|
||||
* 修改模式下提交时会单独调用「仅更新头像」接口清空库内 avatar
|
||||
* (通用更新接口会把空串归一为 null 而跳过该字段)。
|
||||
*/
|
||||
handleRemoveAvatar() {
|
||||
this.$set(this.createForm, 'avatar', '')
|
||||
this.user = { avatar: '' }
|
||||
if (this.$refs.createForm) {
|
||||
this.$refs.createForm.clearValidate('avatar')
|
||||
}
|
||||
},
|
||||
// 头像地址解析:补全 baseApi,空值使用专业学员工装默认头像
|
||||
avatarUrl(avatar) {
|
||||
return getStudentAvatar(avatar)
|
||||
@@ -1452,8 +1471,11 @@ export default {
|
||||
}
|
||||
this.submitting = true
|
||||
const payload = Object.assign({}, this.createForm)
|
||||
// 修改模式下头像已被移除:通用更新接口会把空串归一为 null 并跳过 avatar,
|
||||
// 故在更新成功后调用「仅更新头像」接口显式清空(与班级名单页移除头像行为一致)
|
||||
const avatarRemoved = payload.sId != null && !payload.avatar
|
||||
const submitReq = payload.sId != null
|
||||
? updateStudent(payload)
|
||||
? updateStudent(payload).then(res => (avatarRemoved ? updateStudentAvatar(payload.sId, '') : res))
|
||||
: addStudent(payload)
|
||||
submitReq.then(() => {
|
||||
this.$modal.msgSuccess(payload.sId != null ? '修改成功' : '新增成功')
|
||||
|
||||
@@ -289,6 +289,13 @@
|
||||
<div class="avatar-edit-actions">
|
||||
<el-button size="mini" type="primary" icon="el-icon-picture" @click="handleSelectAvatar">选择图片</el-button>
|
||||
<el-button size="mini" icon="el-icon-camera" :loading="cameraStarting" @click="openCamera">拍照</el-button>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="danger"
|
||||
icon="el-icon-delete"
|
||||
:disabled="!form.avatar"
|
||||
@click="handleRemoveAvatar"
|
||||
>移除头像</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
@@ -1027,6 +1034,19 @@ export default {
|
||||
this.$refs.form.clearValidate('avatar')
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 移除头像:表单与预览同时置空,头像组件回落默认占位图。
|
||||
* 提交时 form.avatar 传空串,教师更新接口的动态 SQL 对空串会写入 avatar = ''(对 null 才跳过),
|
||||
* 因此无需额外接口即可完成清空。
|
||||
*/
|
||||
handleRemoveAvatar() {
|
||||
this.$set(this.form, 'avatar', '')
|
||||
// 替换对象以触发头像组件的 watch,预览回落默认占位图
|
||||
this.user = Object.assign({}, this.user, { avatar: '' })
|
||||
if (this.$refs.form) {
|
||||
this.$refs.form.clearValidate('avatar')
|
||||
}
|
||||
},
|
||||
/** 头像地址解析:补全 baseApi,空值使用专业讲师默认头像 */
|
||||
avatarUrl(avatar) {
|
||||
return getTeacherAvatar(avatar)
|
||||
|
||||
Reference in new issue
Block a user