Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-08-13 15:35:12 +08:00
commit 3989a4f95b
87 files changed
+5606 -892

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
# Redis 租户 Key 约定
通用异步任务的完整接入说明和实现位于 `pjcloud-common/pjcloud-common-async-task`。本文件记录整个后端工程都应遵循的 Redis 租户 Key 规则。
## 核心规则
项目 Redis 基础设施会根据当前租户上下文自动为 Redis Key 增加租户前缀。业务代码只负责定义业务命名空间和业务唯一标识,**不得再次手工拼接 `tenantId`**。
正确的业务 Key:
```text
async:task:progress:user:import:teacher:{userId}:{taskId}
```
错误的业务 Key:
```text
async:task:progress:user:import:teacher:{tenantId}:{userId}:{taskId}
```
错误示例会导致租户维度重复出现在最终 Redis Key 中,增加排查难度,并可能造成同一业务在不同调用方式下无法读取彼此写入的数据。
## 异步线程要求
“业务 Key 不拼接租户 ID”不代表异步线程不需要租户上下文。异步任务、定时任务等脱离 Web 请求线程执行的逻辑,在访问 Redis、数据库或其他租户资源前,仍必须按项目现有方式恢复 `TenantContextHolder`,并在任务结束后清理上下文。
租户上下文的职责是让基础设施自动完成租户隔离;业务 Key 的职责是描述具体业务。两者不得混用。
## 开发检查
- 新增 Redis Key 时只包含业务命名空间及必要的业务唯一标识。
- 禁止使用 `TenantContextHolder.getTenantId()`、请求头中的租户 ID 或用户对象中的租户 ID 手工拼接 Redis Key。
- 异步执行时确认租户上下文已恢复,并在 `finally` 中清理。
- Redis Key 变更前检查现有读写双方,避免造成历史缓存无法读取。
+10
View File
@@ -0,0 +1,10 @@
-- 老师身份扩展信息与学生扩展表分开保存,当前仅记录老师学号。
CREATE TABLE `sys_user_teacher` (
`user_id` bigint NOT NULL COMMENT '系统用户ID',
`teacher_no` varchar(60) NOT NULL COMMENT '老师学号',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
PRIMARY KEY (`user_id`),
UNIQUE KEY `uk_sys_user_teacher_tenant_no` (`tenant_id`, `teacher_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='老师用户扩展信息表';
@@ -0,0 +1,58 @@
# 通用异步任务模块
`pjcloud-common-async-task` 提供可由各业务模块复用的异步事务执行、Redis 进度保存和统一进度查询接口。
## 接入方式
业务模块增加依赖后,Spring Boot 会自动装配 `AsyncTaskExecutorService`、`AsyncTaskProgressService` 和查询接口:
```xml
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-async-task</artifactId>
</dependency>
```
提交任务时由业务方指定稳定的 Redis 业务命名空间、展示名称和总数。接口同步返回初始进度,业务动作进入公共模块的受控专用线程池并在单个事务中执行:
```java
AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(
"user:import:teacher", "老师用户导入", users.size(),
reporter -> users.forEach(user -> {
importUser(user);
reporter.advance("正在导入:" + user.getName());
}));
```
调用端统一轮询:
```text
GET /async-task/progress?redisKey={业务命名空间}&taskId={任务ID}
```
## Redis 租户 Key 约定
项目 Redis 基础设施会根据当前租户上下文自动为 Redis Key 增加租户前缀。业务代码只负责定义业务命名空间和业务唯一标识,**不需要也不得再次手工拼接 `tenantId`**。
正确的业务 Key:
```text
async:task:progress:user:import:teacher:{userId}:{taskId}
```
错误的业务 Key:
```text
async:task:progress:user:import:teacher:{tenantId}:{userId}:{taskId}
```
错误示例会导致租户维度重复出现在最终 Redis Key 中,并可能造成同一业务在不同调用方式下无法读取彼此写入的数据。
“业务 Key 不拼接租户 ID”不代表异步线程不需要租户上下文。异步任务在访问 Redis、数据库或其他租户资源前,仍需恢复 `TenantContextHolder`,并在任务结束后清理上下文。本模块执行器已经统一处理这一步,业务动作不应重复设置租户上下文。
## 事务与进度语义
- 整个业务动作由一个 Spring 事务包裹,业务抛出异常时整体回滚。
- `completed` 表示当前执行位置;失败并回滚时不代表最终成功写入数量。
- Redis 进度默认保留 24 小时,供页面刷新或短时重连后继续查询。
- 任务进度按当前登录用户隔离,调用端不能查询其他用户创建的任务。
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common</artifactId>
<version>5.7.0</version>
</parent>
<artifactId>pjcloud-common-async-task</artifactId>
<packaging>jar</packaging>
<description>pjcloud 通用异步任务与 Redis 进度管理</description>
<dependencies>
<!--统一响应、线程池及基础类型-->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-core</artifactId>
</dependency>
<!--Redis 模板与异步线程所需的租户上下文-->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-data</artifactId>
</dependency>
<!--登录用户、安全上下文及非 Web Feign Token 传递-->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-security</artifactId>
</dependency>
<!--通用异步任务事务边界-->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<!--通用进度查询接口-->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,95 @@
package com.bsd.pjcloud.common.asynctask.config;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
import com.bsd.pjcloud.common.asynctask.service.impl.DefaultAsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.impl.DefaultAsyncTaskProgressService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.core.task.TaskExecutor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.transaction.support.TransactionTemplate;
/**
* 通用异步任务自动配置。
* <p>业务模块引入本公共模块后,在 Redis 和事务模板均可用时自动装配专用线程池、进度服务与事务执行器。</p>
*/
@AutoConfiguration
@AutoConfigureAfter(name = {
"com.bsd.pjcloud.common.data.cache.RedisTemplateConfiguration",
"org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration"
})
@ConditionalOnClass({RedisTemplate.class, TransactionTemplate.class})
@ConditionalOnBean({RedisTemplate.class, TransactionTemplate.class})
public class AsyncTaskAutoConfiguration {
/** 公共异步任务专用工作线程池的 Bean 名称,业务模块可按该名称替换默认线程池。 */
public static final String ASYNC_TASK_WORKER_EXECUTOR_BEAN_NAME = "commonAsyncTaskWorkerExecutor";
/** 默认核心线程数,兼顾常规导入导出并发与业务服务资源占用。 */
private static final int DEFAULT_CORE_POOL_SIZE = 2;
/** 默认最大线程数,限制大批量异步任务同时占用数据库连接和远程调用资源。 */
private static final int DEFAULT_MAX_POOL_SIZE = 8;
/** 默认等待队列容量,超过线程与队列总容量后由执行器拒绝并把任务标记为提交失败。 */
private static final int DEFAULT_QUEUE_CAPACITY = 200;
/** 服务关闭时等待正在执行任务结束的最长秒数,超过后由容器继续关闭流程。 */
private static final int DEFAULT_SHUTDOWN_AWAIT_SECONDS = 30;
/** 公共异步任务线程名称前缀,便于线程转储和日志定位。 */
private static final String DEFAULT_THREAD_NAME_PREFIX = "common-async-task-";
/**
* 装配公共异步任务专用线程池,避免 Spring Boot 默认线程池因其他业务 Executor 存在而不创建。
*
* @return 已完成容量与优雅停机配置的工作线程池
*/
@Bean(name = ASYNC_TASK_WORKER_EXECUTOR_BEAN_NAME)
@ConditionalOnMissingBean(name = ASYNC_TASK_WORKER_EXECUTOR_BEAN_NAME)
public TaskExecutor commonAsyncTaskWorkerExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(DEFAULT_CORE_POOL_SIZE);
executor.setMaxPoolSize(DEFAULT_MAX_POOL_SIZE);
executor.setQueueCapacity(DEFAULT_QUEUE_CAPACITY);
executor.setThreadNamePrefix(DEFAULT_THREAD_NAME_PREFIX);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(DEFAULT_SHUTDOWN_AWAIT_SECONDS);
return executor;
}
/**
* 装配默认 Redis 进度服务,业务模块可通过声明同类型 Bean 完整替换默认实现。
*
* @param redisTemplate 项目统一配置的 Redis 操作模板
* @return 通用异步任务 Redis 进度服务
*/
@Bean
@ConditionalOnMissingBean
public AsyncTaskProgressService asyncTaskProgressService(RedisTemplate<String, Object> redisTemplate) {
return new DefaultAsyncTaskProgressService(redisTemplate);
}
/**
* 装配默认异步事务执行器,显式使用公共异步任务专用线程池以隔离其他业务线程池。
*
* @param asyncTaskWorkerExecutor 公共异步任务专用工作线程池
* @param asyncTaskProgressService Redis 任务进度服务
* @param transactionTemplate Spring 编程式事务模板
* @return 通用异步事务任务执行器
*/
@Bean
@ConditionalOnMissingBean
public AsyncTaskExecutorService asyncTaskExecutorService(
@Qualifier(ASYNC_TASK_WORKER_EXECUTOR_BEAN_NAME) TaskExecutor asyncTaskWorkerExecutor,
AsyncTaskProgressService asyncTaskProgressService, TransactionTemplate transactionTemplate) {
return new DefaultAsyncTaskExecutorService(asyncTaskWorkerExecutor, asyncTaskProgressService, transactionTemplate);
}
}
@@ -0,0 +1,33 @@
package com.bsd.pjcloud.common.asynctask.config;
import com.bsd.pjcloud.common.asynctask.controller.AsyncTaskProgressController;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
/**
* 通用异步任务 Servlet Web 接口自动配置。
* <p>仅在 Web 应用已装配 Redis 进度服务时注册统一查询接口,非 Web 任务消费者不会引入无用端点。</p>
*/
@AutoConfiguration
@AutoConfigureAfter(AsyncTaskAutoConfiguration.class)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnBean(AsyncTaskProgressService.class)
public class AsyncTaskWebAutoConfiguration {
/**
* 装配统一进度查询接口,所有业务模块共享 {@code /async-task/progress} 契约。
*
* @param asyncTaskProgressService Redis 任务进度服务
* @return 通用异步任务进度控制器
*/
@Bean
@ConditionalOnMissingBean
public AsyncTaskProgressController asyncTaskProgressController(AsyncTaskProgressService asyncTaskProgressService) {
return new AsyncTaskProgressController(asyncTaskProgressService);
}
}
@@ -0,0 +1,40 @@
package com.bsd.pjcloud.common.asynctask.constant;
import lombok.Getter;
/**
* 通用异步任务进度状态协议。
* <p>状态编码会写入 Redis 并返回调用端,已有编码不得随意变更。</p>
*/
@Getter
public enum AsyncTaskStatusEnum {
/** 等待后台线程开始执行,Redis 与接口编码为 {@code PENDING}。 */
PENDING("PENDING", "等待执行"),
/** 后台线程正在处理任务,Redis 与接口编码为 {@code RUNNING}。 */
RUNNING("RUNNING", "执行中"),
/** 任务已完整执行成功,Redis 与接口编码为 {@code SUCCESS}。 */
SUCCESS("SUCCESS", "执行成功"),
/** 任务执行异常,Redis 与接口编码为 {@code FAILED}。 */
FAILED("FAILED", "执行失败");
/** Redis 与调用端接口共同使用的固定状态编码。 */
private final String status;
/** 当前状态未提供自定义消息时使用的中文说明。 */
private final String description;
/**
* 创建异步任务状态定义。
*
* @param status Redis 与调用端接口共同使用的固定状态编码
* @param description 当前状态的默认中文说明
*/
AsyncTaskStatusEnum(String status, String description) {
this.status = status;
this.description = description;
}
}
@@ -0,0 +1,50 @@
package com.bsd.pjcloud.common.asynctask.controller;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 通用异步任务进度查询接口。
* <p>任务业务 Key 按登录用户隔离,租户前缀由项目 Redis 基础设施统一处理,只能查询本人创建且尚未过期的任务。</p>
*/
@RestController
@RequestMapping("/async-task")
@Tag(description = "async-task", name = "通用异步任务进度")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class AsyncTaskProgressController {
/** 通用 Redis 任务进度服务。 */
private final AsyncTaskProgressService asyncTaskProgressService;
/**
* 创建异步任务进度查询接口。
*
* @param asyncTaskProgressService 通用 Redis 任务进度服务
*/
public AsyncTaskProgressController(AsyncTaskProgressService asyncTaskProgressService) {
this.asyncTaskProgressService = asyncTaskProgressService;
}
/**
* 查询当前用户的异步任务进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 上传或任务提交接口返回的不透明任务 ID
* @return 当前任务进度;任务不存在或已过期时返回失败响应
*/
@GetMapping("/progress")
@Operation(summary = "查询异步任务进度")
public R<AsyncTaskProgressVO> progress(@RequestParam String redisKey, @RequestParam String taskId) {
AsyncTaskProgressVO progress = asyncTaskProgressService.get(redisKey, taskId);
return progress == null ? R.failed("异步任务不存在或已过期") : R.ok(progress);
}
}
@@ -0,0 +1,16 @@
package com.bsd.pjcloud.common.asynctask.service;
/**
* 通用异步任务业务动作。
* <p>动作在后台事务中执行,抛出异常会回滚整个动作并把 Redis 任务标记为失败。</p>
*/
@FunctionalInterface
public interface AsyncTaskAction {
/**
* 执行具体异步业务。
*
* @param reporter 逐项进度报告器,业务完成一个计数项目后调用一次
*/
void execute(AsyncTaskProgressReporter reporter);
}
@@ -0,0 +1,21 @@
package com.bsd.pjcloud.common.asynctask.service;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
/**
* 通用异步事务任务提交服务。
* <p>同步创建 Redis 任务后,把业务动作交给后台线程执行,并透传当前租户和登录用户上下文。</p>
*/
public interface AsyncTaskExecutorService {
/**
* 创建并提交一个异步事务任务。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskName 任务展示名称
* @param total 需要处理的总项目数
* @param action 后台事务中执行的业务动作
* @return 等待执行的初始任务进度,调用方可立即把任务 ID 返回客户端
*/
AsyncTaskProgressVO submit(String redisKey, String taskName, int total, AsyncTaskAction action);
}
@@ -0,0 +1,16 @@
package com.bsd.pjcloud.common.asynctask.service;
/**
* 异步任务逐项进度报告器。
* <p>业务每完成一个可计数项目后调用一次,由通用执行器递增 Redis 进度。</p>
*/
@FunctionalInterface
public interface AsyncTaskProgressReporter {
/**
* 报告一个项目已处理完成。
*
* @param message 当前项目完成后的进度说明;为空时使用通用说明
*/
void advance(String message);
}
@@ -0,0 +1,65 @@
package com.bsd.pjcloud.common.asynctask.service;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
/**
* 通用异步任务 Redis 进度服务。
* <p>业务 Key 按当前用户隔离,租户前缀由项目 Redis 基础设施统一处理;调用方只提供业务命名空间和任务 ID。</p>
*/
public interface AsyncTaskProgressService {
/**
* 创建等待执行的异步任务并写入 Redis。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskName 任务展示名称
* @param total 需要处理的总项目数,必须大于零
* @return 已写入 Redis 的初始任务进度
*/
AsyncTaskProgressVO create(String redisKey, String taskName, int total);
/**
* 将任务标记为执行中。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
*/
void start(String redisKey, String taskId);
/**
* 更新任务执行进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param completed 当前已处理项目数
* @param message 当前进度说明
*/
void update(String redisKey, String taskId, int completed, String message);
/**
* 将任务标记为成功并把进度置为 100%。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param message 最终成功说明
*/
void success(String redisKey, String taskId, String message);
/**
* 将任务标记为失败并保留失败前执行进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param message 对调用端安全的失败说明
*/
void fail(String redisKey, String taskId, String message);
/**
* 查询当前用户创建的异步任务进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @return 任务进度;任务不存在、已过期或不属于当前用户时返回 {@code null}
*/
AsyncTaskProgressVO get(String redisKey, String taskId);
}
@@ -0,0 +1,158 @@
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.AsyncTaskProgressService;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.security.util.NonWebTokenContextHolder;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.task.TaskExecutor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 通用异步事务任务提交服务默认实现。
* <p>实现会恢复提交线程的租户、安全和 Feign Token 上下文,并以单个 Spring 事务执行完整业务动作。</p>
*/
@Slf4j
public class DefaultAsyncTaskExecutorService implements AsyncTaskExecutorService {
/** 通用异步任务日志标签,用于统一检索任务提交和执行异常。 */
private static final String LOG_TAG = "通用异步任务";
/** 后台事务失败时返回调用端的固定说明,避免暴露 SQL 或远程调用等内部异常细节。 */
private static final String TASK_FAILED_MESSAGE = "任务执行失败,本批数据已回滚";
/** 线程池拒绝任务时返回调用端的固定说明,提示稍后重新提交。 */
private static final String TASK_REJECTED_MESSAGE = "任务提交失败,请稍后重试";
/** 公共异步任务工作线程池,用于限制后台任务并发并与业务专用线程池隔离。 */
private final TaskExecutor asyncTaskWorkerExecutor;
/** Redis 任务进度服务,用于初始化、更新和查询任务状态。 */
private final AsyncTaskProgressService asyncTaskProgressService;
/** Spring 编程式事务模板,保证整个异步业务动作成功或整体回滚。 */
private final TransactionTemplate transactionTemplate;
/**
* 创建通用异步任务执行器。
*
* @param asyncTaskWorkerExecutor 公共异步任务专用工作线程池
* @param asyncTaskProgressService Redis 任务进度服务
* @param transactionTemplate Spring 编程式事务模板
*/
public DefaultAsyncTaskExecutorService(TaskExecutor asyncTaskWorkerExecutor,
AsyncTaskProgressService asyncTaskProgressService, TransactionTemplate transactionTemplate) {
this.asyncTaskWorkerExecutor = asyncTaskWorkerExecutor;
this.asyncTaskProgressService = asyncTaskProgressService;
this.transactionTemplate = transactionTemplate;
}
/**
* 创建并提交一个异步事务任务。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskName 任务展示名称
* @param total 需要处理的总项目数
* @param action 后台事务中执行的业务动作
* @return 等待执行的初始任务进度
*/
@Override
public AsyncTaskProgressVO submit(String redisKey, String taskName, int total, AsyncTaskAction action) {
AsyncTaskProgressVO progress = asyncTaskProgressService.create(redisKey, taskName, total);
Long tenantId = TenantContextHolder.getTenantId();
Authentication authentication = SecurityUtils.getAuthentication();
String token = SecurityUtils.getToken();
SecurityContextHolderStrategy strategy = SecurityContextHolder.getContextHolderStrategy();
SecurityContext securityContext = strategy.createEmptyContext();
securityContext.setAuthentication(authentication);
try {
asyncTaskWorkerExecutor.execute(() -> runTask(progress, tenantId, token, strategy, securityContext, action));
}
catch (RuntimeException exception) {
log.error("|{}|任务提交线程池失败,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
markFailedSafely(progress, TASK_REJECTED_MESSAGE);
throw exception;
}
return progress;
}
/**
* 在恢复租户、安全和 Feign Token 上下文后执行异步事务。
*
* @param progress 初始任务进度
* @param tenantId 请求提交时的租户 ID,仅用于恢复基础设施上下文,不参与业务 Redis Key 拼接
* @param token 请求提交时的 OAuth2 Token,可为空
* @param strategy 当前 Spring Security 上下文策略
* @param securityContext 请求提交时复制出的安全上下文
* @param action 后台事务中执行的业务动作
*/
private void runTask(AsyncTaskProgressVO progress, Long tenantId, String token, SecurityContextHolderStrategy strategy,
SecurityContext securityContext, AsyncTaskAction action) {
try {
TenantContextHolder.setTenantId(tenantId);
strategy.setContext(securityContext);
NonWebTokenContextHolder.setToken(token);
asyncTaskProgressService.start(progress.getRedisKey(), progress.getTaskId());
AtomicInteger completed = new AtomicInteger();
try {
transactionTemplate.executeWithoutResult(status -> action.execute(message -> {
int current = completed.incrementAndGet();
asyncTaskProgressService.update(progress.getRedisKey(), progress.getTaskId(), current, message);
}));
}
catch (Exception exception) {
log.error("|{}|任务事务执行失败,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
markFailedSafely(progress, TASK_FAILED_MESSAGE);
return;
}
markSuccessSafely(progress);
}
catch (Exception exception) {
log.error("|{}|任务启动失败,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
markFailedSafely(progress, TASK_FAILED_MESSAGE);
}
finally {
NonWebTokenContextHolder.clear();
strategy.clearContext();
TenantContextHolder.clear();
}
}
/**
* 尝试把任务写为失败状态;Redis 异常只记录日志,不能覆盖原始业务异常或阻断线程上下文清理。
*
* @param progress 待更新的任务标识和业务命名空间
* @param message 对调用端安全的失败说明
*/
private void markFailedSafely(AsyncTaskProgressVO progress, String message) {
try {
asyncTaskProgressService.fail(progress.getRedisKey(), progress.getTaskId(), message);
}
catch (Exception exception) {
log.error("|{}|写入失败进度异常,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
}
}
/**
* 尝试把已提交事务的任务写为成功状态;Redis 写入失败只记录,不能把已经提交的数据误报为已回滚。
*
* @param progress 已完成数据库事务的任务标识和业务命名空间
*/
private void markSuccessSafely(AsyncTaskProgressVO progress) {
try {
asyncTaskProgressService.success(progress.getRedisKey(), progress.getTaskId(), "任务执行成功");
}
catch (Exception exception) {
log.error("|{}|写入成功进度异常,taskId:{},taskName:{}", LOG_TAG, progress.getTaskId(), progress.getTaskName(), exception);
}
}
}
@@ -0,0 +1,220 @@
package com.bsd.pjcloud.common.asynctask.service.impl;
import com.bsd.pjcloud.common.asynctask.constant.AsyncTaskStatusEnum;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressService;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* 通用异步任务 Redis 进度服务默认实现。
* <p>该实现只拼接业务命名空间、登录用户和任务 ID,租户维度由项目 Redis 基础设施根据当前上下文统一处理。</p>
*/
public class DefaultAsyncTaskProgressService implements AsyncTaskProgressService {
/** 所有通用异步任务 Redis Key 的固定业务命名空间。 */
private static final String REDIS_KEY_PREFIX = "async:task:progress";
/** 任务状态的 Redis 保留时间,供页面刷新或短时重连后继续查询。 */
private static final Duration TASK_TTL = Duration.ofHours(24);
/** 业务 Redis Key 允许的字符和长度,防止调用方构造不可控的 Redis 路径。 */
private static final Pattern REDIS_KEY_PATTERN = Pattern.compile("^[a-zA-Z0-9:_-]{1,80}$");
/** 不透明任务 ID 的固定格式,与本服务生成的 32 位 UUID 一致。 */
private static final Pattern TASK_ID_PATTERN = Pattern.compile("^[a-fA-F0-9]{32}$");
/** Redis 操作模板,使用项目统一 JSON 序列化配置保存进度对象。 */
private final RedisTemplate<String, Object> redisTemplate;
/**
* 创建通用 Redis 进度服务。
*
* @param redisTemplate 项目统一配置的 Redis 操作模板
*/
public DefaultAsyncTaskProgressService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 创建等待执行的异步任务并写入 Redis。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskName 任务展示名称
* @param total 需要处理的总项目数,必须大于零
* @return 已写入 Redis 的初始任务进度
*/
@Override
public AsyncTaskProgressVO create(String redisKey, String taskName, int total) {
validateRedisKey(redisKey);
if (total <= 0) {
throw new IllegalArgumentException("异步任务总数必须大于0");
}
AsyncTaskProgressVO progress = new AsyncTaskProgressVO();
progress.setTaskId(UUID.randomUUID().toString().replace("-", ""));
progress.setRedisKey(redisKey);
progress.setTaskName(taskName);
progress.setStatus(AsyncTaskStatusEnum.PENDING.getStatus());
progress.setTotal(total);
progress.setCompleted(0);
progress.setPercentage(0);
progress.setMessage(AsyncTaskStatusEnum.PENDING.getDescription());
progress.setFinished(false);
progress.setSuccess(false);
save(progress);
return progress;
}
/**
* 将任务标记为执行中。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
*/
@Override
public void start(String redisKey, String taskId) {
AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
progress.setStatus(AsyncTaskStatusEnum.RUNNING.getStatus());
progress.setMessage(AsyncTaskStatusEnum.RUNNING.getDescription());
save(progress);
}
/**
* 更新任务执行进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param completed 当前已处理项目数
* @param message 当前进度说明
*/
@Override
public void update(String redisKey, String taskId, int completed, String message) {
AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
int safeCompleted = Math.max(0, Math.min(completed, progress.getTotal()));
progress.setStatus(AsyncTaskStatusEnum.RUNNING.getStatus());
progress.setCompleted(safeCompleted);
progress.setPercentage(calculatePercentage(safeCompleted, progress.getTotal()));
progress.setMessage(message == null || message.isBlank() ? "正在处理" : message);
save(progress);
}
/**
* 将任务标记为成功并把进度置为 100%。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param message 最终成功说明
*/
@Override
public void success(String redisKey, String taskId, String message) {
AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
progress.setStatus(AsyncTaskStatusEnum.SUCCESS.getStatus());
progress.setCompleted(progress.getTotal());
progress.setPercentage(100);
progress.setMessage(message == null || message.isBlank() ? AsyncTaskStatusEnum.SUCCESS.getDescription() : message);
progress.setFinished(true);
progress.setSuccess(true);
save(progress);
}
/**
* 将任务标记为失败并保留失败前执行进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @param message 对调用端安全的失败说明
*/
@Override
public void fail(String redisKey, String taskId, String message) {
AsyncTaskProgressVO progress = requireProgress(redisKey, taskId);
progress.setStatus(AsyncTaskStatusEnum.FAILED.getStatus());
progress.setMessage(message == null || message.isBlank() ? AsyncTaskStatusEnum.FAILED.getDescription() : message);
progress.setFinished(true);
progress.setSuccess(false);
save(progress);
}
/**
* 查询当前用户创建的异步任务进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @return 任务进度;任务不存在、已过期或不属于当前用户时返回 {@code null}
*/
@Override
public AsyncTaskProgressVO get(String redisKey, String taskId) {
validateRedisKey(redisKey);
Object value = redisTemplate.opsForValue().get(buildRedisKey(redisKey, taskId));
return value instanceof AsyncTaskProgressVO progress ? progress : null;
}
/**
* 获取必须存在的任务进度。
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @return 当前任务进度
*/
private AsyncTaskProgressVO requireProgress(String redisKey, String taskId) {
AsyncTaskProgressVO progress = get(redisKey, taskId);
if (progress == null) {
throw new IllegalStateException("异步任务不存在或已过期");
}
return progress;
}
/**
* 保存任务进度并刷新统一过期时间。
*
* @param progress 待保存的完整进度对象
*/
private void save(AsyncTaskProgressVO progress) {
redisTemplate.opsForValue().set(buildRedisKey(progress.getRedisKey(), progress.getTaskId()), progress, TASK_TTL);
}
/**
* 构建按当前用户隔离的业务 Redis Key。
* <p>租户前缀由项目统一 Redis 基础设施自动处理,此处不得重复拼接租户 ID。</p>
*
* @param redisKey 业务指定的 Redis Key 命名空间
* @param taskId 不透明任务 ID
* @return 仅服务端使用的业务 Redis Key,结构为固定前缀、业务命名空间、用户 ID 和任务 ID
*/
private String buildRedisKey(String redisKey, String taskId) {
PjcloudUser currentUser = SecurityUtils.getUser();
if (currentUser == null || currentUser.getId() == null) {
throw new IllegalStateException("缺少异步任务用户上下文");
}
if (taskId == null || !TASK_ID_PATTERN.matcher(taskId).matches()) {
throw new IllegalArgumentException("异步任务ID格式不正确");
}
return String.join(":", REDIS_KEY_PREFIX, redisKey, String.valueOf(currentUser.getId()), taskId);
}
/**
* 校验业务 Redis Key 命名空间,限制长度与字符范围。
*
* @param redisKey 业务指定的 Redis Key 命名空间
*/
private void validateRedisKey(String redisKey) {
if (redisKey == null || !REDIS_KEY_PATTERN.matcher(redisKey).matches()) {
throw new IllegalArgumentException("异步任务Redis Key格式不正确");
}
}
/**
* 计算取整后的完成百分比。
*
* @param completed 已完成项目数
* @param total 总项目数
* @return 0 至 100 的整数百分比
*/
private int calculatePercentage(int completed, int total) {
return total <= 0 ? 0 : Math.min(100, completed * 100 / total);
}
}
@@ -0,0 +1,58 @@
package com.bsd.pjcloud.common.asynctask.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 通用异步任务进度展示对象。
* <p>对象存入 Redis 并通过通用进度接口返回,可用于导入、导出等后台任务。</p>
*/
@Data
@Schema(description = "通用异步任务进度")
public class AsyncTaskProgressVO implements Serializable {
/** 序列化版本号,用于 Redis 中任务状态的兼容反序列化。 */
private static final long serialVersionUID = 1L;
/** 调用端轮询使用的不透明任务 ID,不包含租户或用户信息。 */
@Schema(description = "任务ID")
private String taskId;
/** 业务指定的 Redis Key 命名空间,用于区分不同异步任务场景。 */
@Schema(description = "Redis Key命名空间")
private String redisKey;
/** 任务展示名称,例如后台用户导入。 */
@Schema(description = "任务名称")
private String taskName;
/** 固定任务状态编码:PENDING、RUNNING、SUCCESS、FAILED。 */
@Schema(description = "任务状态")
private String status;
/** 任务需要处理的总项目数。 */
@Schema(description = "总项目数")
private Integer total;
/** 当前执行到的项目数;任务整体失败时仅表示回滚或失败前的执行位置,不代表最终成功写入数量。 */
@Schema(description = "已处理项目数")
private Integer completed;
/** 任务完成百分比,取值范围为 0 至 100。 */
@Schema(description = "完成百分比")
private Integer percentage;
/** 当前进度或最终结果说明。 */
@Schema(description = "任务消息")
private String message;
/** 是否已进入成功或失败终态,供调用端决定是否停止轮询。 */
@Schema(description = "是否完成")
private Boolean finished;
/** 终态是否成功;任务未结束时固定为 {@code false}。 */
@Schema(description = "是否成功")
private Boolean success;
}
@@ -0,0 +1,2 @@
com.bsd.pjcloud.common.asynctask.config.AsyncTaskAutoConfiguration
com.bsd.pjcloud.common.asynctask.config.AsyncTaskWebAutoConfiguration
@@ -62,6 +62,11 @@
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-async-task</artifactId>
<version>${pjcloud.version}</version>
</dependency>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-core</artifactId>
@@ -35,10 +35,24 @@ public interface CommonRoleConstant {
// 智能柜管理员
String HARD_DOOR_ADMIN_USER = "HARD_DOOR_ADMIN";
/** 线上食堂商户(移动端:商品/商家/报餐统计) */
/**
* 线上食堂商户(移动端:商品/商家/报餐统计)
*/
String ONLINE_CANTEEN_MCH = "ONLINE_CANTEEN_MCH";
/** 食堂骑手(移动端配送) */
/**
* 食堂骑手(移动端配送)
*/
String CANTEEN_RIDER = "CANTEEN_RIDER";
/**
* 添加用户需要审批
*/
String ADD_USER_NEED_CEHCK = "ADD_USER_NEED_CEHCK";
/**
* 添加用户人脸需要审批
*/
String ADD_FACE_NEED_CEHCK = "ADD_FACE_NEED_CEHCK";
}
+1
View File
@@ -33,6 +33,7 @@
<description>pjcloud 公共聚合模块</description>
<modules>
<module>pjcloud-common-async-task</module>
<module>pjcloud-common-audit</module>
<module>pjcloud-common-bom</module>
<module>pjcloud-common-core</module>
@@ -0,0 +1,62 @@
package com.bsd.pjcloud.hard.api.dto.order.app;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* App 退款申请参数。
*
* <p>请求中未提交 {@code carList}、反序列化结果为 {@code null} 时,仅用于兼容从未发生部分退款的旧客户端整单申请;
* 新客户端必须显式提交非空商品列表,空数组不会按旧版整单退款处理。</p>
*/
@Data
@Schema(description = "App 退款申请参数")
public class HardAppRefundApplyDto {
/**
* 申请退款的订单 ID;服务端会再次校验当前登录用户是否为付款人。
*/
@NotNull(message = "订单ID不能为空")
@Schema(description = "订单ID")
private Long orderId;
/**
* 售后方式协议编码;当前 App 仅提交仅退款编码 {@code 10}。
*/
@NotBlank(message = "售后方式不能为空")
@Schema(description = "售后方式")
private String way;
/**
* 用户选择的申请原因文案,持久化到售后单快照。
*/
@NotBlank(message = "申请原因不能为空")
@Schema(description = "申请原因")
private String applyReason;
/**
* 用户填写的问题描述,便于商户审核本次所选商品。
*/
@NotBlank(message = "相关描述不能为空")
@Schema(description = "相关描述")
private String applyDescription;
/**
* 逗号分隔的凭证图片地址;为空表示用户未上传凭证。
*/
@Schema(description = "凭证图片地址,多个地址以逗号分隔")
private String applyPicUrls;
/**
* 本次选择的退款商品及份数;字段为 {@code null} 时才进入旧客户端整单兼容逻辑,显式空数组表示未选择商品;
* 服务端忽略客户端额外提交的价格和名称字段,列表元素不允许为空。
*/
@Valid
@Schema(description = "本次退款商品")
private List<@NotNull(message = "退款商品不能为空") HardAppRefundItemDto> carList;
}
@@ -0,0 +1,31 @@
package com.bsd.pjcloud.hard.api.dto.order.app;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* App 逐商品退款明细。
*
* <p>客户端只提交商品标识和整数份数,商品名称、价格及退款金额均由服务端订单快照确定。</p>
*/
@Data
@Schema(description = "App 逐商品退款明细")
public class HardAppRefundItemDto {
/**
* 原订单商品 ID,对应 {@code hard_car.product_id};长整型在客户端按字符串传输。
*/
@NotNull(message = "退款商品ID不能为空")
@Schema(description = "原订单商品ID")
private Long productId;
/**
* 本次申请退款的整数份数;称重商品不通过该字段申请在线退款。
*/
@NotNull(message = "退款商品数量不能为空")
@Min(value = 1, message = "退款商品数量必须大于零")
@Schema(description = "本次退款份数")
private Integer productCount;
}
@@ -39,6 +39,16 @@ public interface RemoteHardOrderService {
@PostMapping("/hard-order/update-shop-status")
R<Boolean> updateShopStatus(@RequestBody HardUpdateShopStatusDto dto);
/**
* 第三方退款成功后触发硬件订单按全部售后单重新聚合退款金额和状态。
*
* @param orderId 原硬件订单 ID
* @return 聚合刷新结果
*/
@NoToken
@PostMapping("/hard-after-sale/inner/refresh-order-refund-state")
R<Boolean> refreshOrderRefundState(@RequestParam Long orderId);
/**
* 批量查询用户今天及以后的待核销预约订单数量。
*
@@ -0,0 +1,93 @@
package com.bsd.pjcloud.hard.api.vo.order.app;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* App 退款申请页详情,聚合订单实付、售后占用和逐商品可退额度。
*/
@Data
@Schema(description = "App 退款申请页详情")
public class HardAppRefundApplyVo {
/**
* 订单 ID;长整型响应统一序列化为字符串。
*/
@Schema(description = "订单ID")
private Long orderId;
/**
* 原订单实付金额,单位为分,是全部售后累计退款的绝对上限。
*/
@Schema(description = "订单实付金额,单位为分")
private Integer orderPayPrice;
/**
* 已完成售后单的累计退款金额,单位为分。
*/
@Schema(description = "累计已退款金额,单位为分")
private Integer completedRefundPrice;
/**
* 待审核售后单占用金额,单位为分。
*/
@Schema(description = "申请中占用金额,单位为分")
private Integer pendingRefundPrice;
/**
* 扣除已完成和待审核售后后的剩余可退金额,单位为分。
*/
@Schema(description = "剩余可退金额,单位为分")
private Integer remainingRefundPrice;
/**
* 当前订单是否存在待审核申请;存在时禁止再次提交。
*/
@Schema(description = "是否存在待审核申请")
private Boolean hasPending;
/**
* 当前是否具备 App 在线申请条件,需同时满足无待审核、仍有金额和普通商品份数。
*/
@Schema(description = "是否可以继续在线申请")
private Boolean canApply;
/**
* 最新售后单 ID;没有历史申请时为空。
*/
@Schema(description = "最新售后单ID")
private Long latestAfterSaleId;
/**
* 最新售后单详细状态编码;没有历史申请时为空。
*/
@Schema(description = "最新售后状态")
private String latestAfterSaleStatus;
/**
* 最新售后单申请金额,单位为分;没有历史申请时为空。
*/
@Schema(description = "最新申请金额,单位为分")
private Integer latestRefundPrice;
/**
* 最新售后单创建时间;没有历史申请时为空。
*/
@Schema(description = "最新申请时间")
private LocalDateTime latestCreateTime;
/**
* 最新售后单更新时间,用于展示审核、取消或退款完成时间。
*/
@Schema(description = "最新状态更新时间")
private LocalDateTime latestUpdateTime;
/**
* 按原订单商品 ID 聚合后的退款商品列表。
*/
@Schema(description = "退款商品列表")
private List<HardAppRefundProductVo> productList;
}
@@ -0,0 +1,92 @@
package com.bsd.pjcloud.hard.api.vo.order.app;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
* App 可退款商品及数量、金额占用信息。
*/
@Data
@Schema(description = "App 可退款商品")
public class HardAppRefundProductVo {
/**
* 商品 ID,来源于原订单购物车快照;长整型响应统一序列化为字符串。
*/
@Schema(description = "商品ID")
private Long productId;
/**
* 下单时保存的商品名称快照。
*/
@Schema(description = "商品名称")
private String productName;
/**
* 下单时保存的商品图片快照地址。
*/
@Schema(description = "商品图片")
private String productPic;
/**
* 商品性质协议编码,{@code 1} 为普通数量商品,{@code 2} 为称重商品。
*/
@Schema(description = "商品性质")
private String productNature;
/**
* 下单成交单价快照,单位为分,仅用于页面展示。
*/
@Schema(description = "成交单价,单位为分")
private Integer productPrice;
/**
* 原订单购买总份数;同一商品在购物车出现多行时按商品 ID 合并。
*/
@Schema(description = "原购买份数")
private Integer originalCount;
/**
* 原订单购买重量,单位为千克;普通数量商品为空或零,称重商品用于只读展示。
*/
@Schema(description = "原购买重量,单位为千克")
private BigDecimal originalWeight;
/**
* 已完成售后占用的份数,取消和拒绝记录不计入。
*/
@Schema(description = "已退款份数")
private Integer refundedCount;
/**
* 待审核售后占用的份数,存在该数量时订单不能再次提交申请。
*/
@Schema(description = "申请中份数")
private Integer applyingCount;
/**
* 当前尚可申请的份数,等于购买份数减已退款和申请中份数。
*/
@Schema(description = "可退款份数")
private Integer refundableCount;
/**
* 原商品行合计金额快照,单位为分,用于解释实付分摊依据。
*/
@Schema(description = "商品快照合计金额,单位为分")
private Long originalTotalPrice;
/**
* 订单实付按商品快照金额占比分配后的整商品退款额度,单位为分。
*/
@Schema(description = "商品实付分摊金额,单位为分")
private Integer allocatedPrice;
/**
* 是否支持 App 在线逐份退款;称重商品固定为 {@code false}。
*/
@Schema(description = "是否支持在线逐份退款")
private Boolean onlineRefundSupported;
}
@@ -56,13 +56,21 @@ public class HardApiController {
private final HardOrderTotalService hardOrderTotalService;
private final ReservationMenuQueryService reservationMenuQueryService;
private final VerificationRecordService verificationRecordService;
/**
* 食堂、超市 Android 终端心跳状态服务。
*/
private final HardTerminalStatusService hardTerminalStatusService;
/**
* 将 Android 终端唯一标识绑定到服务端设备,并在短时间内拦截相同参数的重复请求。
*
* @param dto 服务端设备 ID 与 Android 终端唯一标识
* @return 绑定完成后的设备配置
*/
@PostMapping("/bind")
@Operation(summary = "绑定服务端设备")
@Idempotent(expireTime = 3, key = "'/hard-api/ad/bind:' + #dto.getId() + ':' + #dto.getUniqueCode()", info = "正在绑定中,请勿重复操作")
public R<HardManagerVo> bind(@RequestBody HardBindDto dto) {
return R.ok(hardManagerService.bind(dto));
}
@@ -13,11 +13,14 @@ import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.idempotent.annotation.Idempotent;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.dto.order.*;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppRefundApplyVo;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -31,6 +34,7 @@ import org.springframework.http.HttpHeaders;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -49,16 +53,40 @@ public class HardAfterSaleController {
private final RemoteMchService remoteMchService;
/**
* 第三方支付退款成功后仅触发订单聚合刷新,重复通知不会重复增加退款金额。
*
* @param orderId 原订单 ID
* @return 聚合刷新结果
*/
@Inner
@PostMapping("/inner/refresh-order-refund-state")
@Operation(summary = "内部刷新订单退款聚合状态")
public R<Boolean> refreshOrderRefundState(@RequestParam Long orderId) {
return R.ok(hardAfterSaleService.refreshOrderRefundState(orderId));
}
/* ************************************* start app start ************************************* */
@ApiCryptoAes
@PostMapping(value = "/app/create")
@Operation(summary = "app申请售后")
@Idempotent(expireTime = 10, info = "正在申请中,请勿重复操作")
public R<Long> appCreate(@RequestBody HardAfterSaleDto dto) {
public R<Long> appCreate(@Valid @RequestBody HardAppRefundApplyDto dto) {
PjcloudUser user = SecurityUtils.getUser();
hardAfterSaleService.validateAppOrderRefund(dto.getOrderId(), user.getId());
dto.setUserId(user.getId());
return R.ok(hardAfterSaleService.create(dto));
return R.ok(hardAfterSaleService.createAppRefund(dto, user.getId()));
}
/**
* 返回当前付款人可申请的商品份数及服务端金额分摊结果。
*
* @param orderId 原订单 ID
* @return App 退款申请页详情
*/
@ApiCryptoAes
@GetMapping(value = "/app/refund-apply-detail")
@Operation(summary = "app查询退款申请详情")
public R<HardAppRefundApplyVo> appRefundApplyDetail(@RequestParam Long orderId) {
return R.ok(hardAfterSaleService.getAppRefundApplyDetail(orderId, SecurityUtils.getUser().getId()));
}
@ApiCryptoAes
@@ -10,6 +10,29 @@ import java.util.List;
@Mapper
public interface HardAfterSaleCarMapper extends PjcloudBaseMapper<HardAfterSaleCarEntity> {
/**
* 查询父购物车下待审核和已完成售后占用的商品汇总,按商品 ID 聚合。
*
* @param carParentId 原订单父购物车 ID
* @param statuses 计入占用的售后状态编码
* @return 各商品占用数量、重量和退款金额汇总
*/
List<HardAfterSaleCarEntity> getOccupiedByCarParentId(@Param("carParentId") Long carParentId, @Param("statuses") List<String> statuses);
/**
* 查询指定售后单保存的商品快照,审核同意时据此退库存。
*
* @param refundId 售后单 ID
* @return 本次申请的商品明细
*/
List<HardAfterSaleCarEntity> getByRefundId(@Param("refundId") Long refundId);
/**
* 查询父购物车下全部有效售后商品汇总;保留后台历史调用契约。
*
* @param carParentId 原订单父购物车 ID
* @return 不区分售后状态的商品汇总
*/
List<HardAfterSaleCarEntity> getByCarPrentId(@Param("carParentId") Long carParentId);
}
}
@@ -26,6 +26,14 @@ import java.util.List;
@Mapper
public interface HardOrderMapper extends PjcloudBaseMapper<HardOrderEntity> {
/**
* 按主键查询并锁定订单行,供退款创建、审核、取消和后台直退串行化同一订单。
*
* @param id 订单主键
* @return 锁定后的订单;不存在时返回 {@code null}
*/
HardOrderEntity selectByIdForUpdate(@Param("id") Long id);
default LambdaQueryWrapper<HardOrderEntity> commonWrapper(HardOrderDto dto) {
LambdaQueryWrapper<HardOrderEntity> wrapper = new LambdaQueryWrapper<HardOrderEntity>()
.eq(ObjectUtil.isNotEmpty(dto.getOrderType()), HardOrderEntity::getOrderType, dto.getOrderType())
@@ -9,9 +9,62 @@ import java.util.List;
public interface HardAfterSaleCarService extends IService<HardAfterSaleCarEntity> {
/**
* 按订单查询全部售后商品汇总,保留后台历史能力。
*
* @param orderId 订单 ID
* @return 售后商品汇总;订单不存在时返回 {@code null}
*/
List<HardAfterSaleCarEntity> getByOrderId(Long orderId);
/**
* 按父购物车查询待审核和已完成售后商品汇总,取消及拒绝记录不占用可退数量。
*
* @param carParentId 父购物车 ID
* @return 按商品 ID 聚合的有效售后商品汇总
*/
List<HardAfterSaleCarEntity> getByCarPrentId(Long carParentId);
/**
* 查询待审核和已完成售后占用的商品汇总。
*
* @param carParentId 父购物车 ID
* @return 按商品 ID 聚合的占用明细
*/
List<HardAfterSaleCarEntity> getOccupiedByCarParentId(Long carParentId);
/**
* 按指定售后状态查询商品占用汇总,供详情分别展示申请中和已退款数量。
*
* @param carParentId 父购物车 ID
* @param statuses 需要计入的售后状态编码
* @return 按商品 ID 聚合的占用明细
*/
List<HardAfterSaleCarEntity> getOccupiedByCarParentId(Long carParentId, List<String> statuses);
/**
* 查询单个售后申请保存的商品快照。
*
* @param refundId 售后单 ID
* @return 本次售后商品明细
*/
List<HardAfterSaleCarEntity> getByRefundId(Long refundId);
/**
* 保存服务端核定的售后商品快照。
*
* @param carList 商品名称、价格、数量和分摊金额均已由服务端确定
* @param order 原订单
* @param afterSale 已创建的售后单
*/
void create(List<HardAfterSaleCarEntity> carList, HardOrderEntity order, HardAfterSaleEntity afterSale);
}
/**
* 保存后台原有退款商品结构,并保留后台页面提交的名称、图片、重量和金额快照。
*
* @param carList 后台管理端已校验的退款商品
* @param order 原订单
* @param afterSale 已创建的售后单
*/
void createAdmin(List<HardAfterSaleCarEntity> carList, HardOrderEntity order, HardAfterSaleEntity afterSale);
}
@@ -7,10 +7,12 @@ import com.bsd.pjcloud.hard.api.dto.order.HardAdminRefundDto;
import com.bsd.pjcloud.hard.api.dto.order.HardAdminRefundReinforceDto;
import com.bsd.pjcloud.hard.api.dto.order.HardAfterSaleDto;
import com.bsd.pjcloud.hard.api.dto.order.HardDisagreeDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppRefundApplyVo;
import java.util.List;
@@ -27,6 +29,24 @@ public interface HardAfterSaleService extends IService<HardAfterSaleEntity> {
*/
Long create(HardAfterSaleDto dto);
/**
* 创建 App 逐商品退款申请,商品和金额均以订单快照及历史售后占用为准。
*
* @param dto 退款原因、凭证及本次选择的商品份数
* @param userId 当前登录付款人 ID
* @return 新建售后单 ID
*/
Long createAppRefund(HardAppRefundApplyDto dto, Long userId);
/**
* 查询 App 退款申请页所需的订单累计金额和逐商品可退额度。
*
* @param orderId 原订单 ID
* @param userId 当前登录付款人 ID
* @return 服务端实时聚合的退款申请详情
*/
HardAppRefundApplyVo getAppRefundApplyDetail(Long orderId, Long userId);
/**
* 取消售后
*/
@@ -74,6 +94,14 @@ public interface HardAfterSaleService extends IService<HardAfterSaleEntity> {
/** 校验 App 商户是否可以审核食堂预约订单的售后单。 */
void validateAppMerchantAfterSaleAccess(Long afterSaleId, Long viewerUserId);
/**
* 第三方支付退款成功回调后重新聚合订单退款状态;重复回调只会覆盖为相同结果。
*
* @param orderId 原订单 ID
* @return {@code true} 表示订单存在且聚合刷新完成
*/
Boolean refreshOrderRefundState(Long orderId);
/**
* 后台退款
*/
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleCarMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardAfterSaleCarService;
@@ -40,9 +41,71 @@ public class HardAfterSaleCarServiceImpl extends ServiceImpl<HardAfterSaleCarMap
return baseMapper.getByCarPrentId(carParentId);
}
/**
* 待审核和已完成记录都会占用商品份数,取消及拒绝记录不参与汇总。
*
* @param carParentId 父购物车 ID
* @return 按商品 ID 聚合的占用明细
*/
@Override
public List<HardAfterSaleCarEntity> getOccupiedByCarParentId(Long carParentId) {
return getOccupiedByCarParentId(carParentId,
List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus()));
}
/**
* 状态集合由售后聚合服务提供;空集合不发起无意义 SQL 查询。
*
* @param carParentId 父购物车 ID
* @param statuses 需要计入的售后状态编码
* @return 按商品 ID 聚合的占用明细
*/
@Override
public List<HardAfterSaleCarEntity> getOccupiedByCarParentId(Long carParentId, List<String> statuses) {
if (carParentId == null || ObjectUtil.isEmpty(statuses)) {
return List.of();
}
return baseMapper.getOccupiedByCarParentId(carParentId, statuses);
}
/**
* 审核与详情展示只读取申请时保存的商品快照,避免依赖已变化的商品主数据。
*
* @param refundId 售后单 ID
* @return 本次售后商品明细
*/
@Override
public List<HardAfterSaleCarEntity> getByRefundId(Long refundId) {
return baseMapper.getByRefundId(refundId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void create(List<HardAfterSaleCarEntity> carList, HardOrderEntity order, HardAfterSaleEntity afterSale) {
saveRefundCars(carList, order, afterSale);
}
/**
* 后台退款沿用原有商品明细契约,持久化动作与 App 服务端核定明细共用。
*
* @param carList 后台管理端退款商品
* @param order 原订单
* @param afterSale 已创建的售后单
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void createAdmin(List<HardAfterSaleCarEntity> carList, HardOrderEntity order, HardAfterSaleEntity afterSale) {
saveRefundCars(carList, order, afterSale);
}
/**
* 为售后商品写入可信订单和售后外键,并清除客户端可能携带的明细主键。
*
* @param carList 待保存售后商品
* @param order 原订单
* @param afterSale 已创建的售后单
*/
private void saveRefundCars(List<HardAfterSaleCarEntity> carList, HardOrderEntity order, HardAfterSaleEntity afterSale) {
if (ObjectUtil.isEmpty(carList)) return;
carList = carList.stream().map(item -> {
item.setParentCarId(order.getParentCarId());
@@ -55,4 +118,4 @@ public class HardAfterSaleCarServiceImpl extends ServiceImpl<HardAfterSaleCarMap
}).toList();
saveBatch(saveList);
}
}
}
@@ -3379,6 +3379,9 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
// 查询购物车
List<HardCarEntity> carList = hardCarService.getByParentCarId(order.getParentCarId());
if (ObjectUtil.isEmpty(carList)) throw new RuntimeException(CarErrorMsg + "错误,购物车不存在");
if (ObjectUtil.isNotEmpty(refundCarList)) {
carList = filterRefundInventoryCars(carList, refundCarList);
}
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
List<CanteenSpuUpdateStockReqDTO.Item> list = carList.stream().map(v -> {
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
@@ -3407,6 +3410,9 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
// 查询购物车
List<HardCarEntity> carList = hardCarService.getByParentCarId(order.getParentCarId());
if (ObjectUtil.isEmpty(carList)) throw new RuntimeException("扣减库存错误,购物车不存在");
if (ObjectUtil.isNotEmpty(refundCarList)) {
carList = filterRefundInventoryCars(carList, refundCarList);
}
// 线下
if (ObjectUtil.equals(HardOrderTypeEnum.BELOW, orderTypeEnum)) {
SupermarketSpuUpdateStockReqDTO dto = new SupermarketSpuUpdateStockReqDTO();
@@ -3497,6 +3503,9 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
// 查询购物车
List<HardCarEntity> carList = hardCarService.getByParentCarId(order.getParentCarId());
if (ObjectUtil.isEmpty(carList)) throw new RuntimeException("扣减库存错误,购物车不存在");
if (ObjectUtil.isNotEmpty(refundCarList)) {
carList = filterRefundInventoryCars(carList, refundCarList);
}
List<DeductionSpuStockDto> list = carList.stream().map(v -> {
DeductionSpuStockDto entity = new DeductionSpuStockDto();
entity.setHardSpuId(v.getProductId());
@@ -3528,6 +3537,9 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
if (ObjectUtil.equals(HardOrderTypeEnum.FARMER, orderTypeEnum)) {
// 查询购物车
List<HardCarEntity> carList = hardCarService.getByParentCarId(order.getParentCarId());
if (ObjectUtil.isNotEmpty(refundCarList)) {
carList = filterRefundInventoryCars(carList, refundCarList);
}
// 扣件库存
MallProductStockChangeDTO dto = new MallProductStockChangeDTO();
dto.setDeviceType(0);
@@ -3572,6 +3584,38 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
}
/**
* 部分退款按商品 ID 聚合本次退款数量和重量,并从原购物车补齐可信商品性质。
* 同一商品存在多条购物车快照时只生成一条库存变更,避免每条原记录都套用本次总退款量。
*
* @param originalCars 原订单购物车快照
* @param refundCars 本次申请保存的退款商品
* @return 每个退款商品仅一条、数量和重量均为本次退款值的库存明细
*/
private List<HardCarEntity> filterRefundInventoryCars(List<HardCarEntity> originalCars, List<HardCarEntity> refundCars) {
Map<Long, HardCarEntity> originalCarMap = originalCars.stream().filter(item -> item.getProductId() != null)
.collect(Collectors.toMap(HardCarEntity::getProductId, item -> item, (left, right) -> left, LinkedHashMap::new));
Map<Long, HardCarEntity> refundCarMap = new LinkedHashMap<>();
for (HardCarEntity refundCar : refundCars) {
if (refundCar.getProductId() == null || !originalCarMap.containsKey(refundCar.getProductId())) {
continue;
}
HardCarEntity target = refundCarMap.computeIfAbsent(refundCar.getProductId(), productId -> HardCarEntity.builder()
.productId(productId)
.productNature(originalCarMap.get(productId).getProductNature())
.productCount(0)
.productWeight(BigDecimal.ZERO)
.build());
if (refundCar.getProductCount() != null) {
target.setProductCount(Math.addExact(target.getProductCount(), refundCar.getProductCount()));
}
if (refundCar.getProductWeight() != null) {
target.setProductWeight(target.getProductWeight().add(refundCar.getProductWeight()));
}
}
return new ArrayList<>(refundCarMap.values());
}
// 发送mq消息
@Override
public void createOrderAfterSendMqMsg(Long orderId, UserInfo userInfo) {
@@ -60,4 +60,51 @@
</where>
group by a.product_id
</select>
</mapper>
<select id="getOccupiedByCarParentId" resultType="com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity">
select min(a.id) as id,
min(a.refund_id) as refundId,
a.parent_car_id as parentCarId,
a.product_id as productId,
max(a.product_name) as productName,
max(a.product_pic) as productPic,
max(a.product_nature) as productNature,
max(a.product_price) as productPrice,
sum(ifnull(a.product_count, 0)) as productCount,
sum(ifnull(a.product_weight, 0)) as productWeight,
sum(ifnull(a.total_price, 0)) as totalPrice
from hard_after_sale_car a
inner join hard_after_sale s on s.id = a.refund_id and s.del_flag = '0'
where a.del_flag = '0'
and a.parent_car_id = #{carParentId}
and s.status in
<foreach collection="statuses" item="status" open="(" close=")" separator=",">
#{status}
</foreach>
group by a.parent_car_id, a.product_id
</select>
<select id="getByRefundId" resultType="com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity">
select a.id,
a.refund_id,
a.parent_car_id,
a.product_id,
a.product_name,
a.product_pic,
a.product_nature,
a.product_price,
a.product_count,
a.product_weight,
a.total_price,
a.create_by,
a.create_time,
a.update_by,
a.update_time,
a.del_flag,
a.tenant_id
from hard_after_sale_car a
where a.del_flag = '0'
and a.refund_id = #{refundId}
order by a.id
</select>
</mapper>
@@ -3,6 +3,15 @@
<mapper namespace="com.bsd.pjcloud.hard.mapper.order.HardOrderMapper">
<!-- 退款事务使用数据库行锁作为单体/微服务两种部署模式下的最终并发保护。 -->
<select id="selectByIdForUpdate" resultType="com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity">
select *
from hard_order
where id = #{id}
and del_flag = '0'
for update
</select>
<sql id="selectA">
select a.id,
@@ -208,7 +217,13 @@
p.user_id as payerUserId
from hard_order a
left join hard_manager m on m.id = a.hard_id
left join hard_after_sale s on s.order_id = a.id
<!-- 多次退款后只关联最新售后单,避免订单列表被一对多关联放大并返回不确定的售后 ID。 -->
left join hard_after_sale s on s.id = (
select max(latest_sale.id)
from hard_after_sale latest_sale
where latest_sale.order_id = a.id
and latest_sale.del_flag = '0'
)
left join hard_parent_car p on p.id = a.parent_car_id and p.del_flag = '0'
<where>
<include refid="whereA"/>
@@ -268,7 +283,12 @@
select count(0)
from hard_order a
left join hard_manager m on m.id = a.hard_id
left join hard_after_sale s on s.order_id = a.id
left join hard_after_sale s on s.id = (
select max(latest_sale.id)
from hard_after_sale latest_sale
where latest_sale.order_id = a.id
and latest_sale.del_flag = '0'
)
<where>
<include refid="whereA"/>
<if test="dto.hardTypeCode != null and dto.hardTypeCode != '' ">
@@ -4,23 +4,33 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 跨服务第三方支付退款参数。
*
* <p>同一支付订单允许多次部分退款时,调用方必须传入不同的售后单 ID 生成平台幂等请求号。</p>
*/
@Data
public class ThirdRemoterRefundDto {
/**
* 订单id
* 原业务订单 ID,用于定位唯一支付记录。
*/
@NotNull(message = "订单id不能为空")
private Long orderId;
/**
* 第三方支付业务类型
* 本次售后单 ID,用作微信商户退款单号和支付宝退款请求号;为空时兼容历史整单退款请求号。
*/
private Long afterSaleId;
/**
* 第三方支付业务类型,决定从哪类业务支付记录中查找原交易。
*/
@NotNull(message = "第三方支付业务类型不能为空")
private ThirdPayOrderBizTypeEnum bizType;
/**
* 退款金额
* 本次退款金额,单位为分,由上游业务服务核定。
*/
@NotNull(message = "退款金额不能为空")
private Integer price;
@@ -15,12 +15,18 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor
public class ThirdMiniWechatRefundDto {
/** 原支付订单号,用于定位第三方交易。 */
@NotEmpty
private String payNo;
/**
* 为null就全额退款
* 本次退款金额,单位为分;为空时兼容历史全额退款。
*/
private Integer refundPrice;
/**
* 本次售后单 ID,用作平台退款幂等请求号;为空时兼容历史固定请求号。
*/
private Long afterSaleId;
}
@@ -0,0 +1,18 @@
package com.bsd.pjcloud.thirdparty.dto.jg;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 极光设备绑定或解绑请求,只允许客户端提交当前设备的 registrationId。
*/
@Data
public class JgDeviceRegistrationDto {
/**
* 极光 SDK 为当前安装实例分配的设备标识;为空表示设备尚未完成极光注册。
*/
@NotBlank(message = "极光设备registrationId不能为空")
private String registrationId;
}
@@ -4,43 +4,72 @@ import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 极光推送配置,统一承载消息推送和用户设备 alias 管理参数。
*/
@Component
@ConfigurationProperties(prefix = "jg")
@Data
public class JgConfig {
/**
* 极光推送应用及设备绑定配置。
*/
private JgPush jgPush;
/**
* 极光推送应用配置和 alias 设备容量策略。
*/
@Data
public static class JgPush {
/**
* 极光推送key
* 极光应用 AppKey,由极光控制台分配。
*/
private String appKey;
/**
* 是否生产模式
* 是否按生产 APNs 环境发送 iOS 通知。
*/
private Boolean isProd;
/**
* 极光推送secret
* 极光应用 MasterSecret,仅供服务端调用极光 API。
*/
private String masterSecret;
/**
* 通知默认大图标
* Android 通知默认大图标地址。
*/
private String largeIcon;
/**
* 通知默认小图标
* Android 通知默认小图标地址。
*/
private String smallIconUri;
/**
* 设备最后在线时间过期阈值,单位天;严格早于该日期的设备才会清理。
*/
private Integer staleDays = 30;
/**
* 普通账号绑定完成后的最大设备数,默认保留一个极光硬上限余量。
*/
private Integer maxBindings = 9;
/**
* 测试账号绑定完成后的最大设备数,用于更积极地清理频繁重装产生的旧设备。
*/
private Integer testMaxBindings = 3;
/**
* 使用测试设备容量策略的用户ID列表,ID来源于系统用户主键。
*/
private List<Long> testUserIds = List.of();
}
}
@@ -1,26 +1,37 @@
package com.bsd.pjcloud.thirdparty.config.initconfig;
import cn.jiguang.sdk.api.DeviceApi;
import cn.jiguang.sdk.api.PushApi;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.config.JgConfig;
import com.github.binarywang.wxpay.config.WxPayConfig;
import com.github.binarywang.wxpay.service.WxPayService;
import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl;
import feign.Logger;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Slf4j
/**
* 极光服务端 SDK Bean 配置,消息推送与设备管理共用同一套应用凭据。
*/
@Configuration
public class JgConfiguration {
/**
* 极光应用配置,来源于 {@code jg.jg-push} 配置项。
*/
private final JgConfig jgConfig;
/**
* 创建极光 SDK 配置类。
*
* @param jgConfig 极光应用配置
*/
public JgConfiguration(JgConfig jgConfig) {
this.jgConfig = jgConfig;
}
/**
* 构建极光消息推送客户端。
*
* @return 使用现有 AppKey 和 MasterSecret 初始化的推送客户端
*/
@Bean
public PushApi initJgPush() {
JgConfig.JgPush jgPush = this.jgConfig.getJgPush();
@@ -31,4 +42,19 @@ public class JgConfiguration {
.build();
}
/**
* 构建极光设备管理客户端,用于查询、绑定和精确解绑 alias 设备。
*
* @return 使用现有 AppKey 和 MasterSecret 初始化的设备客户端
*/
@Bean
public DeviceApi initJgDevice() {
JgConfig.JgPush jgPush = this.jgConfig.getJgPush();
return new DeviceApi.Builder()
.setLoggerLevel(Logger.Level.FULL)
.setAppKey(jgPush.getAppKey())
.setMasterSecret(jgPush.getMasterSecret())
.build();
}
}
@@ -0,0 +1,62 @@
package com.bsd.pjcloud.thirdparty.controller.jg;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.thirdparty.dto.jg.JgDeviceRegistrationDto;
import com.bsd.pjcloud.thirdparty.service.ThirdJgDeviceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* App 极光设备管理接口,用户 alias 始终取自当前 Token,禁止客户端指定其他用户。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/jg-push/device")
@Tag(description = "jg-push-device", name = "极光设备管理")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class ThirdJgDeviceController {
/**
* 极光用户设备管理服务。
*/
private final ThirdJgDeviceService thirdJgDeviceService;
/**
* 将当前 App 安装实例绑定到当前登录用户,并在绑定前清理旧设备。
*
* @param dto 当前设备 registrationId
* @return 无业务数据的成功响应
*/
@Operation(summary = "绑定当前极光设备")
@ApiCryptoAes
@PostMapping("/bind")
public R<Void> bind(@Valid @RequestBody JgDeviceRegistrationDto dto) {
thirdJgDeviceService.bind(SecurityUtils.getUser().getId(), dto.getRegistrationId());
return R.ok();
}
/**
* 仅解除当前 App 安装实例与当前登录用户的 alias 关系。
*
* @param dto 当前设备 registrationId
* @return 无业务数据的成功响应
*/
@Operation(summary = "解绑当前极光设备")
@ApiCryptoAes
@PostMapping("/unbind")
public R<Void> unbind(@Valid @RequestBody JgDeviceRegistrationDto dto) {
thirdJgDeviceService.unbind(SecurityUtils.getUser().getId(), dto.getRegistrationId());
return R.ok();
}
}
@@ -1,26 +1,35 @@
package com.bsd.pjcloud.thirdparty.job;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.thirdparty.config.JgConfig;
import com.bsd.pjcloud.thirdparty.dto.jg.JgPusDto;
import com.bsd.pjcloud.thirdparty.service.ThirdJgPushService;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* App 极光推送定时任务,测试推送对象与设备清理策略共用同一份配置名单。
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class ThirdAppJgPushJob {
/**
* 极光消息推送服务。
*/
private final ThirdJgPushService thirdJgPushService;
/**
* 用户远程服务,供后续恢复全量用户推送任务时读取用户范围。
*/
private final RemoteUserService remoteUserService;
private final List<Long> testUserIdList = List.of(2029755089492066305L, 2034824566572941314L);
/**
* 极光配置,提供统一维护的测试用户ID列表。
*/
private final JgConfig jgConfig;
// @PostConstruct
@@ -30,11 +39,17 @@ public class ThirdAppJgPushJob {
//// pushToAllUsers("晚餐提醒", "晚餐时间就要到了,请尽快预约哦!!");
// }
/**
* 向配置中的测试用户发送定时提醒,避免测试名单在任务和设备策略中重复维护。
*
* @param title 通知标题
* @param subtitle 通知副标题
*/
private void pushToAllUsers(String title, String subtitle) {
JgPusDto jgPusDto = new JgPusDto();
jgPusDto.setTitle(title);
jgPusDto.setSubtitle(subtitle);
jgPusDto.setUserIds(testUserIdList);
jgPusDto.setUserIds(jgConfig.getJgPush().getTestUserIds());
thirdJgPushService.push(jgPusDto);
}
@@ -0,0 +1,24 @@
package com.bsd.pjcloud.thirdparty.service;
/**
* 极光用户设备管理服务,负责服务端 alias 绑定、容量清理和精确解绑。
*/
public interface ThirdJgDeviceService {
/**
* 清理用户 alias 下的过期或超量设备后,绑定当前设备。
*
* @param userId 当前认证用户ID,同时作为极光 alias
* @param registrationId 当前安装实例的极光设备标识
*/
void bind(Long userId, String registrationId);
/**
* 仅解除当前用户 alias 与指定设备的关系,不影响该用户的其他设备。
*
* @param userId 当前认证用户ID,同时作为极光 alias
* @param registrationId 当前安装实例的极光设备标识
*/
void unbind(Long userId, String registrationId);
}
@@ -318,7 +318,8 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
@Override
public boolean refund(ThirdPayOrderEntity thirdPayOrder, ThirdMiniWechatRefundDto dto) {
AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig();
String refundNo = "refund_" + thirdPayOrder.getPayNo();
String refundNo = dto.getAfterSaleId() == null ? "refund_" + thirdPayOrder.getPayNo()
: "refund_" + dto.getAfterSaleId();
MchPaySettingEntity mchPaySettingEntity = JSONUtil.toBean(thirdPayOrder.getMchPaySettingJson(), MchPaySettingEntity.class);
MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(mchPaySettingEntity.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
@@ -462,10 +463,19 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
log.error("|{}|refundCallback,意外的错误,订单不存在,支付宝返回json|{}|", TAG, JSONUtil.toJsonStr(vo));
return null;
}
// 重复的回调
// 一张支付单顺序发生多笔部分退款时,只处理当前退款请求号;旧通知直接确认,避免覆盖新一笔退款状态。
if (ObjectUtil.isNotEmpty(vo.getOut_biz_no()) && ObjectUtil.notEqual(payOrderEntity.getRefundNo(), vo.getOut_biz_no())) {
log.warn("|{}|refundCallback,退款请求号不是当前请求,outBizNo:|{}|", TAG, vo.getOut_biz_no());
return payOrderEntity;
}
// 支付宝重复通知已成功的同一退款请求时直接返回当前订单,避免重复业务处理失败触发平台重试。
if (ObjectUtil.equals(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType())
&& (ObjectUtil.isEmpty(vo.getOut_biz_no()) || ObjectUtil.equals(payOrderEntity.getRefundNo(), vo.getOut_biz_no()))) {
return payOrderEntity;
}
if (ObjectUtil.notEqual(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.REFUNDING.getType())) {
log.error("|{}|refundCallback,意外的错误,重复的回调,支付宝返回json|{}|", TAG, JSONUtil.toJsonStr(vo));
return null;
log.warn("|{}|refundCallback,退款状态不匹配,outBizNo:|{}|", TAG, vo.getOut_biz_no());
return payOrderEntity;
}
// 更新订单状态
ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity();
@@ -912,9 +912,25 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
throw new RuntimeException("订单不存在");
}
String expectedRefundNo = dto.getAfterSaleId() == null ? "refund_" + thirdPayOrder.getPayNo()
: "refund_" + dto.getAfterSaleId();
// 支付表只保存当前一笔退款快照,因此必须等上一笔渠道退款进入终态后才能覆盖退款号。
if (ObjectUtil.equals(thirdPayOrder.getStatus(), ThirdPayOrderStatusEnum.REFUNDING.getType())) {
if (ObjectUtil.equals(thirdPayOrder.getRefundNo(), expectedRefundNo)) {
return thirdPayOrder.getRefundNo();
}
throw new RuntimeException("上一笔退款仍在处理中,请稍后重试");
}
// 同一售后单的远程重试直接返回原退款号,避免渠道重复受理同一笔金额。
if (ObjectUtil.equals(thirdPayOrder.getStatus(), ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType())
&& ObjectUtil.equals(thirdPayOrder.getRefundNo(), expectedRefundNo)) {
return thirdPayOrder.getRefundNo();
}
ThirdMiniWechatRefundDto refundDto = new ThirdMiniWechatRefundDto();
refundDto.setPayNo(thirdPayOrder.getPayNo());
refundDto.setRefundPrice(dto.getPrice());
refundDto.setAfterSaleId(dto.getAfterSaleId());
// 微信小程序
if (ObjectUtil.equals(thirdPayOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) {
ThirdPayOrderEntity refund = thirdMiniAppWechatService.refund(thirdPayOrder, refundDto);
@@ -927,7 +943,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
boolean isRefund = thirdMiniAppAliPayService.refund(thirdPayOrder, refundDto);
if (isRefund) {
// 再查询一下
thirdPayOrder = thirdPayOrderService.getByBiz(ThirdPayOrderBizTypeEnum.YXFJ_PAY, String.valueOf(dto.getOrderId()));
thirdPayOrder = thirdPayOrderService.getByBiz(dto.getBizType(), String.valueOf(dto.getOrderId()));
disposeRefundSuccessAfter(thirdPayOrder);
return thirdPayOrder.getRefundNo();
}
@@ -1356,12 +1372,10 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
}
} else if (ObjectUtil.equals(thirdPayOrder.getBizType(), ThirdPayOrderBizTypeEnum.APP_SHOP_PAY.getType())) {
try {
HardUpdateShopStatusDto dto = new HardUpdateShopStatusDto();
dto.setOrderId(Long.valueOf(thirdPayOrder.getBizId()));
dto.setStatus(HardUpdateShopStatusDto.ChangeShopStatus.REFUND_SUCCESS);
return remoteHardOrderService.updateShopStatus(dto).getData();
R<Boolean> result = remoteHardOrderService.refreshOrderRefundState(Long.valueOf(thirdPayOrder.getBizId()));
return result != null && result.isOk() && Boolean.TRUE.equals(result.getData());
} catch (Exception e) {
log.error("|APP支付-购买商品(有订单)|处理小程序退款成功回调异常", e);
log.error("|APP支付-购买商品(有订单)|刷新退款聚合状态异常", e);
return false;
}
} else {
@@ -242,10 +242,12 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService
if (ObjectUtil.isEmpty(refundFee)) {
refundFee = thirdPayOrder.getPrice();
}
if (!isFzRefund && ObjectUtil.isNotEmpty(thirdPayOrder.getFzPrice())) {
// 显式金额来自上游已核定的部分退款申请,不能被历史分账整单退款规则覆盖。
if (ObjectUtil.isEmpty(dto.getRefundPrice()) && !isFzRefund && ObjectUtil.isNotEmpty(thirdPayOrder.getFzPrice())) {
refundFee = thirdPayOrder.getPrice() - thirdPayOrder.getFzPrice();
}
String refundNo = "refund_" + thirdPayOrder.getPayNo();
String refundNo = dto.getAfterSaleId() == null ? "refund_" + thirdPayOrder.getPayNo()
: "refund_" + dto.getAfterSaleId();
CreateRequest createRequest = new CreateRequest();
@@ -563,11 +565,23 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService
log.error("|{}|refundCallback,意外的错误,订单不存在,微信返回json|{}|", TAG, JSONUtil.toJsonStr(refundNotification));
return null;
}
// 重复的回调
if (ObjectUtil.notEqual(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.REFUNDING.getType())) {
log.error("|{}|refundCallback,意外的错误,重复的回调,微信返回json|{}|", TAG, JSONUtil.toJsonStr(refundNotification));
if (refundNotification.getAmount() == null || refundNotification.getAmount().getTotal() == null
|| refundNotification.getAmount().getRefund() == null
|| refundNotification.getAmount().getTotal().longValue() != payOrderEntity.getPrice().longValue()
|| refundNotification.getAmount().getRefund() <= 0) {
log.warn("|{}|refundCallback,退款金额校验失败,outRefundNo:|{}|", TAG, refundNotification.getOutRefundNo());
return null;
}
// 微信可能重复推送同一退款通知;已成功记录直接返回当前订单,让上层幂等刷新业务聚合状态。
if (ObjectUtil.equals(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType())
&& ObjectUtil.equals(payOrderEntity.getRefundNo(), refundNotification.getOutRefundNo())) {
return payOrderEntity;
}
if (ObjectUtil.notEqual(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.REFUNDING.getType())
|| ObjectUtil.notEqual(payOrderEntity.getRefundNo(), refundNotification.getOutRefundNo())) {
log.warn("|{}|refundCallback,退款状态或请求号不匹配,outRefundNo:|{}|", TAG, refundNotification.getOutRefundNo());
return payOrderEntity;
}
// 更新订单状态
boolean refundSuccess = ObjectUtil.equals(refundNotification.getRefundStatus(), Status.SUCCESS);
ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity();
@@ -0,0 +1,516 @@
package com.bsd.pjcloud.thirdparty.service.impl.jg;
import cn.hutool.core.util.StrUtil;
import cn.jiguang.sdk.api.DeviceApi;
import cn.jiguang.sdk.bean.device.AliasDeleteParam;
import cn.jiguang.sdk.bean.device.AliasGetResult;
import cn.jiguang.sdk.bean.device.DeviceSetParam;
import cn.jiguang.sdk.exception.ApiErrorException;
import com.bsd.pjcloud.common.core.exception.CheckedException;
import com.bsd.pjcloud.thirdparty.config.JgConfig;
import com.bsd.pjcloud.thirdparty.service.ThirdJgDeviceService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
/**
* 极光用户设备管理实现,通过服务端 Device API 控制 alias 的设备数量并处理并发绑定。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ThirdJgDeviceServiceImpl implements ThirdJgDeviceService {
/**
* 极光设备管理日志标签,日志中只输出脱敏 registrationId。
*/
private static final String TAG = "极光设备管理";
/**
* 按用户 alias 隔离的 Redis 锁键前缀,租户命名空间由现有 Redis 基础设施处理。
*/
private static final String ALIAS_LOCK_PREFIX = "thirdparty:jg-push:alias-lock:";
/**
* 极光官方 alias 设备数超过上限的业务错误码,仅该错误允许清理一台后重试绑定。
*/
private static final int ALIAS_BINDING_LIMIT_ERROR_CODE = 7015;
/**
* 单次绑定允许的最大清理轮数,避免极光返回数据不收敛时无限循环。
*/
private static final int MAX_CLEANUP_ROUNDS = 20;
/**
* 普通账号默认最大设备数,给极光 10 台硬上限保留一个位置。
*/
private static final int DEFAULT_MAX_BINDINGS = 9;
/**
* 测试账号默认最大设备数,限制频繁安装测试产生的历史设备。
*/
private static final int DEFAULT_TEST_MAX_BINDINGS = 3;
/**
* 默认过期清理阈值,单位天。
*/
private static final int DEFAULT_STALE_DAYS = 30;
/**
* 极光官方默认 alias 设备硬上限。
*/
private static final int JG_ALIAS_HARD_LIMIT = 10;
/**
* 同一 alias 获取分布式锁的最长等待时间。
*/
private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(3);
/**
* 未获取到锁时的重试间隔,避免高频访问 Redis。
*/
private static final Duration LOCK_RETRY_INTERVAL = Duration.ofMillis(100);
/**
* alias 设备操作锁租期,覆盖正常的极光多轮查询和删除调用。
*/
private static final Duration LOCK_TTL = Duration.ofSeconds(60);
/**
* 设备在线日期的业务时区,避免部署服务器使用 UTC 时改变 30 天清理边界。
*/
private static final ZoneId BUSINESS_ZONE_ID = ZoneId.of("Asia/Shanghai");
/**
* 设备排序规则:在线日期为空的异常设备最旧,其余按日期和 registrationId 稳定排序。
*/
private static final Comparator<AliasGetResult.Data> OLDEST_DEVICE_FIRST = Comparator
.comparing(AliasGetResult.Data::getLastOnlineDate, Comparator.nullsFirst(Comparator.naturalOrder()))
.thenComparing(AliasGetResult.Data::getRegistrationId, Comparator.nullsFirst(Comparator.naturalOrder()));
/**
* 仅当锁值仍属于当前请求时释放锁,防止租期结束后误删后继请求的锁。
*/
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", Long.class);
/**
* 极光设备 API,负责查询、绑定及精确解绑 alias。
*/
private final DeviceApi deviceApi;
/**
* 极光应用及设备容量配置。
*/
private final JgConfig jgConfig;
/**
* Redis 字符串模板,用于同一用户多设备并发登录时串行执行清理和绑定。
*/
private final StringRedisTemplate stringRedisTemplate;
/**
* 清理用户 alias 下的过期或超量设备后绑定当前设备。
*
* @param userId 当前认证用户ID,同时作为极光 alias
* @param registrationId 当前安装实例的极光设备标识
*/
@Override
public void bind(Long userId, String registrationId) {
validateRequest(userId, registrationId);
String normalizedRegistrationId = registrationId.trim();
executeWithAliasLock(userId, () -> {
try {
bindUnderLock(userId, normalizedRegistrationId);
} catch (ApiErrorException exception) {
logJgError("设备绑定流程失败", userId, normalizedRegistrationId, exception);
throw exception;
}
return null;
});
}
/**
* 精确解除当前用户 alias 和当前设备的关系,其他登录设备保持不变。
*
* @param userId 当前认证用户ID,同时作为极光 alias
* @param registrationId 当前安装实例的极光设备标识
*/
@Override
public void unbind(Long userId, String registrationId) {
validateRequest(userId, registrationId);
String normalizedRegistrationId = registrationId.trim();
executeWithAliasLock(userId, () -> {
try {
deleteDevices(String.valueOf(userId), List.of(normalizedRegistrationId));
} catch (ApiErrorException exception) {
logJgError("设备解绑流程失败", userId, normalizedRegistrationId, exception);
throw exception;
}
log.info("|{}|当前设备解绑完成,userId:|{}|,registrationId:|{}|", TAG, userId, maskRegistrationId(normalizedRegistrationId));
return null;
});
}
/**
* 在已持有 alias 锁的前提下执行清理与绑定,并仅对 7015 上限错误做一次恢复重试。
*
* @param userId 当前认证用户ID
* @param registrationId 当前设备标识
*/
private void bindUnderLock(Long userId, String registrationId) {
String alias = String.valueOf(userId);
int maxBindings = resolveMaxBindings(userId);
CleanupResult cleanupResult = cleanupBeforeBind(userId, alias, registrationId, maxBindings);
int recoveryRemovedCount = 0;
try {
setAlias(alias, registrationId);
} catch (ApiErrorException exception) {
if (!Objects.equals(extractErrorCode(exception), ALIAS_BINDING_LIMIT_ERROR_CODE)) {
throw exception;
}
AliasGetResult.Data oldestDevice = queryAliasDevices(alias).stream()
.filter(device -> !Objects.equals(registrationId, device.getRegistrationId()))
.min(OLDEST_DEVICE_FIRST)
.orElse(null);
if (oldestDevice == null) {
log.warn("|{}|极光返回7015但无可清理的非当前设备,userId:|{}|,registrationId:|{}|",
TAG, userId, maskRegistrationId(registrationId));
throw exception;
}
deleteDevices(alias, List.of(oldestDevice.getRegistrationId()));
recoveryRemovedCount = 1;
log.warn("|{}|极光返回7015,额外清理最旧设备后重试,userId:|{}|,registrationId:|{}|,removedRegistrationId:|{}|",
TAG, userId, maskRegistrationId(registrationId), maskRegistrationId(oldestDevice.getRegistrationId()));
setAlias(alias, registrationId);
}
int estimatedAfterCount = (cleanupResult.currentDeviceBound()
? cleanupResult.remainingVisibleCount()
: cleanupResult.remainingVisibleCount() + 1) - recoveryRemovedCount;
log.info("|{}|设备绑定完成,userId:|{}|,registrationId:|{}|,visibleBefore:|{}|,visibleAfterCleanup:|{}|,estimatedAfterBind:|{}|,removed:|{}|,maxBindings:|{}|",
TAG, userId, maskRegistrationId(registrationId), cleanupResult.initialVisibleCount(),
cleanupResult.remainingVisibleCount(), Math.max(estimatedAfterCount, 0),
cleanupResult.removedCount() + recoveryRemovedCount, maxBindings);
}
/**
* 按“过期优先、超量其次”多轮清理 alias,每轮删除后重新查询以处理极光最多返回 10 台的限制。
*
* @param userId 当前认证用户ID
* @param alias 极光 alias
* @param registrationId 当前设备标识
* @param maxBindings 绑定完成后的设备上限
* @return 清理统计和绑定前最终可见设备状态
*/
private CleanupResult cleanupBeforeBind(Long userId, String alias, String registrationId, int maxBindings) {
int staleDays = positiveOrDefault(jgConfig.getJgPush().getStaleDays(), DEFAULT_STALE_DAYS);
LocalDate staleCutoff = LocalDate.now(BUSINESS_ZONE_ID).minusDays(staleDays);
int maxOtherDevices = Math.max(0, maxBindings - 1);
int initialVisibleCount = -1;
int removedCount = 0;
Set<String> previousIdsAfterDelete = null;
for (int round = 1; round <= MAX_CLEANUP_ROUNDS; round++) {
List<AliasGetResult.Data> devices = queryAliasDevices(alias);
if (initialVisibleCount < 0) {
initialVisibleCount = devices.size();
}
Set<String> currentIds = registrationIds(devices);
if (previousIdsAfterDelete != null && previousIdsAfterDelete.equals(currentIds)) {
log.warn("|{}|清理后设备列表未变化,userId:|{}|,round:|{}|,visibleCount:|{}|", TAG, userId, round, devices.size());
throw new CheckedException("极光旧设备清理未生效,请稍后重试");
}
boolean currentDeviceBound = currentIds.contains(registrationId);
List<AliasGetResult.Data> otherDevices = devices.stream()
.filter(device -> !Objects.equals(registrationId, device.getRegistrationId()))
.sorted(OLDEST_DEVICE_FIRST)
.toList();
List<String> staleRegistrationIds = otherDevices.stream()
.filter(device -> device.getLastOnlineDate() == null || device.getLastOnlineDate().isBefore(staleCutoff))
.map(AliasGetResult.Data::getRegistrationId)
.filter(StrUtil::isNotBlank)
.toList();
if (!staleRegistrationIds.isEmpty()) {
deleteDevices(alias, staleRegistrationIds);
removedCount += staleRegistrationIds.size();
previousIdsAfterDelete = currentIds;
log.info("|{}|清理过期设备,userId:|{}|,round:|{}|,removed:|{}|,staleCutoff:|{}|",
TAG, userId, round, staleRegistrationIds.size(), staleCutoff);
continue;
}
if (otherDevices.size() > maxOtherDevices) {
int removeCount = otherDevices.size() - maxOtherDevices;
List<String> overflowRegistrationIds = otherDevices.stream()
.limit(removeCount)
.map(AliasGetResult.Data::getRegistrationId)
.filter(StrUtil::isNotBlank)
.toList();
if (overflowRegistrationIds.isEmpty()) {
throw new CheckedException("极光超量设备缺少有效标识,无法清理");
}
deleteDevices(alias, overflowRegistrationIds);
removedCount += overflowRegistrationIds.size();
previousIdsAfterDelete = currentIds;
log.info("|{}|清理超量设备,userId:|{}|,round:|{}|,removed:|{}|,maxOtherDevices:|{}|",
TAG, userId, round, overflowRegistrationIds.size(), maxOtherDevices);
continue;
}
return new CleanupResult(Math.max(initialVisibleCount, 0), devices.size(), removedCount, currentDeviceBound);
}
log.warn("|{}|设备清理超过最大轮数,userId:|{}|,registrationId:|{}|,maxRounds:|{}|",
TAG, userId, maskRegistrationId(registrationId), MAX_CLEANUP_ROUNDS);
throw new CheckedException("极光旧设备清理次数超过限制,请稍后重试");
}
/**
* 查询 alias 当前可见设备并过滤无效、重复记录;极光默认最多返回 10 条。
*
* @param alias 当前用户 alias
* @return registrationId 唯一的设备列表
*/
private List<AliasGetResult.Data> queryAliasDevices(String alias) {
AliasGetResult result = deviceApi.getAlias(alias);
if (result == null || result.getData() == null) {
return List.of();
}
Set<String> seen = new HashSet<>();
List<AliasGetResult.Data> devices = new ArrayList<>();
for (AliasGetResult.Data device : result.getData()) {
if (device != null && StrUtil.isNotBlank(device.getRegistrationId()) && seen.add(device.getRegistrationId())) {
devices.add(device);
}
}
return devices;
}
/**
* 将当前设备 alias 更新为登录用户ID。
*
* @param alias 当前用户 alias
* @param registrationId 当前设备标识
*/
private void setAlias(String alias, String registrationId) {
DeviceSetParam param = new DeviceSetParam();
param.setAlias(alias);
deviceApi.setDevice(registrationId, param);
}
/**
* 从指定 alias 批量移除设备关系,不会删除整个 alias 或影响未列出的设备。
*
* @param alias 当前用户 alias
* @param registrationIds 需要解除关系的设备标识
*/
private void deleteDevices(String alias, List<String> registrationIds) {
List<String> validRegistrationIds = registrationIds.stream().filter(StrUtil::isNotBlank).distinct().toList();
if (validRegistrationIds.isEmpty()) {
return;
}
AliasDeleteParam.RegistrationIds registrationIdParam = new AliasDeleteParam.RegistrationIds();
registrationIdParam.setRemove(validRegistrationIds);
AliasDeleteParam deleteParam = new AliasDeleteParam();
deleteParam.setRegistrationIds(registrationIdParam);
deviceApi.deleteAliases(alias, deleteParam);
}
/**
* 在当前用户 alias 的 Redis 锁中执行远程设备操作,同一账号串行、不同账号并行。
*
* @param userId 当前认证用户ID
* @param operation 持锁期间执行的设备操作
* @param <T> 操作返回类型
* @return 设备操作返回值
*/
private <T> T executeWithAliasLock(Long userId, Supplier<T> operation) {
String lockKey = ALIAS_LOCK_PREFIX + userId;
String lockToken = UUID.randomUUID().toString();
long deadline = System.nanoTime() + LOCK_WAIT_TIMEOUT.toNanos();
boolean acquired = false;
while (!acquired && System.nanoTime() < deadline) {
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockToken, LOCK_TTL);
acquired = Boolean.TRUE.equals(result);
if (!acquired) {
long remainingNanos = deadline - System.nanoTime();
if (remainingNanos <= 0) {
break;
}
waitBeforeRetry(remainingNanos);
}
}
if (!acquired) {
throw new CheckedException("推送设备正在同步,请稍后重试");
}
try {
return operation.get();
} finally {
releaseLock(lockKey, lockToken);
}
}
/**
* 等待下一次锁获取尝试;最后一次等待不超过三秒总期限,线程中断时恢复中断标志。
*
* @param remainingNanos 距离锁等待截止时间的剩余纳秒数
*/
private void waitBeforeRetry(long remainingNanos) {
try {
long waitNanos = Math.min(LOCK_RETRY_INTERVAL.toNanos(), remainingNanos);
long waitMillis = waitNanos / 1_000_000L;
int waitExtraNanos = (int) (waitNanos % 1_000_000L);
Thread.sleep(waitMillis, waitExtraNanos);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new CheckedException("推送设备同步已中断");
}
}
/**
* 使用随机令牌校验脚本释放 alias 锁,释放异常只记录日志,不覆盖设备操作结果。
*
* @param lockKey alias 锁键
* @param lockToken 当前请求锁令牌
*/
private void releaseLock(String lockKey, String lockToken) {
try {
Long released = stringRedisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), lockToken);
if (!Objects.equals(released, 1L)) {
log.warn("|{}|alias锁未由当前请求释放,lockKey:|{}|,result:|{}|", TAG, lockKey, released);
}
} catch (Exception exception) {
log.warn("|{}|alias锁释放失败,lockKey:|{}|,error:|{}|", TAG, lockKey, exception.getMessage());
}
}
/**
* 校验服务调用参数,避免空用户或空设备标识进入极光 API。
*
* @param userId 当前认证用户ID
* @param registrationId 当前设备标识
*/
private void validateRequest(Long userId, String registrationId) {
if (userId == null) {
throw new CheckedException("当前登录用户无效");
}
if (StrUtil.isBlank(registrationId)) {
throw new CheckedException("极光设备registrationId不能为空");
}
}
/**
* 根据测试用户配置解析最终容量,并强制限制在极光硬上限以内。
*
* @param userId 当前认证用户ID
* @return 绑定完成后的最大设备数
*/
private int resolveMaxBindings(Long userId) {
JgConfig.JgPush pushConfig = jgConfig.getJgPush();
int normalLimit = Math.min(positiveOrDefault(pushConfig.getMaxBindings(), DEFAULT_MAX_BINDINGS), JG_ALIAS_HARD_LIMIT - 1);
List<Long> testUserIds = pushConfig.getTestUserIds();
if (testUserIds != null && testUserIds.contains(userId)) {
return Math.min(positiveOrDefault(pushConfig.getTestMaxBindings(), DEFAULT_TEST_MAX_BINDINGS), normalLimit);
}
return normalLimit;
}
/**
* 对可配置正整数提供安全默认值,防止空值或非法负数破坏清理策略。
*
* @param configuredValue 配置中心读取值
* @param defaultValue 内置默认值
* @return 有效正整数
*/
private int positiveOrDefault(Integer configuredValue, int defaultValue) {
return configuredValue != null && configuredValue > 0 ? configuredValue : defaultValue;
}
/**
* 提取设备列表中的 registrationId 集合,用于判断当前设备和检测清理是否收敛。
*
* @param devices 极光 alias 可见设备
* @return 去重后的设备标识集合
*/
private Set<String> registrationIds(List<AliasGetResult.Data> devices) {
Set<String> registrationIds = new HashSet<>();
for (AliasGetResult.Data device : devices) {
if (device != null && StrUtil.isNotBlank(device.getRegistrationId())) {
registrationIds.add(device.getRegistrationId());
}
}
return registrationIds;
}
/**
* 从极光 SDK 异常中读取业务错误码,结构不完整时返回空值。
*
* @param exception 极光 API 异常
* @return 极光业务错误码,无法读取时为 {@code null}
*/
private Integer extractErrorCode(ApiErrorException exception) {
if (exception == null || exception.getApiError() == null || exception.getApiError().getError() == null) {
return null;
}
return exception.getApiError().getError().getCode();
}
/**
* 记录不含完整 registrationId 的极光错误,便于区分容量、鉴权和网络问题。
*
* @param action 失败操作
* @param userId 当前认证用户ID
* @param registrationId 当前设备标识
* @param exception 极光 API 异常
*/
private void logJgError(String action, Long userId, String registrationId, ApiErrorException exception) {
log.warn("|{}|{},userId:|{}|,registrationId:|{}|,code:|{}|,httpStatus:|{}|",
TAG, action, userId, maskRegistrationId(registrationId), extractErrorCode(exception), exception.getStats());
}
/**
* 脱敏极光设备标识,仅保留首尾四位用于定位同一设备。
*
* @param registrationId 原始极光设备标识
* @return 不可直接用于推送的脱敏标识
*/
private String maskRegistrationId(String registrationId) {
if (StrUtil.isBlank(registrationId)) {
return "";
}
String value = registrationId.trim();
if (value.length() <= 8) {
return "****";
}
return value.substring(0, 4) + "****" + value.substring(value.length() - 4);
}
/**
* 单次绑定前清理结果,用于记录可见设备数量和当前设备是否已绑定。
*
* @param initialVisibleCount 首轮查询可见设备数,最多为极光默认返回上限 10
* @param remainingVisibleCount 清理结束时可见设备数
* @param removedCount 本次累计解除的旧设备数量
* @param currentDeviceBound 清理结束时当前设备是否已经绑定该 alias
*/
private record CleanupResult(int initialVisibleCount, int remainingVisibleCount, int removedCount, boolean currentDeviceBound) {
}
}
@@ -227,4 +227,10 @@ jg:
master-secret: 2a253b41ac1eaa96558c56ef
large-icon: https://public-com.zianxin.com/logo/60x60.png
small-icon-uri: https://public-com.zianxin.com/logo/320x320.png
is-prod: true
is-prod: true
stale-days: 30
max-bindings: 9
test-max-bindings: 3
test-user-ids:
- 2029755089492066305
- 2034824566572941314
@@ -0,0 +1,365 @@
package com.bsd.pjcloud.thirdparty.service.impl.jg;
import cn.jiguang.sdk.api.DeviceApi;
import cn.jiguang.sdk.bean.device.AliasDeleteParam;
import cn.jiguang.sdk.bean.device.AliasGetResult;
import cn.jiguang.sdk.bean.device.DeviceSetParam;
import cn.jiguang.sdk.exception.ApiErrorException;
import com.bsd.pjcloud.thirdparty.config.JgConfig;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.script.RedisScript;
import java.time.Duration;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 极光用户设备管理单元测试,使用模拟 DeviceApi 验证清理、容量、重试和精确解绑规则。
*/
@ExtendWith(MockitoExtension.class)
class ThirdJgDeviceServiceImplTest {
/**
* 普通测试用户ID,不在测试账号配置名单中。
*/
private static final Long NORMAL_USER_ID = 1001L;
/**
* 测试账号用户ID,绑定完成后只允许保留三台设备。
*/
private static final Long TEST_USER_ID = 2001L;
/**
* 当前设备标识,所有清理场景均需保证不会删除该值。
*/
private static final String CURRENT_REGISTRATION_ID = "current-registration-id";
/**
* 设备在线日期采用的业务时区,与生产清理逻辑保持一致。
*/
private static final ZoneId BUSINESS_ZONE_ID = ZoneId.of("Asia/Shanghai");
/**
* 极光设备管理 SDK 模拟对象。
*/
@Mock
private DeviceApi deviceApi;
/**
* Redis 模板模拟对象,用于验证锁获取和安全释放。
*/
@Mock
private StringRedisTemplate stringRedisTemplate;
/**
* Redis ValueOperations 模拟对象,用于控制锁获取结果。
*/
@Mock
private ValueOperations<String, String> valueOperations;
/**
* 每个测试使用的设备容量配置。
*/
private JgConfig jgConfig;
/**
* 被测试的极光设备管理服务。
*/
private ThirdJgDeviceServiceImpl service;
/**
* 初始化普通账号九台、测试账号三台及三十天过期策略,并默认让 Redis 锁立即成功。
*/
@BeforeEach
void setUp() {
JgConfig.JgPush jgPush = new JgConfig.JgPush();
jgPush.setStaleDays(30);
jgPush.setMaxBindings(9);
jgPush.setTestMaxBindings(3);
jgPush.setTestUserIds(List.of(TEST_USER_ID));
jgConfig = new JgConfig();
jgConfig.setJgPush(jgPush);
lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
lenient().when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true);
service = new ThirdJgDeviceServiceImpl(deviceApi, jgConfig, stringRedisTemplate);
}
/**
* 最后在线日期严格早于三十天边界时应删除,边界当天不删除。
*/
@Test
void bindShouldDeleteOnlyDevicesStrictlyOlderThanThirtyDays() {
LocalDate cutoff = LocalDate.now(BUSINESS_ZONE_ID).minusDays(30);
when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID)))
.thenReturn(aliasResult(List.of(device("expired-device", cutoff.minusDays(1)), device("boundary-device", cutoff))))
.thenReturn(aliasResult(List.of(device("boundary-device", cutoff))));
service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID);
assertEquals(List.of("expired-device"), captureRemovedRegistrationIds().get(0));
verify(deviceApi).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class));
}
/**
* 极光未返回最后在线日期的异常设备应按最旧设备优先清理。
*/
@Test
void bindShouldTreatMissingLastOnlineDateAsOldest() {
when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID)))
.thenReturn(aliasResult(List.of(device("missing-date-device", null))))
.thenReturn(aliasResult(List.of()));
service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID);
assertEquals(List.of("missing-date-device"), captureRemovedRegistrationIds().get(0));
}
/**
* 普通账号绑定新设备前最多保留八台旧设备,绑定完成后估算总量为九台。
*/
@Test
void bindShouldKeepAtMostEightOldDevicesForNormalUser() {
List<AliasGetResult.Data> tenDevices = recentDevices("normal", 10);
when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID)))
.thenReturn(aliasResult(tenDevices))
.thenReturn(aliasResult(tenDevices.subList(2, 10)));
service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID);
assertEquals(List.of("normal-0", "normal-1"), captureRemovedRegistrationIds().get(0));
verify(deviceApi).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class));
}
/**
* 测试账号绑定新设备前最多保留两台旧设备,绑定完成后最多三台。
*/
@Test
void bindShouldKeepAtMostTwoOldDevicesForTestUser() {
List<AliasGetResult.Data> fourDevices = recentDevices("test", 4);
when(deviceApi.getAlias(String.valueOf(TEST_USER_ID)))
.thenReturn(aliasResult(fourDevices))
.thenReturn(aliasResult(fourDevices.subList(2, 4)));
service.bind(TEST_USER_ID, CURRENT_REGISTRATION_ID);
assertEquals(List.of("test-0", "test-1"), captureRemovedRegistrationIds().get(0));
verify(deviceApi).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class));
}
/**
* 当前设备即使日期为空也不得进入过期或超量删除列表。
*/
@Test
void bindShouldNeverDeleteCurrentDevice() {
List<AliasGetResult.Data> devices = new ArrayList<>();
devices.add(device(CURRENT_REGISTRATION_ID, null));
devices.addAll(recentDevices("other", 9));
List<AliasGetResult.Data> afterCleanup = new ArrayList<>();
afterCleanup.add(device(CURRENT_REGISTRATION_ID, null));
afterCleanup.addAll(recentDevices("other", 8).subList(1, 8));
afterCleanup.add(device("other-8", LocalDate.now(BUSINESS_ZONE_ID)));
when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID)))
.thenReturn(aliasResult(devices))
.thenReturn(aliasResult(afterCleanup));
service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID);
List<String> removed = captureRemovedRegistrationIds().stream().flatMap(List::stream).toList();
assertTrue(!removed.contains(CURRENT_REGISTRATION_ID));
assertEquals(List.of("other-0"), removed);
}
/**
* 当前设备已经绑定且总量未超限时不清理其他设备,重复绑定保持幂等。
*/
@Test
void bindShouldBeIdempotentWhenCurrentDeviceAlreadyBound() {
List<AliasGetResult.Data> devices = new ArrayList<>();
devices.add(device(CURRENT_REGISTRATION_ID, LocalDate.now(BUSINESS_ZONE_ID)));
devices.addAll(recentDevices("other", 8));
when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID))).thenReturn(aliasResult(devices));
service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID);
verify(deviceApi, never()).deleteAliases(anyString(), any(AliasDeleteParam.class));
verify(deviceApi).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class));
}
/**
* alias 实际超过十台且查询只能返回十台时,应在删除后重新查询直至旧设备降到八台。
*/
@Test
void bindShouldCleanAcrossMultipleTenItemQueries() {
LocalDate cutoff = LocalDate.now(BUSINESS_ZONE_ID).minusDays(30);
List<AliasGetResult.Data> firstTen = recentDevices("device", 10);
firstTen.set(0, device("expired-device", cutoff.minusDays(1)));
List<AliasGetResult.Data> nextTen = recentDevices("hidden", 10);
List<AliasGetResult.Data> finalEight = nextTen.subList(2, 10);
when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID)))
.thenReturn(aliasResult(firstTen))
.thenReturn(aliasResult(nextTen))
.thenReturn(aliasResult(finalEight));
service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID);
List<List<String>> removals = captureRemovedRegistrationIds();
assertEquals(2, removals.size());
assertEquals(List.of("expired-device"), removals.get(0));
assertEquals(List.of("hidden-0", "hidden-1"), removals.get(1));
}
/**
* 退出解绑只应移除请求中的当前 registrationId,不得调用删除整个 alias 的接口。
*/
@Test
void unbindShouldRemoveOnlyCurrentRegistrationId() {
service.unbind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID);
assertEquals(List.of(CURRENT_REGISTRATION_ID), captureRemovedRegistrationIds().get(0));
verify(deviceApi, never()).deleteAlias(anyString());
}
/**
* 极光返回官方 7015 上限错误时,应额外删除最旧非当前设备并只重试一次。
*/
@Test
void bindShouldRemoveOneOldestDeviceAndRetryOnceFor7015() {
List<AliasGetResult.Data> initial = recentDevices("initial", 8);
List<AliasGetResult.Data> limitSnapshot = List.of(
device("oldest-device", LocalDate.now(BUSINESS_ZONE_ID).minusDays(10)),
device("newer-device", LocalDate.now(BUSINESS_ZONE_ID).minusDays(1)));
when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID)))
.thenReturn(aliasResult(initial))
.thenReturn(aliasResult(limitSnapshot));
doThrow(apiError(7015)).doNothing().when(deviceApi).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class));
service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID);
assertEquals(List.of("oldest-device"), captureRemovedRegistrationIds().get(0));
verify(deviceApi, times(2)).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class));
}
/**
* 鉴权、参数或其他极光错误不得通过误删旧设备恢复。
*/
@Test
void bindShouldNotDeleteDevicesForNonLimitError() {
when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID))).thenReturn(aliasResult(recentDevices("device", 8)));
doThrow(apiError(7004)).when(deviceApi).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class));
assertThrows(ApiErrorException.class, () -> service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID));
verify(deviceApi, never()).deleteAliases(anyString(), any(AliasDeleteParam.class));
verify(deviceApi, times(1)).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class));
}
/**
* 首次锁竞争失败时应等待后重试,并在完成后使用 Lua 脚本安全释放锁。
*/
@Test
void bindShouldRetryLockAndReleaseItWithTokenScript() {
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false, true);
when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID))).thenReturn(aliasResult(List.of()));
service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID);
ArgumentCaptor<String> lockTokenCaptor = ArgumentCaptor.forClass(String.class);
verify(valueOperations, times(2)).setIfAbsent(eq("thirdparty:jg-push:alias-lock:" + NORMAL_USER_ID), lockTokenCaptor.capture(), eq(Duration.ofSeconds(60)));
ArgumentCaptor<Object> unlockTokenCaptor = ArgumentCaptor.forClass(Object.class);
verify(stringRedisTemplate).execute(any(RedisScript.class), eq(List.of("thirdparty:jg-push:alias-lock:" + NORMAL_USER_ID)), unlockTokenCaptor.capture());
assertEquals(lockTokenCaptor.getAllValues().get(1), unlockTokenCaptor.getValue());
}
/**
* 构建按传入日期排序的极光 alias 设备记录。
*
* @param registrationId 设备标识
* @param lastOnlineDate 最后在线日期,可为空
* @return 极光设备记录
*/
private AliasGetResult.Data device(String registrationId, LocalDate lastOnlineDate) {
AliasGetResult.Data data = new AliasGetResult.Data();
data.setRegistrationId(registrationId);
data.setLastOnlineDate(lastOnlineDate);
return data;
}
/**
* 构建指定数量的近期在线设备,索引越小日期越旧,便于断言删除顺序。
*
* @param prefix registrationId 前缀
* @param count 设备数量
* @return 按日期从旧到新排列的设备列表
*/
private List<AliasGetResult.Data> recentDevices(String prefix, int count) {
List<AliasGetResult.Data> devices = new ArrayList<>();
LocalDate firstDate = LocalDate.now(BUSINESS_ZONE_ID).minusDays(Math.min(count, 20));
for (int index = 0; index < count; index++) {
devices.add(device(prefix + "-" + index, firstDate.plusDays(index)));
}
return devices;
}
/**
* 构建极光查询 alias 的返回对象。
*
* @param devices 可见设备列表
* @return 极光 alias 查询结果
*/
private AliasGetResult aliasResult(List<AliasGetResult.Data> devices) {
AliasGetResult result = new AliasGetResult();
result.setData(devices);
return result;
}
/**
* 构建带指定业务错误码的极光 SDK 异常。
*
* @param code 极光业务错误码
* @return 可由 DeviceApi 模拟抛出的异常
*/
private ApiErrorException apiError(int code) {
ApiErrorException.ApiError.Error error = new ApiErrorException.ApiError.Error();
error.setCode(code);
error.setMessage("mock error");
ApiErrorException.ApiError apiError = new ApiErrorException.ApiError();
apiError.setError(error);
return new ApiErrorException(400, apiError);
}
/**
* 捕获所有批量解绑请求中的 registrationId,供测试断言删除范围和顺序。
*
* @return 每次极光删除调用对应的设备标识列表
*/
private List<List<String>> captureRemovedRegistrationIds() {
ArgumentCaptor<AliasDeleteParam> captor = ArgumentCaptor.forClass(AliasDeleteParam.class);
verify(deviceApi, org.mockito.Mockito.atLeastOnce()).deleteAliases(anyString(), captor.capture());
return captor.getAllValues().stream().map(param -> param.getRegistrationIds().getRemove()).toList();
}
}
@@ -1,26 +1,50 @@
package com.bsd.pjcloud.admin.api.constant.user;
import cn.hutool.core.util.ObjectUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 用户身份
* 用户身份协议枚举。
* <p>身份编码会写入用户身份表并由 PC、App 等调用端传输,已有编码不得随意变更。</p>
*/
@AllArgsConstructor
@Getter
public enum UserIdentityTypeEnum {
/** 默认用户身份,协议及持久化编码为 {@code DEFAULT}。 */
DEFAULT("DEFAULT", "默认"),
/** 学生身份,协议及持久化编码为 {@code STUDENT}。 */
STUDENT("STUDENT", "学生"),
/** 老师身份,协议及持久化编码为 {@code TEACHER}。 */
TEACHER("TEACHER", "老师"),
;
/** 前后端接口及数据库共同使用的固定身份编码。 */
private final String type;
/** 身份的中文展示名称。 */
private final String description;
/**
* 创建固定用户身份定义。
*
* @param type 前后端接口及数据库共同使用的固定身份编码
* @param description 身份的中文展示名称
*/
UserIdentityTypeEnum(String type, String description) {
this.type = type;
this.description = description;
}
/**
* 按固定协议编码解析用户身份。
*
* @param type 前端提交或数据库读取的身份编码
* @return 匹配的身份枚举;编码为空或不支持时返回 {@code null}
*/
public static UserIdentityTypeEnum valueOfByType(String type) {
if (ObjectUtil.isEmpty(type)) return null;
return Arrays.stream(values())
@@ -0,0 +1,27 @@
package com.bsd.pjcloud.admin.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 删除用户请求项。
* <p>流程信息来自用户列表快照,后端删除时仍以数据库中的实时审批状态和流程ID为准。</p>
*/
@Data
@Schema(description = "删除用户请求项")
public class SysUserDeleteDto {
/**
* 待删除的系统用户ID。
*/
@NotNull(message = "用户ID不能为空")
@Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long userId;
/**
* 前端列表中的审批流程实例ID快照;允许为空,不作为后端取消流程的可信数据源。
*/
@Schema(description = "审批流程实例ID快照")
private String processId;
}
@@ -10,14 +10,14 @@ import lombok.Data;
import java.util.List;
/**
* 监护人代被监护人提交人脸审核。
* 当前用户为本人或有效被监护人提交人脸审核。
*/
@Data
@Schema(description = "监护人代被监护人提交人脸审核")
@Schema(description = "为本人或有效被监护人提交人脸审核")
public class SysUserFaceGuardianWardCreateDto {
@NotNull(message = "被监护人不能为空")
@Schema(description = "被监护人用户ID")
@NotNull(message = "目标用户不能为空")
@Schema(description = "本人或有效被监护人用户ID")
private Long wardUserId;
@NotEmpty(message = "请上传人脸图片")
@@ -20,6 +20,7 @@
package com.bsd.pjcloud.admin.api.dto;
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.SysUser;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -67,6 +68,12 @@ public class UserDTO extends SysUser implements BaseIsIncludeTreeInterface {
@Schema(description = "学生身份扩展信息")
private SysUserStudentDto studentInfo;
/**
* 老师身份扩展信息,与学生身份扩展信息独立传输。
*/
@Schema(description = "老师身份扩展信息")
private SysUserTeacherDto teacherInfo;
/**
* 新密码
*/
@@ -101,6 +101,12 @@ public class SysUserRelationCreateUserDto {
@Schema(description = "学生身份扩展信息")
private SysUserStudentDto studentInfo;
/**
* 老师身份扩展信息,与学生身份扩展信息独立传输。
*/
@Schema(description = "老师身份扩展信息")
private SysUserTeacherDto teacherInfo;
@AssertTrue(message = "关系主语用户和关系对象用户必须且只能填写一个")
@Schema(hidden = true)
public boolean isRelationEndpointValid() {
@@ -0,0 +1,19 @@
package com.bsd.pjcloud.admin.api.dto.user;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 老师身份扩展信息。
* <p>老师扩展信息与学生扩展信息独立传输,当前仅保存老师学号。</p>
*/
@Data
@Schema(description = "老师身份扩展信息")
public class SysUserTeacherDto {
/**
* 老师学号,由老师用户表单或老师 Excel 模板提供。
*/
@Schema(description = "老师学号")
private String teacherNo;
}
@@ -0,0 +1,59 @@
package com.bsd.pjcloud.admin.api.entity.user;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 老师用户扩展信息表实体。
* <p>以用户 ID 关联系统用户,仅承载老师身份专属的学号,避免复用学生扩展表。</p>
*/
@Data
@TenantTable
@TableName("sys_user_teacher")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "老师用户扩展信息表")
public class SysUserTeacherEntity extends Model<SysUserTeacherEntity> {
/**
* 系统用户 ID,同时作为老师扩展表主键,来源于 {@code sys_user.user_id}。
*/
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "系统用户ID")
private Long userId;
/**
* 老师学号,在同一租户内必须唯一。
*/
@Schema(description = "老师学号")
private String teacherNo;
/**
* 老师扩展记录创建时间,由 MyBatis-Plus 插入填充器维护。
*/
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建时间")
private LocalDateTime createTime;
/**
* 老师扩展记录最后修改时间,由 MyBatis-Plus更新填充器维护。
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改时间")
private LocalDateTime updateTime;
/**
* 所属租户 ID,由多租户数据层写入并用于数据隔离。
*/
@Schema(description = "所属租户ID")
private Long tenantId;
}
@@ -24,4 +24,14 @@ public interface RemoteUserRelationService {
@NoToken
@GetMapping("/sysUserRelation/inner/guardian-ward/user-ids")
R<List<Long>> listActiveGuardianWardUserIds(@RequestParam("guardianUserId") Long guardianUserId);
/**
* 查询指定用户所在完整关系图谱中的其他用户 ID。
*
* @param userId 当前用户 ID
* @return 同一关系图谱中的其他用户 ID 列表,不包含当前用户自身
*/
@NoToken
@GetMapping("/sysUserRelation/inner/related-user/user-ids")
R<List<Long>> listRelatedUserIds(@RequestParam("userId") Long userId);
}
@@ -0,0 +1,38 @@
package com.bsd.pjcloud.admin.api.vo;
import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.bsd.pjcloud.common.excel.annotation.ExcelLine;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* 学生家长用户导入模板对应的数据模型。
* <p>手机号同时作为登录账号和初始密码;姓名允许为空,导入时按“手机号 + 学生家长”生成默认姓名。</p>
*/
@Data
@ColumnWidth(30)
public class GuardianUserExcelVO {
/** Excel 原始行号,仅用于向调用端定位导入错误,不参与持久化。 */
@ExcelLine
@ExcelIgnore
private Long lineNum;
/** 学生家长姓名;为空时由导入服务按手机号生成默认姓名。 */
@ExcelProperty("姓名")
private String name;
/** 学生家长手机号,同时作为登录账号、默认身份手机号和初始密码。 */
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
@ExcelProperty("手机号")
private String phone;
/** 所属部门名称或路径,必须匹配当前租户已有部门。 */
@NotBlank(message = "部门不能为空")
@ExcelProperty("部门")
private String deptName;
}
@@ -4,22 +4,22 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 被监护人人脸审核撤回结果。
* 本人或被监护人人脸审核撤回结果。
*/
@Data
@Schema(description = "被监护人人脸审核撤回结果")
@Schema(description = "本人或被监护人人脸审核撤回结果")
public class SysUserFaceGuardianWardWithdrawVo {
/**
* 被监护人用户ID
* 目标用户ID
*/
@Schema(description = "被监护人用户ID")
@Schema(description = "本人或有效被监护人用户ID")
private Long wardUserId;
/**
* 被监护人姓名
* 目标用户姓名
*/
@Schema(description = "被监护人姓名")
@Schema(description = "本人或有效被监护人姓名")
private String wardName;
/**
@@ -0,0 +1,50 @@
package com.bsd.pjcloud.admin.api.vo;
import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.bsd.pjcloud.common.excel.annotation.ExcelLine;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 老师用户导入模板对应的数据模型。
* <p>模板中的手机号作为登录账号,“学号”写入老师扩展表,不复用学生扩展数据。</p>
*/
@Data
@ColumnWidth(30)
public class TeacherUserExcelVO {
/** Excel 原始行号,仅用于向调用端定位导入错误,不参与持久化。 */
@ExcelLine
@ExcelIgnore
private Long lineNum;
/** 老师姓名,写入用户姓名和昵称。 */
@NotBlank(message = "姓名不能为空")
@ExcelProperty("姓名")
private String name;
/** 老师手机号,同时作为默认身份绑定手机号和首次登录密码。 */
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
@ExcelProperty("手机号")
private String phone;
/** 所属部门名称,用于匹配当前租户已有部门。 */
@NotBlank(message = "部门不能为空")
@ExcelProperty("部门")
private String deptName;
/**
* 老师模板中的学号,写入老师扩展表保存。
* <p>长度允许 2 至 60 个字符,且不能包含中文。</p>
*/
@NotBlank(message = "学号不能为空")
@Size(min = 2, max = 60, message = "学号长度必须介于2到60个字符之间")
@Pattern(regexp = "^[^\\u4e00-\\u9fa5]+$", message = "学号不能包含中文")
@ExcelProperty("学号")
private String teacherNo;
}
@@ -20,6 +20,7 @@
package com.bsd.pjcloud.admin.api.vo;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseFacesInterface;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.bsd.pjcloud.admin.api.entity.SysPost;
@@ -230,6 +231,12 @@ public class UserVO implements BaseFacesInterface, Serializable {
@Schema(description = "学生身份扩展信息")
private SysUserStudentDto studentInfo;
/**
* 老师身份扩展信息,与学生身份扩展信息独立返回。
*/
@Schema(description = "老师身份扩展信息")
private SysUserTeacherDto teacherInfo;
/**
* 昵称
*/
+5
View File
@@ -33,6 +33,11 @@
<description>pjcloud 通用用户权限管理系统业务处理模块</description>
<dependencies>
<!--通用异步事务任务与 Redis 进度管理-->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-async-task</artifactId>
</dependency>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-encrypt-api</artifactId>
@@ -27,6 +27,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.ForgotPasswordDTO;
import com.bsd.pjcloud.admin.api.dto.SysUserDeleteDto;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.dto.UserDeptSimpleInfo;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
@@ -45,6 +46,7 @@ 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;
import com.bsd.pjcloud.common.security.annotation.Inner;
@@ -93,6 +95,9 @@ public class SysUserController {
private final SysUserService userService;
/** 用户 Excel 导入导出服务,统一承接用户模块的文件转换与导入编排。 */
private final SysUserImportExportService userImportExportService;
private final RemoterVisitorService remoterVisitorService;
private final SysDeptService sysDeptService;
@@ -247,17 +252,24 @@ public class SysUserController {
}
/**
* 删除用户信息
* 删除用户信息;审核中的用户会先取消当前审批流程。
*
* @param ids ID
* @return R
* @param users 待删除用户及其前端流程快照
* @return 删除结果;请求为空或删除前置校验失败时返回失败信息
*/
@SysLog("删除用户信息")
@DeleteMapping
@HasPermission("sys_user_del")
@Operation(summary = "删除用户", description = "根据ID删除用户")
public R userDel(@RequestBody Long[] ids) {
return R.ok(userService.deleteUserByIds(ids));
@Operation(summary = "删除用户", description = "根据ID删除用户,审核中的用户会先取消审批流程")
public R<Boolean> userDel(@Valid @RequestBody List<SysUserDeleteDto> users) {
if (CollUtil.isEmpty(users)) {
return R.failed("请选择要删除的用户");
}
if (users.stream().anyMatch(user -> user == null || user.getUserId() == null)) {
return R.failed("用户ID不能为空");
}
Long[] userIds = users.stream().map(SysUserDeleteDto::getUserId).distinct().toArray(Long[]::new);
return R.ok(userService.deleteUserByIds(userIds));
}
/**
@@ -389,40 +401,88 @@ public class SysUserController {
}
/**
* 导出excel 表格
* 按当前数据权限导出用户 Excel。
*
* @param userDTO 查询条件
* @return
* @param userDTO 用户筛选条件
* @return 可由响应转换器直接写入 Excel 的用户数据
*/
@ResponseExcel
@PostMapping("/export")
@HasPermission("sys_user_export")
public List export(@RequestBody UserDTO userDTO) {
return userService.listUser(userDTO, null);
public List<UserExcelVO> export(@RequestBody UserDTO userDTO) {
return userImportExportService.listUser(userDTO, null);
}
/**
* 导入用户
* 导入后台用户基础信息,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。
*
* @param excelVOList Excel 中解析出的后台用户数据
* @param bindingResult Excel 通用校验产生的错误信息
* @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度
*/
@PostMapping("/import")
@HasPermission("sys_user_export")
public R importUser(@RequestExcel List<UserExcelVO> excelVOList, BindingResult bindingResult) {
return userService.importUser(excelVOList, bindingResult);
public R<?> importUser(@RequestExcel List<UserExcelVO> excelVOList, BindingResult bindingResult) {
return userImportExportService.importUser(excelVOList, bindingResult);
}
/**
* 导入学生用户
* 导入学生用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。
*
* @param studentUserExcelList Excel 中解析出的学生用户数据
* @param bindingResult Excel 通用校验产生的错误信息
* @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度
*/
@PostMapping("/import-student")
@HasPermission("sys_student_user_export")
public R importStudentUser(@RequestExcel List<StudentUserExcelVO> studentUserExcelList, BindingResult bindingResult) {
return userService.importStudentUser(studentUserExcelList, bindingResult);
@Idempotent(expireTime = 5, key = "'/user/import-student:' + #studentUserExcelList.hashCode() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()", info = "正在导入中,请勿重复操作")
public R<?> importStudentUser(@RequestExcel List<StudentUserExcelVO> studentUserExcelList, BindingResult bindingResult) {
return userImportExportService.importStudentUser(studentUserExcelList, bindingResult);
}
/**
* 导入学生家长用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。
* <p>手机号和部门必填;姓名为空时按“手机号 + 学生家长”生成,手机号同时作为登录账号和初始密码。</p>
*
* @param guardianUserExcelList Excel 中解析出的学生家长用户数据
* @param bindingResult Excel 通用校验产生的错误信息
* @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度
*/
@PostMapping("/import-guardian")
@HasPermission("sys_guardian_user_export")
@Idempotent(expireTime = 5, key = "'/user/import-guardian:' + #guardianUserExcelList.hashCode() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()", info = "正在导入中,请勿重复操作")
public R<?> importGuardianUser(@RequestExcel(ignoreEmptyRow = true) List<GuardianUserExcelVO> guardianUserExcelList, BindingResult bindingResult) {
return userImportExportService.importGuardianUser(guardianUserExcelList, bindingResult);
}
/**
* 导入老师用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。
* <p>模板中的手机号作为登录账号,学号写入老师扩展表,老师身份单独保存为 TEACHER。</p>
*
* @param teacherUserExcelList Excel 中解析出的老师用户数据
* @param bindingResult Excel 通用校验产生的错误信息
* @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度
*/
@PostMapping("/import-teacher")
@HasPermission("sys_teacher_user_export")
@Idempotent(expireTime = 5, key = "'/user/import-teacher:' + #teacherUserExcelList.hashCode() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()", info = "正在导入中,请勿重复操作")
public R<?> importTeacherUser(@RequestExcel List<TeacherUserExcelVO> teacherUserExcelList, BindingResult bindingResult) {
return userImportExportService.importTeacherUser(teacherUserExcelList, bindingResult);
}
/**
* 导入用户及 Excel 中内嵌的人脸图片,接口同步校验并复制图片字节,校验通过后返回异步任务供调用端轮询。
*
* @param file 原始 Excel 文件,用于读取内嵌的人脸图片
* @param excelList Excel 中解析出的用户数据
* @param bindingResult Excel 通用校验产生的错误信息
* @return 校验失败时包含校验或图片处理错误;校验通过时包含异步任务 ID 和初始进度
*/
@PostMapping("/import-face")
@HasPermission("sys_user_export")
public R importUserFace(@RequestParam("file") MultipartFile file, @RequestExcel List<UserExcelFaceVo> excelList, BindingResult bindingResult) {
return userService.importUserFace(file, excelList, bindingResult);
@Idempotent(expireTime = 5, key = "'/user/import-face:' + #file.getSize() + ':' + #excelList.hashCode() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()", info = "正在导入中,请勿重复操作")
public R<?> importUserFace(@RequestParam("file") MultipartFile file, @RequestExcel List<UserExcelFaceVo> excelList, BindingResult bindingResult) {
return userImportExportService.importUserFace(file, excelList, bindingResult);
}
/**
@@ -107,10 +107,10 @@ public class SysUserFaceController {
}
/**
* 监护人代被监护人提交人脸审核。
* 当前用户为本人或有效被监护人提交人脸,是否审核由提交人角色决定。
*/
@Operation(summary = "监护人提交被监护人人脸", description = "仅监护关系中的监护人可提交,所有图片必须进入审核流程")
@SysLog("监护人提交被监护人人脸")
@Operation(summary = "提交本人或被监护人人脸", description = "仅可为本人或有效被监护人提交,拥有指定角色时进入审核流程,否则直接通过")
@SysLog("提交本人或被监护人人脸")
@ApiCryptoAes
@PostMapping("/app/guardian-ward")
@Idempotent(expireTime = 10, info = "正在处理中,请勿重复操作")
@@ -129,9 +129,9 @@ public class SysUserFaceController {
}
/**
* 分页查询当前监护人名下被监护人的人脸审核记录。
* 分页查询当前用户的有效被监护人,或显式指定的本人/有效被监护人人脸审核记录。
*/
@Operation(summary = "查询被监护人人脸审核记录", description = "查询当前用户全部有效被监护人的审核记录,并返回被监护人姓名")
@Operation(summary = "查询本人或被监护人人脸审核记录", description = "不指定目标时查询全部有效被监护人,指定目标时允许本人或有效被监护人")
@ApiCryptoAes
@GetMapping("/app/guardian-ward/page")
public R<Page<SysUserFaceVo>> pageGuardianWardFaces(@ParameterObject Page page, @RequestParam(required = false) Long wardUserId, @RequestParam(required = false) String processStatus) {
@@ -139,9 +139,9 @@ public class SysUserFaceController {
}
/**
* 查询当前监护人名下被监护人的人脸审核详情。
* 查询当前用户本人或其有效被监护人的人脸审核详情。
*/
@Operation(summary = "查询被监护人人脸审核详情", description = "仅被监护人的有效监护人可以查看")
@Operation(summary = "查询本人或被监护人人脸审核详情", description = "仅本人或目标用户的有效监护人可以查看")
@ApiCryptoAes
@GetMapping("/app/guardian-ward/detail")
public R<SysUserFaceVo> getGuardianWardFaceDetail(@RequestParam Long id) {
@@ -149,10 +149,10 @@ public class SysUserFaceController {
}
/**
* 撤回并删除当前监护人名下被监护人仍在审核中的人脸记录。
* 撤回并删除当前用户本人或其有效被监护人仍在审核中的人脸记录。
*/
@Operation(summary = "撤回被监护人人脸审核", description = "仅审核中的记录可以撤回,撤回后删除旧记录并返回原图供重新编辑")
@SysLog("撤回被监护人人脸审核")
@Operation(summary = "撤回本人或被监护人人脸审核", description = "仅审核中的记录可以撤回,撤回后删除旧记录并返回原图供重新编辑")
@SysLog("撤回本人或被监护人人脸审核")
@ApiCryptoAes
@PutMapping("/app/guardian-ward/cancel")
@Idempotent(expireTime = 3, info = "正在处理中,请勿重复操作")
@@ -63,6 +63,18 @@ public class SysUserRelationController {
return R.ok(sysUserRelationService.listActiveGuardianWardUserIds(guardianUserId));
}
/**
* 内部查询指定用户所在完整关系图谱中的其他用户 ID。
*
* @param userId 当前用户 ID
* @return 同一关系图谱中的其他用户 ID 列表,不包含当前用户自身
*/
@Inner
@GetMapping("/inner/related-user/user-ids")
public R<List<Long>> listRelatedUserIds(@RequestParam Long userId) {
return R.ok(sysUserRelationService.listRelatedUserIds(userId));
}
/**
* 查询用户关系图谱
*/
@@ -36,6 +36,14 @@ public final class AppleIdentityTokenVerifier {
private AppleIdentityTokenVerifier() {
}
/**
* 使用 Apple 公钥校验身份令牌的签名、发行方、受众和有效期,并返回令牌中的用户唯一标识。
*
* @param identityToken App 端本次 Apple 授权返回的 JWT,不允许为空,也不应缓存后重复使用
* @param audience Apple 登录配置的 App Bundle ID,用于匹配 JWT 的 audience
* @return Apple 为当前应用用户签发的稳定 subject 标识
* @throws CheckedException 令牌为空、配置缺失、签名或声明不合法、令牌过期时抛出
*/
public static String verifyAndGetSubject(String identityToken, String audience) {
if (StrUtil.isBlank(identityToken)) {
throw new CheckedException("Apple身份令牌为空");
@@ -65,8 +73,13 @@ public final class AppleIdentityTokenVerifier {
if (aud == null || !aud.contains(audience)) {
throw new CheckedException("Apple身份令牌audience不匹配");
}
Date issuedAt = claims.getIssueTime();
Date exp = claims.getExpirationTime();
if (exp == null || exp.before(new Date())) {
Date now = new Date();
if (exp == null || exp.before(now)) {
// 仅记录时间声明用于定位旧令牌或服务器时钟问题,严禁输出完整身份令牌。
log.warn("Apple identityToken时间校验失败: iat={}, exp={}, now={}, expiredMs={}", issuedAt, exp, now,
exp == null ? null : now.getTime() - exp.getTime());
throw new CheckedException("Apple身份令牌已过期");
}
String sub = claims.getSubject();
@@ -0,0 +1,12 @@
package com.bsd.pjcloud.admin.mapper.user;
import com.bsd.pjcloud.admin.api.entity.user.SysUserTeacherEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 老师用户扩展信息数据访问接口。
*/
@Mapper
public interface SysUserTeacherMapper extends PjcloudBaseMapper<SysUserTeacherEntity> {
}
@@ -21,7 +21,10 @@ public interface SysUserFaceService extends IService<SysUserFaceEntity> {
Long create(SysUserFaceEntity sysUserFace);
/**
* 监护人代被监护人提交人脸,并强制进入审核流程。
* 当前用户为本人或有效被监护人提交人脸,并根据提交人角色决定是否进入审核流程。
*
* @param dto 目标用户及人脸图片
* @return 新增的人脸记录ID列表
*/
List<Long> createGuardianWardFaces(SysUserFaceGuardianWardCreateDto dto);
@@ -36,17 +39,17 @@ public interface SysUserFaceService extends IService<SysUserFaceEntity> {
List<SysUserFaceStatusCountVo> listStatusCounts(List<Long> userIds);
/**
* 分页查询当前监护人名下全部或指定被监护人的人脸审核记录。
* 分页查询当前用户的有效被监护人,或显式指定的本人/有效被监护人人脸审核记录。
*/
Page<SysUserFaceVo> pageGuardianWardFaces(Page page, Long wardUserId, String processStatus);
/**
* 查询当前监护人名下被监护人的人脸审核详情。
* 查询当前用户本人或其有效被监护人的人脸审核详情。
*/
SysUserFaceVo getGuardianWardFaceDetail(Long id);
/**
* 撤回并删除当前监护人名下被监护人仍在审核中的人脸记录。
* 撤回并删除当前用户本人或其有效被监护人仍在审核中的人脸记录。
*/
SysUserFaceGuardianWardWithdrawVo cancelGuardianWardFace(Long id);
@@ -0,0 +1,75 @@
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;
import com.bsd.pjcloud.admin.api.vo.UserExcelFaceVo;
import com.bsd.pjcloud.admin.api.vo.UserExcelVO;
import com.bsd.pjcloud.common.core.util.R;
import org.springframework.validation.BindingResult;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* 用户模块导入导出服务。
* <p>统一承接后台用户、学生、学生家长、老师和人脸 Excel 导入以及用户导出,避免导入导出编排继续膨胀用户核心服务。</p>
*/
public interface SysUserImportExportService {
/**
* 按查询条件和数据权限生成后台用户导出数据。
*
* @param userDTO 用户筛选条件
* @param ids 指定导出的用户 ID;为空时按筛选条件导出
* @return 可由 Excel 响应转换器直接输出的用户列表
*/
List<UserExcelVO> listUser(UserDTO userDTO, Long[] ids);
/**
* 导入后台用户基础信息;同步完成整份 Excel 校验,任一行错误均不创建任务,校验通过后异步统一写入。
*
* @param excelVOList Excel 解析出的后台用户数据
* @param bindingResult Excel 通用校验结果,错误目标包含原始行号
* @return 校验失败时返回对应行号和原因;校验通过时返回可供轮询的异步任务进度
*/
R<?> importUser(List<UserExcelVO> excelVOList, BindingResult bindingResult);
/**
* 导入学生用户、学生扩展信息及监护关系;同步完成整表校验,任一行错误均不创建任务,校验通过后异步统一写入。
*
* @param studentUserExcelList Excel 解析出的学生数据
* @param bindingResult Excel 通用校验结果,错误目标包含原始行号
* @return 校验失败时返回对应行号和原因;校验通过时返回可供轮询的异步任务进度
*/
R<?> importStudentUser(List<StudentUserExcelVO> studentUserExcelList, BindingResult bindingResult);
/**
* 导入学生家长用户,并为其保存默认身份;同步完成整表校验,任一行错误均不创建任务,校验通过后异步统一写入。
*
* @param guardianUserExcelList Excel 解析出的学生家长数据
* @param bindingResult Excel 通用校验结果,错误目标包含原始行号
* @return 校验失败时返回对应行号和原因;校验通过时返回可供轮询的异步任务进度
*/
R<?> importGuardianUser(List<GuardianUserExcelVO> guardianUserExcelList, BindingResult bindingResult);
/**
* 导入老师用户,并为其保存默认身份和老师身份;同步完成整表校验,任一行错误均不创建任务,校验通过后异步统一写入。
*
* @param teacherUserExcelList Excel 解析出的老师数据
* @param bindingResult Excel 通用校验结果,错误目标包含原始行号
* @return 校验失败时返回对应行号和原因;校验通过时返回可供轮询的异步任务进度
*/
R<?> importTeacherUser(List<TeacherUserExcelVO> teacherUserExcelList, BindingResult bindingResult);
/**
* 导入后台用户及 Excel 中内嵌的人脸图片;同步完成整表校验和图片复制,任一行错误均不创建任务,校验通过后异步统一写入。
*
* @param file 原始 Excel 文件,用于读取内嵌图片
* @param excelList Excel 解析出的用户数据
* @param bindingResult Excel 通用校验结果,错误目标包含原始行号
* @return 校验失败时返回对应行号和原因;校验通过时返回可供轮询的异步任务进度
*/
R<?> importUserFace(MultipartFile file, List<UserExcelFaceVo> excelList, BindingResult bindingResult);
}
@@ -37,6 +37,14 @@ public interface SysUserRelationService extends IService<SysUserRelationEntity>
*/
List<Long> listActiveGuardianWardUserIds(Long guardianUserId);
/**
* 查询指定用户所在完整关系图谱中的其他用户 ID。
*
* @param userId 当前用户 ID
* @return 同一关系图谱中的其他用户 ID 列表,不包含当前用户自身
*/
List<Long> listRelatedUserIds(Long userId);
/**
* 查询指定监护人当前有效的被监护人选择项。
*
@@ -32,9 +32,6 @@ import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto;
import org.springframework.validation.BindingResult;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
@@ -64,10 +61,10 @@ public interface SysUserService extends IService<SysUser> {
IPage<UserVO> innerPage(Page page, UserDTO userDTO);
/**
* 删除用户
* 删除指定用户,并在删除前取消其中仍处于审核中的审批流程。
*
* @param ids 用户
* @return boolean
* @param ids 待删除的用户ID数组,不能为空
* @return 用户及其关联数据进入删除流程后返回 {@code true}
*/
Boolean deleteUserByIds(Long[] ids);
@@ -106,23 +103,6 @@ public interface SysUserService extends IService<SysUser> {
*/
String generateUniqueSysUserUsername();
/**
* 查询全部的用户
*/
List<UserExcelVO> listUser(UserDTO userDTO, Long[] ids);
/**
* excel 导入用户
*/
R importUser(List<UserExcelVO> excelVOList, BindingResult bindingResult);
/**
* 导入学生用户
*/
R importStudentUser(List<StudentUserExcelVO> studentUserExcelList, BindingResult bindingResult);
R importUserFace(MultipartFile file, List<UserExcelFaceVo> excelList, BindingResult bindingResult);
/**
* 注册用户
*
@@ -0,0 +1,19 @@
package com.bsd.pjcloud.admin.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.admin.api.entity.user.SysUserTeacherEntity;
/**
* 老师用户扩展信息服务。
*/
public interface SysUserTeacherService extends IService<SysUserTeacherEntity> {
/**
* 判断当前租户中老师学号是否已被其他用户使用。
*
* @param userId 编辑时排除的当前用户 ID;新增时为空
* @param teacherNo 待校验的老师学号
* @return 存在其他相同老师学号时返回 {@code true}
*/
boolean existsTeacherNo(Long userId, String teacherNo);
}
@@ -11,6 +11,7 @@ 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.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysRole;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo;
@@ -27,6 +28,7 @@ import com.bsd.pjcloud.admin.service.SysUserService;
import com.bsd.pjcloud.admin.utils.flow.FlowUtil;
import com.bsd.pjcloud.admin.utils.flow.dto.UserFaceApplyFlowDto;
import com.bsd.pjcloud.common.core.constant.CommonConstants;
import com.bsd.pjcloud.common.core.constant.CommonRoleConstant;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.exception.CheckedException;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
@@ -72,9 +74,9 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
static private final String TAG = "用户人脸";
/**
* 监护人端允许查看的人脸审核状态。
* App 关系人业务允许查看的人脸审核状态。
*/
private static final List<String> GUARDIAN_WARD_VISIBLE_STATUSES = List.of(
private static final List<String> APP_RELATION_FACE_VISIBLE_STATUSES = List.of(
TaskCheckStatusEnum.PADDING.getCode(),
TaskCheckStatusEnum.RESULT.getCode(),
TaskCheckStatusEnum.REJECT.getCode()
@@ -90,6 +92,12 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
private final FlowUtil flowUtil;
/**
* 新增用户人脸,并根据当前操作人是否拥有“添加人脸需要审批”角色决定审核方式。
*
* @param sysUserFace 待新增的人脸记录,用户ID由调用入口按其业务范围设置
* @return 新增人脸记录ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(SysUserFaceEntity sysUserFace) {
@@ -97,8 +105,8 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
throw new RuntimeException("最多只能上传3张人脸图片");
}
PjcloudUser applyUser = SecurityUtils.getUser();
boolean isDeptAdmin = sysRoleService.isDeptAdmin(applyUser.getId());
if (isDeptAdmin) {
boolean needFaceApproval = needFaceApproval(applyUser.getId());
if (needFaceApproval) {
sysUserFace.setProcessStatus(TaskCheckStatusEnum.PADDING.getCode());
} else {
sysUserFace.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode());
@@ -112,7 +120,7 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
updateObj.setId(sysUserFace.getId());
updateObj.setAutoCode(String.valueOf(count));
baseMapper.updateById(updateObj);
if (isDeptAdmin) {
if (needFaceApproval) {
// 开启审批
SysUser userDetail = sysUserService.getById(applyUser.getId());
getSelf().applyFlow(userDetail, sysUserFace);
@@ -123,10 +131,10 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
}
/**
* 监护人代被监护人提交一至两张人脸图片,所有记录强制进入审核流程。
* 当前用户为本人或有效被监护人提交一至两张人脸图片,并根据提交人角色决定是否进入审核流程。
*
* @param dto 被监护人及人脸图片
* @return 新增的人脸记录ID
* @param dto 目标用户及人脸图片
* @return 新增的人脸记录ID列表
*/
@Override
@Transactional(rollbackFor = Exception.class)
@@ -135,35 +143,56 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
if (new HashSet<>(dto.getFaceImgs()).size() != dto.getFaceImgs().size()) {
throw new RuntimeException("请勿重复提交同一张人脸图片");
}
SysUser guardianUser = sysUserService.getById(loginUser.getId());
if (guardianUser == null) {
throw new RuntimeException("监护人用户不存在");
SysUser applyUser = sysUserService.getById(loginUser.getId());
if (applyUser == null) {
throw new RuntimeException("当前用户不存在");
}
if (sysUserService.getById(dto.getWardUserId()) == null) {
throw new RuntimeException("被监护人用户不存在");
throw new RuntimeException("目标用户不存在");
}
if (!sysUserRelationService.hasActiveGuardianWardRelation(loginUser.getId(), dto.getWardUserId())) {
throw new RuntimeException("当前用户不是该用户的有效监护人,无法代为上传人脸");
validateSelfOrGuardianWardUser(loginUser.getId(), dto.getWardUserId());
boolean needFaceApproval = needFaceApproval(loginUser.getId());
if (needFaceApproval && sysDeptService.getById(applyUser.getDeptId()) == null) {
throw new RuntimeException("当前用户所属部门不存在,无法发起人脸审核");
}
if (sysDeptService.getById(guardianUser.getDeptId()) == null) {
throw new RuntimeException("监护人所属部门不存在,无法发起人脸审核");
}
validateGuardianWardFaceLimit(dto.getWardUserId(), dto.getFaceImgs().size());
validateAppRelationFaceLimit(dto.getWardUserId(), dto.getFaceImgs().size());
List<Long> faceIds = new ArrayList<>();
for (String faceImg : dto.getFaceImgs()) {
SysUserFaceEntity userFace = new SysUserFaceEntity();
userFace.setUserId(dto.getWardUserId());
userFace.setFaceImg(faceImg);
userFace.setProcessStatus(TaskCheckStatusEnum.PADDING.getCode());
if (needFaceApproval) {
userFace.setProcessStatus(TaskCheckStatusEnum.PADDING.getCode());
} else {
userFace.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode());
userFace.setProcessTime(LocalDateTime.now());
}
baseMapper.insert(userFace);
updateFaceAutoCode(userFace);
getSelf().applyFlow(guardianUser, userFace);
if (needFaceApproval) {
getSelf().applyFlow(applyUser, userFace);
}
faceIds.add(userFace.getId());
}
if (!needFaceApproval) {
sendFaceMq(List.of(dto.getWardUserId()), null);
}
return faceIds;
}
/**
* 判断指定提交人上传人脸时是否必须审批;审批规则只认专用角色,不再从其他管理身份推断。
*
* @param applyUserId 人脸提交人的用户ID
* @return 拥有添加人脸审批角色时返回 {@code true},否则返回 {@code false}
*/
private boolean needFaceApproval(Long applyUserId) {
List<SysRole> applyUserRoles = sysRoleService.findRolesByUserId(applyUserId);
return ObjectUtil.isNotEmpty(applyUserRoles) && applyUserRoles.stream()
.anyMatch(role -> ObjectUtil.equals(role.getRoleCode(), CommonRoleConstant.ADD_FACE_NEED_CEHCK));
}
/**
* 查询当前监护人名下可选择的有效被监护人,并仅返回页面展示所需字段。
*
@@ -203,37 +232,35 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
}
/**
* 分页查询当前监护人名下全部或指定被监护人的人脸审核记录。
* 分页查询当前用户的有效被监护人,或显式指定的本人/有效被监护人人脸审核记录。
*/
@Override
public Page<SysUserFaceVo> pageGuardianWardFaces(Page page, Long wardUserId, String processStatus) {
Long guardianUserId = SecurityUtils.getUser().getId();
List<Long> wardUserIds = sysUserRelationService.listActiveGuardianWardUserIds(guardianUserId);
if (wardUserIds.isEmpty()) {
return new Page<>(page.getCurrent(), page.getSize());
}
if (wardUserId != null && !wardUserIds.contains(wardUserId)) {
throw new RuntimeException("当前用户不是该用户的有效监护人,无权查看人脸审核记录");
}
Long loginUserId = SecurityUtils.getUser().getId();
List<Long> wardUserIds = sysUserRelationService.listActiveGuardianWardUserIds(loginUserId);
SysUserFaceDto query = new SysUserFaceDto();
if (wardUserId == null) {
if (wardUserIds.isEmpty()) {
return new Page<>(page.getCurrent(), page.getSize());
}
query.setUserIds(wardUserIds);
} else {
validateSelfOrGuardianWardUser(loginUserId, wardUserId);
query.setUserId(wardUserId);
}
if (StrUtil.isNotBlank(processStatus)) {
if (!GUARDIAN_WARD_VISIBLE_STATUSES.contains(processStatus)) {
if (!APP_RELATION_FACE_VISIBLE_STATUSES.contains(processStatus)) {
throw new RuntimeException("不支持查询该人脸审核状态");
}
query.setProcessStatus(processStatus);
} else {
query.setProcessStatuses(GUARDIAN_WARD_VISIBLE_STATUSES);
query.setProcessStatuses(APP_RELATION_FACE_VISIBLE_STATUSES);
}
return baseMapper.pageJoin(page, query);
}
/**
* 查询当前监护人名下被监护人的人脸审核详情。
* 查询当前用户本人或其有效被监护人的人脸审核详情。
*
* @param id 人脸审核记录ID
* @return 人脸审核详情
@@ -244,15 +271,15 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
if (detail == null) {
throw new RuntimeException("人脸审核记录不存在");
}
validateGuardianWardUser(SecurityUtils.getUser().getId(), detail.getUserId());
validateSelfOrGuardianWardUser(SecurityUtils.getUser().getId(), detail.getUserId());
return detail;
}
/**
* 撤回并删除当前监护人名下被监护人仍在审核中的人脸记录。
* 撤回并删除当前用户本人或其有效被监护人仍在审核中的人脸记录。
*
* @param id 人脸审核记录ID
* @return 重新编辑人脸时需要的被监护人和图片信息
* @return 重新编辑人脸时需要的目标用户和图片信息
*/
@Override
@Transactional(rollbackFor = Exception.class)
@@ -261,7 +288,7 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
if (detail == null) {
throw new RuntimeException("人脸审核记录不存在");
}
validateGuardianWardUser(SecurityUtils.getUser().getId(), detail.getUserId());
validateSelfOrGuardianWardUser(SecurityUtils.getUser().getId(), detail.getUserId());
if (!ObjectUtil.equals(detail.getProcessStatus(), TaskCheckStatusEnum.PADDING.getCode())) {
throw new RuntimeException("只有审核中的人脸记录可以撤销");
}
@@ -291,12 +318,17 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
}
/**
* 校验指定用户是否属于当前监护人的有效被监护人范围。
* 校验目标是否为当前登录用户本人或其有效被监护人,普通关系人不得访问人脸业务。
*
* @param loginUserId 当前登录用户ID
* @param targetUserId 目标用户ID
*/
private void validateGuardianWardUser(Long guardianUserId, Long wardUserId) {
List<Long> wardUserIds = sysUserRelationService.listActiveGuardianWardUserIds(guardianUserId);
if (!wardUserIds.contains(wardUserId)) {
throw new RuntimeException("当前用户不是该用户的有效监护人,无权查看或操作人脸审核记录");
private void validateSelfOrGuardianWardUser(Long loginUserId, Long targetUserId) {
if (Objects.equals(loginUserId, targetUserId)) {
return;
}
if (!sysUserRelationService.hasActiveGuardianWardRelation(loginUserId, targetUserId)) {
throw new RuntimeException("仅可查看或操作本人及有效被监护人的人脸审核记录");
}
}
@@ -312,11 +344,14 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
}
/**
* 校验被监护人审核中和已通过的人脸总数是否超过三张。
* 校验本人或被监护人审核中和已通过的人脸总数是否超过三张。
*
* @param targetUserId 目标用户ID
* @param uploadCount 本次新增图片数量
*/
private void validateGuardianWardFaceLimit(Long wardUserId, int uploadCount) {
private void validateAppRelationFaceLimit(Long targetUserId, int uploadCount) {
Long count = baseMapper.selectCount(new LambdaQueryWrapper<SysUserFaceEntity>()
.eq(SysUserFaceEntity::getUserId, wardUserId)
.eq(SysUserFaceEntity::getUserId, targetUserId)
.in(SysUserFaceEntity::getProcessStatus,
TaskCheckStatusEnum.PADDING.getCode(), TaskCheckStatusEnum.RESULT.getCode()));
if (count + uploadCount > 3) {
@@ -0,0 +1,916 @@
package com.bsd.pjcloud.admin.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Validator;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bsd.pjcloud.admin.api.constant.UserStaffingTypeEnum;
import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum;
import com.bsd.pjcloud.admin.api.constant.user.UserRelationTypeEnum;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationCreateUserDto;
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.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysPost;
import com.bsd.pjcloud.admin.api.entity.SysRole;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity;
import com.bsd.pjcloud.admin.api.entity.user.SysUserRelationTypeEntity;
import com.bsd.pjcloud.admin.api.entity.user.SysUserStudentEntity;
import com.bsd.pjcloud.admin.api.entity.user.SysUserTeacherEntity;
import com.bsd.pjcloud.admin.api.util.PersonNameUtils;
import com.bsd.pjcloud.admin.api.vo.GuardianUserExcelVO;
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;
import com.bsd.pjcloud.admin.service.SysFileService;
import com.bsd.pjcloud.admin.service.SysPostService;
import com.bsd.pjcloud.admin.service.SysRoleService;
import com.bsd.pjcloud.admin.service.SysUserFaceService;
import com.bsd.pjcloud.admin.service.SysUserImportExportService;
import com.bsd.pjcloud.admin.service.SysUserRelationService;
import com.bsd.pjcloud.admin.service.SysUserRelationTypeService;
import com.bsd.pjcloud.admin.service.SysUserService;
import com.bsd.pjcloud.admin.utils.excel.ExcelTool;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.constant.CommonConstants;
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;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.PictureData;
import org.springframework.beans.BeanUtils;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.stereotype.Service;
import org.springframework.validation.BindingResult;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 用户模块导入导出服务实现。
* <p>本类只负责 Excel 数据校验、业务对象组装和导出转换,用户、关系及人脸落库仍由各自核心服务处理。
* 所有导入均先同步完成整表校验,再交给通用异步事务执行器统一写入,避免错误行导致部分数据已入库。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SysUserImportExportServiceImpl implements SysUserImportExportService {
/** 用户导入日志标签,用于定位 Excel 解析或图片处理异常。 */
private static final String LOG_TAG = "系统用户导入导出";
/** 学生学号允许的最大长度,与学生扩展表字段约束保持一致。 */
private static final int STUDENT_NO_MAX_LENGTH = 255;
/** 部门路径分隔规则,同时兼容模板中使用的正斜杠、反斜杠和箭头,并忽略分隔符两侧空格。 */
private static final String DEPARTMENT_PATH_SEPARATOR_REGEX = "\\s*(?:->|/|\\\\)\\s*";
/** 普通后台用户导入任务的业务 Redis Key 命名空间。 */
private static final String BACKEND_USER_IMPORT_REDIS_KEY = "user:import:backend";
/** 学生用户导入任务的业务 Redis Key 命名空间。 */
private static final String STUDENT_USER_IMPORT_REDIS_KEY = "user:import:student";
/** 学生家长用户导入任务的业务 Redis Key 命名空间。 */
private static final String GUARDIAN_USER_IMPORT_REDIS_KEY = "user:import:guardian";
/** 老师用户导入任务的业务 Redis Key 命名空间。 */
private static final String TEACHER_USER_IMPORT_REDIS_KEY = "user:import:teacher";
/** 带人脸图片的后台用户导入任务业务 Redis Key 命名空间。 */
private static final String FACE_USER_IMPORT_REDIS_KEY = "user:import:face";
/** 用户查询 Mapper,用于应用数据权限生成导出数据。 */
private final SysUserMapper sysUserMapper;
/** 用户核心服务,用于用户查询、账号生成和完整用户创建。 */
private final SysUserService sysUserService;
/** 部门服务,用于加载当前租户的部门树并解析模板中的部门名称或路径。 */
private final SysDeptService sysDeptService;
/** 角色服务,用于解析普通导入角色和身份导入固定角色。 */
private final SysRoleService sysRoleService;
/** 岗位服务,用于解析普通用户导入模板中的岗位名称。 */
private final SysPostService sysPostService;
/** 学生 Mapper,用于批量检查同一学校下的重复学号。 */
private final SysUserStudentMapper sysUserStudentMapper;
/** 老师 Mapper,用于批量检查当前租户中已存在的老师学号。 */
private final SysUserTeacherMapper sysUserTeacherMapper;
/** 用户关系类型服务,用于获取当前租户启用的监护关系类型。 */
private final SysUserRelationTypeService sysUserRelationTypeService;
/** 用户关系服务,用于在同一事务入口创建学生用户及监护关系。 */
private final SysUserRelationService sysUserRelationService;
/** 文件服务,用于保存人脸导入模板中提取出的图片。 */
private final SysFileService sysFileService;
/** 用户人脸服务,用于按现有人脸审批规则创建人脸记录。 */
private final SysUserFaceService sysUserFaceService;
/** 通用异步事务任务执行器,用于在同步整表校验通过后后台统一落库并报告进度。 */
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());
}
}
return excelVO;
}).toList();
}
/**
* 导入后台用户基础信息,并以 Excel 行号汇总业务校验错误;整表无错误后才统一写入。
*
* @param excelVOList Excel 解析出的后台用户数据
* @param bindingResult Excel 通用校验结果
* @return 导入结果;失败时返回对应行号和原因
*/
@Override
public R<?> importUser(List<UserExcelVO> excelVOList, BindingResult bindingResult) {
List<ErrorMessage> errorMessageList = getErrorMessageList(bindingResult);
if (CollUtil.isEmpty(excelVOList)) {
return CollUtil.isNotEmpty(errorMessageList) ? R.failed(errorMessageList) : R.failed("Excel内容为空");
}
DepartmentIndex departmentIndex = new DepartmentIndex(sysDeptService.list());
List<SysRole> roleList = sysRoleService.list();
List<SysPost> postList = sysPostService.list();
Map<String, Long> usernameCountMap = countNonBlankValues(excelVOList.stream().map(UserExcelVO::getUsername).toList());
Map<UserExcelVO, BackendUserImportContext> importContextMap = new HashMap<>();
for (UserExcelVO excel : excelVOList) {
Set<String> errorMessages = new HashSet<>();
boolean usernameExists = sysUserService.exists(Wrappers.<SysUser>lambdaQuery().eq(SysUser::getUsername, excel.getUsername()));
if (usernameExists) {
errorMessages.add(MsgUtils.getMessage(ErrorCodes.SYS_USER_USERNAME_EXISTING, excel.getUsername()));
}
if (StrUtil.isNotBlank(excel.getUsername()) && usernameCountMap.getOrDefault(excel.getUsername(), 0L) > 1) {
errorMessages.add("用户名:" + excel.getUsername() + ",在Excel中重复");
}
SysDept department = resolveDepartment(excel.getDeptName(), departmentIndex, errorMessages);
List<String> roleNames = StrUtil.split(excel.getRoleNameList(), StrUtil.COMMA);
List<SysRole> roles = roleList.stream().filter(role -> roleNames.stream().anyMatch(name -> ObjectUtil.equals(role.getRoleName(), name))).toList();
if (roles.size() != roleNames.size()) {
errorMessages.add(MsgUtils.getMessage(ErrorCodes.SYS_ROLE_ROLENAME_INEXISTENCE, excel.getRoleNameList()));
}
List<String> postNames = StrUtil.split(excel.getPostNameList(), StrUtil.COMMA);
List<SysPost> posts = postList.stream().filter(post -> postNames.stream().anyMatch(name -> ObjectUtil.equals(post.getPostName(), name))).toList();
if (posts.size() != postNames.size()) {
errorMessages.add(MsgUtils.getMessage(ErrorCodes.SYS_POST_POSTNAME_INEXISTENCE, excel.getPostNameList()));
}
UserStaffingTypeEnum staffingType = UserStaffingTypeEnum.NONE_STAFFING;
if (StrUtil.isNotBlank(excel.getStaffingType())) {
staffingType = UserStaffingTypeEnum.valueOfByDescription(excel.getStaffingType());
if (staffingType == null) {
errorMessages.add("编制选择错误,请选择<在编>和<非编>");
}
}
if (CollUtil.isNotEmpty(errorMessages)) {
errorMessageList.add(new ErrorMessage(excel.getLineNum(), errorMessages));
continue;
}
excel.setStaffingTypeParse(staffingType.getType());
importContextMap.put(excel, new BackendUserImportContext(department, roles, posts));
}
if (CollUtil.isNotEmpty(errorMessageList)) {
return R.failed(errorMessageList);
}
AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(BACKEND_USER_IMPORT_REDIS_KEY, "后台用户导入",
excelVOList.size(), reporter -> excelVOList.forEach(excel -> {
BackendUserImportContext context = importContextMap.get(excel);
insertBackendUser(excel, context.department, context.roles, context.posts);
reporter.advance("正在导入后台用户:" + excel.getUsername());
}));
return R.ok(progress, "Excel校验通过,已开始后台导入");
}
/**
* 导入学生用户,并在整表校验通过后创建学生扩展数据和监护关系。
*
* @param studentUserExcelList Excel 解析出的学生数据
* @param bindingResult Excel 通用校验结果
* @return 导入结果;失败时返回对应行号和原因
*/
@Override
public R<?> importStudentUser(List<StudentUserExcelVO> studentUserExcelList, BindingResult bindingResult) {
List<ErrorMessage> errorMessageList = getErrorMessageList(bindingResult);
if (CollUtil.isEmpty(studentUserExcelList)) {
return CollUtil.isNotEmpty(errorMessageList) ? R.failed(errorMessageList) : R.failed("Excel内容为空");
}
studentUserExcelList.forEach(excel -> {
excel.setUsername(StrUtil.trim(excel.getUsername()));
excel.setGuardianPhone(StrUtil.trim(excel.getGuardianPhone()));
excel.setStudentNo(StrUtil.trim(excel.getStudentNo()));
excel.setSchoolName(StrUtil.trim(excel.getSchoolName()));
excel.setClassName(StrUtil.trim(excel.getClassName()));
});
List<String> guardianPhones = studentUserExcelList.stream().map(StudentUserExcelVO::getGuardianPhone)
.filter(StrUtil::isNotBlank).distinct().toList();
Map<String, SysUser> guardianUserMap = guardianPhones.isEmpty() ? Map.of() : sysUserService.list(
Wrappers.<SysUser>lambdaQuery().in(SysUser::getPhone, guardianPhones)
.eq(SysUser::getProcessStatus, TaskCheckStatusEnum.RESULT.getCode()))
.stream().collect(Collectors.toMap(SysUser::getPhone, Function.identity(), (first, second) -> first));
Map<String, Map<String, Long>> schoolStudentNoCountMap = studentUserExcelList.stream()
.filter(excel -> StrUtil.isNotBlank(excel.getSchoolName()) && StrUtil.isNotBlank(excel.getStudentNo()))
.collect(Collectors.groupingBy(StudentUserExcelVO::getSchoolName,
Collectors.groupingBy(StudentUserExcelVO::getStudentNo, Collectors.counting())));
List<String> distinctStudentNos = studentUserExcelList.stream().map(StudentUserExcelVO::getStudentNo)
.filter(StrUtil::isNotBlank).distinct().toList();
List<String> distinctSchoolNames = studentUserExcelList.stream().map(StudentUserExcelVO::getSchoolName)
.filter(StrUtil::isNotBlank).distinct().toList();
Map<String, Set<String>> existingSchoolStudentNoMap = distinctSchoolNames.isEmpty() || distinctStudentNos.isEmpty()
? Map.of() : sysUserStudentMapper.selectList(Wrappers.<SysUserStudentEntity>lambdaQuery()
.in(SysUserStudentEntity::getSchoolName, distinctSchoolNames)
.in(SysUserStudentEntity::getStudentNo, distinctStudentNos))
.stream().collect(Collectors.groupingBy(SysUserStudentEntity::getSchoolName,
Collectors.mapping(SysUserStudentEntity::getStudentNo, Collectors.toSet())));
List<Long> studentRoleIds = resolveIdentityImportRoleIds(studentUserExcelList, errorMessageList, "学生");
if (studentRoleIds == null) {
return R.failed(errorMessageList);
}
SysUserRelationTypeEntity guardianWardRelationType = sysUserRelationTypeService.getOne(
Wrappers.<SysUserRelationTypeEntity>lambdaQuery()
.eq(SysUserRelationTypeEntity::getRelationCode, UserRelationTypeEnum.GUARDIAN_WARD.getRelationCode())
.eq(SysUserRelationTypeEntity::getStatus, CommonConstants.STATUS_NORMAL)
.last("limit 1"));
if (guardianWardRelationType == null) {
studentUserExcelList.forEach(excel -> errorMessageList.add(new ErrorMessage(
excel.getLineNum(), Set.of("未配置或未启用监护关系类型"))));
return R.failed(errorMessageList);
}
for (StudentUserExcelVO excel : studentUserExcelList) {
Set<String> errorMessages = new LinkedHashSet<>();
if (StrUtil.isBlank(excel.getUsername())) {
errorMessages.add("请输入学生姓名");
}
if (StrUtil.isBlank(excel.getStudentNo())) {
errorMessages.add("请输入学号");
} else {
if (StrUtil.isNotBlank(excel.getSchoolName())) {
Map<String, Long> currentSchoolCounts = schoolStudentNoCountMap.getOrDefault(excel.getSchoolName(), Map.of());
if (currentSchoolCounts.getOrDefault(excel.getStudentNo(), 0L) > 1) {
errorMessages.add("学生,学校名称:" + excel.getSchoolName() + ",学号:" + excel.getStudentNo() + ",在excel中重复");
}
if (existingSchoolStudentNoMap.getOrDefault(excel.getSchoolName(), Set.of()).contains(excel.getStudentNo())) {
errorMessages.add("学生,学校名称:" + excel.getSchoolName() + ",学号:" + excel.getStudentNo() + ",在系统中重复");
}
}
if (StrUtil.length(excel.getStudentNo()) > STUDENT_NO_MAX_LENGTH) {
errorMessages.add("学生,学号:" + excel.getStudentNo() + ",长度不能超过255个字符");
}
}
if (StrUtil.isBlank(excel.getGuardianPhone())) {
errorMessages.add("请输入监护人手机号");
} else {
SysUser guardian = guardianUserMap.get(excel.getGuardianPhone());
if (guardian == null) {
errorMessages.add("监护人手机号:" + excel.getGuardianPhone() + ",对应的用户未添加或未审批通过");
} else {
excel.setGuardianUserId(guardian.getUserId());
excel.setGuardianDeptId(guardian.getDeptId());
}
}
if (CollUtil.isNotEmpty(errorMessages)) {
errorMessageList.add(new ErrorMessage(excel.getLineNum(), errorMessages));
}
}
if (CollUtil.isNotEmpty(errorMessageList)) {
return R.failed(errorMessageList);
}
AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(STUDENT_USER_IMPORT_REDIS_KEY, "学生用户导入",
studentUserExcelList.size(), reporter -> studentUserExcelList.forEach(excel -> {
insertStudentUser(excel, guardianWardRelationType.getRelationTypeId(), studentRoleIds);
reporter.advance("正在导入学生用户:" + excel.getUsername());
}));
return R.ok(progress, "Excel校验通过,已开始后台导入");
}
/**
* 导入学生家长用户,手机号作为登录账号和初始密码,整表校验通过后统一写入。
*
* @param guardianUserExcelList Excel 解析出的学生家长数据
* @param bindingResult Excel 通用校验结果
* @return 导入结果;失败时返回对应行号和原因
*/
@Override
public R<?> importGuardianUser(List<GuardianUserExcelVO> guardianUserExcelList, BindingResult bindingResult) {
List<ErrorMessage> errorMessageList = getErrorMessageList(bindingResult);
if (CollUtil.isEmpty(guardianUserExcelList)) {
return CollUtil.isNotEmpty(errorMessageList) ? R.failed(errorMessageList) : R.failed("Excel内容为空");
}
guardianUserExcelList.forEach(excel -> {
excel.setName(PersonNameUtils.normalize(excel.getName()));
excel.setPhone(StrUtil.trim(excel.getPhone()));
excel.setDeptName(StrUtil.trim(excel.getDeptName()));
});
Map<String, Long> phoneCountMap = countNonBlankValues(guardianUserExcelList.stream()
.map(GuardianUserExcelVO::getPhone).toList());
List<String> phones = phoneCountMap.keySet().stream().toList();
Set<String> existingPhones = phones.isEmpty() ? Set.of() : sysUserService.list(
Wrappers.<SysUser>lambdaQuery().in(SysUser::getPhone, phones))
.stream().map(SysUser::getPhone).collect(Collectors.toSet());
Set<String> existingUsernames = phones.isEmpty() ? Set.of() : sysUserService.list(
Wrappers.<SysUser>lambdaQuery().in(SysUser::getUsername, phones))
.stream().map(SysUser::getUsername).collect(Collectors.toSet());
DepartmentIndex departmentIndex = new DepartmentIndex(sysDeptService.list());
Map<GuardianUserExcelVO, SysDept> departmentMap = new HashMap<>();
List<Long> guardianRoleIds = resolveIdentityImportRoleIds(guardianUserExcelList, errorMessageList, "学生家长");
if (guardianRoleIds == null) {
return R.failed(errorMessageList);
}
for (GuardianUserExcelVO excel : guardianUserExcelList) {
Set<String> errorMessages = new LinkedHashSet<>();
if (StrUtil.isBlank(excel.getName())) {
excel.setName(excel.getPhone() + "学生家长");
} else if (!PersonNameUtils.isValid(excel.getName())) {
errorMessages.add("姓名仅支持中文,或英文(单词间可用空格),且不能超过32个字符");
}
if (StrUtil.isBlank(excel.getPhone())) {
errorMessages.add("手机号不能为空");
} else {
if (!Validator.isMobile(excel.getPhone())) {
errorMessages.add("手机号格式不正确");
}
if (phoneCountMap.getOrDefault(excel.getPhone(), 0L) > 1) {
errorMessages.add("手机号:" + excel.getPhone() + ",在Excel中重复");
}
if (existingPhones.contains(excel.getPhone())) {
errorMessages.add("手机号:" + excel.getPhone() + ",在系统中已存在");
}
if (existingUsernames.contains(excel.getPhone())) {
errorMessages.add("手机号:" + excel.getPhone() + ",在系统中已作为登录账号使用");
}
}
SysDept department = resolveDepartment(excel.getDeptName(), departmentIndex, errorMessages);
if (CollUtil.isNotEmpty(errorMessages)) {
errorMessageList.add(new ErrorMessage(excel.getLineNum(), errorMessages));
continue;
}
departmentMap.put(excel, department);
}
if (CollUtil.isNotEmpty(errorMessageList)) {
return R.failed(errorMessageList);
}
AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(GUARDIAN_USER_IMPORT_REDIS_KEY, "学生家长用户导入",
guardianUserExcelList.size(), reporter -> guardianUserExcelList.forEach(excel -> {
insertGuardianUser(excel, departmentMap.get(excel), guardianRoleIds);
reporter.advance("正在导入学生家长用户:" + excel.getName());
}));
return R.ok(progress, "Excel校验通过,已开始后台导入");
}
/**
* 导入老师用户,模板手机号作为登录账号、学号写入老师扩展表,整表校验通过后统一写入。
*
* @param teacherUserExcelList Excel 解析出的老师数据
* @param bindingResult Excel 通用校验结果
* @return 导入结果;失败时返回对应行号和原因
*/
@Override
public R<?> importTeacherUser(List<TeacherUserExcelVO> teacherUserExcelList, BindingResult bindingResult) {
List<ErrorMessage> errorMessageList = getErrorMessageList(bindingResult);
if (CollUtil.isEmpty(teacherUserExcelList)) {
return CollUtil.isNotEmpty(errorMessageList) ? R.failed(errorMessageList) : R.failed("Excel内容为空");
}
teacherUserExcelList.forEach(excel -> {
excel.setName(PersonNameUtils.normalize(excel.getName()));
excel.setPhone(StrUtil.trim(excel.getPhone()));
excel.setDeptName(StrUtil.trim(excel.getDeptName()));
excel.setTeacherNo(StrUtil.trim(excel.getTeacherNo()));
});
Map<String, Long> teacherNoCountMap = countNonBlankValues(teacherUserExcelList.stream().map(TeacherUserExcelVO::getTeacherNo).toList());
Map<String, Long> phoneCountMap = countNonBlankValues(teacherUserExcelList.stream().map(TeacherUserExcelVO::getPhone).toList());
List<String> teacherNos = teacherNoCountMap.keySet().stream().toList();
List<String> phones = phoneCountMap.keySet().stream().toList();
Set<String> existingTeacherNos = teacherNos.isEmpty() ? Set.of() : sysUserTeacherMapper.selectList(
Wrappers.<SysUserTeacherEntity>lambdaQuery().in(SysUserTeacherEntity::getTeacherNo, teacherNos))
.stream().map(SysUserTeacherEntity::getTeacherNo).collect(Collectors.toSet());
Set<String> existingPhones = phones.isEmpty() ? Set.of() : sysUserService.list(
Wrappers.<SysUser>lambdaQuery().in(SysUser::getPhone, phones))
.stream().map(SysUser::getPhone).collect(Collectors.toSet());
Set<String> existingUsernames = phones.isEmpty() ? Set.of() : sysUserService.list(
Wrappers.<SysUser>lambdaQuery().in(SysUser::getUsername, phones))
.stream().map(SysUser::getUsername).collect(Collectors.toSet());
DepartmentIndex departmentIndex = new DepartmentIndex(sysDeptService.list());
Map<TeacherUserExcelVO, SysDept> departmentMap = new HashMap<>();
List<Long> teacherRoleIds = resolveIdentityImportRoleIds(teacherUserExcelList, errorMessageList, "老师");
if (teacherRoleIds == null) {
return R.failed(errorMessageList);
}
for (TeacherUserExcelVO excel : teacherUserExcelList) {
Set<String> errorMessages = new LinkedHashSet<>();
if (!PersonNameUtils.isValid(excel.getName())) {
errorMessages.add("姓名仅支持中文,或英文(单词间可用空格),且不能超过32个字符");
}
if (StrUtil.isBlank(excel.getTeacherNo())) {
errorMessages.add("学号不能为空");
} else {
if (excel.getTeacherNo().length() < 2 || excel.getTeacherNo().length() > 60) {
errorMessages.add("学号长度必须介于2到60个字符之间");
}
if (excel.getTeacherNo().chars().anyMatch(character -> character >= 0x4e00 && character <= 0x9fa5)) {
errorMessages.add("学号不能包含中文");
}
if (teacherNoCountMap.getOrDefault(excel.getTeacherNo(), 0L) > 1) {
errorMessages.add("学号:" + excel.getTeacherNo() + ",在Excel中重复");
}
if (existingTeacherNos.contains(excel.getTeacherNo())) {
errorMessages.add("学号:" + excel.getTeacherNo() + ",在系统中已存在");
}
}
if (StrUtil.isBlank(excel.getPhone())) {
errorMessages.add("手机号不能为空");
} else {
if (!Validator.isMobile(excel.getPhone())) {
errorMessages.add("手机号格式不正确");
}
if (phoneCountMap.getOrDefault(excel.getPhone(), 0L) > 1) {
errorMessages.add("手机号:" + excel.getPhone() + ",在Excel中重复");
}
if (existingPhones.contains(excel.getPhone())) {
errorMessages.add("手机号:" + excel.getPhone() + ",在系统中已存在");
}
if (existingUsernames.contains(excel.getPhone())) {
errorMessages.add("手机号:" + excel.getPhone() + ",在系统中已作为登录账号使用");
}
}
SysDept department = resolveDepartment(excel.getDeptName(), departmentIndex, errorMessages);
if (CollUtil.isNotEmpty(errorMessages)) {
errorMessageList.add(new ErrorMessage(excel.getLineNum(), errorMessages));
continue;
}
departmentMap.put(excel, department);
}
if (CollUtil.isNotEmpty(errorMessageList)) {
return R.failed(errorMessageList);
}
AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(TEACHER_USER_IMPORT_REDIS_KEY, "老师用户导入",
teacherUserExcelList.size(), reporter -> teacherUserExcelList.forEach(excel -> {
insertTeacherUser(excel, departmentMap.get(excel), teacherRoleIds);
reporter.advance("正在导入老师用户:" + excel.getName());
}));
return R.ok(progress, "Excel校验通过,已开始后台导入");
}
/**
* 导入后台用户及 Excel 内嵌人脸图片,整表校验通过后沿用现有角色和人脸审批规则统一写入。
*
* @param file 原始 Excel 文件
* @param excelList Excel 解析出的用户数据
* @param bindingResult Excel 通用校验结果
* @return 导入结果;失败时返回对应行号和原因
*/
@Override
public R<?> importUserFace(MultipartFile file, List<UserExcelFaceVo> excelList, BindingResult bindingResult) {
try {
List<ErrorMessage> errorMessageList = getErrorMessageList(bindingResult);
if (CollUtil.isEmpty(excelList)) {
return CollUtil.isNotEmpty(errorMessageList) ? R.failed(errorMessageList) : R.failed("Excel内容为空");
}
DepartmentIndex departmentIndex = new DepartmentIndex(sysDeptService.list());
List<SysRole> roles = sysRoleService.list();
List<SysUser> users = sysUserService.list();
String walletAutoCreateRole = ParamResolver.getStr("WALLET_AUTO_CREATE");
String defaultUserRole = ParamResolver.getStr("USER_DEFAULT_ROLE");
List<Long> roleIds = roles.stream().filter(role -> ObjectUtil.equals(role.getRoleCode(), walletAutoCreateRole)
|| ObjectUtil.equals(role.getRoleCode(), defaultUserRole)).map(SysRole::getRoleId).toList();
List<String> phones = excelList.stream().map(UserExcelFaceVo::getMobile).filter(StrUtil::isNotBlank).toList();
Map<String, PictureData> pictureDataMap = ExcelTool.getPictureFromExcel(file, 1);
Map<String, byte[]> images = pictureDataMap == null ? Map.of() : pictureDataMap.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getData()));
Map<UserExcelFaceVo, SysDept> departmentMap = new HashMap<>();
for (UserExcelFaceVo excel : excelList) {
Set<String> errorMessages = new HashSet<>();
if (StrUtil.isBlank(excel.getName())) {
errorMessages.add("用户昵称为空");
}
if (StrUtil.isBlank(excel.getMobile())) {
errorMessages.add("手机号码为空");
} else if (!Validator.isMobile(excel.getMobile().trim())) {
errorMessages.add("手机号码格式不正确");
} else if (users.stream().anyMatch(user -> ObjectUtil.equals(user.getPhone(), excel.getMobile().trim()))) {
errorMessages.add("手机号已存在");
}
SysDept department = resolveDepartment(excel.getDeptName(), departmentIndex, errorMessages);
if (StrUtil.isNotBlank(excel.getMobile()) && phones.stream().filter(excel.getMobile()::equals).count() > 1) {
errorMessages.add("用手机号 <" + excel.getMobile() + "> 重复");
}
String staffingType = null;
if (StrUtil.isNotBlank(excel.getStaffingType())) {
UserStaffingTypeEnum staffingTypeEnum = UserStaffingTypeEnum.valueOfByDescription(excel.getStaffingType());
if (staffingTypeEnum == null) {
errorMessages.add("编制选择错误,请选择<在编>和<非编>");
} else {
staffingType = staffingTypeEnum.getType();
}
}
if (CollUtil.isNotEmpty(errorMessages)) {
errorMessageList.add(new ErrorMessage(excel.getLineNum(), errorMessages));
continue;
}
excel.setStaffingType(staffingType);
departmentMap.put(excel, department);
}
if (CollUtil.isNotEmpty(errorMessageList)) {
return R.failed(errorMessageList);
}
AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(FACE_USER_IMPORT_REDIS_KEY, "后台用户导入",
excelList.size(), reporter -> excelList.forEach(excel -> {
byte[] face = images.get(String.valueOf(excel.getLineNum() - 1));
insertFaceUser(excel, departmentMap.get(excel).getDeptId(), roleIds, face);
reporter.advance("正在导入后台用户:" + excel.getName());
}));
return R.ok(progress, "Excel校验通过,已开始后台导入");
} catch (Exception exception) {
log.error("|{}|导入用户发生异常", LOG_TAG, exception);
throw new RuntimeException("导入错误", exception);
}
}
/**
* 统一解析用户导入模板中的部门名称或部门路径。
* <p>单个部门名只有在当前租户内唯一时才可使用;多段路径按最后一段定位目标部门,并向上逐级核对父部门,
* 因此既支持完整路径,也允许省略目标部门之上的更高层组织。</p>
*
* @param departmentValue Excel 中填写的部门名称或路径,路径可使用 {@code /}、{@code \} 或 {@code ->} 分隔
* @param departmentIndex 当前租户全部有效部门构建的查询索引
* @param errorMessages 当前 Excel 行的错误集合;解析失败时追加具体原因
* @return 唯一匹配的目标部门;未找到或存在歧义时返回 {@code null}
*/
private SysDept resolveDepartment(String departmentValue, DepartmentIndex departmentIndex, Set<String> errorMessages) {
if (StrUtil.isBlank(departmentValue)) {
errorMessages.add("部门不能为空");
return null;
}
String normalizedValue = StrUtil.trim(departmentValue);
List<String> pathSegments = Arrays.stream(normalizedValue.split(DEPARTMENT_PATH_SEPARATOR_REGEX, -1))
.map(StrUtil::trim).toList();
if (pathSegments.stream().anyMatch(StrUtil::isBlank)) {
errorMessages.add("部门路径:" + normalizedValue + ",格式不正确");
return null;
}
String targetDepartmentName = pathSegments.get(pathSegments.size() - 1);
List<SysDept> nameCandidates = departmentIndex.departmentsByName.getOrDefault(targetDepartmentName, List.of());
if (pathSegments.size() == 1) {
if (nameCandidates.isEmpty()) {
errorMessages.add("部门:" + normalizedValue + ",在系统中不存在");
return null;
}
if (nameCandidates.size() > 1) {
errorMessages.add("部门名称:" + normalizedValue + ",存在多个匹配项,请填写部门路径");
return null;
}
return nameCandidates.get(0);
}
List<SysDept> pathCandidates = nameCandidates.stream()
.filter(department -> matchesDepartmentPath(department, pathSegments, departmentIndex.departmentsById))
.toList();
if (pathCandidates.isEmpty()) {
errorMessages.add("部门路径:" + normalizedValue + ",在系统中不存在或层级关系不匹配");
return null;
}
if (pathCandidates.size() > 1) {
errorMessages.add("部门路径:" + normalizedValue + ",存在多个匹配项,请填写更完整的部门路径");
return null;
}
return pathCandidates.get(0);
}
/**
* 从目标部门开始向父级回溯,校验 Excel 路径中的每一段是否构成连续父子链。
*
* @param targetDepartment 路径最后一段对应的候选目标部门
* @param pathSegments 已去除空格的部门路径片段,顺序为上级到下级
* @param departmentsById 当前租户按部门 ID 建立的部门索引
* @return {@code true} 表示候选部门及其父级链与路径完全一致
*/
private boolean matchesDepartmentPath(SysDept targetDepartment, List<String> pathSegments, Map<Long, SysDept> departmentsById) {
SysDept currentDepartment = targetDepartment;
for (int index = pathSegments.size() - 1; index >= 0; index--) {
if (currentDepartment == null || !ObjectUtil.equals(pathSegments.get(index), StrUtil.trim(currentDepartment.getName()))) {
return false;
}
if (index > 0) {
currentDepartment = departmentsById.get(currentDepartment.getParentId());
}
}
return true;
}
/**
* 当前租户部门树的导入查询索引,避免每一行 Excel 都重新扫描全部部门。
*/
private static final class DepartmentIndex {
/** 按部门 ID 索引全部有效部门,用于校验路径中的连续父子关系。 */
private final Map<Long, SysDept> departmentsById;
/** 按去除首尾空格后的部门名称分组,用于识别不存在和同名歧义。 */
private final Map<String, List<SysDept>> departmentsByName;
/**
* 使用当前租户查询出的全部有效部门构建导入索引,后续仅用于部门匹配查询。
*
* @param departments 当前租户的有效部门;为空时按空部门树处理
*/
private DepartmentIndex(List<SysDept> departments) {
List<SysDept> safeDepartments = Optional.ofNullable(departments).orElseGet(List::of);
this.departmentsById = safeDepartments.stream().filter(department -> department.getDeptId() != null)
.collect(Collectors.toMap(SysDept::getDeptId, Function.identity(), (first, second) -> first));
this.departmentsByName = safeDepartments.stream().filter(department -> StrUtil.isNotBlank(department.getName()))
.collect(Collectors.groupingBy(department -> StrUtil.trim(department.getName())));
}
}
/**
* 普通后台用户通过整表校验后保留的部门、角色和岗位解析结果,供统一写入阶段使用。
*/
private static final class BackendUserImportContext {
/** 当前 Excel 行唯一匹配的部门。 */
private final SysDept department;
/** 当前 Excel 行已匹配的角色列表。 */
private final List<SysRole> roles;
/** 当前 Excel 行已匹配的岗位列表。 */
private final List<SysPost> posts;
/**
* 保存当前 Excel 行的关联对象解析结果,避免统一写入前重复查询。
*
* @param department 当前行唯一匹配的部门
* @param roles 当前行已匹配的角色列表
* @param posts 当前行已匹配的岗位列表
*/
private BackendUserImportContext(SysDept department, List<SysRole> roles, List<SysPost> posts) {
this.department = department;
this.roles = roles;
this.posts = posts;
}
}
/**
* 创建普通后台用户,密码按原有导入协议初始化为手机号。
*
* @param excel 当前校验通过的后台用户行
* @param department 已匹配的部门
* @param roles 已匹配的角色
* @param posts 已匹配的岗位
*/
private void insertBackendUser(UserExcelVO excel, SysDept department, List<SysRole> roles, List<SysPost> posts) {
UserDTO user = new UserDTO();
user.setUsername(excel.getUsername());
user.setPhone(excel.getPhone());
user.setNickname(excel.getNickname());
user.setName(excel.getName());
user.setEmail(excel.getEmail());
user.setLockFlag(excel.getLockFlag());
user.setStaffingType(excel.getStaffingTypeParse());
user.setPasswordModifyTime(LocalDateTime.now());
user.setPassword(excel.getPhone());
user.setDeptId(department.getDeptId());
user.setPost(posts.stream().map(SysPost::getPostId).toList());
user.setRole(roles.stream().map(SysRole::getRoleId).toList());
sysUserService.saveUser(user);
}
/**
* 创建学生用户及监护关系,学生扩展信息只属于 STUDENT 身份。
*
* @param excel 当前校验通过的学生行
* @param guardianWardRelationTypeId 监护关系类型 ID
* @param studentRoleIds 学生固定角色 ID
*/
private void insertStudentUser(StudentUserExcelVO excel, Long guardianWardRelationTypeId, List<Long> studentRoleIds) {
SysUserRelationCreateUserDto relationUser = new SysUserRelationCreateUserDto();
relationUser.setSubjectUserId(excel.getGuardianUserId());
relationUser.setRelationTypeId(guardianWardRelationTypeId);
relationUser.setUsername(sysUserService.generateUniqueSysUserUsername());
relationUser.setPassword(excel.getGuardianPhone());
relationUser.setName(excel.getUsername());
relationUser.setNickname(excel.getUsername());
relationUser.setStaffingType(UserStaffingTypeEnum.NONE_STAFFING.getType());
relationUser.setIdentityTypes(List.of(UserIdentityTypeEnum.STUDENT.getType()));
relationUser.setRole(studentRoleIds);
relationUser.setDeptId(excel.getGuardianDeptId());
SysUserStudentDto studentInfo = new SysUserStudentDto();
studentInfo.setStudentNo(excel.getStudentNo());
studentInfo.setSchoolName(excel.getSchoolName());
studentInfo.setClassName(excel.getClassName());
relationUser.setStudentInfo(studentInfo);
sysUserRelationService.createUserAndRelation(relationUser);
}
/**
* 创建学生家长用户;手机号同时作为登录账号、默认身份手机号和初始密码。
*
* @param excel 当前校验通过的学生家长行
* @param department 已匹配的部门
* @param roleIds 普通用户和自动创建钱包角色 ID
*/
private void insertGuardianUser(GuardianUserExcelVO excel, SysDept department, List<Long> roleIds) {
UserDTO user = new UserDTO();
user.setUsername(excel.getPhone());
user.setPassword(excel.getPhone());
user.setPhone(excel.getPhone());
user.setName(excel.getName());
user.setNickname(excel.getName());
user.setDeptId(department.getDeptId());
user.setStaffingType(UserStaffingTypeEnum.NONE_STAFFING.getType());
user.setIdentityTypes(List.of(UserIdentityTypeEnum.DEFAULT.getType()));
user.setRole(roleIds);
sysUserService.saveUser(user);
}
/**
* 创建老师用户;模板手机号作为登录账号,学号写入老师扩展表,老师身份与学生扩展数据完全分离。
*
* @param excel 当前校验通过的老师行
* @param department 已匹配的部门
* @param roleIds 老师固定角色 ID
*/
private void insertTeacherUser(TeacherUserExcelVO excel, SysDept department, List<Long> roleIds) {
UserDTO user = new UserDTO();
user.setUsername(excel.getPhone());
user.setPassword(excel.getPhone());
user.setPhone(excel.getPhone());
user.setName(excel.getName());
user.setNickname(excel.getName());
user.setDeptId(department.getDeptId());
user.setStaffingType(UserStaffingTypeEnum.NONE_STAFFING.getType());
user.setIdentityTypes(List.of(UserIdentityTypeEnum.DEFAULT.getType(), UserIdentityTypeEnum.TEACHER.getType()));
user.setRole(roleIds);
SysUserTeacherDto teacherInfo = new SysUserTeacherDto();
teacherInfo.setTeacherNo(excel.getTeacherNo());
user.setTeacherInfo(teacherInfo);
sysUserService.saveUser(user);
}
/**
* 创建带人脸图片的后台用户,并在用户创建完成后交给人脸服务处理审批状态。
*
* @param excel 当前校验通过的人脸用户行
* @param deptId 已匹配的部门 ID
* @param roleIds 默认角色 ID
* @param face Excel 同步校验阶段提取的人脸图片字节;未提供时为空
*/
private void insertFaceUser(UserExcelFaceVo excel, Long deptId, List<Long> roleIds, byte[] face) {
String username = StrUtil.blankToDefault(excel.getUsername(), excel.getMobile()).replaceAll("\\s+", "");
UserDTO user = new UserDTO();
user.setUsername(username);
user.setName(excel.getName());
user.setNickname(excel.getName());
user.setPhone(excel.getMobile().replaceAll("\\s+", ""));
user.setDeptId(deptId);
user.setStaffingType(excel.getStaffingType());
user.setPasswordModifyTime(LocalDateTime.now());
user.setPassword(user.getPhone());
user.setRole(roleIds);
Long userId = sysUserService.saveUser(user);
if (face != null) {
insertFace(userId, face);
}
}
/**
* 上传 Excel 内嵌人脸图片并创建用户人脸记录。
*
* @param userId 新建用户 ID
* @param face Excel 同步校验阶段复制的人脸图片字节
* @throws RuntimeException 图片上传或人脸记录创建失败时抛出,以回滚当前整批导入事务
*/
private void insertFace(Long userId, byte[] face) {
try {
MultipartFile file = new MockMultipartFile("file", UUID.randomUUID() + "_face.png", "image/png",
new ByteArrayInputStream(face));
Object responseData = sysFileService.uploadFile(file, null, null, null).getData();
if (!(responseData instanceof Map<?, ?> responseMap) || responseMap.get("url") == null) {
throw new IllegalStateException("人脸图片上传结果缺少访问地址");
}
SysUserFaceEntity userFace = new SysUserFaceEntity();
userFace.setUserId(userId);
userFace.setFaceImg(responseMap.get("url").toString());
sysUserFaceService.create(userFace);
} catch (Exception exception) {
log.error("|{}|处理用户人脸图片失败,userId:{}", LOG_TAG, userId, exception);
throw new RuntimeException("处理用户人脸图片失败", exception);
}
}
/**
* 解析学生、学生家长或老师导入需要绑定的普通用户和钱包角色。
*
* @param rows 导入行,仅用于把全局配置错误映射回各行
* @param errorMessageList 导入错误集合
* @param identityName 身份中文名称,用于错误提示
* @return 两个固定角色的 ID;配置缺失时返回 {@code null}
*/
private List<Long> resolveIdentityImportRoleIds(List<?> rows, List<ErrorMessage> errorMessageList, String identityName) {
List<String> roleCodes = List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET);
List<SysRole> roles = Optional.ofNullable(sysRoleService.findRolesByCodes(roleCodes)).orElseGet(List::of);
Map<String, Long> roleIdMap = roles.stream().collect(Collectors.toMap(SysRole::getRoleCode, SysRole::getRoleId, (first, second) -> first));
List<String> missingRoleCodes = roleCodes.stream().filter(roleCode -> !roleIdMap.containsKey(roleCode)).toList();
if (missingRoleCodes.isEmpty()) {
return roleCodes.stream().map(roleIdMap::get).toList();
}
String error = "未配置" + identityName + "导入所需角色:" + String.join(",", missingRoleCodes);
for (Object row : rows) {
Long lineNumber = row instanceof StudentUserExcelVO student ? student.getLineNum()
: row instanceof GuardianUserExcelVO guardian ? guardian.getLineNum()
: row instanceof TeacherUserExcelVO teacher ? teacher.getLineNum() : null;
errorMessageList.add(new ErrorMessage(lineNumber, Set.of(error)));
}
return null;
}
/**
* 统计非空模板值在当前 Excel 中的出现次数。
*
* @param values 待统计的模板列值
* @return 按去除空值后的原始字符串分组的次数
*/
private Map<String, Long> countNonBlankValues(List<String> values) {
return values.stream().filter(StrUtil::isNotBlank)
.collect(Collectors.groupingBy(Function.identity(), HashMap::new, Collectors.counting()));
}
/**
* 获取 Excel 解析器写入 BindingResult 目标中的逐行错误集合。
*
* @param bindingResult Excel 参数绑定结果
* @return 可继续追加业务错误的列表;解析器未提供列表时返回新列表
*/
@SuppressWarnings("unchecked")
private List<ErrorMessage> getErrorMessageList(BindingResult bindingResult) {
Object target = bindingResult == null ? null : bindingResult.getTarget();
if (target instanceof List<?> targetList) {
return (List<ErrorMessage>) targetList;
}
return new ArrayList<>();
}
}
@@ -20,7 +20,6 @@
package com.bsd.pjcloud.admin.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Validator;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.RandomUtil;
@@ -39,14 +38,9 @@ import com.bsd.pjcloud.admin.api.constant.UserExpireEnum;
import com.bsd.pjcloud.admin.api.constant.UserStaffingTypeEnum;
import com.bsd.pjcloud.admin.api.constant.UserStateEnum;
import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum;
import com.bsd.pjcloud.admin.api.constant.user.UserRelationTypeEnum;
import com.bsd.pjcloud.admin.api.dto.ForgotPasswordDTO;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationCreateUserDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.entity.user.SysUserStudentEntity;
import com.bsd.pjcloud.admin.api.entity.user.SysUserRelationTypeEntity;
import com.bsd.pjcloud.admin.api.util.PersonNameUtils;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.entity.*;
@@ -58,9 +52,7 @@ import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper;
import com.bsd.pjcloud.admin.mapper.SysUserMapper;
import com.bsd.pjcloud.admin.mapper.SysUserPostMapper;
import com.bsd.pjcloud.admin.mapper.SysUserRoleMapper;
import com.bsd.pjcloud.admin.mapper.user.SysUserStudentMapper;
import com.bsd.pjcloud.admin.service.*;
import com.bsd.pjcloud.admin.utils.excel.ExcelTool;
import com.bsd.pjcloud.admin.utils.flow.FlowUtil;
import com.bsd.pjcloud.admin.utils.flow.dto.UserApplyFlowDto;
import com.bsd.pjcloud.common.audit.annotation.Audit;
@@ -76,19 +68,19 @@ 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.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.excel.vo.ErrorMessage;
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;
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.wallet.api.feign.RemoteWalletService;
import cn.hutool.extra.spring.SpringUtil;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.PictureData;
import org.springframework.beans.BeanUtils;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
@@ -96,17 +88,13 @@ import org.springframework.cache.annotation.CacheEvict;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.BindingResult;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.CompletableFuture;
@@ -127,16 +115,12 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
private static final int RANDOM_USERNAME_MAX_ATTEMPTS = 10;
private static final int STUDENT_NO_MAX_LENGTH = 255;
private static final PasswordEncoder ENCODER = new BCryptPasswordEncoder();
private final SysMenuService sysMenuService;
private final SysRoleService sysRoleService;
private final SysPostService sysPostService;
private final SysDeptService sysDeptService;
private final SysUserTagService sysUserTagService;
@@ -144,35 +128,26 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
private final SysUserIdentityService sysUserIdentityService;
/**
* 学生导入需要复用关系服务的事务入口;延迟注入用于解除关系服务反向依赖用户服务形成的循环依赖。
* 用户关系服务,用于删除用户时同步清理关联关系;延迟注入用于解除关系服务反向依赖用户服务形成的循环依赖。
*/
@Lazy
private final SysUserRelationService sysUserRelationService;
/**
* 用于按业务编码获取当前租户启用的监护关系类型。
*/
private final SysUserRelationTypeService sysUserRelationTypeService;
@Lazy
private final SysUserFaceService sysUserFaceService;
private final SysUserFaceMapper sysUserFaceMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final SysUserPostMapper sysUserPostMapper;
/**
* 学生导入前批量查询已存在学号,避免在循环中逐行访问数据库。
*/
private final SysUserStudentMapper sysUserStudentMapper;
private final CacheManager cacheManager;
private final StringRedisTemplate redisTemplate;
private final RemoteWalletService remoteWalletService;
private final SysFileService sysFileService;
/**
* 审批流程远程服务,用于在删除审核中用户前终止对应流程实例。
*/
private final RemoteFlowEngineService remoteFlowEngineService;
private final FlowUtil flowUtil;
@@ -222,15 +197,17 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
sysUserIdentityService.validateDto(userDto);
SysUser sysUser = new SysUser();
BeanUtils.copyProperties(userDto, sysUser);
// 判断是否为部门管理员
// 是否进入审批由当前操作人的专用角色控制,不能再通过部门管理员身份推断。
PjcloudUser applyUser = SecurityUtils.getUser();
boolean isDeptAdmin = false;
boolean needApproval = false;
if (skipAudit) {
sysUser.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode());
sysUser.setProcessTime(LocalDateTime.now());
} else if (null != applyUser) {
isDeptAdmin = sysRoleService.isDeptAdmin(applyUser.getId());
if (isDeptAdmin) {
List<SysRole> applyUserRoles = sysRoleService.findRolesByUserId(applyUser.getId());
needApproval = CollUtil.isNotEmpty(applyUserRoles) && applyUserRoles.stream()
.anyMatch(role -> ObjectUtil.equals(role.getRoleCode(), CommonRoleConstant.ADD_USER_NEED_CEHCK));
if (needApproval) {
List<SysRole> defaultRoles = sysRoleService.findRolesByCodes(List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET));
if (ObjectUtil.isEmpty(defaultRoles)) {
userDto.setRole(null);
@@ -280,7 +257,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
return userRole;
}).forEach(sysUserRoleMapper::insert);
if (isDeptAdmin && !skipAudit) {
if (needApproval) {
getSelf().applyFlow(sysUser);
} else {
// 事务提交前其他连接无法读取新用户,因此仅发布事件,由监听器在提交成功后异步创建钱包。
@@ -403,16 +380,23 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
}
/**
* 删除用户
* 删除用户,并在删除前终止仍处于审核中的用户审批流程。
* <p>审批状态和流程ID以数据库实时数据为准;流程取消失败时抛出异常并停止本次删除。</p>
*
* @param ids 用户ID 列表
* @return Boolean
* @return 所有用户及其关联数据进入删除流程后返回 {@code true}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean deleteUserByIds(Long[] ids) {
if (ArrayUtil.isEmpty(ids)) {
throw new RuntimeException("请选择要删除的用户");
}
List<Long> userIds = CollUtil.toList(ids);
List<SysUser> userList = baseMapper.selectBatchIds(userIds);
if (userList.size() != userIds.stream().distinct().count()) {
throw new RuntimeException("部分用户不存在或已删除,请刷新后重试");
}
// 判断是否可以删除
userList.forEach(v -> {
String errorMsg = remoteWalletService.isDel(v.getUserId()).getData();
@@ -421,6 +405,27 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
}
});
// 只终止数据库中仍为审核中的流程;前端携带的流程ID可能已经过期,不能作为删除依据。
List<String> pendingProcessIds = new ArrayList<>();
for (SysUser user : userList) {
if (!ObjectUtil.equals(user.getProcessStatus(), TaskCheckStatusEnum.PADDING.getCode())) {
continue;
}
if (StrUtil.isBlank(user.getProcessId())) {
throw new RuntimeException("审核中的用户缺少流程ID,无法删除");
}
pendingProcessIds.add(user.getProcessId());
}
if (CollUtil.isNotEmpty(pendingProcessIds)) {
TaskParamDto taskParam = new TaskParamDto();
taskParam.setProcessInstanceIdList(pendingProcessIds.stream().distinct().toList());
R<String> stopResult = remoteFlowEngineService.stopProcessInstance(taskParam);
if (stopResult == null || !stopResult.isOk()) {
String errorMessage = stopResult == null ? null : stopResult.getMsg();
throw new RuntimeException(StrUtil.blankToDefault(errorMessage, "取消用户审批流程失败"));
}
}
// 删除 spring cache
Cache cache = cacheManager.getCache(CacheConstants.USER_DETAILS);
for (SysUser sysUser : userList) {
@@ -435,9 +440,9 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
this.removeBatchByIds(userIds);
// 删除钱包
CompletableFuture.runAsync(() -> {
remoteWalletService.delWallet(List.of(ids));
remoteWalletService.delWallet(userIds);
});
for (Long userId : ids) {
for (Long userId : userIds) {
remoteWalletService.userChange(userId, Boolean.TRUE);
}
@@ -577,273 +582,9 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
return this.list(Wrappers.<SysUser>query().lambda().eq(SysUser::getDeptId, parentId));
}
/**
* 查询全部的用户
*
* @param userDTO 查询条件
* @param ids ids 用户列表
* @return list
*/
@Override
public List<UserExcelVO> listUser(UserDTO userDTO, Long[] ids) {
// 根据数据权限查询全部的用户信息
List<UserVO> voList = baseMapper.selectVoListByScope(userDTO, ids, DataScope.of());
// 转换成execl 对象输出
List<UserExcelVO> userExcelVOList = voList.stream().map(userVO -> {
UserExcelVO excelVO = new UserExcelVO();
BeanUtils.copyProperties(userVO, excelVO);
String roleNameList = userVO.getRoleList().stream().map(SysRole::getRoleName).collect(Collectors.joining(StrUtil.COMMA));
excelVO.setRoleNameList(roleNameList);
String postNameList = userVO.getPostList().stream().map(SysPost::getPostName).collect(Collectors.joining(StrUtil.COMMA));
excelVO.setPostNameList(postNameList);
if (ObjectUtil.isNotEmpty(userVO.getStaffingType())) {
UserStaffingTypeEnum staffingTypeEnum = UserStaffingTypeEnum.valueOfByType(userVO.getStaffingType());
if (ObjectUtil.isNotEmpty(staffingTypeEnum)) {
excelVO.setStaffingType(staffingTypeEnum.getDescription());
}
}
return excelVO;
}).collect(Collectors.toList());
return userExcelVOList;
}
/**
* excel 导入用户, 插入正确的 错误的提示行号
*/
@Override
public R importUser(List<UserExcelVO> excelVOList, BindingResult bindingResult) {
PjcloudUser user = SecurityUtils.getUser();
// 通用校验获取失败的数据
List<ErrorMessage> errorMessageList = (List<ErrorMessage>) bindingResult.getTarget();
List<SysDept> deptList = sysDeptService.list();
List<SysRole> roleList = sysRoleService.list();
List<SysPost> postList = sysPostService.list();
// 执行数据插入操作 组装 UserDto
for (UserExcelVO excel : excelVOList) {
// 个性化校验逻辑
List<SysUser> userList = this.list();
Set<String> errorMsg = new HashSet<>();
// 校验用户名是否存在
boolean exsitUserName = userList.stream().anyMatch(sysUser -> excel.getUsername().equals(sysUser.getUsername()));
if (exsitUserName) {
errorMsg.add(MsgUtils.getMessage(ErrorCodes.SYS_USER_USERNAME_EXISTING, excel.getUsername()));
}
// 判断输入的部门名称列表是否合法
Optional<SysDept> deptOptional = deptList.stream().filter(dept -> excel.getDeptName().equals(dept.getName())).findFirst();
if (!deptOptional.isPresent()) {
errorMsg.add(MsgUtils.getMessage(ErrorCodes.SYS_DEPT_DEPTNAME_INEXISTENCE, excel.getDeptName()));
}
// 判断输入的角色名称列表是否合法
List<String> roleNameList = StrUtil.split(excel.getRoleNameList(), StrUtil.COMMA);
List<SysRole> roleCollList = roleList.stream().filter(role -> roleNameList.stream().anyMatch(name -> role.getRoleName().equals(name))).collect(Collectors.toList());
if (roleCollList.size() != roleNameList.size()) {
errorMsg.add(MsgUtils.getMessage(ErrorCodes.SYS_ROLE_ROLENAME_INEXISTENCE, excel.getRoleNameList()));
}
// 判断输入的部门名称列表是否合法
List<String> postNameList = StrUtil.split(excel.getPostNameList(), StrUtil.COMMA);
List<SysPost> postCollList = postList.stream().filter(post -> postNameList.stream().anyMatch(name -> post.getPostName().equals(name))).collect(Collectors.toList());
// 判断输入的编制是否合法
if (ObjectUtil.isEmpty(excel.getStaffingType())) {
excel.setStaffingType(UserStaffingTypeEnum.NONE_STAFFING.getDescription());
excel.setStaffingTypeParse(UserStaffingTypeEnum.NONE_STAFFING.getType());
} else {
UserStaffingTypeEnum staffingTypeEnum = UserStaffingTypeEnum.valueOfByDescription(excel.getStaffingType());
if (ObjectUtil.isEmpty(staffingTypeEnum)) {
errorMsg.add(MsgUtils.getMessage("编制选择错误,请选择<在编>和<非编>", excel.getStaffingType()));
} else {
excel.setStaffingTypeParse(staffingTypeEnum.getType());
}
}
if (postCollList.size() != postNameList.size()) {
errorMsg.add(MsgUtils.getMessage(ErrorCodes.SYS_POST_POSTNAME_INEXISTENCE, excel.getPostNameList()));
}
// 数据合法情况
if (CollUtil.isEmpty(errorMsg)) {
insertExcelUser(excel, deptOptional, roleCollList, postCollList, user);
} else {
// 数据不合法情况
errorMessageList.add(new ErrorMessage(excel.getLineNum(), errorMsg));
}
}
if (CollUtil.isNotEmpty(errorMessageList)) {
return R.failed(errorMessageList);
}
return R.ok(null, MsgUtils.getMessage(ErrorCodes.SYS_USER_IMPORT_SUCCEED));
}
/**
* 导入学生用户。
* <p>先汇总校验错误并保留 Excel 行号,校验通过的行再创建学生用户、学生身份扩展和监护关系。</p>
*
* @param studentUserExcelList Excel 中通过通用校验的学生数据
* @param bindingResult Excel 通用校验产生的错误信息
* @return 导入结果;存在错误时返回对应行号和错误原因
*/
@Override
public R importStudentUser(List<StudentUserExcelVO> studentUserExcelList, BindingResult bindingResult) {
List<ErrorMessage> errorMessageList = (List<ErrorMessage>) bindingResult.getTarget();
if (CollUtil.isEmpty(studentUserExcelList)) {
return CollUtil.isNotEmpty(errorMessageList) ? R.failed(errorMessageList) : R.failed("Excel内容为空");
}
// 导入校验和后续落库统一使用去除首尾空格后的值,避免“同一学号不同空格”绕过重复校验。
studentUserExcelList.forEach(excel -> {
excel.setUsername(StrUtil.trim(excel.getUsername()));
excel.setGuardianPhone(StrUtil.trim(excel.getGuardianPhone()));
excel.setStudentNo(StrUtil.trim(excel.getStudentNo()));
excel.setSchoolName(StrUtil.trim(excel.getSchoolName()));
excel.setClassName(StrUtil.trim(excel.getClassName()));
});
// 监护人必须已存在且审批通过;批量查询后按手机号映射,避免逐行查询产生 N+1 问题。
List<String> guardianPhoneList = studentUserExcelList.stream()
.map(StudentUserExcelVO::getGuardianPhone)
.filter(StrUtil::isNotBlank)
.distinct()
.toList();
Map<String, SysUser> guardianUserMap = guardianPhoneList.isEmpty() ? Map.of() : this.list(
Wrappers.<SysUser>lambdaQuery()
.in(SysUser::getPhone, guardianPhoneList)
.eq(SysUser::getProcessStatus, TaskCheckStatusEnum.RESULT.getCode()))
.stream()
.collect(Collectors.toMap(SysUser::getPhone, user -> user, (first, second) -> first));
// 学号只要求在同一学校内唯一,按“学校名称 + 学号”统计 Excel 重复并批量查询已有学生。
List<String> studentNoList = studentUserExcelList.stream()
.map(StudentUserExcelVO::getStudentNo)
.filter(StrUtil::isNotBlank)
.toList();
Map<String, Map<String, Long>> schoolStudentNoCountMap = studentUserExcelList.stream()
.filter(excel -> StrUtil.isNotBlank(excel.getSchoolName()) && StrUtil.isNotBlank(excel.getStudentNo()))
.collect(Collectors.groupingBy(StudentUserExcelVO::getSchoolName,
Collectors.groupingBy(StudentUserExcelVO::getStudentNo, Collectors.counting())));
List<String> distinctStudentNoList = studentNoList.stream().distinct().toList();
List<String> distinctSchoolNameList = studentUserExcelList.stream()
.map(StudentUserExcelVO::getSchoolName)
.filter(StrUtil::isNotBlank)
.distinct()
.toList();
Map<String, Set<String>> existingSchoolStudentNoMap = distinctSchoolNameList.isEmpty() || distinctStudentNoList.isEmpty()
? Map.of() : sysUserStudentMapper.selectList(Wrappers.<SysUserStudentEntity>lambdaQuery()
.in(SysUserStudentEntity::getSchoolName, distinctSchoolNameList)
.in(SysUserStudentEntity::getStudentNo, distinctStudentNoList))
.stream()
.collect(Collectors.groupingBy(SysUserStudentEntity::getSchoolName,
Collectors.mapping(SysUserStudentEntity::getStudentNo, Collectors.toSet())));
// 学生导入固定绑定普通用户和自动创建钱包角色,不能依赖可能被租户调整的默认角色参数。
List<String> studentRoleCodes = List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET);
Map<String, Long> studentRoleIdMap = sysRoleService.findRolesByCodes(studentRoleCodes).stream().collect(Collectors.toMap(SysRole::getRoleCode, SysRole::getRoleId, (first, second) -> first));
List<String> missingStudentRoleCodes = studentRoleCodes.stream().filter(roleCode -> !studentRoleIdMap.containsKey(roleCode)).toList();
if (CollUtil.isNotEmpty(missingStudentRoleCodes)) {
String errorMsg = "未配置学生导入所需角色:" + String.join(",", missingStudentRoleCodes);
studentUserExcelList.forEach(excel -> errorMessageList.add(new ErrorMessage(excel.getLineNum(), Set.of(errorMsg))));
return R.failed(errorMessageList);
}
List<Long> studentRoleIds = studentRoleCodes.stream().map(studentRoleIdMap::get).toList();
// 监护关系属于可配置业务数据,导入前必须确认当前租户已配置并启用 GUARDIAN_WARD。
SysUserRelationTypeEntity guardianWardRelationType = sysUserRelationTypeService.getOne(
Wrappers.<SysUserRelationTypeEntity>lambdaQuery()
.eq(SysUserRelationTypeEntity::getRelationCode, UserRelationTypeEnum.GUARDIAN_WARD.getRelationCode())
.eq(SysUserRelationTypeEntity::getStatus, CommonConstants.STATUS_NORMAL)
.last("limit 1"));
if (guardianWardRelationType == null) {
// 配置缺失会导致所有学生都无法建立监护关系,因此按原 Excel 行号统一返回错误。
studentUserExcelList.forEach(excel -> errorMessageList.add(new ErrorMessage(
excel.getLineNum(), Set.of("未配置或未启用监护关系类型"))));
return R.failed(errorMessageList);
}
for (StudentUserExcelVO excel : studentUserExcelList) {
Set<String> errorMsg = new LinkedHashSet<>();
if (StrUtil.isBlank(excel.getUsername())) {
errorMsg.add("请输入学生姓名");
}
// 学号只在当前学校范围内判重;登录账号由系统单独生成,不再复用学号。
if (StrUtil.isBlank(excel.getStudentNo())) {
errorMsg.add("请输入学号");
} else {
// 学校名称缺失由 Excel 通用校验返回;此处跳过组合判重,避免异常行影响整批导入。
if (StrUtil.isNotBlank(excel.getSchoolName())) {
Map<String, Long> currentSchoolStudentNoCountMap = schoolStudentNoCountMap.getOrDefault(excel.getSchoolName(), Map.of());
if (currentSchoolStudentNoCountMap.getOrDefault(excel.getStudentNo(), 0L) > 1) {
errorMsg.add("学生,学校名称:" + excel.getSchoolName() + ",学号:" + excel.getStudentNo() + ",在excel中重复");
}
Set<String> currentSchoolStudentNoSet = existingSchoolStudentNoMap.getOrDefault(excel.getSchoolName(), Set.of());
if (currentSchoolStudentNoSet.contains(excel.getStudentNo())) {
errorMsg.add("学生,学校名称:" + excel.getSchoolName() + ",学号:" + excel.getStudentNo() + ",在系统中重复");
}
}
if (StrUtil.length(excel.getStudentNo()) > STUDENT_NO_MAX_LENGTH) {
errorMsg.add("学生,学号:" + excel.getStudentNo() + ",长度不能超过255个字符");
}
}
// 只有审批通过的监护人才允许绑定,后续关系方向固定为“监护人 -> 学生”。
if (StrUtil.isBlank(excel.getGuardianPhone())) {
errorMsg.add("请输入监护人手机号");
} else {
SysUser currentGuardianUser = guardianUserMap.get(excel.getGuardianPhone());
if (StrUtil.isNotBlank(excel.getGuardianPhone()) && currentGuardianUser == null) {
errorMsg.add("监护人手机号:" + excel.getGuardianPhone() + ",对应的用户未添加或未审批通过");
} else if (currentGuardianUser != null) {
excel.setGuardianUserId(currentGuardianUser.getUserId());
excel.setGuardianDeptId(currentGuardianUser.getDeptId());
}
}
// 当前行存在任一业务错误时只记录行号,不执行用户及关系落库。
if (CollUtil.isNotEmpty(errorMsg)) {
errorMessageList.add(new ErrorMessage(excel.getLineNum(), errorMsg));
continue;
}
// 每一行复用现有事务方法创建完整业务数据,避免出现孤立的用户或关系记录。
insertStudentExcelUser(excel, guardianWardRelationType.getRelationTypeId(), studentRoleIds);
}
if (CollUtil.isNotEmpty(errorMessageList)) {
return R.failed(errorMessageList);
}
return R.ok(null, "导入学生用户成功");
}
/**
* 复用“创建用户并建立关系”的事务入口,确保单行的用户主表、学生扩展和监护关系同时成功或回滚。
*
* @param excel 当前校验通过的学生 Excel 数据
* @param guardianWardRelationTypeId 当前租户启用的监护关系类型 ID
* @param studentRoleIds 学生必须具备的普通用户和自动创建钱包角色 ID
*/
private void insertStudentExcelUser(StudentUserExcelVO excel, Long guardianWardRelationTypeId, List<Long> studentRoleIds) {
SysUserRelationCreateUserDto relationUserDto = new SysUserRelationCreateUserDto();
// GUARDIAN_WARD 是有向关系,监护人作为主语,当前新建学生作为关系对象。
relationUserDto.setSubjectUserId(excel.getGuardianUserId());
relationUserDto.setRelationTypeId(guardianWardRelationTypeId);
// 学生登录账号与学号解耦,使用数据库中未占用的随机数字账号,监护人手机号仍作为首次登录密码。
relationUserDto.setUsername(generateUniqueSysUserUsername());
relationUserDto.setPassword(excel.getGuardianPhone());
relationUserDto.setName(excel.getUsername());
relationUserDto.setNickname(excel.getUsername());
relationUserDto.setStaffingType(UserStaffingTypeEnum.NONE_STAFFING.getType());
relationUserDto.setIdentityTypes(List.of(UserIdentityTypeEnum.STUDENT.getType()));
relationUserDto.setRole(studentRoleIds);
relationUserDto.setDeptId(excel.getGuardianDeptId());
// 学号、学校和班级写入学生扩展表,不向用户主表新增或复用同义字段。
SysUserStudentDto studentInfo = new SysUserStudentDto();
studentInfo.setStudentNo(excel.getStudentNo());
studentInfo.setSchoolName(excel.getSchoolName());
studentInfo.setClassName(excel.getClassName());
relationUserDto.setStudentInfo(studentInfo);
// 该服务内部已包含用户、身份扩展和关系的事务边界。
sysUserRelationService.createUserAndRelation(relationUserDto);
}
/**
* 生成数据库中未占用的 sys_user 登录账号。
* <p>随机账号满足现有用户名长度和字符约束,并在返回前查询数据库规避已有账号冲突。</p>
* <p>该能力同时由独立的用户名生成接口和学生导入使用,因此保留在用户核心服务。</p>
*
* @return 未被占用的随机数字账号
*/
@@ -858,177 +599,6 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
throw new IllegalStateException("自动生成用户账号失败,请重试");
}
@Override
public R importUserFace(MultipartFile file, List<UserExcelFaceVo> excelList, BindingResult bindingResult) {
try {
if (ObjectUtil.isEmpty(excelList)) {
R.failed("excel内容为空");
}
// 校验导入的内容
List<ErrorMessage> errorMessageList = (List<ErrorMessage>) bindingResult.getTarget();
List<String> deptNames = excelList.stream().map(UserExcelFaceVo::getDeptName).distinct().filter(ObjectUtil::isNotEmpty).toList();
// 部门
List<SysDept> sysDeptList = sysDeptService.selectByNames(deptNames);
// 角色
List<SysRole> roleList = sysRoleService.list();
// 用户
List<SysUser> userList = this.list();
String walletAutoCreate = ParamResolver.getStr("WALLET_AUTO_CREATE");
String userDefaultRole = ParamResolver.getStr("USER_DEFAULT_ROLE");
List<Long> roleIds = roleList.stream().filter(v -> ObjectUtil.equals(v.getRoleCode(), walletAutoCreate) || ObjectUtil.equals(v.getRoleCode(), userDefaultRole)).map(SysRole::getRoleId).toList();
// 用户手机号重复判断
List<String> userPoneList = excelList.stream().map(UserExcelFaceVo::getMobile).filter(ObjectUtil::isNotEmpty).toList();
// 用户名重复判断
List<String> usernameList = excelList.stream().map(UserExcelFaceVo::getUsername).filter(ObjectUtil::isNotEmpty).toList();
// 读取图片
Map<String, PictureData> images = ExcelTool.getPictureFromExcel(file, 1);
for (UserExcelFaceVo v : excelList) {
Long deptId = null;
Set<String> errorMsg = new HashSet<>();
String username = v.getUsername(), name = v.getName(), mobile = v.getMobile(), sex = v.getSex(), deptName = v.getDeptName();
if (ObjectUtil.isEmpty(name)) {
errorMsg.add("用户昵称为空");
}
if (ObjectUtil.isEmpty(mobile)) {
errorMsg.add("手机号码为空");
} else if (!Validator.isMobile(mobile.trim())) {
errorMsg.add("手机号码格式不正确");
} else {
// 校验用户名是否存在
boolean existMobile = userList.stream().anyMatch(i -> ObjectUtil.equals(i.getPhone(), mobile.trim()));
if (existMobile) {
errorMsg.add("手机号已存在");
}
}
if (ObjectUtil.isEmpty(deptName)) {
errorMsg.add("部门名称为空");
} else {
Optional<SysDept> targetDept = sysDeptList.stream().filter(i -> ObjectUtil.equals(i.getName(), deptName)).findFirst();
if (targetDept.isEmpty()) {
errorMsg.add("没有该部门");
} else {
deptId = targetDept.get().getDeptId();
}
}
PictureData face = null;
if (ObjectUtil.isNotEmpty(images)) {
String key = String.valueOf(v.getLineNum() - 1);
face = images.getOrDefault(key, null);
}
// if (ObjectUtil.isEmpty(face)) {
// errorMsg.add("人脸照片为空");
// }
if (ObjectUtil.isNotEmpty(v.getMobile())) {
long userPhone = userPoneList.stream().filter(i -> ObjectUtil.equals(i, v.getMobile())).count();
if (userPhone > 1) {
errorMsg.add("用手机号 <" + v.getMobile() + "> 重复");
}
}
if (ObjectUtil.isNotEmpty(v.getUsername())) {
long repetitionUsername = usernameList.stream().filter(i -> ObjectUtil.equals(i, v.getUsername())).count();
if (repetitionUsername > 1) {
// errorMsg.add("用户名 <" + v.getUsername() + "> 重复");
}
}
// 判断输入的编制是否合法
String staffingType = null;
if (ObjectUtil.isEmpty(v.getStaffingType())) {
// errorMsg.add("请选择编制");
} else {
UserStaffingTypeEnum staffingTypeEnum = UserStaffingTypeEnum.valueOfByDescription(v.getStaffingType());
if (ObjectUtil.isEmpty(staffingTypeEnum)) {
errorMsg.add("编制选择错误,请选择<在编>和<非编>");
} else {
staffingType = staffingTypeEnum.getType();
}
}
if (CollUtil.isEmpty(errorMsg)) {
v.setStaffingType(staffingType);
insertExcelUser(v, deptId, roleIds, face);
} else {
errorMessageList.add(new ErrorMessage(v.getLineNum(), errorMsg));
}
}
if (CollUtil.isNotEmpty(errorMessageList)) {
return R.failed(errorMessageList);
}
return R.ok(null, MsgUtils.getMessage(ErrorCodes.SYS_USER_IMPORT_SUCCEED));
} catch (Exception e) {
e.printStackTrace();
log.error("|{}|导入用户意外的错误error:|{}|", TAG, e.getMessage());
throw new RuntimeException("导入错误");
}
}
/**
* 插入excel User
*/
private void insertExcelUser(UserExcelVO excel, Optional<SysDept> deptOptional, List<SysRole> roleCollList, List<SysPost> postCollList, PjcloudUser user) {
UserDTO userDTO = new UserDTO();
userDTO.setUsername(excel.getUsername());
userDTO.setPhone(excel.getPhone());
userDTO.setNickname(excel.getNickname());
userDTO.setName(excel.getName());
userDTO.setEmail(excel.getEmail());
userDTO.setLockFlag(excel.getLockFlag());
userDTO.setStaffingType(excel.getStaffingTypeParse());
// 批量导入初始密码为手机号
userDTO.setPasswordModifyTime(LocalDateTime.now());
userDTO.setPassword(userDTO.getPhone());
// 根据部门名称查询部门ID
userDTO.setDeptId(deptOptional.get().getDeptId());
// 插入岗位名称
List<Long> postIdList = postCollList.stream().map(SysPost::getPostId).collect(Collectors.toList());
userDTO.setPost(postIdList);
// 根据角色名称查询角色ID
List<Long> roleIdList = roleCollList.stream().map(SysRole::getRoleId).collect(Collectors.toList());
userDTO.setRole(roleIdList);
// 插入用户
this.saveUser(userDTO);
}
public void insertExcelUser(UserExcelFaceVo excel, Long deptId, List<Long> roleList, PictureData face) {
String username = ObjectUtil.isEmpty(excel.getUsername()) ? excel.getMobile() : excel.getUsername();
UserDTO userDTO = new UserDTO();
userDTO.setUsername(username.replaceAll("\\s+", ""));
userDTO.setName(excel.getName().replaceAll("\\s+", ""));
userDTO.setNickname(excel.getName().replaceAll("\\s+", ""));
userDTO.setPhone(excel.getMobile().replaceAll("\\s+", ""));
userDTO.setDeptId(deptId);
userDTO.setStaffingType(excel.getStaffingType());
// 批量导入初始密码为手机号
userDTO.setPasswordModifyTime(LocalDateTime.now());
userDTO.setPassword(userDTO.getPhone().replaceAll("\\s+", ""));
// 根据角色名称查询角色ID
userDTO.setRole(roleList);
// 插入用户
Long usrId = this.saveUser(userDTO);
userDTO.setUserId(usrId);
// 更新人脸
if (ObjectUtil.isNotEmpty(face)) {
insertExcelFace(userDTO, face);
}
}
public void insertExcelFace(UserDTO user, PictureData face) {
try {
// 查询用户
MultipartFile multipartFile = new MockMultipartFile("file", UUID.randomUUID() + "_face.png", "image/png", new ByteArrayInputStream(face.getData()));
Map data = (Map) sysFileService.uploadFile(multipartFile, null, null, null).getData();
String url = data.get("url").toString();
SysUserFaceEntity updateObj = new SysUserFaceEntity();
updateObj.setUserId(user.getUserId());
updateObj.setFaceImg(url);
sysUserFaceService.create(updateObj);
} catch (Exception e) {
e.printStackTrace();
log.error("|{}|获取人脸图片错误error:|{}|", TAG, e.getMessage());
}
}
/**
* 注册用户 赋予用户默认角色
*
@@ -7,14 +7,17 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
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.SysUser;
import com.bsd.pjcloud.admin.api.entity.user.SysUserIdentityEntity;
import com.bsd.pjcloud.admin.api.entity.user.SysUserStudentEntity;
import com.bsd.pjcloud.admin.api.entity.user.SysUserTeacherEntity;
import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.admin.mapper.SysUserMapper;
import com.bsd.pjcloud.admin.mapper.user.SysUserIdentityMapper;
import com.bsd.pjcloud.admin.service.SysUserIdentityService;
import com.bsd.pjcloud.admin.service.SysUserStudentService;
import com.bsd.pjcloud.admin.service.SysUserTeacherService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
@@ -30,14 +33,32 @@ import java.util.Set;
@RequiredArgsConstructor
public class SysUserIdentityServiceImpl extends ServiceImpl<SysUserIdentityMapper, SysUserIdentityEntity> implements SysUserIdentityService {
/** 学生学号最大长度,与学生扩展表字段长度保持一致。 */
private static final int STUDENT_NO_MAX_LENGTH = 255;
/** 老师学号最小长度,与老师导入模板校验规则保持一致。 */
private static final int TEACHER_NO_MIN_LENGTH = 2;
/** 老师学号最大长度,与老师扩展表字段长度保持一致。 */
private static final int TEACHER_NO_MAX_LENGTH = 60;
/** 中国大陆手机号校验规则,用于默认身份绑定手机号。 */
private static final String PHONE_PATTERN = "^1[3-9]\\d{9}$";
/** 学生扩展信息服务,负责学生资料持久化和学号查重。 */
private final SysUserStudentService sysUserStudentService;
/** 老师扩展信息服务,负责老师学号持久化和查重。 */
private final SysUserTeacherService sysUserTeacherService;
/** 用户 Mapper,用于编辑默认身份时读取原手机号。 */
private final SysUserMapper sysUserMapper;
/**
* 校验并规范化用户身份请求参数。
*
* @param userDto 新增或编辑用户时提交的完整用户信息
*/
@Override
public void validateDto(UserDTO userDto) {
if (userDto.getIdentityTypes() == null) {
@@ -65,13 +86,25 @@ public class SysUserIdentityServiceImpl extends ServiceImpl<SysUserIdentityMappe
userDto.setPhone(null);
}
if (!identityTypes.contains(UserIdentityTypeEnum.STUDENT.getType())) {
if (identityTypes.contains(UserIdentityTypeEnum.STUDENT.getType())) {
validateStudentInfo(userDto);
} else {
userDto.setStudentInfo(null);
return;
}
validateStudentInfo(userDto);
if (identityTypes.contains(UserIdentityTypeEnum.TEACHER.getType())) {
validateTeacherInfo(userDto);
} else {
userDto.setTeacherInfo(null);
}
}
/**
* 覆盖保存用户身份,并分别维护学生和老师扩展信息。
*
* @param userId 已保存的系统用户 ID
* @param userDto 已完成身份参数校验的用户信息
*/
@Override
public void saveUserIdentity(Long userId, UserDTO userDto) {
if (userDto.getIdentityTypes() == null) {
@@ -90,16 +123,30 @@ public class SysUserIdentityServiceImpl extends ServiceImpl<SysUserIdentityMappe
.toList();
saveBatch(identities);
if (!userDto.getIdentityTypes().contains(UserIdentityTypeEnum.STUDENT.getType())) {
if (userDto.getIdentityTypes().contains(UserIdentityTypeEnum.STUDENT.getType())) {
SysUserStudentEntity student = new SysUserStudentEntity();
BeanUtils.copyProperties(userDto.getStudentInfo(), student);
student.setUserId(userId);
sysUserStudentService.saveOrUpdate(student);
} else {
sysUserStudentService.removeById(userId);
return;
}
SysUserStudentEntity student = new SysUserStudentEntity();
BeanUtils.copyProperties(userDto.getStudentInfo(), student);
student.setUserId(userId);
sysUserStudentService.saveOrUpdate(student);
if (userDto.getIdentityTypes().contains(UserIdentityTypeEnum.TEACHER.getType())) {
SysUserTeacherEntity teacher = new SysUserTeacherEntity();
BeanUtils.copyProperties(userDto.getTeacherInfo(), teacher);
teacher.setUserId(userId);
sysUserTeacherService.saveOrUpdate(teacher);
} else {
sysUserTeacherService.removeById(userId);
}
}
/**
* 删除用户身份以及学生、老师扩展信息。
*
* @param userIds 待删除的系统用户 ID 列表
*/
@Override
public void deleteUserIdentityByUserIds(List<Long> userIds) {
if (CollUtil.isEmpty(userIds)) {
@@ -107,9 +154,15 @@ public class SysUserIdentityServiceImpl extends ServiceImpl<SysUserIdentityMappe
}
// 先清理各身份的扩展信息,再删除身份记录,避免用户删除后遗留无法归属的身份数据。
sysUserStudentService.remove(Wrappers.<SysUserStudentEntity>lambdaQuery().in(SysUserStudentEntity::getUserId, userIds));
sysUserTeacherService.remove(Wrappers.<SysUserTeacherEntity>lambdaQuery().in(SysUserTeacherEntity::getUserId, userIds));
remove(Wrappers.<SysUserIdentityEntity>lambdaQuery().in(SysUserIdentityEntity::getUserId, userIds));
}
/**
* 填充用户身份及各身份的独立扩展信息,供管理端编辑回显。
*
* @param userVo 已查询到基础数据的用户展示对象
*/
@Override
public void fillUserIdentity(UserVO userVo) {
if (userVo == null || userVo.getUserId() == null) {
@@ -122,17 +175,29 @@ public class SysUserIdentityServiceImpl extends ServiceImpl<SysUserIdentityMappe
.toList();
userVo.setIdentityTypes(identityTypes);
if (!identityTypes.contains(UserIdentityTypeEnum.STUDENT.getType())) {
return;
if (identityTypes.contains(UserIdentityTypeEnum.STUDENT.getType())) {
SysUserStudentEntity student = sysUserStudentService.getById(userVo.getUserId());
if (student != null) {
SysUserStudentDto studentInfo = new SysUserStudentDto();
BeanUtils.copyProperties(student, studentInfo);
userVo.setStudentInfo(studentInfo);
}
}
SysUserStudentEntity student = sysUserStudentService.getById(userVo.getUserId());
if (student != null) {
SysUserStudentDto studentInfo = new SysUserStudentDto();
BeanUtils.copyProperties(student, studentInfo);
userVo.setStudentInfo(studentInfo);
if (identityTypes.contains(UserIdentityTypeEnum.TEACHER.getType())) {
SysUserTeacherEntity teacher = sysUserTeacherService.getById(userVo.getUserId());
if (teacher != null) {
SysUserTeacherDto teacherInfo = new SysUserTeacherDto();
BeanUtils.copyProperties(teacher, teacherInfo);
userVo.setTeacherInfo(teacherInfo);
}
}
}
/**
* 校验并规范化学生身份扩展信息。
*
* @param userDto 包含学生身份和学生扩展信息的用户请求
*/
private void validateStudentInfo(UserDTO userDto) {
SysUserStudentDto studentInfo = userDto.getStudentInfo();
if (studentInfo == null || StrUtil.isBlank(studentInfo.getStudentNo())) {
@@ -154,6 +219,34 @@ public class SysUserIdentityServiceImpl extends ServiceImpl<SysUserIdentityMappe
studentInfo.setSchoolName(schoolName);
}
/**
* 校验并规范化老师身份扩展信息。
*
* @param userDto 包含老师身份和老师扩展信息的用户请求
*/
private void validateTeacherInfo(UserDTO userDto) {
SysUserTeacherDto teacherInfo = userDto.getTeacherInfo();
if (teacherInfo == null || StrUtil.isBlank(teacherInfo.getTeacherNo())) {
throw new IllegalArgumentException("老师学号不能为空");
}
String teacherNo = teacherInfo.getTeacherNo().trim();
if (teacherNo.length() < TEACHER_NO_MIN_LENGTH || teacherNo.length() > TEACHER_NO_MAX_LENGTH) {
throw new IllegalArgumentException("老师学号长度必须介于2到60个字符之间");
}
if (teacherNo.chars().anyMatch(character -> character >= 0x4e00 && character <= 0x9fa5)) {
throw new IllegalArgumentException("老师学号不能包含中文");
}
if (sysUserTeacherService.existsTeacherNo(userDto.getUserId(), teacherNo)) {
throw new IllegalArgumentException("老师学号已存在");
}
teacherInfo.setTeacherNo(teacherNo);
}
/**
* 校验并规范化默认身份绑定的手机号。
*
* @param userDto 包含默认身份和手机号的用户请求
*/
private void validateDefaultIdentity(UserDTO userDto) {
if (StrUtil.isBlank(userDto.getPhone())) {
SysUser currentUser = userDto.getUserId() == null ? null : sysUserMapper.selectById(userDto.getUserId());
@@ -136,6 +136,19 @@ public class SysUserRelationServiceImpl extends ServiceImpl<SysUserRelationMappe
.toList();
}
/**
* 查询指定用户所在完整关系图谱中的其他用户 ID,范围与关系图谱页面保持一致。
*
* @param userId 当前用户 ID
* @return 同一关系图谱中的其他用户 ID 列表,不包含当前用户自身
*/
@Override
public List<Long> listRelatedUserIds(Long userId) {
return collectRelationGraph(userId).userIds.stream()
.filter(relatedUserId -> !Objects.equals(userId, relatedUserId))
.toList();
}
/**
* 查询指定监护人当前有效的被监护人,并按关系记录顺序返回展示信息。
*
@@ -0,0 +1,33 @@
package com.bsd.pjcloud.admin.service.impl.user;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.entity.user.SysUserTeacherEntity;
import com.bsd.pjcloud.admin.mapper.user.SysUserTeacherMapper;
import com.bsd.pjcloud.admin.service.SysUserTeacherService;
import org.springframework.stereotype.Service;
/**
* 老师用户扩展信息服务实现。
*/
@Service
public class SysUserTeacherServiceImpl extends ServiceImpl<SysUserTeacherMapper, SysUserTeacherEntity> implements SysUserTeacherService {
/**
* 判断当前租户中老师学号是否已被其他用户使用。
*
* @param userId 编辑时排除的当前用户 ID;新增时为空
* @param teacherNo 待校验的老师学号
* @return 存在其他相同老师学号时返回 {@code true}
*/
@Override
public boolean existsTeacherNo(Long userId, String teacherNo) {
if (StrUtil.isBlank(teacherNo)) {
return false;
}
return exists(Wrappers.<SysUserTeacherEntity>lambdaQuery()
.eq(SysUserTeacherEntity::getTeacherNo, teacherNo.trim())
.ne(userId != null, SysUserTeacherEntity::getUserId, userId));
}
}
@@ -89,6 +89,24 @@ class SysUserRelationServiceImplTest {
assertFalse(service.hasActiveGuardianWardRelation(GUARDIAN_USER_ID, 999L));
}
/**
* 验证关系人查询覆盖完整连通图,并排除当前用户和重复用户。
*/
@Test
void shouldListDistinctUsersFromCompleteRelationGraph() {
SysUserRelationServiceImpl service = spy(
new SysUserRelationServiceImpl(mock(SysUserService.class), mock(SysUserRelationTypeService.class)));
SysUserRelationEntity outgoingRelation = createRelation(2079104982669385730L, FIRST_WARD_USER_ID);
SysUserRelationEntity incomingRelation = createRelation(2079129636280758273L, FIRST_WARD_USER_ID);
incomingRelation.setSubjectUserId(SECOND_WARD_USER_ID);
doReturn(List.of(outgoingRelation), List.of(outgoingRelation, incomingRelation), List.of(incomingRelation))
.when(service).list(org.mockito.ArgumentMatchers.<Wrapper<SysUserRelationEntity>>any());
List<Long> relatedUserIds = service.listRelatedUserIds(GUARDIAN_USER_ID);
assertEquals(List.of(FIRST_WARD_USER_ID, SECOND_WARD_USER_ID), relatedUserIds);
}
private SysUserRelationEntity createRelation(Long relationId, Long wardUserId) {
SysUserRelationEntity relation = new SysUserRelationEntity();
relation.setRelationId(relationId);
@@ -1,5 +1,6 @@
package com.bsd.pjcloud.wallet.api.dto;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
@@ -10,7 +11,7 @@ import lombok.Data;
import java.util.List;
@Data
public class WalletActiveDto extends WalletActiveEntity {
public class WalletActiveDto extends WalletActiveEntity implements BaseIsIncludeTreeInterface {
@Schema(description = "数量")
@Max(200)
@@ -21,10 +22,14 @@ public class WalletActiveDto extends WalletActiveEntity {
@NotNull(message = "商户id不能为空")
private Long merchantId;
private Long merchantIdList;
private Long walletTypeId;
private List<Long> userIds;
private Boolean isInclude;
private List<Long> deptIds;
/**
@@ -3,23 +3,33 @@ package com.bsd.pjcloud.wallet.api.vo.recharge.app;
import lombok.Data;
/**
* 被监护人饭卡列表项,仅返回移动端展示所需的脱敏信息。
* 用户业务摘要列表项,兼容被监护人代办与本人及关系人列表,仅返回移动端展示所需的脱敏信息。
*/
@Data
public class GuardianWardRechargeItemVo {
/**
* 被监护人用户ID,仅用于后续受保护的业务请求,不在列表界面直接展示。
* 摘要对应的用户 ID,仅用于后续受保护的业务请求,不在列表界面直接展示。
*/
private Long wardUserId;
/**
* 被监护人展示名称。
* 是否为当前登录用户本人。
*/
private Boolean currentUser;
/**
* 当前登录用户是否可以按有效监护关系为该用户代办业务。
*/
private Boolean guardianWard;
/**
* 用户展示名称。
*/
private String wardName;
/**
* 被监护人头像地址。
* 用户头像地址。
*/
private String wardAvatar;
@@ -75,6 +75,19 @@ public class WalletRechargeController {
return R.ok(walletRechargeService.listGuardianWards(user.getId()));
}
/**
* 查询当前用户本人及同一完整关系图谱中全部关系人的业务摘要。
*
* @return 本人及关系人的饭卡、人脸和预约摘要
*/
@Operation(summary = "查询本人及关系人列表", description = "查询当前登录用户本人及同一完整关系图谱中全部关系人的业务摘要")
@ApiCryptoAes
@GetMapping("/app/related-user/list")
public R<List<GuardianWardRechargeItemVo>> relatedUserList() {
PjcloudUser user = SecurityUtils.getUser();
return R.ok(walletRechargeService.listRelatedUsers(user.getId()));
}
@Operation(summary = "查询监护代充值上下文", description = "返回目标饭卡、有效充值套餐和目标食堂支付平台")
@ApiCryptoAes
@GetMapping("/app/guardian-ward/context")
@@ -35,8 +35,17 @@ public interface WalletActiveMapper extends PjcloudBaseMapper<WalletActiveEntity
.eq(ObjectUtil.isNotEmpty(dto.getIsActiveHardware()), WalletActiveEntity::getIsActiveHardware, dto.getIsActiveHardware())
.eq(ObjectUtil.isNotEmpty(dto.getDeptId()), WalletActiveEntity::getDeptId, dto.getDeptId())
.in(ObjectUtil.isNotEmpty(dto.getUserIds()), WalletActiveEntity::getUserId, dto.getUserIds())
.in(ObjectUtil.isNotEmpty(dto.getDeptIds()), WalletActiveEntity::getDeptId, dto.getDeptIds())
.in(ObjectUtil.isNotEmpty(dto.getMerchantIdList()), WalletActiveEntity::getMerchantId, dto.getMerchantIdList())
.orderByDesc(WalletActiveEntity::getCardSort);
if (ObjectUtil.isNotEmpty(dto.getIsInclude())) {
if (dto.getIsInclude()) {
wrapper.in(ObjectUtil.isNotEmpty(dto.getDeptIds()), WalletActiveEntity::getDeptId, dto.getDeptIds());
} else {
wrapper.notIn(ObjectUtil.isNotEmpty(dto.getDeptIds()), WalletActiveEntity::getDeptId, dto.getDeptIds());
}
} else {
wrapper.in(ObjectUtil.isNotEmpty(dto.getDeptIds()), WalletActiveEntity::getDeptId, dto.getDeptIds());
}
if (ObjectUtil.isNotEmpty(dto.getIsBindUser())) {
if (dto.getIsBindUser()) {
wrapper.isNotNull(WalletActiveEntity::getUserId);
@@ -37,6 +37,14 @@ public interface WalletRechargeService extends IService<WalletRechargeEntity> {
*/
List<GuardianWardRechargeItemVo> listGuardianWards(Long guardianUserId);
/**
* 查询当前用户本人及同一完整关系图谱中全部关系人的饭卡、人脸和预约摘要。
*
* @param currentUserId 当前登录用户 ID
* @return 本人及关系人摘要列表,本人固定排在首位
*/
List<GuardianWardRechargeItemVo> listRelatedUsers(Long currentUserId);
/**
* 查询被监护人的代充值上下文。
*
@@ -51,9 +51,11 @@ import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
@@ -83,13 +85,13 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
/** 用户关系远程服务,用于实时校验监护授权。 */
private final RemoteUserRelationService remoteUserRelationService;
/** 用户远程服务,用于返回被监护人的脱敏基本信息。 */
/** 用户远程服务,用于返回本人、关系人及被监护人的脱敏基本信息。 */
private final RemoteUserService remoteUserService;
/** 用户人脸远程服务,用于一次聚合各被监护人的人脸业务状态。 */
/** 用户人脸远程服务,用于一次聚合列表中各用户的人脸业务状态。 */
private final RemoteUserFaceService remoteUserFaceService;
/** 订单远程服务,用于一次聚合各被监护人的待核销预约数量。 */
/** 订单远程服务,用于一次聚合列表中各用户的待核销预约数量。 */
private final RemoteHardOrderService remoteHardOrderService;
@Override
@@ -130,25 +132,55 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
if (CollUtil.isEmpty(wardUserIds)) {
return List.of();
}
return listUserBusinessItems(wardUserIds, Set.copyOf(wardUserIds), null);
}
/**
* 查询当前用户本人及同一完整关系图谱中的全部关系人摘要;本人固定排在首位,关系人按图谱遍历顺序返回。
*
* @param currentUserId 当前登录用户 ID
* @return 本人及关系人业务摘要
*/
@Override
public List<GuardianWardRechargeItemVo> listRelatedUsers(Long currentUserId) {
List<Long> relatedUserIds = listRelatedUserIds(currentUserId);
List<Long> wardUserIds = listActiveWardUserIds(currentUserId);
LinkedHashSet<Long> targetUserIds = new LinkedHashSet<>();
targetUserIds.add(currentUserId);
targetUserIds.addAll(relatedUserIds);
return listUserBusinessItems(List.copyOf(targetUserIds), Set.copyOf(wardUserIds), currentUserId);
}
/**
* 按指定用户顺序聚合饭卡、人脸和待核销预约,并补充本人及监护代办身份。
*
* @param userIds 需要聚合的用户 ID,返回顺序与此列表保持一致
* @param wardUserIds 当前用户可代办的有效被监护人 ID
* @param currentUserId 当前登录用户 ID;旧监护列表传空,不标记本人
* @return 用户业务摘要列表
*/
private List<GuardianWardRechargeItemVo> listUserBusinessItems(List<Long> userIds, Set<Long> wardUserIds, Long currentUserId) {
R<List<SysUser>> userResult;
try {
userResult = remoteUserService.getInnerUserById(wardUserIds);
userResult = remoteUserService.getInnerUserById(userIds);
} catch (Exception exception) {
throw new RuntimeException("查询被监护人信息失败", exception);
throw new RuntimeException("查询关系人信息失败", exception);
}
if (userResult == null || !userResult.isOk() || userResult.getData() == null) {
throw new RuntimeException("查询被监护人信息失败");
throw new RuntimeException("查询关系人信息失败");
}
Map<Long, SysUser> userMap = new LinkedHashMap<>();
userResult.getData().forEach(user -> userMap.put(user.getUserId(), user));
Map<Long, SysUserFaceStatusCountVo> faceCountMap = listWardFaceStatusCounts(wardUserIds);
Map<Long, HardPendingReservationCountVo> reservationCountMap = listWardPendingReservationCounts(wardUserIds);
return wardUserIds.stream().map(wardUserId -> {
GuardianWardRechargeItemVo item = buildWardItem(userMap.get(wardUserId), walletManageService.getWallet(wardUserId, WalletTypeEnum.EAT));
SysUserFaceStatusCountVo faceCount = faceCountMap.get(wardUserId);
Map<Long, SysUserFaceStatusCountVo> faceCountMap = listUserFaceStatusCounts(userIds);
Map<Long, HardPendingReservationCountVo> reservationCountMap = listUserPendingReservationCounts(userIds);
return userIds.stream().map(userId -> {
GuardianWardRechargeItemVo item = buildWardItem(userMap.get(userId), walletManageService.getWallet(userId, WalletTypeEnum.EAT));
item.setCurrentUser(Objects.equals(currentUserId, userId));
item.setGuardianWard(wardUserIds.contains(userId));
SysUserFaceStatusCountVo faceCount = faceCountMap.get(userId);
item.setAvailableFaceCount(faceCount == null ? 0L : ObjectUtil.defaultIfNull(faceCount.getAvailableCount(), 0L));
item.setPendingFaceCount(faceCount == null ? 0L : ObjectUtil.defaultIfNull(faceCount.getPendingCount(), 0L));
HardPendingReservationCountVo reservationCount = reservationCountMap.get(wardUserId);
HardPendingReservationCountVo reservationCount = reservationCountMap.get(userId);
item.setPendingReservationCount(reservationCount == null ? 0L : ObjectUtil.defaultIfNull(reservationCount.getPendingCount(), 0L));
return item;
}).toList();
@@ -232,8 +264,11 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
// 查询钱包
WalletManageEntity wallet = walletManageService.getWallet(dto.getWalletId());
if (ObjectUtil.isEmpty(wallet)) throw new RuntimeException("用户钱包不存在");
WalletRechargeEntity refund = getByRefundId(dto.getOrderId());
if (ObjectUtil.isNotEmpty(refund)) throw new RuntimeException("已经存在退款单");
// 每次部分退款以售后单 ID 幂等;重复调用返回原退款单,不再次增加钱包余额。
WalletRechargeEntity refund = getByRefundId(dto.getAfterSaleId());
if (ObjectUtil.isNotEmpty(refund)) {
return BeanUtils.toBean(refund, WalletRechargeVo.class);
}
WalletRechargeEntity recharge = WalletRechargeEntity.builder()
.walletId(wallet.getId())
.payRefundCode(dto.getRefundCode())
@@ -268,17 +303,39 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
}
/**
* 人脸统计失败时拒绝返回不完整列表,避免把未知状态误展示为零。
* 关系服务异常时拒绝返回不完整列表,避免页面把关系人缺失误判为无关系。
*
* @param currentUserId 当前登录用户 ID
* @return 同一完整关系图谱中的其他用户 ID
*/
private Map<Long, SysUserFaceStatusCountVo> listWardFaceStatusCounts(List<Long> wardUserIds) {
R<List<SysUserFaceStatusCountVo>> result;
private List<Long> listRelatedUserIds(Long currentUserId) {
R<List<Long>> result;
try {
result = remoteUserFaceService.listStatusCounts(wardUserIds);
result = remoteUserRelationService.listRelatedUserIds(currentUserId);
} catch (Exception exception) {
throw new RuntimeException("查询被监护人人脸情况失败", exception);
throw new RuntimeException("查询用户关系失败", exception);
}
if (result == null || !result.isOk() || result.getData() == null) {
throw new RuntimeException("查询被监护人人脸情况失败");
throw new RuntimeException("查询用户关系失败");
}
return result.getData().stream().filter(Objects::nonNull).distinct().toList();
}
/**
* 人脸统计失败时拒绝返回不完整列表,避免把未知状态误展示为零。
*
* @param userIds 需要统计人脸状态的用户 ID
* @return 按用户 ID 索引的人脸状态统计
*/
private Map<Long, SysUserFaceStatusCountVo> listUserFaceStatusCounts(List<Long> userIds) {
R<List<SysUserFaceStatusCountVo>> result;
try {
result = remoteUserFaceService.listStatusCounts(userIds);
} catch (Exception exception) {
throw new RuntimeException("查询用户人脸情况失败", exception);
}
if (result == null || !result.isOk() || result.getData() == null) {
throw new RuntimeException("查询用户人脸情况失败");
}
Map<Long, SysUserFaceStatusCountVo> countMap = new LinkedHashMap<>();
result.getData().stream().filter(Objects::nonNull).filter(item -> item.getUserId() != null).forEach(item -> countMap.put(item.getUserId(), item));
@@ -287,16 +344,19 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
/**
* 预约统计失败时拒绝返回不完整列表,确保待核销数量来自订单服务的统一口径。
*
* @param userIds 需要统计待核销预约的用户 ID
* @return 按用户 ID 索引的待核销预约统计
*/
private Map<Long, HardPendingReservationCountVo> listWardPendingReservationCounts(List<Long> wardUserIds) {
private Map<Long, HardPendingReservationCountVo> listUserPendingReservationCounts(List<Long> userIds) {
R<List<HardPendingReservationCountVo>> result;
try {
result = remoteHardOrderService.listPendingReservationCounts(wardUserIds);
result = remoteHardOrderService.listPendingReservationCounts(userIds);
} catch (Exception exception) {
throw new RuntimeException("查询被监护人待核销预约失败", exception);
throw new RuntimeException("查询用户待核销预约失败", exception);
}
if (result == null || !result.isOk() || result.getData() == null) {
throw new RuntimeException("查询被监护人待核销预约失败");
throw new RuntimeException("查询用户待核销预约失败");
}
Map<Long, HardPendingReservationCountVo> countMap = new LinkedHashMap<>();
result.getData().stream().filter(Objects::nonNull).filter(item -> item.getUserId() != null).forEach(item -> countMap.put(item.getUserId(), item));