批量上传人脸:只上传头像、下发到人脸服务;不授权门锁;

This commit is contained in:
buliping committed 2026-11-13 13:50:57 +08:00
1 parent 6d2ad20723
commit e683d4da59
29 files changed
+4257 -101

No files matched your search

@@ -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)
@@ -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;
}
}
/**
@@ -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);
}
}
@@ -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)。
*/
@@ -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)。
*/
@@ -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);
}
}
@@ -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
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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的学员统计,或统计全部)。
*
@@ -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)
*
@@ -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);
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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) &lt;&gt; ''
<!-- 无有效住宿记录时无法下发门锁授权,直接从候选中排除,避免勾选后必然失败 -->
and a.accommodation_id is not null
and (a.lock_result is null or a.lock_result &lt;&gt; 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>
+31
View File
@@ -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 }
})
}
+24
View File
@@ -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({
+9
View File
@@ -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
+24 -2
View File
@@ -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)