feat:增加老师额外的用户字段。

feat:增加了老师的导入,和导入的进度条。
feat:app分配次退款。
feat:上传人脸不需要审核。
feat:部门管理员导入用户不需要审核。
This commit is contained in:
dzc committed 2026-08-12 19:57:53 +08:00
1 parent 07028116c1
commit 5a7f8dcfbb
62 files changed
+3884 -760

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>
@@ -50,4 +50,9 @@ public interface CommonRoleConstant {
*/
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;
}
@@ -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;
}
@@ -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();
@@ -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())
@@ -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;
}
@@ -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>
@@ -95,6 +95,9 @@ public class SysUserController {
private final SysUserService userService;
/** 用户 Excel 导入导出服务,统一承接用户模块的文件转换与导入编排。 */
private final SysUserImportExportService userImportExportService;
private final RemoterVisitorService remoterVisitorService;
private final SysDeptService sysDeptService;
@@ -398,54 +401,73 @@ 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 导入结果;失败时包含对应行号和错误原因
* @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度
*/
@PostMapping("/import-student")
@HasPermission("sys_student_user_export")
@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 userService.importStudentUser(studentUserExcelList, bindingResult);
public R<?> importStudentUser(@RequestExcel List<StudentUserExcelVO> studentUserExcelList, BindingResult bindingResult) {
return userImportExportService.importStudentUser(studentUserExcelList, bindingResult);
}
/**
* 导入用户及 Excel 中内嵌的人脸图片。
* 导入老师用户,接口同步校验整份 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 导入结果;失败时包含校验错误或人脸图片处理错误
* @return 校验失败时包含校验或图片处理错误;校验通过时包含异步任务 ID 和初始进度
*/
@PostMapping("/import-face")
@HasPermission("sys_user_export")
@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 userService.importUserFace(file, excelList, bindingResult);
public R<?> importUserFace(@RequestParam("file") MultipartFile file, @RequestExcel List<UserExcelFaceVo> excelList, BindingResult bindingResult) {
return userImportExportService.importUserFace(file, excelList, bindingResult);
}
/**
@@ -107,9 +107,9 @@ public class SysUserFaceController {
}
/**
* 当前用户为本人或有效被监护人提交人脸审核。
* 当前用户为本人或有效被监护人提交人脸,是否审核由提交人角色决定。
*/
@Operation(summary = "提交本人或被监护人人脸", description = "仅可为本人或有效被监护人提交,所有图片必须进入审核流程")
@Operation(summary = "提交本人或被监护人人脸", description = "仅可为本人或有效被监护人提交,拥有指定角色时进入审核流程,否则直接通过")
@SysLog("提交本人或被监护人人脸")
@ApiCryptoAes
@PostMapping("/app/guardian-ward")
@@ -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);
@@ -0,0 +1,65 @@
package com.bsd.pjcloud.admin.service;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
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 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);
}
@@ -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;
/**
@@ -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;
@@ -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
* @return 新增的人脸记录ID列表
*/
@Override
@Transactional(rollbackFor = Exception.class)
@@ -143,7 +151,8 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
throw new RuntimeException("目标用户不存在");
}
validateSelfOrGuardianWardUser(loginUser.getId(), dto.getWardUserId());
if (sysDeptService.getById(applyUser.getDeptId()) == null) {
boolean needFaceApproval = needFaceApproval(loginUser.getId());
if (needFaceApproval && sysDeptService.getById(applyUser.getDeptId()) == null) {
throw new RuntimeException("当前用户所属部门不存在,无法发起人脸审核");
}
validateAppRelationFaceLimit(dto.getWardUserId(), dto.getFaceImgs().size());
@@ -153,15 +162,37 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
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(applyUser, 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));
}
/**
* 查询当前监护人名下可选择的有效被监护人,并仅返回页面展示所需字段。
*
@@ -0,0 +1,814 @@
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.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 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 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 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 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,7 +68,6 @@ 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;
@@ -90,7 +81,6 @@ 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;
@@ -98,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;
@@ -129,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;
@@ -146,29 +128,17 @@ 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;
@@ -179,8 +149,6 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
*/
private final RemoteFlowEngineService remoteFlowEngineService;
private final SysFileService sysFileService;
private final FlowUtil flowUtil;
/**
@@ -614,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 未被占用的随机数字账号
*/
@@ -895,178 +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+", ""));
// `name`和`nickname`是可以有空格的外国人的名字可能有空格比如`FRANCISCO DIOGO BLANCO DUARTE`、`CHEK HUO LEUNG`
userDTO.setName(excel.getName());
userDTO.setNickname(excel.getName());
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());
@@ -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));
}
}
@@ -264,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())