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