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
+ *
+ * @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
-