diff --git a/pjcloud-common/pjcloud-common-async-task/README.md b/pjcloud-common/pjcloud-common-async-task/README.md index 8111da270..400ea0170 100644 --- a/pjcloud-common/pjcloud-common-async-task/README.md +++ b/pjcloud-common/pjcloud-common-async-task/README.md @@ -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 users = queryUsers(); + reporter.updateTotal(users.size(), "已查询 " + users.size() + " 位用户"); + return exportUsers(users, reporter); + }); +``` + 调用端统一轮询: ```text diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskExecutorService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskExecutorService.java index c540e5486..a4c866eb6 100644 --- a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskExecutorService.java +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskExecutorService.java @@ -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("当前异步任务执行器不支持返回任务结果"); + } } diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressReporter.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressReporter.java index ea57776e0..638b5c2c3 100644 --- a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressReporter.java +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressReporter.java @@ -7,6 +7,17 @@ package com.bsd.pjcloud.common.asynctask.service; @FunctionalInterface public interface AsyncTaskProgressReporter { + /** + * 在后台任务取得真实数据量后更新总项目数。 + *

适用于查询本身耗时、必须先返回任务 ID 的导出场景;固定总数任务无需调用。

+ * + * @param total 后台查询得到的真实总项目数,必须大于零 + * @param message 总数更新后的进度说明;为空时使用通用说明 + */ + default void updateTotal(int total, String message) { + throw new UnsupportedOperationException("当前异步任务进度报告器不支持动态更新总数"); + } + /** * 报告一个项目已处理完成。 * diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressService.java index 029e70401..197a00489 100644 --- a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressService.java +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressService.java @@ -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); + /** + * 将任务标记为成功,并在实现支持时保存业务动作返回的结果文件信息。 + *

默认实现退化为原有成功状态写入,保证既有自定义进度服务无需同步修改; + * 需要向调用端返回附件的实现应覆写本方法。

+ * + * @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); + } + /** * 将任务标记为失败并保留失败前执行进度。 * diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResult.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResult.java new file mode 100644 index 000000000..2b4f26b07 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResult.java @@ -0,0 +1,40 @@ +package com.bsd.pjcloud.common.asynctask.service; + +/** + * 通用异步任务成功结果。 + *

导入等无附件任务可返回 {@link #empty()},导出任务可返回下载地址和文件名称;结果只会在任务成功终态写入进度对象。

+ * + * @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); + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResultAction.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResultAction.java new file mode 100644 index 000000000..e4ebf978a --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskResultAction.java @@ -0,0 +1,17 @@ +package com.bsd.pjcloud.common.asynctask.service; + +/** + * 携带成功结果的通用异步任务业务动作。 + *

用于导出等需要在成功终态返回结果文件的任务;动作在后台事务中执行,抛出异常会回滚并标记任务失败。

+ */ +@FunctionalInterface +public interface AsyncTaskResultAction { + + /** + * 执行具体异步业务并返回成功结果。 + * + * @param reporter 逐项进度报告器,业务完成一个计数项目后调用一次 + * @return 任务成功终态需要返回调用端的可选结果文件信息 + */ + AsyncTaskResult execute(AsyncTaskProgressReporter reporter); +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskExecutorService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskExecutorService.java index eabc61d12..00e251e50 100644 --- a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskExecutorService.java +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskExecutorService.java @@ -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); diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskProgressService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskProgressService.java index ab73c5e8c..b25dcae6e 100644 --- a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskProgressService.java +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskProgressService.java @@ -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); } diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/vo/AsyncTaskProgressVO.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/vo/AsyncTaskProgressVO.java index 4fc128c41..37c8aa6c8 100644 --- a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/vo/AsyncTaskProgressVO.java +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/vo/AsyncTaskProgressVO.java @@ -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; } diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceSyncSnapshotDto.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceSyncSnapshotDto.java new file mode 100644 index 000000000..9e7a891e0 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceSyncSnapshotDto.java @@ -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; + +/** + * 终端部门人脸固定上界游标快照查询参数。 + * + *

首次请求不传 {@code snapshotMaxId},后续分页必须原样携带首次响应中的上界, + * 避免同步期间新增记录改变当前对账集合。

+ */ +@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; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncRecordVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncRecordVo.java new file mode 100644 index 000000000..a6d473d8a --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncRecordVo.java @@ -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; + +/** + * 终端人脸同步快照中的单条权威记录。 + * + *

记录只包含审核通过且位于目标部门树内的人脸;终端使用 {@code sampleId} 作为虹软搜索标识, + * 使用 {@code sourceUpdatedAt} 与图片地址共同判断是否需要重新提取特征。

+ */ +@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; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncSnapshotVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncSnapshotVo.java new file mode 100644 index 000000000..e3d966a03 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceSyncSnapshotVo.java @@ -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; + +/** + * 终端部门人脸同步的游标快照响应。 + * + *

首次请求生成固定的 {@code snapshotMaxId},后续分页必须原样携带;终端只有在全部页面成功返回并校验后, + * 才能据此删除不在目标集合中的本地孤儿记录。

+ */ +@Data +@Schema(description = "终端部门人脸同步游标快照") +public class SysUserFaceSyncSnapshotVo { + + /** + * 当前页面的人脸记录,按数据库主键升序排列。 + */ + @Schema(description = "当前页面记录") + private List records; + + /** + * 下一页起始游标;当前页为空时沿用请求游标,末页无需继续请求。 + */ + @Schema(description = "下一页起始游标") + private Long nextAfterId; + + /** + * 是否仍有位于固定快照上界内的后续记录。 + */ + @Schema(description = "是否还有下一页") + private Boolean hasMore; + + /** + * 首次请求时固定的人脸数据库主键上界,后续分页必须原样携带。 + */ + @Schema(description = "固定快照主键上界") + private Long snapshotMaxId; +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index 68070e18e..0db02629c 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -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 export(@RequestBody UserDTO userDTO) { - return userImportExportService.listUser(userDTO, null); + public R export(@RequestBody UserDTO userDTO) { + return R.ok(userExportService.submitExport(userDTO), "用户导出任务已提交"); } /** diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java index c6b290340..b6e22ff5d 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java @@ -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 adV3SyncSnapshot(@Valid @RequestBody SysUserFaceSyncSnapshotDto dto) { + return R.ok(sysUserFaceService.syncSnapshot(dto)); + } /* ************************************* start android start ************************************* */ @Operation(summary = "设置为全局人脸", description = "设置为全局人脸") diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/FaceChangeEvent.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/FaceChangeEvent.java new file mode 100644 index 000000000..3d3cd34dd --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/FaceChangeEvent.java @@ -0,0 +1,82 @@ +package com.bsd.pjcloud.admin.handler; + +import java.util.List; + +/** + * 人脸数据事务提交后的终端变更通知事件。 + * + *

事件只携带用户和目标部门标识,终端收到 MQ 后必须回查 V3 真源;旧、新部门同时出现时用于处理用户调动。

+ */ +public final class FaceChangeEvent { + + /** + * 发生人脸变化的用户主键。 + */ + private final Long userId; + + /** + * 需要收到通知的用户部门主键列表;部门调动时同时包含旧、新部门。 + */ + private final List 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 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 getTargetDeptIds() { + return targetDeptIds; + } + + /** + * 获取全局人脸状态变化值。 + * + * @return 全局人脸状态,普通 CRUD 时为空 + */ + public Boolean getGlobalFace() { + return globalFace; + } + + /** + * 获取事件发布时的租户主键。 + * + * @return 租户主键 + */ + public Long getTenantId() { + return tenantId; + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/FaceChangeEventListener.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/FaceChangeEventListener.java new file mode 100644 index 000000000..54bf84c10 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/FaceChangeEventListener.java @@ -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 通知监听器。 + * + *

监听器不传输人脸数据,只向目标硬件组发送用户变更通知;通知失败由每日部门快照最终修复。

+ */ +@Slf4j +@Component +public class FaceChangeEventListener { + + /** + * MQTT 服务远程接口,用于按用户部门解析硬件组并发布 FACE 通知。 + */ + private final RemoteMqttEventService remoteMqttEventService; + + /** + * 创建人脸变更监听器。 + * + * @param remoteMqttEventService MQTT 事件远程接口 + */ + public FaceChangeEventListener(RemoteMqttEventService remoteMqttEventService) { + this.remoteMqttEventService = remoteMqttEventService; + } + + /** + * 在本地事务成功提交后异步发布 FACE 通知。 + * + *

同一硬件组可能因旧、新部门相同收到重复通知,终端按用户合并并回查,因此这里仅对部门主键去重。

+ * + * @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 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()); + } + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java index 257abafd4..def07295c 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java @@ -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 List 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 deptIds, @Param("processStatus") String processStatus); + + /** + * 查询目标部门树和固定主键上界内的最大人脸主键,用于建立稳定快照边界。 + * + * @param deptIds 商户部门及其子部门主键 + * @param userId 可选用户主键,MQ 增量同步时使用 + * @param processStatus 审核通过状态编码 + * @return 快照最大主键;没有记录时返回 {@code 0} + */ + Long selectSyncSnapshotMaxId(@Param("deptIds") List deptIds, @Param("userId") Long userId, @Param("processStatus") String processStatus); + + /** + * 按数据库主键升序读取终端人脸快照的一页,多取一条用于判断是否存在下一页。 + * + * @param deptIds 商户部门及其子部门主键 + * @param userId 可选用户主键 + * @param afterId 排他起始游标 + * @param snapshotMaxId 固定快照主键上界 + * @param processStatus 审核通过状态编码 + * @param fetchSize 实际读取数量,通常为页面大小加一 + * @return 按人脸数据库主键严格升序排列的记录 + */ + List listSyncSnapshotRecords(@Param("deptIds") List deptIds, + @Param("userId") Long userId, + @Param("afterId") Long afterId, + @Param("snapshotMaxId") Long snapshotMaxId, + @Param("processStatus") String processStatus, + @Param("fetchSize") int fetchSize); /** * 按用户批量统计可用与审核中的人脸数量。 diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserExportService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserExportService.java new file mode 100644 index 000000000..0a9d58459 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserExportService.java @@ -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 导出服务。 + *

独立承接查询条件快照、身份资料组装、模板填充和结果文件保存,避免 CRUD 页面与导入编排承担导出细节。

+ */ +public interface SysUserExportService { + + /** + * 提交用户导出任务。 + * + * @param userDTO 用户列表当前筛选条件;用户 ID 集合为空时导出全部筛选结果 + * @return 已创建的异步任务初始进度,调用端据此轮询并下载结果文件 + */ + AsyncTaskProgressVO submitExport(UserDTO userDTO); +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java index 7b376eadc..e0868db94 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java @@ -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 { + /** + * 新增用户人脸并分配租户内唯一的 32 位虹软搜索编号。 + * + * @param sysUserFace 待新增的人脸记录 + * @return 新增记录主键 + */ Long create(SysUserFaceEntity sysUserFace); + /** + * 按固定主键上界游标读取本部门及子部门的审核通过人脸。 + * + * @param dto 商户部门、可选用户、排他游标、页面大小及固定快照上界 + * @return 当前页快照和下一页信息 + */ + SysUserFaceSyncSnapshotVo syncSnapshot(SysUserFaceSyncSnapshotDto dto); + /** * 当前用户为本人或有效被监护人提交人脸,并根据提交人角色决定是否进入审核流程。 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserImportExportService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserImportExportService.java index f6a1f0c36..c608d477e 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserImportExportService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserImportExportService.java @@ -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; /** - * 用户模块导入导出服务。 - *

统一承接后台用户、学生、学生家长、老师和人脸 Excel 导入以及用户导出,避免导入导出编排继续膨胀用户核心服务。

+ * 用户模块导入服务。 + *

统一承接后台用户、学生、学生家长、老师和人脸 Excel 导入,用户导出由独立服务隔离。

*/ public interface SysUserImportExportService { - /** - * 按查询条件和数据权限生成后台用户导出数据。 - * - * @param userDTO 用户筛选条件 - * @param ids 指定导出的用户 ID;为空时按筛选条件导出 - * @return 可由 Excel 响应转换器直接输出的用户列表 - */ - List listUser(UserDTO userDTO, Long[] ids); - /** * 导入后台用户基础信息;同步完成整份 Excel 校验,任一行错误均不创建任务,校验通过后异步统一写入。 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserExportServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserExportServiceImpl.java new file mode 100644 index 000000000..e5dd8ab70 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserExportServiceImpl.java @@ -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 导出服务实现。 + *

同步请求只冻结筛选条件并创建任务;异步线程查询用户后更新真实总数,再补齐身份资料、填充二级表头模板并保存到文件中心。

+ */ +@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; + + /** + * 提交用户导出任务。 + *

请求线程只复制筛选条件并提交任务,避免用户查询阻塞接口响应;异步线程恢复租户和用户上下文后执行带数据权限的查询。

+ * + * @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 users = sysUserMapper.selectVoListByScope(querySnapshot, null, DataScope.of()); + if (CollUtil.isEmpty(users)) { + throw new IllegalStateException("没有符合当前条件的用户"); + } + reporter.updateTotal(users.size(), "已查询 " + users.size() + " 位用户"); + sysUserIdentityService.fillUserIdentities(users); + List> 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 列表元素类型 + * @return 空值保持为空,非空值返回新的可变列表 + */ + private List copyList(List source) { + return source == null ? null : new ArrayList<>(source); + } + + /** + * 将用户及其身份扩展资料转换为模板填充行。 + * + * @param users 已按筛选条件查询并补齐身份资料的用户列表 + * @param reporter 异步进度报告器;最后一位用户在文件上传成功后再计数,避免文件未就绪时提前显示完成 + * @return 与模板占位符一一对应的用户导出行 + */ + private List> buildRows(List users, AsyncTaskProgressReporter reporter) { + List> 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 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> 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 对模板做一次内存标准化。 + *

可视化模板生成器可能省略可选的工作表范围元数据,而 EasyExcel 4.0.2 在结束模板填充时依赖该元数据; + * 先由 POI 读写可补齐标准结构,同时保留二级表头、样式和命名列表占位符。

+ * + * @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 流式模板填充支持的默认命名空间。 + *

只处理工作表 XML 标签,不改变单元格值、样式、合并区域或其他工作簿部件; + * 否则 EasyExcel 追加第二条及后续数据行时会把新增 {@code sheetData} 写到根节点之外,导致生成文件损坏。

+ * + * @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("文件服务会把 ISO-8859-1 字节还原为 UTF-8;异步任务没有浏览器请求头,需在内存文件中补齐同一编码边界,避免文件管理记录乱码。

+ * + * @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 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 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 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(",")); + } + +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java index 731d37971..a07095525 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java @@ -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 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该方法显式排除外部门全局人脸。调用方必须完整拉取所有页面后再做本地孤儿清理; + * 服务端仅审计当前部门树内实际生效的人脸编号;外部门、已删除和未审核历史记录不会阻断本机同步。

+ * + * @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 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 fetchedRecords = baseMapper.listSyncSnapshotRecords( + deptIds, userId, normalizedAfterId, normalizedSnapshotMaxId, + TaskCheckStatusEnum.RESULT.getCode(), normalizedLimit + 1); + boolean hasMore = fetchedRecords.size() > normalizedLimit; + List 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首次使用时在分布式锁内以数据库现有最大编号初始化;Redis 不可用时拒绝新增, + * 禁止退回全表计数,因为计数在并发新增和逻辑删除后都可能产生重复编号。

+ * + * @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); + } + + /** + * 只读审计当前租户全部历史人脸的虹软搜索编号是否重复。 + * + *

该审计仅用于 Redis 序列首次初始化,发现冲突时阻断新增但不自动修改任何历史数据。

+ */ + private void assertFaceAutoCodeUnique() { + Long duplicateCount = baseMapper.countDuplicateAutoCodes(); + if (duplicateCount != null && duplicateCount > 0) { + throw new RuntimeException("检测到重复的人脸编号,已停止新增,请先执行历史数据修复"); + } + } + + /** + * 审计当前终端部门树内实际生效的人脸编号是否冲突。 + * + *

同步只维护本部门及子部门,因此外部门、已删除和未审核记录即使存在历史重复编号, + * 也不应阻断当前终端;当前范围内的冲突仍必须停止下发,避免虹软相同搜索标识互相覆盖。

+ * + * @param deptIds 终端绑定部门及其有效子部门主键 + * @param processStatus 审核通过状态编码 + */ + private void assertSyncFaceAutoCodeUnique(List 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 list) { if (ObjectUtil.isEmpty(list)) { return; } - // 发送mq List faceList = baseMapper.selectList(new LambdaQueryWrapper().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= 3; } - // 发送mq - private void sendFaceMq(List 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 list = sysUserService.list(new LambdaQueryWrapper().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 targetDeptIds, Boolean globalFace) { + List 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()); } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java index 994a55901..8ca362e6f 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java @@ -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; /** - * 用户模块导入导出服务实现。 - *

本类只负责 Excel 数据校验、业务对象组装和导出转换,用户、关系及人脸落库仍由各自核心服务处理。 + * 用户模块导入服务实现。 + *

本类只负责 Excel 数据校验和导入业务对象组装,用户、关系及人脸落库仍由各自核心服务处理。 * 所有导入均先同步完成整表校验,再交给通用异步事务执行器统一写入,避免错误行导致部分数据已入库。

*/ @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 listUser(UserDTO userDTO, Long[] ids) { - List 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 行号汇总业务校验错误;整表无错误后才统一写入。 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index c951c08aa..d9ab1f762 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -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 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 impl return Boolean.TRUE; } + /** + * 用户部门发生变化时同时通知旧、新部门硬件组回查该用户的人脸。 + * + *

事件在当前用户更新事务中发布,只有事务提交后才会发送 MQ;旧部门据此删除本地人脸, + * 新部门据此新增人脸。重复或乱序消息由终端按用户回查 V3 真源实现幂等。

+ * + * @param databaseUser 更新前的数据库用户,用于取得旧部门 + * @param updatedUser 本次待更新用户,用于取得新部门;未提交部门字段时不发送通知 + */ + private void publishDepartmentFaceChange(SysUser databaseUser, SysUser updatedUser) { + if (updatedUser.getDeptId() == null || Objects.equals(databaseUser.getDeptId(), updatedUser.getDeptId())) { + return; + } + List targetDeptIds = Stream.of(databaseUser.getDeptId(), updatedUser.getDeptId()) + .filter(Objects::nonNull) + .distinct() + .toList(); + applicationEventPublisher.publishEvent(new FaceChangeEvent( + databaseUser.getUserId(), targetDeptIds, null, TenantContextHolder.getTenantId())); + } + /** * 查询上级部门的用户信息 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/user_export.xlsx b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/user_export.xlsx new file mode 100644 index 000000000..8d4753f42 Binary files /dev/null and b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/user_export.xlsx differ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml index c44dcbcac..5d7d00474 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml @@ -77,8 +77,82 @@ order by a.create_time desc , a.id desc - + select coalesce(max(cast(auto_code as unsigned)), 0) + from sys_user_face + where auto_code regexp '^[0-9]+$' + + + + + + + + +