Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-08-17 17:45:47 +08:00
commit f3f5f5a1ce
28 files changed
+1490 -125

No files matched your search

@@ -24,6 +24,27 @@ AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(
}));
```
导出等需要在成功终态返回结果文件的任务使用兼容扩展入口,原有无结果任务的 `submit` 契约保持不变:
```java
AsyncTaskProgressVO progress = asyncTaskExecutorService.submitWithResult(
"user:export", "用户信息导出", 3, reporter -> {
String fileUrl = exportAndUpload(reporter);
return AsyncTaskResult.file(fileUrl, "用户信息.xlsx");
});
```
查询数据本身耗时且提交接口需要立即返回时,可以先用大于零的占位总数创建任务,再在异步线程查询完成后更新真实总数:
```java
AsyncTaskProgressVO progress = asyncTaskExecutorService.submitWithResult(
"user:export", "用户信息导出", 1, reporter -> {
List<UserVO> users = queryUsers();
reporter.updateTotal(users.size(), "已查询 " + users.size() + " 位用户");
return exportUsers(users, reporter);
});
```
调用端统一轮询:
```text
@@ -18,4 +18,17 @@ public interface AsyncTaskExecutorService {
* @return 等待执行的初始任务进度,调用方可立即把任务 ID 返回客户端
*/
AsyncTaskProgressVO submit(String redisKey, String taskName, int total, AsyncTaskAction action);
/**
* 创建并提交一个可在成功终态携带结果文件的异步事务任务。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskName 任务展示名称
* @param total 需要处理的总项目数
* @param action 后台事务中执行并返回结果文件信息的业务动作
* @return 等待执行的初始任务进度,调用方可立即把任务 ID 返回客户端
*/
default AsyncTaskProgressVO submitWithResult(String redisKey, String taskName, int total, AsyncTaskResultAction action) {
throw new UnsupportedOperationException("当前异步任务执行器不支持返回任务结果");
}
}
@@ -7,6 +7,17 @@ package com.bsd.pjcloud.common.asynctask.service;
@FunctionalInterface
public interface AsyncTaskProgressReporter {
/**
* 在后台任务取得真实数据量后更新总项目数。
* <p>适用于查询本身耗时、必须先返回任务 ID 的导出场景;固定总数任务无需调用。</p>
*
* @param total 后台查询得到的真实总项目数,必须大于零
* @param message 总数更新后的进度说明;为空时使用通用说明
*/
default void updateTotal(int total, String message) {
throw new UnsupportedOperationException("当前异步任务进度报告器不支持动态更新总数");
}
/**
* 报告一个项目已处理完成。
*
@@ -26,6 +26,18 @@ public interface AsyncTaskProgressService {
*/
void start(String redisKey, String taskId);
/**
* 在任务开始后更新真实总项目数并重新计算完成百分比。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param total 后台查询得到的真实总项目数,必须大于零
* @param message 总数更新后的进度说明
*/
default void updateTotal(String redisKey, String taskId, int total, String message) {
throw new UnsupportedOperationException("当前异步任务进度服务不支持动态更新总数");
}
/**
* 更新任务执行进度。
*
@@ -45,6 +57,20 @@ public interface AsyncTaskProgressService {
*/
void success(String redisKey, String taskId, String message);
/**
* 将任务标记为成功,并在实现支持时保存业务动作返回的结果文件信息。
* <p>默认实现退化为原有成功状态写入,保证既有自定义进度服务无需同步修改;
* 需要向调用端返回附件的实现应覆写本方法。</p>
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param message 最终成功说明
* @param result 业务动作返回的可选结果文件信息
*/
default void success(String redisKey, String taskId, String message, AsyncTaskResult result) {
success(redisKey, taskId, message);
}
/**
* 将任务标记为失败并保留失败前执行进度。
*
@@ -0,0 +1,40 @@
package com.bsd.pjcloud.common.asynctask.service;
/**
* 通用异步任务成功结果。
* <p>导入等无附件任务可返回 {@link #empty()},导出任务可返回下载地址和文件名称;结果只会在任务成功终态写入进度对象。</p>
*
* @param fileUrl 任务生成文件的下载地址;无结果文件时为 {@code null}
* @param fileName 下载时使用的业务文件名称;无结果文件时为 {@code null}
*/
public record AsyncTaskResult(String fileUrl, String fileName) {
/**
* 创建通用异步任务成功结果。
*
* @param fileUrl 任务生成文件的下载地址;无结果文件时为 {@code null}
* @param fileName 下载时使用的业务文件名称;无结果文件时为 {@code null}
*/
public AsyncTaskResult {
}
/**
* 创建不携带结果文件的成功结果。
*
* @return 无文件地址和名称的任务结果
*/
public static AsyncTaskResult empty() {
return new AsyncTaskResult(null, null);
}
/**
* 创建携带下载文件的成功结果。
*
* @param fileUrl 项目现有文件中心返回的文件下载地址
* @param fileName 下载时使用的业务文件名称
* @return 包含结果文件信息的任务结果
*/
public static AsyncTaskResult file(String fileUrl, String fileName) {
return new AsyncTaskResult(fileUrl, fileName);
}
}
@@ -0,0 +1,17 @@
package com.bsd.pjcloud.common.asynctask.service;
/**
* 携带成功结果的通用异步任务业务动作。
* <p>用于导出等需要在成功终态返回结果文件的任务;动作在后台事务中执行,抛出异常会回滚并标记任务失败。</p>
*/
@FunctionalInterface
public interface AsyncTaskResultAction {
/**
* 执行具体异步业务并返回成功结果。
*
* @param reporter 逐项进度报告器,业务完成一个计数项目后调用一次
* @return 任务成功终态需要返回调用端的可选结果文件信息
*/
AsyncTaskResult execute(AsyncTaskProgressReporter reporter);
}
@@ -2,7 +2,10 @@ package com.bsd.pjcloud.common.asynctask.service.impl;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskAction;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResultAction;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.security.util.NonWebTokenContextHolder;
@@ -67,6 +70,23 @@ public class DefaultAsyncTaskExecutorService implements AsyncTaskExecutorService
*/
@Override
public AsyncTaskProgressVO submit(String redisKey, String taskName, int total, AsyncTaskAction action) {
return submitWithResult(redisKey, taskName, total, reporter -> {
action.execute(reporter);
return AsyncTaskResult.empty();
});
}
/**
* 创建并提交一个可在成功终态携带结果文件的异步事务任务。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskName 任务展示名称
* @param total 需要处理的总项目数
* @param action 后台事务中执行并返回结果文件信息的业务动作
* @return 等待执行的初始任务进度
*/
@Override
public AsyncTaskProgressVO submitWithResult(String redisKey, String taskName, int total, AsyncTaskResultAction action) {
AsyncTaskProgressVO progress = asyncTaskProgressService.create(redisKey, taskName, total);
Long tenantId = TenantContextHolder.getTenantId();
Authentication authentication = SecurityUtils.getAuthentication();
@@ -96,25 +116,24 @@ public class DefaultAsyncTaskExecutorService implements AsyncTaskExecutorService
* @param action 后台事务中执行的业务动作
*/
private void runTask(AsyncTaskProgressVO progress, Long tenantId, String token, SecurityContextHolderStrategy strategy,
SecurityContext securityContext, AsyncTaskAction action) {
SecurityContext securityContext, AsyncTaskResultAction action) {
try {
TenantContextHolder.setTenantId(tenantId);
strategy.setContext(securityContext);
NonWebTokenContextHolder.setToken(token);
asyncTaskProgressService.start(progress.getRedisKey(), progress.getTaskId());
AtomicInteger completed = new AtomicInteger();
AsyncTaskResult result;
try {
transactionTemplate.executeWithoutResult(status -> action.execute(message -> {
int current = completed.incrementAndGet();
asyncTaskProgressService.update(progress.getRedisKey(), progress.getTaskId(), current, message);
}));
AsyncTaskProgressReporter reporter = createProgressReporter(progress, completed);
result = transactionTemplate.execute(status -> action.execute(reporter));
}
catch (Exception exception) {
log.error("|{}|任务事务执行失败,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
markFailedSafely(progress, TASK_FAILED_MESSAGE);
return;
}
markSuccessSafely(progress);
markSuccessSafely(progress, result);
}
catch (Exception exception) {
log.error("|{}|任务启动失败,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
@@ -127,6 +146,40 @@ public class DefaultAsyncTaskExecutorService implements AsyncTaskExecutorService
}
}
/**
* 创建同时支持逐项推进和动态总数更新的任务进度报告器。
*
* @param progress 当前任务的业务命名空间和任务 ID
* @param completed 当前任务线程内的已处理计数器
* @return 绑定当前任务 Redis 进度的报告器
*/
private AsyncTaskProgressReporter createProgressReporter(AsyncTaskProgressVO progress, AtomicInteger completed) {
return new AsyncTaskProgressReporter() {
/**
* 将后台查询得到的真实总数写入 Redis,并同步约束线程内已处理计数。
*
* @param total 真实总项目数,必须大于零
* @param message 总数更新后的进度说明
*/
@Override
public void updateTotal(int total, String message) {
completed.updateAndGet(current -> Math.min(current, total));
asyncTaskProgressService.updateTotal(progress.getRedisKey(), progress.getTaskId(), total, message);
}
/**
* 将已处理项目数递增一项并写入 Redis。
*
* @param message 当前项目完成后的进度说明
*/
@Override
public void advance(String message) {
int current = completed.incrementAndGet();
asyncTaskProgressService.update(progress.getRedisKey(), progress.getTaskId(), current, message);
}
};
}
/**
* 尝试把任务写为失败状态;Redis 异常只记录日志,不能覆盖原始业务异常或阻断线程上下文清理。
*
@@ -146,10 +199,11 @@ public class DefaultAsyncTaskExecutorService implements AsyncTaskExecutorService
* 尝试把已提交事务的任务写为成功状态;Redis 写入失败只记录,不能把已经提交的数据误报为已回滚。
*
* @param progress 已完成数据库事务的任务标识和业务命名空间
* @param result 业务动作返回的可选结果文件信息
*/
private void markSuccessSafely(AsyncTaskProgressVO progress) {
private void markSuccessSafely(AsyncTaskProgressVO progress, AsyncTaskResult result) {
try {
asyncTaskProgressService.success(progress.getRedisKey(), progress.getTaskId(), "任务执行成功");
asyncTaskProgressService.success(progress.getRedisKey(), progress.getTaskId(), "任务执行成功", result);
}
catch (Exception exception) {
log.error("|{}|写入成功进度异常,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
@@ -1,6 +1,7 @@
package com.bsd.pjcloud.common.asynctask.service.impl;
import com.bsd.pjcloud.common.asynctask.constant.AsyncTaskStatusEnum;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
@@ -84,6 +85,29 @@ public class DefaultAsyncTaskProgressService implements AsyncTaskProgressService
save(progress);
}
/**
* 在任务开始后更新真实总项目数并重新计算完成百分比。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param total 后台查询得到的真实总项目数,必须大于零
* @param message 总数更新后的进度说明
*/
@Override
public void updateTotal(String redisKey, String taskId, int total, String message) {
if (total <= 0) {
throw new IllegalArgumentException("异步任务总数必须大于0");
}
AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
int safeCompleted = Math.max(0, Math.min(progress.getCompleted(), total));
progress.setStatus(AsyncTaskStatusEnum.RUNNING.getStatus());
progress.setTotal(total);
progress.setCompleted(safeCompleted);
progress.setPercentage(calculatePercentage(safeCompleted, total));
progress.setMessage(message == null || message.isBlank() ? "正在处理" : message);
save(progress);
}
/**
* 更新任务执行进度。
*
@@ -112,6 +136,19 @@ public class DefaultAsyncTaskProgressService implements AsyncTaskProgressService
*/
@Override
public void success(String redisKey, String taskId, String message) {
success(redisKey, taskId, message, null);
}
/**
* 将任务标记为成功、把进度置为 100%,并保存可选的结果文件信息。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param message 最终成功说明
* @param result 业务动作返回的可选结果文件信息
*/
@Override
public void success(String redisKey, String taskId, String message, AsyncTaskResult result) {
AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
progress.setStatus(AsyncTaskStatusEnum.SUCCESS.getStatus());
progress.setCompleted(progress.getTotal());
@@ -119,6 +156,10 @@ public class DefaultAsyncTaskProgressService implements AsyncTaskProgressService
progress.setMessage(message == null || message.isBlank() ? AsyncTaskStatusEnum.SUCCESS.getDescription() : message);
progress.setFinished(true);
progress.setSuccess(true);
if (result != null) {
progress.setResultFileUrl(result.fileUrl());
progress.setResultFileName(result.fileName());
}
save(progress);
}
@@ -55,4 +55,12 @@ public class AsyncTaskProgressVO implements Serializable {
/** 终态是否成功;任务未结束时固定为 {@code false}。 */
@Schema(description = "是否成功")
private Boolean success;
/** 任务成功后生成文件的下载地址;导入等无附件任务为空。 */
@Schema(description = "任务结果文件下载地址")
private String resultFileUrl;
/** 任务成功后生成文件的下载名称;导入等无附件任务为空。 */
@Schema(description = "任务结果文件名称")
private String resultFileName;
}
@@ -0,0 +1,53 @@
package com.bsd.pjcloud.admin.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 终端部门人脸固定上界游标快照查询参数。
*
* <p>首次请求不传 {@code snapshotMaxId},后续分页必须原样携带首次响应中的上界,
* 避免同步期间新增记录改变当前对账集合。</p>
*/
@Data
@Schema(description = "终端部门人脸同步快照查询")
public class SysUserFaceSyncSnapshotDto {
/**
* 终端绑定的商户部门主键;查询范围包含该部门及所有未删除子部门。
*/
@NotNull(message = "商户部门不能为空")
@Schema(description = "商户部门主键", requiredMode = Schema.RequiredMode.REQUIRED)
private Long merchantDeptId;
/**
* 可选用户主键;MQ 增量通知回查时传入,部门完整快照时为空。
*/
@Schema(description = "可选用户主键")
private Long userId;
/**
* 排他起始游标;首次请求为空或 0,后续使用上一页的 nextAfterId。
*/
@Min(value = 0, message = "人脸同步游标不能小于0")
@Schema(description = "排他起始游标")
private Long afterId;
/**
* 单页记录数;空值使用服务端默认值,允许范围为 1 至 500。
*/
@Min(value = 1, message = "人脸同步分页大小不能小于1")
@Max(value = 500, message = "人脸同步分页大小不能超过500")
@Schema(description = "单页记录数")
private Integer limit;
/**
* 首次响应固定的人脸数据库主键上界;首次请求为空,后续分页必须原样携带。
*/
@Min(value = 0, message = "人脸同步快照上界不能小于0")
@Schema(description = "固定快照主键上界")
private Long snapshotMaxId;
}
@@ -0,0 +1,65 @@
package com.bsd.pjcloud.admin.api.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 终端人脸同步快照中的单条权威记录。
*
* <p>记录只包含审核通过且位于目标部门树内的人脸;终端使用 {@code sampleId} 作为虹软搜索标识,
* 使用 {@code sourceUpdatedAt} 与图片地址共同判断是否需要重新提取特征。</p>
*/
@Data
@Schema(description = "终端人脸同步快照记录")
public class SysUserFaceSyncRecordVo {
/**
* 人脸记录数据库主键,用于游标排序和快照范围约束。
*/
@Schema(description = "人脸记录数据库主键")
private Long id;
/**
* 虹软人脸库搜索标识,来源于人脸记录 {@code auto_code},必须是 32 位正整数且在当前租户内唯一。
*/
@Schema(description = "虹软人脸库搜索标识")
private String sampleId;
/**
* 人脸所属用户主键,MQ 增量通知按该字段回查同一用户的最多三张人脸。
*/
@Schema(description = "用户主键")
private Long userId;
/**
* 用户姓名,仅用于终端本地展示和识别结果元数据。
*/
@Schema(description = "用户姓名")
private String username;
/**
* 用户当前所属部门主键,数据来源为 {@code sys_user.dept_id}。
*/
@Schema(description = "当前所属部门主键")
private Long deptId;
/**
* 用户当前所属部门名称,仅用于终端本地展示。
*/
@Schema(description = "当前所属部门名称")
private String deptName;
/**
* 服务端人脸图片地址;地址变化会触发终端重新下载和提取特征。
*/
@Schema(description = "人脸图片地址")
private String faceImg;
/**
* 服务端人脸记录最后修改时间;终端将其持久化到现有更新时间字段作为图片版本依据。
*/
@Schema(description = "服务端最后修改时间")
private LocalDateTime sourceUpdatedAt;
}
@@ -0,0 +1,41 @@
package com.bsd.pjcloud.admin.api.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
* 终端部门人脸同步的游标快照响应。
*
* <p>首次请求生成固定的 {@code snapshotMaxId},后续分页必须原样携带;终端只有在全部页面成功返回并校验后,
* 才能据此删除不在目标集合中的本地孤儿记录。</p>
*/
@Data
@Schema(description = "终端部门人脸同步游标快照")
public class SysUserFaceSyncSnapshotVo {
/**
* 当前页面的人脸记录,按数据库主键升序排列。
*/
@Schema(description = "当前页面记录")
private List<SysUserFaceSyncRecordVo> records;
/**
* 下一页起始游标;当前页为空时沿用请求游标,末页无需继续请求。
*/
@Schema(description = "下一页起始游标")
private Long nextAfterId;
/**
* 是否仍有位于固定快照上界内的后续记录。
*/
@Schema(description = "是否还有下一页")
private Boolean hasMore;
/**
* 首次请求时固定的人脸数据库主键上界,后续分页必须原样携带。
*/
@Schema(description = "固定快照主键上界")
private Long snapshotMaxId;
}
@@ -38,6 +38,7 @@ import com.bsd.pjcloud.admin.api.entity.SysUserRole;
import com.bsd.pjcloud.admin.api.entity.user.SysUserTagEntity;
import com.bsd.pjcloud.admin.api.vo.*;
import com.bsd.pjcloud.admin.service.*;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.common.core.constant.CommonConstants;
import com.bsd.pjcloud.common.core.exception.ErrorCodes;
@@ -45,7 +46,6 @@ import com.bsd.pjcloud.common.core.util.MsgUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.excel.annotation.RequestExcel;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
import com.bsd.pjcloud.common.idempotent.annotation.Idempotent;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.annotation.HasPermission;
@@ -98,6 +98,9 @@ public class SysUserController {
/** 用户 Excel 导入导出服务,统一承接用户模块的文件转换与导入编排。 */
private final SysUserImportExportService userImportExportService;
/** 用户 Excel 导出服务,独立承接模板填充、身份资料组装和结果文件保存。 */
private final SysUserExportService userExportService;
private final RemoterVisitorService remoterVisitorService;
private final SysDeptService sysDeptService;
@@ -401,16 +404,15 @@ public class SysUserController {
}
/**
* 按当前数据权限导出用户 Excel。
* 按当前筛选条件和数据权限提交用户 Excel 异步导出任务。
*
* @param userDTO 用户筛选条件
* @return 可由响应转换器直接写入 Excel 的用户数据
* @return 供前端轮询的初始进度,成功终态包含导出文件下载地址
*/
@ResponseExcel
@PostMapping("/export")
@HasPermission("sys_user_export")
public List<UserExcelVO> export(@RequestBody UserDTO userDTO) {
return userImportExportService.listUser(userDTO, null);
public R<AsyncTaskProgressVO> export(@RequestBody UserDTO userDTO) {
return R.ok(userExportService.submitExport(userDTO), "用户导出任务已提交");
}
/**
@@ -10,11 +10,13 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceGuardianWardCreateDto;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceSyncSnapshotDto;
import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo;
import com.bsd.pjcloud.admin.service.SysUserFaceService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
@@ -200,6 +202,18 @@ public class SysUserFaceController {
dto.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode());
return R.ok(sysUserFaceService.allPageJoin(page, dto).getRecords());
}
/**
* 按固定数据库主键上界游标返回本部门及子部门的人脸权威快照。
*
* @param dto 商户部门、可选用户、排他游标、页面大小及固定快照上界
* @return 当前页快照数据
*/
@Operation(summary = "android人脸部门快照", description = "按数据库主键游标同步本部门及子部门审核通过的人脸")
@PostMapping("/ad/v3/sync-snapshot")
public R<SysUserFaceSyncSnapshotVo> adV3SyncSnapshot(@Valid @RequestBody SysUserFaceSyncSnapshotDto dto) {
return R.ok(sysUserFaceService.syncSnapshot(dto));
}
/* ************************************* start android start ************************************* */
@Operation(summary = "设置为全局人脸", description = "设置为全局人脸")
@@ -0,0 +1,82 @@
package com.bsd.pjcloud.admin.handler;
import java.util.List;
/**
* 人脸数据事务提交后的终端变更通知事件。
*
* <p>事件只携带用户和目标部门标识,终端收到 MQ 后必须回查 V3 真源;旧、新部门同时出现时用于处理用户调动。</p>
*/
public final class FaceChangeEvent {
/**
* 发生人脸变化的用户主键。
*/
private final Long userId;
/**
* 需要收到通知的用户部门主键列表;部门调动时同时包含旧、新部门。
*/
private final List<Long> targetDeptIds;
/**
* 全局人脸状态变化值;普通人脸 CRUD 时为空,保持既有 MQ 协议兼容。
*/
private final Boolean globalFace;
/**
* 发布事件时的租户主键,用于异步线程恢复数据库和 Feign 租户上下文。
*/
private final Long tenantId;
/**
* 创建事务后人脸变更事件。
*
* @param userId 发生变化的用户主键
* @param targetDeptIds 需要通知的旧、新部门主键列表
* @param globalFace 全局人脸状态,普通人脸 CRUD 时为空
* @param tenantId 事件发布时的租户主键
*/
public FaceChangeEvent(Long userId, List<Long> targetDeptIds, Boolean globalFace, Long tenantId) {
this.userId = userId;
this.targetDeptIds = List.copyOf(targetDeptIds);
this.globalFace = globalFace;
this.tenantId = tenantId;
}
/**
* 获取发生变化的用户主键。
*
* @return 用户主键
*/
public Long getUserId() {
return userId;
}
/**
* 获取需要收到 MQ 通知的目标部门。
*
* @return 不可变的部门主键列表
*/
public List<Long> getTargetDeptIds() {
return targetDeptIds;
}
/**
* 获取全局人脸状态变化值。
*
* @return 全局人脸状态,普通 CRUD 时为空
*/
public Boolean getGlobalFace() {
return globalFace;
}
/**
* 获取事件发布时的租户主键。
*
* @return 租户主键
*/
public Long getTenantId() {
return tenantId;
}
}
@@ -0,0 +1,73 @@
package com.bsd.pjcloud.admin.handler;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import java.util.Objects;
/**
* 人脸数据提交后的 MQ 通知监听器。
*
* <p>监听器不传输人脸数据,只向目标硬件组发送用户变更通知;通知失败由每日部门快照最终修复。</p>
*/
@Slf4j
@Component
public class FaceChangeEventListener {
/**
* MQTT 服务远程接口,用于按用户部门解析硬件组并发布 FACE 通知。
*/
private final RemoteMqttEventService remoteMqttEventService;
/**
* 创建人脸变更监听器。
*
* @param remoteMqttEventService MQTT 事件远程接口
*/
public FaceChangeEventListener(RemoteMqttEventService remoteMqttEventService) {
this.remoteMqttEventService = remoteMqttEventService;
}
/**
* 在本地事务成功提交后异步发布 FACE 通知。
*
* <p>同一硬件组可能因旧、新部门相同收到重复通知,终端按用户合并并回查,因此这里仅对部门主键去重。</p>
*
* @param event 用户、目标部门和租户上下文
*/
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void publishFaceChange(FaceChangeEvent event) {
try {
TenantBroker.runAs(event.getTenantId(), tenantId -> event.getTargetDeptIds().stream()
.filter(Objects::nonNull)
.distinct()
.forEach(deptId -> publishToDepartment(event, deptId)));
} catch (Exception exception) {
log.error("人脸事务提交后发布 FACE 通知异常,userId:{},tenantId:{}", event.getUserId(), event.getTenantId(), exception);
}
}
/**
* 向单个用户部门所属硬件组发送人脸变更通知并记录业务失败。
*
* @param event 人脸变更事件
* @param deptId 目标用户部门主键
*/
private void publishToDepartment(FaceChangeEvent event, Long deptId) {
R<Boolean> result = remoteMqttEventService.sendFaceChangeMsg(deptId, event.getUserId(), event.getGlobalFace());
if (result == null) {
log.error("发布 FACE 通知失败,userId:{},deptId:{},MQTT 服务未返回结果", event.getUserId(), deptId);
return;
}
if (!result.isOk() || !Boolean.TRUE.equals(result.getData())) {
log.error("发布 FACE 通知失败,userId:{},deptId:{},code:{},msg:{}", event.getUserId(), deptId, result.getCode(), result.getMsg());
}
}
}
@@ -9,6 +9,7 @@ import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncRecordVo;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
@@ -26,7 +27,56 @@ public interface SysUserFaceMapper extends PjcloudBaseMapper<SysUserFaceEntity>
List<SysUserFaceVo> groupConcatFaces(@Param("pa") SysUserFaceDto dto);
Long selectAllCount();
/**
* 查询当前租户全部历史人脸中最大的可解析数字编号,用于首次初始化 Redis 原子序列且永不复用旧编号。
*
* @return 最大数字编号;没有可用记录时返回 {@code 0}
*/
Long selectMaxNumericAutoCode();
/**
* 只读审计当前租户全部历史人脸编号是否存在重复。
*
* @return 重复编号分组数量;大于零时禁止首次初始化编号序列
*/
Long countDuplicateAutoCodes();
/**
* 审计目标部门树内审核通过且未删除的人脸编号是否存在数值冲突。
*
* @param deptIds 终端绑定部门及其有效子部门主键
* @param processStatus 审核通过状态编码
* @return 当前同步范围的重复数字编号分组数量;大于零时禁止下发快照
*/
Long countSyncDuplicateAutoCodes(@Param("deptIds") List<Long> deptIds, @Param("processStatus") String processStatus);
/**
* 查询目标部门树和固定主键上界内的最大人脸主键,用于建立稳定快照边界。
*
* @param deptIds 商户部门及其子部门主键
* @param userId 可选用户主键,MQ 增量同步时使用
* @param processStatus 审核通过状态编码
* @return 快照最大主键;没有记录时返回 {@code 0}
*/
Long selectSyncSnapshotMaxId(@Param("deptIds") List<Long> deptIds, @Param("userId") Long userId, @Param("processStatus") String processStatus);
/**
* 按数据库主键升序读取终端人脸快照的一页,多取一条用于判断是否存在下一页。
*
* @param deptIds 商户部门及其子部门主键
* @param userId 可选用户主键
* @param afterId 排他起始游标
* @param snapshotMaxId 固定快照主键上界
* @param processStatus 审核通过状态编码
* @param fetchSize 实际读取数量,通常为页面大小加一
* @return 按人脸数据库主键严格升序排列的记录
*/
List<SysUserFaceSyncRecordVo> listSyncSnapshotRecords(@Param("deptIds") List<Long> deptIds,
@Param("userId") Long userId,
@Param("afterId") Long afterId,
@Param("snapshotMaxId") Long snapshotMaxId,
@Param("processStatus") String processStatus,
@Param("fetchSize") int fetchSize);
/**
* 按用户批量统计可用与审核中的人脸数量。
@@ -0,0 +1,19 @@
package com.bsd.pjcloud.admin.service;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
/**
* 系统用户 Excel 导出服务。
* <p>独立承接查询条件快照、身份资料组装、模板填充和结果文件保存,避免 CRUD 页面与导入编排承担导出细节。</p>
*/
public interface SysUserExportService {
/**
* 提交用户导出任务。
*
* @param userDTO 用户列表当前筛选条件;用户 ID 集合为空时导出全部筛选结果
* @return 已创建的异步任务初始进度,调用端据此轮询并下载结果文件
*/
AsyncTaskProgressVO submitExport(UserDTO userDTO);
}
@@ -4,22 +4,41 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceGuardianWardCreateDto;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceSyncSnapshotDto;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto;
import java.util.ArrayList;
import java.util.List;
/**
* 人脸样本业务服务,负责审核、人脸 CRUD 与终端同步真源查询。
*/
public interface SysUserFaceService extends IService<SysUserFaceEntity> {
/**
* 新增用户人脸并分配租户内唯一的 32 位虹软搜索编号。
*
* @param sysUserFace 待新增的人脸记录
* @return 新增记录主键
*/
Long create(SysUserFaceEntity sysUserFace);
/**
* 按固定主键上界游标读取本部门及子部门的审核通过人脸。
*
* @param dto 商户部门、可选用户、排他游标、页面大小及固定快照上界
* @return 当前页快照和下一页信息
*/
SysUserFaceSyncSnapshotVo syncSnapshot(SysUserFaceSyncSnapshotDto dto);
/**
* 当前用户为本人或有效被监护人提交人脸,并根据提交人角色决定是否进入审核流程。
*
@@ -1,6 +1,5 @@
package com.bsd.pjcloud.admin.service;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.vo.GuardianUserExcelVO;
import com.bsd.pjcloud.admin.api.vo.StudentUserExcelVO;
import com.bsd.pjcloud.admin.api.vo.TeacherUserExcelVO;
@@ -13,20 +12,11 @@ import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* 用户模块导入导出服务。
* <p>统一承接后台用户、学生、学生家长、老师和人脸 Excel 导入以及用户导出,避免导入导出编排继续膨胀用户核心服务。</p>
* 用户模块导入服务。
* <p>统一承接后台用户、学生、学生家长、老师和人脸 Excel 导入,用户导出由独立服务隔离。</p>
*/
public interface SysUserImportExportService {
/**
* 按查询条件和数据权限生成后台用户导出数据。
*
* @param userDTO 用户筛选条件
* @param ids 指定导出的用户 ID;为空时按筛选条件导出
* @return 可由 Excel 响应转换器直接输出的用户列表
*/
List<UserExcelVO> listUser(UserDTO userDTO, Long[] ids);
/**
* 导入后台用户基础信息;同步完成整份 Excel 校验,任一行错误均不创建任务,校验通过后异步统一写入。
*
@@ -0,0 +1,391 @@
package com.bsd.pjcloud.admin.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.metadata.fill.FillConfig;
import com.alibaba.excel.write.metadata.fill.FillWrapper;
import com.bsd.pjcloud.admin.api.constant.UserStaffingTypeEnum;
import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.entity.SysPost;
import com.bsd.pjcloud.admin.api.entity.SysRole;
import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.admin.mapper.SysUserMapper;
import com.bsd.pjcloud.admin.service.SysFileService;
import com.bsd.pjcloud.admin.service.SysUserExportService;
import com.bsd.pjcloud.admin.service.SysUserIdentityService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
/**
* 系统用户 Excel 导出服务实现。
* <p>同步请求只冻结筛选条件并创建任务;异步线程查询用户后更新真实总数,再补齐身份资料、填充二级表头模板并保存到文件中心。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SysUserExportServiceImpl implements SysUserExportService {
/** 用户导出日志标签,用于定位模板填充和文件保存异常。 */
private static final String LOG_TAG = "系统用户导出";
/** 用户导出任务的业务 Redis Key 命名空间。 */
private static final String USER_EXPORT_REDIS_KEY = "user:export";
/** 用户导出任务展示名称。 */
private static final String USER_EXPORT_TASK_NAME = "用户信息导出";
/** 用户查询完成前创建任务使用的占位总数,异步查询后立即替换为真实用户数。 */
private static final int USER_EXPORT_INITIAL_TOTAL = 1;
/** 用户导出模板类路径,模板包含基础信息和各身份的二级表头。 */
private static final String USER_EXPORT_TEMPLATE_PATH = "file/user_export.xlsx";
/** 文件中心使用的 Excel MIME 类型。 */
private static final String EXCEL_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
/** 文件中心导出文件业务目录。 */
private static final String EXPORT_FILE_DIR = "user-export";
/** 文件中心导出文件分类,用于后台文件管理区分系统生成附件。 */
private static final String EXPORT_FILE_TYPE = "USER_EXPORT";
/** 业务导出文件名时间格式,避免同一用户连续导出时名称难以区分。 */
private static final DateTimeFormatter EXPORT_FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
/** 用户查询 Mapper,用于应用列表筛选和当前登录人的数据权限。 */
private final SysUserMapper sysUserMapper;
/** 用户身份服务,用于批量补齐默认、学生和老师身份资料。 */
private final SysUserIdentityService sysUserIdentityService;
/** 文件服务,用于将异步生成的工作簿保存至项目统一文件中心。 */
private final SysFileService sysFileService;
/** 通用异步任务执行器,用于恢复租户和安全上下文并上报导出进度。 */
private final AsyncTaskExecutorService asyncTaskExecutorService;
/**
* 提交用户导出任务。
* <p>请求线程只复制筛选条件并提交任务,避免用户查询阻塞接口响应;异步线程恢复租户和用户上下文后执行带数据权限的查询。</p>
*
* @param userDTO 用户列表当前筛选条件;用户 ID 集合为空时导出全部筛选结果
* @return 已创建的异步任务初始进度
*/
@Override
public AsyncTaskProgressVO submitExport(UserDTO userDTO) {
UserDTO querySnapshot = copyQuery(userDTO);
return asyncTaskExecutorService.submitWithResult(USER_EXPORT_REDIS_KEY, USER_EXPORT_TASK_NAME,
USER_EXPORT_INITIAL_TOTAL, reporter -> {
List<UserVO> users = sysUserMapper.selectVoListByScope(querySnapshot, null, DataScope.of());
if (CollUtil.isEmpty(users)) {
throw new IllegalStateException("没有符合当前条件的用户");
}
reporter.updateTotal(users.size(), "已查询 " + users.size() + " 位用户");
sysUserIdentityService.fillUserIdentities(users);
List<Map<String, Object>> rows = buildRows(users, reporter);
String resultFileName = "用户信息_" + LocalDateTime.now().format(EXPORT_FILE_TIME_FORMATTER) + ".xlsx";
String resultFileUrl = writeAndUpload(rows, resultFileName);
reporter.advance("导出文件已生成,共 " + users.size() + " 位用户");
return AsyncTaskResult.file(resultFileUrl, resultFileName);
});
}
/**
* 复制用户查询条件,避免控制器请求对象在异步任务开始前被其他代码修改。
*
* @param source 控制器接收的用户筛选条件,可为空
* @return 供异步任务独占使用的查询条件副本
*/
private UserDTO copyQuery(UserDTO source) {
UserDTO target = new UserDTO();
if (source == null) {
return target;
}
org.springframework.beans.BeanUtils.copyProperties(source, target);
target.setRole(copyList(source.getRole()));
target.setPost(copyList(source.getPost()));
target.setIdentityTypes(copyList(source.getIdentityTypes()));
target.setUserIds(copyList(source.getUserIds()));
target.setDeptIds(copyList(source.getDeptIds()));
return target;
}
/**
* 复制可能为空的查询集合,避免异步任务与请求对象共享可变列表。
*
* @param source 请求参数中的可变列表
* @param <T> 列表元素类型
* @return 空值保持为空,非空值返回新的可变列表
*/
private <T> List<T> copyList(List<T> source) {
return source == null ? null : new ArrayList<>(source);
}
/**
* 将用户及其身份扩展资料转换为模板填充行。
*
* @param users 已按筛选条件查询并补齐身份资料的用户列表
* @param reporter 异步进度报告器;最后一位用户在文件上传成功后再计数,避免文件未就绪时提前显示完成
* @return 与模板占位符一一对应的用户导出行
*/
private List<Map<String, Object>> buildRows(List<UserVO> users, AsyncTaskProgressReporter reporter) {
List<Map<String, Object>> rows = new ArrayList<>();
for (int index = 0; index < users.size(); index++) {
UserVO user = users.get(index);
SysUserStudentDto student = user.getStudentInfo();
SysUserTeacherDto teacher = user.getTeacherInfo();
Map<String, Object> row = new LinkedHashMap<>();
row.put("sequence", index + 1);
row.put("userId", user.getUserId() == null ? null : user.getUserId().toString());
row.put("username", user.getUsername());
row.put("name", user.getName());
row.put("nickname", user.getNickname());
row.put("gender", resolveGender(user.getGender()));
row.put("email", user.getEmail());
row.put("createTime", user.getCreateTime());
row.put("deptName", user.getDeptName());
row.put("postNameList", joinPostNames(user.getPostList()));
row.put("roleNameList", joinRoleNames(user.getRoleList()));
row.put("staffingType", resolveStaffingType(user.getStaffingType()));
row.put("lockFlag", resolveLockFlag(user.getLockFlag()));
row.put("phone", user.getPhone());
row.put("identityTypeNames", joinIdentityNames(user.getIdentityTypes()));
row.put("studentNo", student == null ? null : student.getStudentNo());
row.put("schoolName", student == null ? null : student.getSchoolName());
row.put("className", student == null ? null : student.getClassName());
row.put("studentExtJson", student == null ? null : student.getExtJson());
row.put("teacherNo", teacher == null ? null : teacher.getTeacherNo());
rows.add(row);
if (index < users.size() - 1) {
reporter.advance("正在整理第 " + (index + 1) + " / " + users.size() + " 位用户");
}
}
return rows;
}
/**
* 使用类路径模板填充用户行并保存到文件中心。
*
* @param rows 已完成业务文案转换的用户导出行
* @param resultFileName 用户下载时使用的文件名称
* @return 文件中心提供的受控下载地址
*/
private String writeAndUpload(List<Map<String, Object>> rows, String resultFileName) {
try {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (InputStream template = normalizeTemplate();
ExcelWriter excelWriter = EasyExcel.write(output).withTemplate(template).build()) {
WriteSheet writeSheet = EasyExcel.writerSheet().build();
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.FALSE).build();
excelWriter.fill(new FillWrapper("users", rows), fillConfig, writeSheet);
}
MockMultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(resultFileName),
EXCEL_CONTENT_TYPE, output.toByteArray());
R<?> uploadResult = sysFileService.uploadFile(exportFile, EXPORT_FILE_DIR, null, EXPORT_FILE_TYPE);
if (uploadResult == null || !uploadResult.isOk() || !(uploadResult.getData() instanceof Map<?, ?> resultData)) {
throw new IllegalStateException("文件中心未返回有效的用户导出结果");
}
Object resultUrl = resultData.get("url");
if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) {
throw new IllegalStateException("文件中心未返回用户导出下载地址");
}
return resultUrl.toString();
} catch (Exception exception) {
log.error("|{}|生成或保存用户导出文件失败", LOG_TAG, exception);
throw new IllegalStateException("生成用户导出文件失败", exception);
}
}
/**
* 使用项目已有 Apache POI 对模板做一次内存标准化。
* <p>可视化模板生成器可能省略可选的工作表范围元数据,而 EasyExcel 4.0.2 在结束模板填充时依赖该元数据;
* 先由 POI 读写可补齐标准结构,同时保留二级表头、样式和命名列表占位符。</p>
*
* @return 已补齐工作表元数据的模板输入流,由调用方负责关闭
*/
private InputStream normalizeTemplate() {
try (InputStream template = requireTemplate(); XSSFWorkbook workbook = new XSSFWorkbook(template);
ByteArrayOutputStream normalized = new ByteArrayOutputStream()) {
workbook.write(normalized);
return normalizeWorksheetNamespaces(normalized.toByteArray());
} catch (Exception exception) {
throw new IllegalStateException("用户导出模板初始化失败", exception);
}
}
/**
* 将工作表 XML 的带前缀命名空间转换为 EasyExcel 流式模板填充支持的默认命名空间。
* <p>只处理工作表 XML 标签,不改变单元格值、样式、合并区域或其他工作簿部件;
* 否则 EasyExcel 追加第二条及后续数据行时会把新增 {@code sheetData} 写到根节点之外,导致生成文件损坏。</p>
*
* @param workbookBytes 已由 POI 补齐工作表范围元数据的模板字节
* @return 可供 EasyExcel 多行填充的工作簿输入流,由调用方负责关闭
*/
private InputStream normalizeWorksheetNamespaces(byte[] workbookBytes) {
try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(workbookBytes));
ByteArrayOutputStream normalized = new ByteArrayOutputStream();
ZipOutputStream output = new ZipOutputStream(normalized)) {
ZipEntry entry;
while ((entry = input.getNextEntry()) != null) {
output.putNextEntry(new ZipEntry(entry.getName()));
byte[] content = input.readAllBytes();
if (entry.getName().startsWith("xl/worksheets/") && entry.getName().endsWith(".xml")) {
String worksheetXml = new String(content, StandardCharsets.UTF_8)
.replace("xmlns:x=", "xmlns=")
.replace("<x:", "<")
.replace("</x:", "</");
output.write(worksheetXml.getBytes(StandardCharsets.UTF_8));
} else {
output.write(content);
}
output.closeEntry();
input.closeEntry();
}
output.finish();
return new ByteArrayInputStream(normalized.toByteArray());
} catch (Exception exception) {
throw new IllegalStateException("用户导出模板工作表标准化失败", exception);
}
}
/**
* 按现有文件服务的请求头解码约定转换程序生成的中文原始文件名。
* <p>文件服务会把 ISO-8859-1 字节还原为 UTF-8;异步任务没有浏览器请求头,需在内存文件中补齐同一编码边界,避免文件管理记录乱码。</p>
*
* @param resultFileName 用户下载时使用的 UTF-8 中文文件名
* @return 可由现有文件服务正确还原的原始文件名
*/
private String encodeUploadOriginalFilename(String resultFileName) {
return new String(resultFileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
}
/**
* 加载用户导出模板并在资源缺失时给出明确异常。
*
* @return 用户导出模板输入流,由调用方负责关闭
*/
private InputStream requireTemplate() {
InputStream template = Thread.currentThread().getContextClassLoader().getResourceAsStream(USER_EXPORT_TEMPLATE_PATH);
if (template == null) {
throw new IllegalStateException("用户导出模板不存在");
}
return template;
}
/**
* 转换用户性别协议编码为导出文案。
*
* @param gender 用户表性别编码:0 未知、1 男、2 女
* @return 中文性别文案;未知历史编码保留原值便于排查
*/
private String resolveGender(Integer gender) {
if (gender == null || gender == 0) {
return "未知";
}
if (gender == 1) {
return "男";
}
if (gender == 2) {
return "女";
}
return gender.toString();
}
/**
* 转换编制协议编码为导出文案。
*
* @param staffingType 用户表编制类型编码
* @return 已配置的中文描述;未知历史编码保留原值
*/
private String resolveStaffingType(String staffingType) {
UserStaffingTypeEnum staffing = UserStaffingTypeEnum.valueOfByType(staffingType);
return staffing == null ? staffingType : staffing.getDescription();
}
/**
* 转换账号锁定标记为导出文案。
*
* @param lockFlag 用户表锁定标记:0 启用、9 禁用
* @return 启用状态中文文案;未知历史编码保留原值
*/
private String resolveLockFlag(String lockFlag) {
if ("0".equals(lockFlag)) {
return "启用";
}
if ("9".equals(lockFlag)) {
return "禁用";
}
return lockFlag;
}
/**
* 拼接用户岗位名称,空岗位列表输出空字符串。
*
* @param posts 用户查询返回的岗位列表
* @return 以中文逗号分隔的岗位名称
*/
private String joinPostNames(List<SysPost> posts) {
return CollUtil.isEmpty(posts) ? "" : posts.stream().filter(Objects::nonNull).map(SysPost::getPostName)
.filter(StrUtil::isNotBlank).collect(Collectors.joining(","));
}
/**
* 拼接用户角色名称,空角色列表输出空字符串。
*
* @param roles 用户查询返回的角色列表
* @return 以中文逗号分隔的角色名称
*/
private String joinRoleNames(List<SysRole> roles) {
return CollUtil.isEmpty(roles) ? "" : roles.stream().filter(Objects::nonNull).map(SysRole::getRoleName)
.filter(StrUtil::isNotBlank).collect(Collectors.joining(","));
}
/**
* 按协议枚举顺序拼接用户身份中文名称。
*
* @param identityTypes 用户身份表返回的协议编码集合
* @return 以中文逗号分隔的身份名称,未知历史编码保留原值
*/
private String joinIdentityNames(List<String> identityTypes) {
if (CollUtil.isEmpty(identityTypes)) {
return "";
}
return identityTypes.stream().map(identityType -> {
UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType);
return identity == null ? identityType : identity.getDescription();
}).filter(StrUtil::isNotBlank).collect(Collectors.joining(","));
}
}
@@ -10,6 +10,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceGuardianWardCreateDto;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceSyncSnapshotDto;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysRole;
import com.bsd.pjcloud.admin.api.entity.SysUser;
@@ -18,7 +19,10 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncRecordVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo;
import com.bsd.pjcloud.admin.api.vo.user.SysUserGuardianWardOptionVo;
import com.bsd.pjcloud.admin.handler.FaceChangeEvent;
import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper;
import com.bsd.pjcloud.admin.service.SysDeptService;
import com.bsd.pjcloud.admin.service.SysRoleService;
@@ -34,6 +38,7 @@ import com.bsd.pjcloud.common.core.exception.CheckedException;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.flow.task.api.feign.RemoteFlowEngineService;
@@ -41,13 +46,14 @@ import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum;
import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto;
import com.bsd.pjcloud.flow.task.dto.TaskDto;
import com.bsd.pjcloud.flow.task.dto.TaskParamDto;
import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService;
import cn.hutool.extra.spring.SpringUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -59,6 +65,9 @@ import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import org.redisson.Redisson;
import org.redisson.api.RLock;
/**
* 人脸样本表
*
@@ -71,7 +80,35 @@ import java.util.stream.Collectors;
@Service
public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUserFaceEntity> implements SysUserFaceService {
static private final String TAG = "用户人脸";
/**
* 人脸业务日志标签。
*/
private static final String TAG = "用户人脸";
/**
* 人脸编号 Redis 原子序列业务键;租户前缀由 Redis 基础设施根据当前上下文自动处理。
*/
private static final String FACE_AUTO_CODE_SEQUENCE_KEY = "upms:user-face:auto-code";
/**
* 人脸编号首次初始化分布式锁业务键;租户锁粒度由 Redis 基础设施自动隔离。
*/
private static final String FACE_AUTO_CODE_LOCK_KEY = "upms:user-face:auto-code:init";
/**
* 虹软搜索标识允许的最大 32 位正整数。
*/
private static final long FACE_AUTO_CODE_MAX_VALUE = Integer.MAX_VALUE;
/**
* V3 人脸快照默认页面大小,兼顾终端内存占用和请求次数。
*/
private static final int FACE_SYNC_DEFAULT_LIMIT = 200;
/**
* V3 人脸快照单页上限,防止异常参数导致数据库和终端瞬时压力过大。
*/
private static final int FACE_SYNC_MAX_LIMIT = 500;
/**
* App 关系人业务允许查看的人脸审核状态。
@@ -87,7 +124,20 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
private final SysRoleService sysRoleService;
private final SysUserRelationService sysUserRelationService;
private final RemoteMqttEventService remoteMqttEventService;
/**
* Redis 字符串客户端,用于租户隔离的人脸搜索编号原子递增。
*/
private final StringRedisTemplate stringRedisTemplate;
/**
* Redisson 客户端,用于保护 Redis 人脸编号序列的首次数据库初始化。
*/
private final Redisson redisson;
/**
* Spring 业务事件发布器,确保 FACE 通知在数据库事务成功提交后发送。
*/
private final ApplicationEventPublisher applicationEventPublisher;
private final RemoteFlowEngineService remoteFlowEngineService;
private final FlowUtil flowUtil;
@@ -112,21 +162,17 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
sysUserFace.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode());
sysUserFace.setProcessTime(LocalDateTime.now());
}
// 插入
sysUserFace.setAutoCode(allocateFaceAutoCode());
// 编号在插入前完成分配,数据库事务回滚时允许产生空洞,但不得回退或复用已经发放的虹软搜索标识。
baseMapper.insert(sysUserFace);
// 自增code
Long count = baseMapper.selectAllCount();
SysUserFaceEntity updateObj = new SysUserFaceEntity();
updateObj.setId(sysUserFace.getId());
updateObj.setAutoCode(String.valueOf(count));
baseMapper.updateById(updateObj);
if (needFaceApproval) {
// 开启审批
SysUser userDetail = sysUserService.getById(applyUser.getId());
getSelf().applyFlow(userDetail, sysUserFace);
}
// 发送mq
sendFaceMq(List.of(sysUserFace.getUserId()), null);
if (!needFaceApproval) {
publishFaceChange(sysUserFace.getUserId(), null);
}
return sysUserFace.getId();
}
@@ -162,6 +208,7 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
SysUserFaceEntity userFace = new SysUserFaceEntity();
userFace.setUserId(dto.getWardUserId());
userFace.setFaceImg(faceImg);
userFace.setAutoCode(allocateFaceAutoCode());
if (needFaceApproval) {
userFace.setProcessStatus(TaskCheckStatusEnum.PADDING.getCode());
} else {
@@ -169,14 +216,13 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
userFace.setProcessTime(LocalDateTime.now());
}
baseMapper.insert(userFace);
updateFaceAutoCode(userFace);
if (needFaceApproval) {
getSelf().applyFlow(applyUser, userFace);
}
faceIds.add(userFace.getId());
}
if (!needFaceApproval) {
sendFaceMq(List.of(dto.getWardUserId()), null);
publishFaceChange(dto.getWardUserId(), null);
}
return faceIds;
}
@@ -217,6 +263,87 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
return baseMapper.listStatusCounts(distinctUserIds, TaskCheckStatusEnum.RESULT.getCode(), TaskCheckStatusEnum.PADDING.getCode());
}
/**
* 按固定数据库主键上界读取本部门及子部门的审核通过人脸快照。
*
* <p>该方法显式排除外部门全局人脸。调用方必须完整拉取所有页面后再做本地孤儿清理;
* 服务端仅审计当前部门树内实际生效的人脸编号;外部门、已删除和未审核历史记录不会阻断本机同步。</p>
*
* @param dto 商户部门、可选用户、排他游标、页面大小及固定快照上界
* @return 当前页面、下一游标和固定快照上界
*/
@Override
public SysUserFaceSyncSnapshotVo syncSnapshot(SysUserFaceSyncSnapshotDto dto) {
Long merchantDeptId = dto.getMerchantDeptId();
Long userId = dto.getUserId();
Long afterId = dto.getAfterId();
Integer limit = dto.getLimit();
Long snapshotMaxId = dto.getSnapshotMaxId();
if (merchantDeptId == null) {
throw new RuntimeException("商户部门不能为空");
}
List<Long> deptIds = sysDeptService.listDescendant(merchantDeptId).stream()
.map(SysDept::getDeptId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (deptIds.isEmpty()) {
throw new RuntimeException("商户部门不存在或已删除");
}
assertSyncFaceAutoCodeUnique(deptIds, TaskCheckStatusEnum.RESULT.getCode());
long normalizedAfterId = afterId == null ? 0L : afterId;
if (normalizedAfterId < 0) {
throw new RuntimeException("人脸同步游标不能小于0");
}
int normalizedLimit = limit == null ? FACE_SYNC_DEFAULT_LIMIT : limit;
if (normalizedLimit <= 0 || normalizedLimit > FACE_SYNC_MAX_LIMIT) {
throw new RuntimeException("人脸同步分页大小必须在1到" + FACE_SYNC_MAX_LIMIT + "之间");
}
Long normalizedSnapshotMaxId = snapshotMaxId;
if (normalizedSnapshotMaxId == null) {
normalizedSnapshotMaxId = baseMapper.selectSyncSnapshotMaxId(deptIds, userId, TaskCheckStatusEnum.RESULT.getCode());
}
if (normalizedSnapshotMaxId == null || normalizedSnapshotMaxId < 0) {
throw new RuntimeException("人脸同步快照上界无效");
}
if (normalizedAfterId > normalizedSnapshotMaxId) {
throw new RuntimeException("人脸同步游标超过快照上界");
}
List<SysUserFaceSyncRecordVo> fetchedRecords = baseMapper.listSyncSnapshotRecords(
deptIds, userId, normalizedAfterId, normalizedSnapshotMaxId,
TaskCheckStatusEnum.RESULT.getCode(), normalizedLimit + 1);
boolean hasMore = fetchedRecords.size() > normalizedLimit;
List<SysUserFaceSyncRecordVo> records = hasMore
? new ArrayList<>(fetchedRecords.subList(0, normalizedLimit))
: new ArrayList<>(fetchedRecords);
records.forEach(this::validateSyncRecord);
SysUserFaceSyncSnapshotVo snapshot = new SysUserFaceSyncSnapshotVo();
snapshot.setRecords(records);
snapshot.setNextAfterId(records.isEmpty() ? normalizedAfterId : records.get(records.size() - 1).getId());
snapshot.setHasMore(hasMore);
snapshot.setSnapshotMaxId(normalizedSnapshotMaxId);
return snapshot;
}
/**
* 校验快照记录的虹软搜索标识可被 Android 端安全解析为 32 位正整数。
*
* @param record 待下发的快照记录
*/
private void validateSyncRecord(SysUserFaceSyncRecordVo record) {
try {
long sampleId = Long.parseLong(record.getSampleId());
if (sampleId <= 0 || sampleId > FACE_AUTO_CODE_MAX_VALUE) {
throw new NumberFormatException("out of range");
}
} catch (NumberFormatException exception) {
throw new RuntimeException("存在无效的人脸编号,faceId=" + record.getId() + ",请先完成数据修复", exception);
}
}
/**
* 将被监护人用户转换为移动端选择项。
*
@@ -333,14 +460,86 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
}
/**
* 更新人脸记录的自增编码。
* 从租户隔离的 Redis 原子序列分配虹软 32 位搜索标识。
*
* <p>首次使用时在分布式锁内以数据库现有最大编号初始化;Redis 不可用时拒绝新增,
* 禁止退回全表计数,因为计数在并发新增和逻辑删除后都可能产生重复编号。</p>
*
* @return 可安全转换为 32 位正整数的十进制编号
*/
private void updateFaceAutoCode(SysUserFaceEntity userFace) {
Long count = baseMapper.selectAllCount();
SysUserFaceEntity updateObj = new SysUserFaceEntity();
updateObj.setId(userFace.getId());
updateObj.setAutoCode(String.valueOf(count));
baseMapper.updateById(updateObj);
private String allocateFaceAutoCode() {
try {
String currentValue = stringRedisTemplate.opsForValue().get(FACE_AUTO_CODE_SEQUENCE_KEY);
if (currentValue != null) {
return incrementFaceAutoCode(FACE_AUTO_CODE_SEQUENCE_KEY);
}
RLock lock = redisson.getLock(FACE_AUTO_CODE_LOCK_KEY);
lock.lock();
try {
// 仅 Redis 序列首次建立时审计历史数据和数据库最大值,正常分配直接走原子递增快路径。
if (stringRedisTemplate.opsForValue().get(FACE_AUTO_CODE_SEQUENCE_KEY) == null) {
assertFaceAutoCodeUnique();
Long databaseMax = baseMapper.selectMaxNumericAutoCode();
long initialValue = databaseMax == null ? 0L : databaseMax;
if (initialValue > FACE_AUTO_CODE_MAX_VALUE) {
throw new RuntimeException("现有人脸编号已超过虹软32位搜索标识上限");
}
stringRedisTemplate.opsForValue().set(FACE_AUTO_CODE_SEQUENCE_KEY, String.valueOf(initialValue));
}
return incrementFaceAutoCode(FACE_AUTO_CODE_SEQUENCE_KEY);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
} catch (RuntimeException exception) {
throw exception;
} catch (Exception exception) {
throw new RuntimeException("人脸编号服务不可用,已拒绝新增以避免编号冲突", exception);
}
}
/**
* 对已经初始化的 Redis 序列执行一次原子递增并校验虹软 32 位正整数边界。
*
* @param sequenceKey 人脸编号序列业务键,租户前缀由 Redis 基础设施自动补充
* @return 可直接写入 {@code autoCode} 的十进制编号
*/
private String incrementFaceAutoCode(String sequenceKey) {
Long nextValue = stringRedisTemplate.opsForValue().increment(sequenceKey);
if (nextValue == null || nextValue <= 0 || nextValue > FACE_AUTO_CODE_MAX_VALUE) {
throw new RuntimeException("虹软32位人脸搜索标识已耗尽");
}
return String.valueOf(nextValue);
}
/**
* 只读审计当前租户全部历史人脸的虹软搜索编号是否重复。
*
* <p>该审计仅用于 Redis 序列首次初始化,发现冲突时阻断新增但不自动修改任何历史数据。</p>
*/
private void assertFaceAutoCodeUnique() {
Long duplicateCount = baseMapper.countDuplicateAutoCodes();
if (duplicateCount != null && duplicateCount > 0) {
throw new RuntimeException("检测到重复的人脸编号,已停止新增,请先执行历史数据修复");
}
}
/**
* 审计当前终端部门树内实际生效的人脸编号是否冲突。
*
* <p>同步只维护本部门及子部门,因此外部门、已删除和未审核记录即使存在历史重复编号,
* 也不应阻断当前终端;当前范围内的冲突仍必须停止下发,避免虹软相同搜索标识互相覆盖。</p>
*
* @param deptIds 终端绑定部门及其有效子部门主键
* @param processStatus 审核通过状态编码
*/
private void assertSyncFaceAutoCodeUnique(List<Long> deptIds, String processStatus) {
Long duplicateCount = baseMapper.countSyncDuplicateAutoCodes(deptIds, processStatus);
if (duplicateCount != null && duplicateCount > 0) {
throw new RuntimeException("当前部门范围存在重复的人脸编号,已停止同步,请先修复冲突数据");
}
}
/**
@@ -359,18 +558,36 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
}
}
/**
* 修改指定人脸图片并在事务提交后通知用户当前部门终端回查。
*
* @param sysUserFace 仅使用记录主键和新图片地址,用户与虹软编号保持数据库原值
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void edit(SysUserFaceEntity sysUserFace) {
SysUserFaceEntity userFace = baseMapper.selectById(sysUserFace.getId());
if (userFace == null) {
throw new CheckedException("人脸样本表不存在");
}
baseMapper.selectById(userFace.getUserId());
// 发送mq
sendFaceMq(List.of(sysUserFace.getUserId()), null);
SysUserFaceEntity updateObject = new SysUserFaceEntity();
updateObject.setId(userFace.getId());
updateObject.setFaceImg(sysUserFace.getFaceImg());
updateObject.setUpdateTime(LocalDateTime.now());
if (baseMapper.updateById(updateObject) <= 0) {
throw new RuntimeException("人脸样本修改失败,请刷新后重试");
}
publishFaceChange(userFace.getUserId(), null);
}
/**
* 修改用户全局人脸标记并在事务提交后通知当前部门硬件组。
*
* @param dto 用户主键和全局人脸状态
* @return 修改成功返回 {@code true}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean editGlobalFace(SysUserFaceDto dto) {
SysUser user = sysUserService.getById(dto.getUserId());
if (ObjectUtil.isEmpty(user)) {
@@ -380,26 +597,31 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
updateObj.setUserId(dto.getUserId());
updateObj.setIsGlobalFace(dto.getIsGlobalFace());
sysUserService.updateById(updateObj);
// 发送mq
sendFaceMq(List.of(dto.getUserId()), ObjectUtil.equal(YesNoEnum.YES.getCode(), dto.getIsGlobalFace()));
publishFaceChange(dto.getUserId(), ObjectUtil.equal(YesNoEnum.YES.getCode(), dto.getIsGlobalFace()));
return true;
}
/**
* 逻辑删除指定人脸记录,停止关联审批流程,并在删除事务提交后通知涉及用户。
*
* @param list 待删除的人脸记录主键列表
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void delByIds(ArrayList<Long> list) {
if (ObjectUtil.isEmpty(list)) {
return;
}
// 发送mq
List<SysUserFaceEntity> faceList = baseMapper.selectList(new LambdaQueryWrapper<SysUserFaceEntity>().in(SysUserFaceEntity::getId, list));
if (ObjectUtil.isNotEmpty(faceList)) {
sendFaceMq(faceList.stream().map(SysUserFaceEntity::getUserId).toList(), null);
}
// 取消审批
TaskParamDto dto = new TaskParamDto();
dto.setProcessInstanceIdList(faceList.stream().map(SysUserFaceEntity::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList());
remoteFlowEngineService.stopProcessInstance(dto).getData();
if (ObjectUtil.isNotEmpty(dto.getProcessInstanceIdList())) {
remoteFlowEngineService.stopProcessInstance(dto).getData();
}
baseMapper.deleteBatchIds(list);
faceList.stream().map(SysUserFaceEntity::getUserId).filter(Objects::nonNull).distinct()
.forEach(userId -> publishFaceChange(userId, null));
}
@Override
@@ -538,7 +760,7 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
return;
}
if (isConsent) {
sendFaceMq(List.of(detail.getUserId()), null);
publishFaceChange(detail.getUserId(), null);
}
} catch (Exception e) {
e.printStackTrace();
@@ -571,20 +793,41 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
return count >= 3;
}
// 发送mq
private void sendFaceMq(List<Long> userIds, Boolean isGlobalFace) {
if (ObjectUtil.isEmpty(userIds)) {
/**
* 发布当前用户部门的人脸变更事件;实际 MQ 调用由 AFTER_COMMIT 监听器执行。
*
* @param userId 发生变化的用户主键
* @param globalFace 全局人脸状态,普通 CRUD 时为空
*/
private void publishFaceChange(Long userId, Boolean globalFace) {
SysUser user = sysUserService.getById(userId);
if (user == null || user.getDeptId() == null) {
log.warn("|{}|用户不存在或缺少部门,忽略人脸变更通知,userId:|{}|", TAG, userId);
return;
}
List<SysUser> list = sysUserService.list(new LambdaQueryWrapper<SysUser>().in(SysUser::getUserId, userIds));
if (ObjectUtil.isEmpty(list)) {
return;
}
for (SysUser v : list) {
remoteMqttEventService.sendFaceChangeMsg(v.getDeptId(), v.getUserId(), isGlobalFace);
}
publishFaceChange(userId, List.of(user.getDeptId()), globalFace);
}
/**
* 发布指定旧、新部门的人脸变更事件,供用户部门调动等跨部门场景复用。
*
* @param userId 发生变化的用户主键
* @param targetDeptIds 需要通知的目标部门主键
* @param globalFace 全局人脸状态,普通 CRUD 时为空
*/
private void publishFaceChange(Long userId, List<Long> targetDeptIds, Boolean globalFace) {
List<Long> validDeptIds = targetDeptIds.stream().filter(Objects::nonNull).distinct().toList();
if (userId == null || validDeptIds.isEmpty()) {
return;
}
applicationEventPublisher.publishEvent(new FaceChangeEvent(userId, validDeptIds, globalFace, TenantContextHolder.getTenantId()));
}
/**
* 获取当前 Spring 代理实例,确保内部审批方法仍经过事务代理。
*
* @return 当前服务的 Spring 代理实例
*/
private SysUserFaceServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
@@ -26,8 +26,6 @@ import com.bsd.pjcloud.admin.api.vo.StudentUserExcelVO;
import com.bsd.pjcloud.admin.api.vo.TeacherUserExcelVO;
import com.bsd.pjcloud.admin.api.vo.UserExcelFaceVo;
import com.bsd.pjcloud.admin.api.vo.UserExcelVO;
import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.admin.mapper.SysUserMapper;
import com.bsd.pjcloud.admin.mapper.user.SysUserStudentMapper;
import com.bsd.pjcloud.admin.mapper.user.SysUserTeacherMapper;
import com.bsd.pjcloud.admin.service.SysDeptService;
@@ -47,7 +45,6 @@ import com.bsd.pjcloud.common.core.constant.CommonRoleConstant;
import com.bsd.pjcloud.common.core.exception.ErrorCodes;
import com.bsd.pjcloud.common.core.util.MsgUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.resolver.ParamResolver;
import com.bsd.pjcloud.common.excel.vo.ErrorMessage;
import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum;
@@ -76,8 +73,8 @@ import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 用户模块导入导出服务实现。
* <p>本类只负责 Excel 数据校验、业务对象组装和导出转换,用户、关系及人脸落库仍由各自核心服务处理。
* 用户模块导入服务实现。
* <p>本类只负责 Excel 数据校验和导入业务对象组装,用户、关系及人脸落库仍由各自核心服务处理。
* 所有导入均先同步完成整表校验,再交给通用异步事务执行器统一写入,避免错误行导致部分数据已入库。</p>
*/
@Slf4j
@@ -109,9 +106,6 @@ public class SysUserImportExportServiceImpl implements SysUserImportExportServic
/** 带人脸图片的后台用户导入任务业务 Redis Key 命名空间。 */
private static final String FACE_USER_IMPORT_REDIS_KEY = "user:import:face";
/** 用户查询 Mapper,用于应用数据权限生成导出数据。 */
private final SysUserMapper sysUserMapper;
/** 用户核心服务,用于用户查询、账号生成和完整用户创建。 */
private final SysUserService sysUserService;
@@ -145,38 +139,6 @@ public class SysUserImportExportServiceImpl implements SysUserImportExportServic
/** 通用异步事务任务执行器,用于在同步整表校验通过后后台统一落库并报告进度。 */
private final AsyncTaskExecutorService asyncTaskExecutorService;
/**
* 按查询条件和数据权限生成后台用户导出数据。
*
* @param userDTO 用户筛选条件
* @param ids 指定导出的用户 ID;为空时按筛选条件导出
* @return 可由 Excel 响应转换器直接输出的用户列表
*/
@Override
public List<UserExcelVO> listUser(UserDTO userDTO, Long[] ids) {
List<UserVO> userList = sysUserMapper.selectVoListByScope(userDTO, ids, DataScope.of());
return userList.stream().map(userVO -> {
UserExcelVO excelVO = new UserExcelVO();
BeanUtils.copyProperties(userVO, excelVO);
excelVO.setRoleNameList(userVO.getRoleList().stream().map(SysRole::getRoleName).collect(Collectors.joining(StrUtil.COMMA)));
excelVO.setPostNameList(userVO.getPostList().stream().map(SysPost::getPostName).collect(Collectors.joining(StrUtil.COMMA)));
if (ObjectUtil.isNotEmpty(userVO.getStaffingType())) {
UserStaffingTypeEnum staffingType = UserStaffingTypeEnum.valueOfByType(userVO.getStaffingType());
if (staffingType != null) {
excelVO.setStaffingType(staffingType.getDescription());
}
}
// 启用状态:0=启用,9=禁用(与列表开关一致)
if ("0".equals(userVO.getLockFlag())) {
excelVO.setLockFlag("启用");
}
else if ("9".equals(userVO.getLockFlag())) {
excelVO.setLockFlag("禁用");
}
return excelVO;
}).toList();
}
/**
* 导入后台用户基础信息,并以 Excel 行号汇总业务校验错误;整表无错误后才统一写入。
*
@@ -48,6 +48,7 @@ import com.bsd.pjcloud.admin.api.entity.user.SysUserTagEntity;
import com.bsd.pjcloud.admin.api.utils.DataUtil;
import com.bsd.pjcloud.admin.api.vo.*;
import com.bsd.pjcloud.admin.handler.UserWalletCreateEvent;
import com.bsd.pjcloud.admin.handler.FaceChangeEvent;
import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper;
import com.bsd.pjcloud.admin.mapper.SysUserMapper;
import com.bsd.pjcloud.admin.mapper.SysUserPostMapper;
@@ -99,6 +100,7 @@ import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* @author pjcloud
@@ -560,6 +562,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
}
this.updateById(sysUser);
publishDepartmentFaceChange(dbUser, sysUser);
if (userDto.getIdentityTypes() != null
&& !userDto.getIdentityTypes().contains(UserIdentityTypeEnum.DEFAULT.getType())) {
@@ -601,6 +604,27 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
return Boolean.TRUE;
}
/**
* 用户部门发生变化时同时通知旧、新部门硬件组回查该用户的人脸。
*
* <p>事件在当前用户更新事务中发布,只有事务提交后才会发送 MQ;旧部门据此删除本地人脸,
* 新部门据此新增人脸。重复或乱序消息由终端按用户回查 V3 真源实现幂等。</p>
*
* @param databaseUser 更新前的数据库用户,用于取得旧部门
* @param updatedUser 本次待更新用户,用于取得新部门;未提交部门字段时不发送通知
*/
private void publishDepartmentFaceChange(SysUser databaseUser, SysUser updatedUser) {
if (updatedUser.getDeptId() == null || Objects.equals(databaseUser.getDeptId(), updatedUser.getDeptId())) {
return;
}
List<Long> targetDeptIds = Stream.of(databaseUser.getDeptId(), updatedUser.getDeptId())
.filter(Objects::nonNull)
.distinct()
.toList();
applicationEventPublisher.publishEvent(new FaceChangeEvent(
databaseUser.getUserId(), targetDeptIds, null, TenantContextHolder.getTenantId()));
}
/**
* 查询上级部门的用户信息
*
@@ -77,8 +77,82 @@
order by a.create_time desc , a.id desc
</select>
<select id="selectAllCount" resultType="java.lang.Long">
select count(0) from sys_user_face
<select id="selectMaxNumericAutoCode" resultType="java.lang.Long">
select coalesce(max(cast(auto_code as unsigned)), 0)
from sys_user_face
where auto_code regexp '^[0-9]+$'
</select>
<select id="countDuplicateAutoCodes" resultType="java.lang.Long">
select count(0)
from (
select cast(auto_code as unsigned) as numeric_auto_code
from sys_user_face
where auto_code regexp '^[0-9]+$'
group by cast(auto_code as unsigned)
having count(0) &gt; 1
) duplicate_codes
</select>
<select id="countSyncDuplicateAutoCodes" resultType="java.lang.Long">
select count(0)
from (
select cast(a.auto_code as unsigned) as numeric_auto_code
from sys_user_face a
inner join sys_user u on u.user_id = a.user_id and u.del_flag = '0'
inner join sys_dept d on d.dept_id = u.dept_id and d.del_flag = '0'
where a.del_flag = '0'
and a.process_status = #{processStatus}
and a.auto_code regexp '^[0-9]+$'
and d.dept_id in
<foreach collection="deptIds" item="deptId" open="(" close=")" separator=",">
#{deptId}
</foreach>
group by cast(a.auto_code as unsigned)
having count(0) &gt; 1
) duplicate_codes
</select>
<select id="selectSyncSnapshotMaxId" resultType="java.lang.Long">
select coalesce(max(a.id), 0)
from sys_user_face a
inner join sys_user u on u.user_id = a.user_id and u.del_flag = '0'
where a.del_flag = '0'
and a.process_status = #{processStatus}
and u.dept_id in
<foreach collection="deptIds" item="deptId" open="(" close=")" separator=",">
#{deptId}
</foreach>
<if test="userId != null">
and a.user_id = #{userId}
</if>
</select>
<select id="listSyncSnapshotRecords" resultType="com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncRecordVo">
select a.id,
a.auto_code as sampleId,
a.user_id as userId,
u.name as username,
d.dept_id as deptId,
d.name as deptName,
a.face_img as faceImg,
a.update_time as sourceUpdatedAt
from sys_user_face a
inner join sys_user u on u.user_id = a.user_id and u.del_flag = '0'
inner join sys_dept d on d.dept_id = u.dept_id and d.del_flag = '0'
where a.del_flag = '0'
and a.process_status = #{processStatus}
and a.id &gt; #{afterId}
and a.id &lt;= #{snapshotMaxId}
and d.dept_id in
<foreach collection="deptIds" item="deptId" open="(" close=")" separator=",">
#{deptId}
</foreach>
<if test="userId != null">
and a.user_id = #{userId}
</if>
order by a.id asc
limit #{fetchSize}
</select>
<select id="listStatusCounts" resultType="com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo">
@@ -301,6 +301,7 @@
u.password,
u.salt,
u.phone,
u.gender,
u.avatar,
u.wx_openid,
u.wx_pc_openid,
@@ -353,10 +353,14 @@ public class WalletActiveServiceImpl extends ServiceImpl<WalletActiveMapper, Wal
}
/**
* 导入开卡信息,会直接覆盖
* 导入开卡信息并覆盖用户当前饭卡卡号;用户尚未绑定饭卡时,先在同一事务中自动分配并绑定饭卡。
*
* @param excelVOList Excel 中读取的开卡数据
* @param bindingResult Excel 行级校验错误容器
* @return 导入结果;校验失败时返回逐行错误,成功时返回导入成功提示
*/
@Override
@Transactional
@Transactional(rollbackFor = Exception.class)
public R importWalletActive(List<WalletImportActiveDto> excelVOList, BindingResult bindingResult) {
ImmutablePair<List<ErrorMessage>, List<WalletImportActiveDto>> pair = validateExcelData(excelVOList, bindingResult, false);
List<ErrorMessage> errorMessages = pair.getLeft();
@@ -366,6 +370,19 @@ public class WalletActiveServiceImpl extends ServiceImpl<WalletActiveMapper, Wal
List<WalletActiveEntity> updateActiveList = new ArrayList<>();
List<WalletManageEntity> updateWalletManageList = new ArrayList<>();
for (WalletImportActiveDto v : list) {
if (ObjectUtil.isEmpty(v.getId())) {
WalletManageEntity wallet = walletManageService.getWallet(v.getWalletId());
// 校验阶段只确认饭卡钱包存在,实际绑定延迟到导入事务内,避免其他行校验失败后产生半成品数据。
getSelf().createAndActiveCard(wallet);
WalletActiveEntity targetWalletActive = baseMapper.selectList(new LambdaQueryWrapper<WalletActiveEntity>()
.eq(WalletActiveEntity::getUserId, v.getUserId()))
.stream().findFirst().orElse(null);
if (ObjectUtil.isEmpty(targetWalletActive)) {
throw new RuntimeException("用户 <" + v.getUsername() + "> 自动绑定饭卡失败,请检查所属食堂商户和开卡配置");
}
v.setId(targetWalletActive.getId());
v.setWalletId(targetWalletActive.getWalletId());
}
if (ObjectUtil.isNotEmpty(v.getId())) {
WalletActiveEntity entity = new WalletActiveEntity();
entity.setId(v.getId());
@@ -386,6 +403,14 @@ public class WalletActiveServiceImpl extends ServiceImpl<WalletActiveMapper, Wal
return R.ok(null, "导入成功");
}
/**
* 校验开卡导入数据并补充后续导入需要的用户、钱包及开卡记录标识。
*
* @param list Excel 中读取的原始行数据,空白行会被过滤
* @param bindingResult Excel 解析阶段产生的行级错误容器
* @param validatePrice 预留的金额校验开关,当前开卡导入流程不使用
* @return 左值为校验错误,右值为校验通过且已补充关联标识的导入数据
*/
public ImmutablePair<List<ErrorMessage>, List<WalletImportActiveDto>> validateExcelData(List<WalletImportActiveDto> list, BindingResult bindingResult, boolean validatePrice) {
if (ObjectUtil.isEmpty(list)) {
throw new RuntimeException("读取excel数据错误");
@@ -408,8 +433,8 @@ public class WalletActiveServiceImpl extends ServiceImpl<WalletActiveMapper, Wal
if (ObjectUtil.isEmpty(v.getCardNumber())) {
errors.add("请输入卡号");
} else {
if (!List.of(10, 12).contains(v.getCardNumber().length())) {
errors.add("卡号长度错误,卡号长度必须为10、12");
if (!List.of(8, 10, 12).contains(v.getCardNumber().length())) {
errors.add("卡号长度错误,卡号长度必须为8、10、12");
}
}
if (ObjectUtil.isNotEmpty(v.getUsername()) && ObjectUtil.isNotEmpty(v.getUserMobile())) {
@@ -438,18 +463,24 @@ public class WalletActiveServiceImpl extends ServiceImpl<WalletActiveMapper, Wal
List<Long> userIdList = list.stream().map(WalletImportActiveDto::getUserId).distinct().filter(ObjectUtil::isNotEmpty).toList();
if (ObjectUtil.isNotEmpty(userIdList)) {
List<WalletActiveEntity> walletActiveList = baseMapper.selectList(new LambdaQueryWrapper<WalletActiveEntity>().in(WalletActiveEntity::getUserId, userIdList));
List<WalletManageEntity> walletManageList = walletManageService.getWalletsByUserIds(userIdList, WalletTypeEnum.EAT);
for (WalletImportActiveDto v : list) {
WalletActiveEntity targetWalletActive = walletActiveList.stream().filter(i -> ObjectUtil.equals(i.getUserId(), v.getUserId())).findFirst().orElse(null);
if (ObjectUtil.isEmpty(targetWalletActive)) {
Set<String> errors = new LinkedHashSet<>();
// 找到错误信息
ErrorMessage errorMessage = errorMessageList.stream().filter(i -> ObjectUtil.equals(i.getLineNum(), v.getLineNum())).findFirst().orElse(null);
if (ObjectUtil.isNotEmpty(errorMessage)) {
errors = errorMessage.getErrors();
}
errors.add("用户 <" + v.getUsername() + "> 没有绑定饭卡");
if (ObjectUtil.isEmpty(errorMessage)) {
errorMessageList.add(new ErrorMessage(v.getLineNum(), errors));
WalletManageEntity targetWalletManage = walletManageList.stream().filter(i -> ObjectUtil.equals(i.getUserId(), v.getUserId())).findFirst().orElse(null);
if (ObjectUtil.isEmpty(targetWalletManage)) {
Set<String> errors = new LinkedHashSet<>();
// 饭卡钱包是自动绑卡的必要载体,缺失时保留行号并阻止整批数据写入。
ErrorMessage errorMessage = errorMessageList.stream().filter(i -> ObjectUtil.equals(i.getLineNum(), v.getLineNum())).findFirst().orElse(null);
if (ObjectUtil.isNotEmpty(errorMessage)) {
errors = errorMessage.getErrors();
}
errors.add("用户 <" + v.getUsername() + "> 饭卡钱包不存在");
if (ObjectUtil.isEmpty(errorMessage)) {
errorMessageList.add(new ErrorMessage(v.getLineNum(), errors));
}
} else {
v.setWalletId(targetWalletManage.getId());
}
} else {
v.setId(targetWalletActive.getId());
@@ -477,4 +508,4 @@ public class WalletActiveServiceImpl extends ServiceImpl<WalletActiveMapper, Wal
private WalletActiveServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
}
}