diff --git a/README_REDIS_TENANT.md b/README_REDIS_TENANT.md new file mode 100644 index 00000000..93be10fe --- /dev/null +++ b/README_REDIS_TENANT.md @@ -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 变更前检查现有读写双方,避免造成历史缓存无法读取。 diff --git a/db/20260812_add_sys_user_teacher.sql b/db/20260812_add_sys_user_teacher.sql new file mode 100644 index 00000000..1d1e1909 --- /dev/null +++ b/db/20260812_add_sys_user_teacher.sql @@ -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='老师用户扩展信息表'; diff --git a/pjcloud-common/pjcloud-common-async-task/README.md b/pjcloud-common/pjcloud-common-async-task/README.md new file mode 100644 index 00000000..8111da27 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/README.md @@ -0,0 +1,58 @@ +# 通用异步任务模块 + +`pjcloud-common-async-task` 提供可由各业务模块复用的异步事务执行、Redis 进度保存和统一进度查询接口。 + +## 接入方式 + +业务模块增加依赖后,Spring Boot 会自动装配 `AsyncTaskExecutorService`、`AsyncTaskProgressService` 和查询接口: + +```xml + + com.bsd + pjcloud-common-async-task + +``` + +提交任务时由业务方指定稳定的 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 小时,供页面刷新或短时重连后继续查询。 +- 任务进度按当前登录用户隔离,调用端不能查询其他用户创建的任务。 diff --git a/pjcloud-common/pjcloud-common-async-task/pom.xml b/pjcloud-common/pjcloud-common-async-task/pom.xml new file mode 100644 index 00000000..2199740e --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + com.bsd + pjcloud-common + 5.7.0 + + + pjcloud-common-async-task + jar + + pjcloud 通用异步任务与 Redis 进度管理 + + + + + com.bsd + pjcloud-common-core + + + + com.bsd + pjcloud-common-data + + + + com.bsd + pjcloud-common-security + + + + org.springframework + spring-tx + + + + org.springframework + spring-webmvc + provided + + + diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/config/AsyncTaskAutoConfiguration.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/config/AsyncTaskAutoConfiguration.java new file mode 100644 index 00000000..24ad79eb --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/config/AsyncTaskAutoConfiguration.java @@ -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; + +/** + * 通用异步任务自动配置。 + *

业务模块引入本公共模块后,在 Redis 和事务模板均可用时自动装配专用线程池、进度服务与事务执行器。

+ */ +@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 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); + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/config/AsyncTaskWebAutoConfiguration.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/config/AsyncTaskWebAutoConfiguration.java new file mode 100644 index 00000000..fb3cbd09 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/config/AsyncTaskWebAutoConfiguration.java @@ -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 接口自动配置。 + *

仅在 Web 应用已装配 Redis 进度服务时注册统一查询接口,非 Web 任务消费者不会引入无用端点。

+ */ +@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); + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/constant/AsyncTaskStatusEnum.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/constant/AsyncTaskStatusEnum.java new file mode 100644 index 00000000..99bac3c1 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/constant/AsyncTaskStatusEnum.java @@ -0,0 +1,40 @@ +package com.bsd.pjcloud.common.asynctask.constant; + +import lombok.Getter; + +/** + * 通用异步任务进度状态协议。 + *

状态编码会写入 Redis 并返回调用端,已有编码不得随意变更。

+ */ +@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; + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/controller/AsyncTaskProgressController.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/controller/AsyncTaskProgressController.java new file mode 100644 index 00000000..358d78ff --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/controller/AsyncTaskProgressController.java @@ -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; + +/** + * 通用异步任务进度查询接口。 + *

任务业务 Key 按登录用户隔离,租户前缀由项目 Redis 基础设施统一处理,只能查询本人创建且尚未过期的任务。

+ */ +@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 progress(@RequestParam String redisKey, @RequestParam String taskId) { + AsyncTaskProgressVO progress = asyncTaskProgressService.get(redisKey, taskId); + return progress == null ? R.failed("异步任务不存在或已过期") : R.ok(progress); + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskAction.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskAction.java new file mode 100644 index 00000000..ae64e038 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskAction.java @@ -0,0 +1,16 @@ +package com.bsd.pjcloud.common.asynctask.service; + +/** + * 通用异步任务业务动作。 + *

动作在后台事务中执行,抛出异常会回滚整个动作并把 Redis 任务标记为失败。

+ */ +@FunctionalInterface +public interface AsyncTaskAction { + + /** + * 执行具体异步业务。 + * + * @param reporter 逐项进度报告器,业务完成一个计数项目后调用一次 + */ + void execute(AsyncTaskProgressReporter reporter); +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskExecutorService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskExecutorService.java new file mode 100644 index 00000000..c540e548 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskExecutorService.java @@ -0,0 +1,21 @@ +package com.bsd.pjcloud.common.asynctask.service; + +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; + +/** + * 通用异步事务任务提交服务。 + *

同步创建 Redis 任务后,把业务动作交给后台线程执行,并透传当前租户和登录用户上下文。

+ */ +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); +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressReporter.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressReporter.java new file mode 100644 index 00000000..ea57776e --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressReporter.java @@ -0,0 +1,16 @@ +package com.bsd.pjcloud.common.asynctask.service; + +/** + * 异步任务逐项进度报告器。 + *

业务每完成一个可计数项目后调用一次,由通用执行器递增 Redis 进度。

+ */ +@FunctionalInterface +public interface AsyncTaskProgressReporter { + + /** + * 报告一个项目已处理完成。 + * + * @param message 当前项目完成后的进度说明;为空时使用通用说明 + */ + void advance(String message); +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressService.java new file mode 100644 index 00000000..029e7040 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/AsyncTaskProgressService.java @@ -0,0 +1,65 @@ +package com.bsd.pjcloud.common.asynctask.service; + +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; + +/** + * 通用异步任务 Redis 进度服务。 + *

业务 Key 按当前用户隔离,租户前缀由项目 Redis 基础设施统一处理;调用方只提供业务命名空间和任务 ID。

+ */ +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); +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskExecutorService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskExecutorService.java new file mode 100644 index 00000000..eabc61d1 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskExecutorService.java @@ -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; + +/** + * 通用异步事务任务提交服务默认实现。 + *

实现会恢复提交线程的租户、安全和 Feign Token 上下文,并以单个 Spring 事务执行完整业务动作。

+ */ +@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); + } + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskProgressService.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskProgressService.java new file mode 100644 index 00000000..ab73c5e8 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/service/impl/DefaultAsyncTaskProgressService.java @@ -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 进度服务默认实现。 + *

该实现只拼接业务命名空间、登录用户和任务 ID,租户维度由项目 Redis 基础设施根据当前上下文统一处理。

+ */ +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 redisTemplate; + + /** + * 创建通用 Redis 进度服务。 + * + * @param redisTemplate 项目统一配置的 Redis 操作模板 + */ + public DefaultAsyncTaskProgressService(RedisTemplate 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。 + *

租户前缀由项目统一 Redis 基础设施自动处理,此处不得重复拼接租户 ID。

+ * + * @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); + } +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/vo/AsyncTaskProgressVO.java b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/vo/AsyncTaskProgressVO.java new file mode 100644 index 00000000..4fc128c4 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/java/com/bsd/pjcloud/common/asynctask/vo/AsyncTaskProgressVO.java @@ -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; + +/** + * 通用异步任务进度展示对象。 + *

对象存入 Redis 并通过通用进度接口返回,可用于导入、导出等后台任务。

+ */ +@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; +} diff --git a/pjcloud-common/pjcloud-common-async-task/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/pjcloud-common/pjcloud-common-async-task/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..16dfa647 --- /dev/null +++ b/pjcloud-common/pjcloud-common-async-task/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.bsd.pjcloud.common.asynctask.config.AsyncTaskAutoConfiguration +com.bsd.pjcloud.common.asynctask.config.AsyncTaskWebAutoConfiguration diff --git a/pjcloud-common/pjcloud-common-bom/pom.xml b/pjcloud-common/pjcloud-common-bom/pom.xml index aa59cd92..b4ee9fdd 100644 --- a/pjcloud-common/pjcloud-common-bom/pom.xml +++ b/pjcloud-common/pjcloud-common-bom/pom.xml @@ -62,6 +62,11 @@ + + com.bsd + pjcloud-common-async-task + ${pjcloud.version} + com.bsd pjcloud-common-core diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CommonRoleConstant.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CommonRoleConstant.java index e7ba8f70..ddab5aef 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CommonRoleConstant.java +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/CommonRoleConstant.java @@ -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"; + } diff --git a/pjcloud-common/pom.xml b/pjcloud-common/pom.xml index 268b4ca6..eb2486a0 100644 --- a/pjcloud-common/pom.xml +++ b/pjcloud-common/pom.xml @@ -33,6 +33,7 @@ pjcloud 公共聚合模块 + pjcloud-common-async-task pjcloud-common-audit pjcloud-common-bom pjcloud-common-core diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppRefundApplyDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppRefundApplyDto.java new file mode 100644 index 00000000..9116c82f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppRefundApplyDto.java @@ -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 退款申请参数。 + * + *

请求中未提交 {@code carList}、反序列化结果为 {@code null} 时,仅用于兼容从未发生部分退款的旧客户端整单申请; + * 新客户端必须显式提交非空商品列表,空数组不会按旧版整单退款处理。

+ */ +@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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppRefundItemDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppRefundItemDto.java new file mode 100644 index 00000000..c3942c03 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppRefundItemDto.java @@ -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 逐商品退款明细。 + * + *

客户端只提交商品标识和整数份数,商品名称、价格及退款金额均由服务端订单快照确定。

+ */ +@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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java index c0def772..b00587c2 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java @@ -39,6 +39,16 @@ public interface RemoteHardOrderService { @PostMapping("/hard-order/update-shop-status") R updateShopStatus(@RequestBody HardUpdateShopStatusDto dto); + /** + * 第三方退款成功后触发硬件订单按全部售后单重新聚合退款金额和状态。 + * + * @param orderId 原硬件订单 ID + * @return 聚合刷新结果 + */ + @NoToken + @PostMapping("/hard-after-sale/inner/refresh-order-refund-state") + R refreshOrderRefundState(@RequestParam Long orderId); + /** * 批量查询用户今天及以后的待核销预约订单数量。 * diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppRefundApplyVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppRefundApplyVo.java new file mode 100644 index 00000000..2699614f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppRefundApplyVo.java @@ -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 productList; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppRefundProductVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppRefundProductVo.java new file mode 100644 index 00000000..d4ce9b53 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppRefundProductVo.java @@ -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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java index 63d882bc..639db090 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java @@ -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 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 appCreate(@RequestBody HardAfterSaleDto dto) { + public R 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 appRefundApplyDetail(@RequestParam Long orderId) { + return R.ok(hardAfterSaleService.getAppRefundApplyDetail(orderId, SecurityUtils.getUser().getId())); } @ApiCryptoAes diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAfterSaleCarMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAfterSaleCarMapper.java index 8b57cbb6..5a374397 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAfterSaleCarMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAfterSaleCarMapper.java @@ -10,6 +10,29 @@ import java.util.List; @Mapper public interface HardAfterSaleCarMapper extends PjcloudBaseMapper { + /** + * 查询父购物车下待审核和已完成售后占用的商品汇总,按商品 ID 聚合。 + * + * @param carParentId 原订单父购物车 ID + * @param statuses 计入占用的售后状态编码 + * @return 各商品占用数量、重量和退款金额汇总 + */ + List getOccupiedByCarParentId(@Param("carParentId") Long carParentId, @Param("statuses") List statuses); + + /** + * 查询指定售后单保存的商品快照,审核同意时据此退库存。 + * + * @param refundId 售后单 ID + * @return 本次申请的商品明细 + */ + List getByRefundId(@Param("refundId") Long refundId); + + /** + * 查询父购物车下全部有效售后商品汇总;保留后台历史调用契约。 + * + * @param carParentId 原订单父购物车 ID + * @return 不区分售后状态的商品汇总 + */ List getByCarPrentId(@Param("carParentId") Long carParentId); -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java index 26d102f6..d88ad49b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java @@ -26,6 +26,14 @@ import java.util.List; @Mapper public interface HardOrderMapper extends PjcloudBaseMapper { + /** + * 按主键查询并锁定订单行,供退款创建、审核、取消和后台直退串行化同一订单。 + * + * @param id 订单主键 + * @return 锁定后的订单;不存在时返回 {@code null} + */ + HardOrderEntity selectByIdForUpdate(@Param("id") Long id); + default LambdaQueryWrapper commonWrapper(HardOrderDto dto) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(ObjectUtil.isNotEmpty(dto.getOrderType()), HardOrderEntity::getOrderType, dto.getOrderType()) diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java index 779316aa..2151d427 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java @@ -9,9 +9,62 @@ import java.util.List; public interface HardAfterSaleCarService extends IService { + /** + * 按订单查询全部售后商品汇总,保留后台历史能力。 + * + * @param orderId 订单 ID + * @return 售后商品汇总;订单不存在时返回 {@code null} + */ List getByOrderId(Long orderId); + /** + * 按父购物车查询待审核和已完成售后商品汇总,取消及拒绝记录不占用可退数量。 + * + * @param carParentId 父购物车 ID + * @return 按商品 ID 聚合的有效售后商品汇总 + */ List getByCarPrentId(Long carParentId); + /** + * 查询待审核和已完成售后占用的商品汇总。 + * + * @param carParentId 父购物车 ID + * @return 按商品 ID 聚合的占用明细 + */ + List getOccupiedByCarParentId(Long carParentId); + + /** + * 按指定售后状态查询商品占用汇总,供详情分别展示申请中和已退款数量。 + * + * @param carParentId 父购物车 ID + * @param statuses 需要计入的售后状态编码 + * @return 按商品 ID 聚合的占用明细 + */ + List getOccupiedByCarParentId(Long carParentId, List statuses); + + /** + * 查询单个售后申请保存的商品快照。 + * + * @param refundId 售后单 ID + * @return 本次售后商品明细 + */ + List getByRefundId(Long refundId); + + /** + * 保存服务端核定的售后商品快照。 + * + * @param carList 商品名称、价格、数量和分摊金额均已由服务端确定 + * @param order 原订单 + * @param afterSale 已创建的售后单 + */ void create(List carList, HardOrderEntity order, HardAfterSaleEntity afterSale); -} \ No newline at end of file + + /** + * 保存后台原有退款商品结构,并保留后台页面提交的名称、图片、重量和金额快照。 + * + * @param carList 后台管理端已校验的退款商品 + * @param order 原订单 + * @param afterSale 已创建的售后单 + */ + void createAdmin(List carList, HardOrderEntity order, HardAfterSaleEntity afterSale); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java index 5a5cf740..1c4a02bc 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java @@ -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 { */ 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 { /** 校验 App 商户是否可以审核食堂预约订单的售后单。 */ void validateAppMerchantAfterSaleAccess(Long afterSaleId, Long viewerUserId); + /** + * 第三方支付退款成功回调后重新聚合订单退款状态;重复回调只会覆盖为相同结果。 + * + * @param orderId 原订单 ID + * @return {@code true} 表示订单存在且聚合刷新完成 + */ + Boolean refreshOrderRefundState(Long orderId); + /** * 后台退款 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java index 515ddf1d..11ecfed6 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java @@ -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 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 getOccupiedByCarParentId(Long carParentId, List statuses) { + if (carParentId == null || ObjectUtil.isEmpty(statuses)) { + return List.of(); + } + return baseMapper.getOccupiedByCarParentId(carParentId, statuses); + } + + /** + * 审核与详情展示只读取申请时保存的商品快照,避免依赖已变化的商品主数据。 + * + * @param refundId 售后单 ID + * @return 本次售后商品明细 + */ + @Override + public List getByRefundId(Long refundId) { + return baseMapper.getByRefundId(refundId); + } + @Override @Transactional(rollbackFor = Exception.class) public void create(List 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 carList, HardOrderEntity order, HardAfterSaleEntity afterSale) { + saveRefundCars(carList, order, afterSale); + } + + /** + * 为售后商品写入可信订单和售后外键,并清除客户端可能携带的明细主键。 + * + * @param carList 待保存售后商品 + * @param order 原订单 + * @param afterSale 已创建的售后单 + */ + private void saveRefundCars(List 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 implements HardAfterSaleService { - static private final String TAG = "售后订单"; + /** 售后日志标签,用于识别本领域产生的支付与并发异常。 */ + private static final String TAG = "售后订单"; + + /** 同一订单退款分布式锁的键前缀,创建、取消、审核和后台退款必须共用。 */ + private static final String REFUND_ORDER_LOCK_PREFIX = "hard:refund:order:"; + + /** 退款订单锁最长持有时间,覆盖一次常规远程支付退款调用。 */ + private static final long REFUND_ORDER_LOCK_EXPIRE_MILLIS = 60_000L; + + /** 退款订单锁最大等待时间,超时后提示用户稍后重试。 */ + private static final long REFUND_ORDER_LOCK_WAIT_MILLIS = 3_000L; /** * 每日菜谱购物车的服务端场景标识,保存在父购物车现有扩展 JSON 中,用于区分同为食堂预约的在线商品订单。 @@ -86,6 +103,9 @@ public class HardAfterSaleServiceImpl extends ServiceImpl pageJoin(Page page, HardAfterSaleDto dto) { @@ -150,6 +170,142 @@ public class HardAfterSaleServiceImpl extends ServiceImpl getSelf().createAppRefundInTransaction(dto, userId)); + } + + /** + * 锁定订单后重新读取全部占用,防止两个并发申请都通过可退数量校验。 + * + * @param dto 退款申请参数 + * @param userId 当前登录付款人 ID + * @return 新建售后单 ID + */ + @Transactional(rollbackFor = Exception.class) + public Long createAppRefundInTransaction(HardAppRefundApplyDto dto, Long userId) { + HardOrderEntity order = requireRefundableOrderForUpdate(dto.getOrderId()); + validateAppOrderStateRefundable(order); + validateAppOrderRefund(order.getId(), userId); + if (!ObjectUtil.equals(dto.getWay(), "10")) { + throw new RuntimeException("当前仅支持仅退款"); + } + List afterSales = listOrderAfterSales(order.getId()); + if (afterSales.stream().anyMatch(item -> ObjectUtil.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) { + throw new RuntimeException("当前订单已有退款申请待审核,请勿重复提交"); + } + + List originalCars = getOriginalCars(order); + List refundCars; + if (dto.getCarList() == null) { + if (hasEffectiveRefund(afterSales)) { + throw new RuntimeException("当前版本不支持部分退款后的继续申请,请升级应用后重试"); + } + refundCars = buildLegacyWholeOrderRefundCars(order, originalCars); + } else { + refundCars = buildAppRefundCars(order, originalCars, dto.getCarList()); + } + int refundPrice = refundCars.stream().map(HardAfterSaleCarEntity::getTotalPrice).filter(Objects::nonNull) + .mapToInt(Math::toIntExact).sum(); + int completedPrice = sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus()); + if (refundPrice <= 0 || completedPrice + refundPrice > safePrice(order.getPayPrice())) { + throw new RuntimeException("本次退款金额超出订单剩余可退金额"); + } + + HardAfterSaleEntity afterSale = new HardAfterSaleEntity(); + afterSale.setStatus(HardSaleStatusEnum.APPLY.getStatus()); + afterSale.setNo("hard_" + order.getId()); + afterSale.setOrderId(order.getId()); + afterSale.setOrderNo(String.valueOf(order.getId())); + afterSale.setUserId(userId); + afterSale.setWay(dto.getWay()); + afterSale.setApplyReason(dto.getApplyReason()); + afterSale.setApplyDescription(dto.getApplyDescription()); + afterSale.setApplyPicUrls(dto.getApplyPicUrls()); + afterSale.setRefundPrice(refundPrice); + afterSale.setType(ObjectUtil.equal(HardOrderStatusEnum.CANCEL.getType(), order.getOrderStatus()) + ? HardSaleTypeEnum.AFTER_SALE.getType() : HardSaleTypeEnum.IN_SALE.getType()); + baseMapper.insert(afterSale); + hardAfterSaleCarService.create(refundCars, order, afterSale); + insertAfterSaleLog(afterSale.getId(), userId, HardAfterSaleOperateTypeEnum.MEMBER_CREATE.getType(), + order.getOrderStatus(), HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.APPLY.getContent()); + recomputeOrderRefundState(order.getId()); + return afterSale.getId(); + } + + /** + * 查询时仍执行付款人权限校验,防止通过订单 ID 枚举其他用户的商品和支付金额。 + * + * @param orderId 原订单 ID + * @param userId 当前登录付款人 ID + * @return 订单与商品退款聚合详情 + */ + @Override + public HardAppRefundApplyVo getAppRefundApplyDetail(Long orderId, Long userId) { + validateAppOrderRefund(orderId, userId); + HardOrderEntity order = hardOrderService.getById(orderId); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + validateOrderTypeRefundable(order); + validateAppOrderStateRefundable(order); + if (safePrice(order.getPayPrice()) <= 0) { + throw new RuntimeException("订单实付金额异常"); + } + List originalCars = getOriginalCars(order); + List afterSales = listOrderAfterSales(orderId); + Map allocations = buildProductAllocations(order, originalCars); + Map completedCounts = occupiedCountMap(order.getParentCarId(), List.of(HardSaleStatusEnum.COMPLETE.getStatus())); + Map pendingCounts = occupiedCountMap(order.getParentCarId(), List.of(HardSaleStatusEnum.APPLY.getStatus())); + + HardAppRefundApplyVo detail = new HardAppRefundApplyVo(); + detail.setOrderId(orderId); + detail.setOrderPayPrice(safePrice(order.getPayPrice())); + detail.setCompletedRefundPrice(sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus())); + detail.setPendingRefundPrice(sumRefundPrice(afterSales, HardSaleStatusEnum.APPLY.getStatus())); + detail.setRemainingRefundPrice(Math.max(0, detail.getOrderPayPrice() - detail.getCompletedRefundPrice() - detail.getPendingRefundPrice())); + detail.setHasPending(detail.getPendingRefundPrice() > 0 || afterSales.stream() + .anyMatch(item -> ObjectUtil.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))); + HardAfterSaleEntity latest = afterSales.stream().max(Comparator.comparing(HardAfterSaleEntity::getId)).orElse(null); + if (latest != null) { + detail.setLatestAfterSaleId(latest.getId()); + detail.setLatestAfterSaleStatus(latest.getStatus()); + detail.setLatestRefundPrice(latest.getRefundPrice()); + detail.setLatestCreateTime(latest.getCreateTime()); + detail.setLatestUpdateTime(latest.getUpdateTime()); + } + + List products = allocations.values().stream().map(allocation -> { + HardAppRefundProductVo product = new HardAppRefundProductVo(); + product.setProductId(allocation.getProductId()); + product.setProductName(allocation.getProductName()); + product.setProductPic(allocation.getProductPic()); + product.setProductNature(allocation.getProductNature()); + product.setProductPrice(allocation.getProductPrice()); + product.setOriginalCount(allocation.getOriginalCount()); + product.setOriginalWeight(allocation.getOriginalWeight()); + product.setRefundedCount(completedCounts.getOrDefault(allocation.getProductId(), 0)); + product.setApplyingCount(pendingCounts.getOrDefault(allocation.getProductId(), 0)); + product.setRefundableCount(Math.max(0, allocation.getOriginalCount() - product.getRefundedCount() - product.getApplyingCount())); + product.setOriginalTotalPrice(allocation.getOriginalTotalPrice()); + product.setAllocatedPrice(allocation.getAllocatedPrice()); + product.setOnlineRefundSupported(ObjectUtil.equals(allocation.getProductNature(), HardProductNatureEnum.DEFAULT.getType())); + return product; + }).toList(); + detail.setProductList(products); + detail.setCanApply(!detail.getHasPending() && detail.getRemainingRefundPrice() > 0 && products.stream() + .anyMatch(item -> Boolean.TRUE.equals(item.getOnlineRefundSupported()) && item.getRefundableCount() > 0)); + return detail; + } + /** * 售后读取沿用订单查看权限,避免代订订单通过售后接口越权暴露。 */ @@ -207,17 +363,33 @@ public class HardAfterSaleServiceImpl extends ServiceImpl getSelf().cancelAfterSaleInTransaction(userId, afterSaleId)); + } + + /** + * 在订单行锁内取消待审核申请并释放商品与金额占用。 + * + * @param userId 操作用户 ID + * @param afterSaleId 待取消售后单 ID + * @return {@code true} 表示取消成功 + */ + @Transactional(rollbackFor = Exception.class) + public Boolean cancelAfterSaleInTransaction(Long userId, Long afterSaleId) { HardAfterSaleEntity afterSale = baseMapper.selectById(afterSaleId); if (ObjectUtil.isEmpty(afterSale)) throw new RuntimeException("售后单不存在"); - HardOrderEntity order = hardOrderService.getById(afterSale.getOrderId()); + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(afterSale.getOrderId()); if (ObjectUtil.isEmpty(order)) throw new RuntimeException("订单不存在"); if (!ObjectUtil.equals(HardSaleStatusEnum.APPLY.getStatus(), afterSale.getStatus())) { throw new RuntimeException("取消售后单失败,售后单状态不是【待审核】"); @@ -225,22 +397,9 @@ public class HardAfterSaleServiceImpl extends ServiceImpl !ObjectUtil.equals(item.getId(), afterSaleId)) + .filter(item -> ObjectUtil.equals(item.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())) + .map(HardAfterSaleEntity::getRefundPrice).filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + if (completedRefundPrice > safePrice(order.getPayPrice()) + || completedRefundPrice + refundPrice > safePrice(order.getPayPrice())) { + throw new RuntimeException("退款总金额大于支付总金额"); } HardOrderTypeEnum hardOrderType = HardOrderTypeEnum.valueOfByType(order.getOrderType()); HardPayTypeEnum hardPayType = HardPayTypeEnum.valueOfByType(order.getPayType()); @@ -341,11 +509,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundRes = thirdAppPlusPayService.remoterRefund(refundDto); @@ -362,51 +527,50 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundRes = thirdAppPlusPayService.remoterRefund(refundDto); if (!refundRes.isOk()) { - throw new RuntimeException("微信退款错误:" + refundRes.getMsg()); + throw new RuntimeException("支付宝退款错误:" + refundRes.getMsg()); } if (ObjectUtil.isEmpty(refundRes.getData())) { - throw new RuntimeException("微信退款错误"); + throw new RuntimeException("支付宝退款错误"); } - // 售后成功 - HardOrderEntity updateObj = HardOrderEntity.builder() - .id(order.getId()) - .orderStatus(HardOrderStatusEnum.REFUND.getType()) - .refundStatus(HardRefundStatusEnum.SUCCESS.getStatus()) - .payRefundId(order.getId()) - .refundPrice(order.getPayPrice()) - .refundTime(LocalDateTime.now()) - .build(); - hardOrderService.updateById(updateObj); + hardOrderService.updateById(HardOrderEntity.builder().id(order.getId()).payRefundId(order.getId()).build()); } else { throw new RuntimeException("不支持的退款类型"); } + baseMapper.updateById(HardAfterSaleEntity.builder().id(afterSaleId).refundTime(LocalDateTime.now()).build()); + recomputeOrderRefundState(order.getId()); } @Override - // @GlobalTransactional - @Transactional(rollbackFor = Exception.class) public Boolean agreeAfterSale(Long userId, Long id) { + HardAfterSaleEntity current = baseMapper.selectById(id); + if (current == null) { + throw new RuntimeException("售后单不存在"); + } + return withOrderRefundLock(current.getOrderId(), () -> getSelf().agreeAfterSaleInTransaction(userId, id)); + } + + /** + * 审核同意时只使用申请时保存的商品快照和服务端核定金额,避免审核期间商品信息变化。 + * + * @param userId 审核用户 ID + * @param id 售后单 ID + * @return {@code true} 表示退款和库存恢复已执行 + */ + @Transactional(rollbackFor = Exception.class) + public Boolean agreeAfterSaleInTransaction(Long userId, Long id) { HardAfterSaleEntity afterSale = validateAfterSaleAuditable(id); - HardOrderEntity order = hardOrderService.getById(afterSale.getOrderId()); + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(afterSale.getOrderId()); if (order == null) throw new RuntimeException("设备订单不存在"); // 更新售后单的状态 HardAfterSaleEntity updateRefundObj = HardAfterSaleEntity.builder() @@ -416,28 +580,40 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = hardAfterSaleCarService.getByRefundId(id); + if (ObjectUtil.isEmpty(refundCars)) { + hardOrderService.fastenerInventory(order, YesNoEnum.NO); + } else { + hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO); + } + recomputeOrderRefundState(order.getId()); return true; } @Override - @Transactional(rollbackFor = Exception.class) public Boolean disagreeRefund(Long userId, HardDisagreeDto dto) { + HardAfterSaleEntity current = baseMapper.selectById(dto.getId()); + if (current == null) { + throw new RuntimeException("售后单不存在"); + } + return withOrderRefundLock(current.getOrderId(), () -> getSelf().disagreeRefundInTransaction(userId, dto)); + } + + /** + * 拒绝待审核申请并释放该申请占用的商品份数与金额。 + * + * @param userId 审核用户 ID + * @param dto 售后单 ID 与拒绝原因 + * @return {@code true} 表示拒绝成功 + */ + @Transactional(rollbackFor = Exception.class) + public Boolean disagreeRefundInTransaction(Long userId, HardDisagreeDto dto) { HardAfterSaleEntity afterSale = validateAfterSaleAuditable(dto.getId()); - HardOrderEntity order = hardOrderService.getById(afterSale.getOrderId()); + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(afterSale.getOrderId()); if (order == null) throw new RuntimeException("设备订单不存在"); // 更新售后单的状态 HardAfterSaleEntity updateRefundObj = HardAfterSaleEntity.builder() @@ -448,19 +624,9 @@ public class HardAfterSaleServiceImpl extends ServiceImpl afterList = baseMapper.selectList(new LambdaQueryWrapper() .eq(HardAfterSaleEntity::getOrderId, order.getId()) - .eq(HardAfterSaleEntity::getType, HardSaleTypeEnum.AFTER_SALE.getType()) .in(HardAfterSaleEntity::getStatus, List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())) ); // 退款金额 int totalPrice = afterList.stream().filter(v -> ObjectUtil.isNotEmpty(v.getRefundPrice())).map(HardAfterSaleEntity::getRefundPrice).reduce(0, Integer::sum); - if (totalPrice > order.getPayPrice()) { + if (totalPrice >= safePrice(order.getPayPrice())) { + if (totalPrice > safePrice(order.getPayPrice())) { + throw new RuntimeException("退款总金额大于支付总金额"); + } + throw new RuntimeException("订单已全部退款"); + } + if (totalPrice < 0) { throw new RuntimeException("退款总金额大于支付总金额"); } return orderVo; @@ -497,12 +664,15 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCarList) { if (ObjectUtil.isEmpty(refundCarList)) throw new RuntimeException("售后商品列表不能为空"); // 查询订单购物车 - List orderCarList = hardCarService.getByOrderId(order.getId()); + List orderCarList = hardCarService.getByParentCarId(order.getParentCarId()); if (ObjectUtil.isEmpty(orderCarList)) throw new RuntimeException("订单购物车不存在"); // 查询已退款商品 - List refundSuccessCarList = hardAfterSaleCarService.getByCarPrentId(order.getParentCarId()); + List refundSuccessCarList = hardAfterSaleCarService.getOccupiedByCarParentId(order.getParentCarId()); for (HardAfterSaleCarEntity refundCar : refundCarList) { - HardCarVo orderCar = orderCarList.stream().filter(v -> ObjectUtil.equal(v.getId(), refundCar.getId())).findFirst().orElse(null); + HardCarEntity orderCar = orderCarList.stream() + .filter(v -> ObjectUtil.equal(v.getProductId(), refundCar.getProductId()) + || ObjectUtil.equal(v.getId(), refundCar.getId())) + .findFirst().orElse(null); if (orderCar == null) throw new RuntimeException("退款商品<" + refundCar.getProductName() + ">不存在"); int refundCount = 0; if (ObjectUtil.isNotEmpty(refundCar.getProductCount())) { @@ -519,12 +689,508 @@ public class HardAfterSaleServiceImpl extends ServiceImpl ObjectUtil.equal(item.getProductId(), orderCar.getProductId())) + .map(HardCarEntity::getProductCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + if (originalCount < refundCount) { throw new RuntimeException("退款商品<" + refundCar.getProductName() + ">数量超出"); } } } + /** + * 对第三方支付回调暴露幂等的订单退款聚合刷新入口。 + * + * @param orderId 原订单 ID + * @return {@code true} 表示订单存在且刷新完成 + */ + @Override + public Boolean refreshOrderRefundState(Long orderId) { + return withOrderRefundLock(orderId, () -> getSelf().refreshOrderRefundStateInTransaction(orderId)); + } + + /** + * 数据库行锁保证同一实例内的回调不会与申请、审核或取消交叉覆盖订单状态。 + * + * @param orderId 原订单 ID + * @return {@code true} 表示刷新完成 + */ + @Transactional(rollbackFor = Exception.class) + public Boolean refreshOrderRefundStateInTransaction(Long orderId) { + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(orderId); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + recomputeOrderRefundState(orderId); + return true; + } + + /** + * 获取并校验可退款订单,同时持有数据库订单行锁直至事务结束。 + * + * @param orderId 原订单 ID + * @return 已锁定的订单实体 + */ + private HardOrderEntity requireRefundableOrderForUpdate(Long orderId) { + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(orderId); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + validateOrderTypeRefundable(order); + if (safePrice(order.getPayPrice()) <= 0) { + throw new RuntimeException("订单实付金额异常"); + } + return order; + } + + /** + * 校验订单类型是否位于既有可退款范围,不扩大任何历史退款权限。 + * + * @param order 原订单 + */ + private void validateOrderTypeRefundable(HardOrderEntity order) { + HardOrderTypeEnum orderTypeEnum = HardOrderTypeEnum.valueOfByType(order.getOrderType()); + if (!HardOrderTypeEnum.isRefund(orderTypeEnum)) { + throw new RuntimeException("该订单类型不支持退款"); + } + } + + /** + * App 申请只允许已进入支付或履约阶段的订单,防止未支付、离线待支付和已关闭订单通过接口直接退款。 + * + * @param order 待查询或申请退款的订单 + */ + private void validateAppOrderStateRefundable(HardOrderEntity order) { + if (List.of(HardOrderStatusEnum.CREATE.getType(), HardOrderStatusEnum.OFFLINE_CREATE.getType(), + HardOrderStatusEnum.UNFOLLOW.getType()).contains(order.getOrderStatus())) { + throw new RuntimeException("当前订单状态不支持退款"); + } + } + + /** + * 查询订单全部售后单,取消和拒绝记录也保留用于展示最新状态及恢复原订单状态。 + * + * @param orderId 原订单 ID + * @return 按售后单 ID 升序排列的记录 + */ + private List listOrderAfterSales(Long orderId) { + return baseMapper.selectList(new LambdaQueryWrapper() + .eq(HardAfterSaleEntity::getOrderId, orderId) + .orderByAsc(HardAfterSaleEntity::getId)); + } + + /** + * 读取订单购物车快照,退款金额和商品展示均不得回查可能已改价或下架的商品主数据。 + * + * @param order 原订单 + * @return 原始购物车行 + */ + private List getOriginalCars(HardOrderEntity order) { + List cars = hardCarService.getByParentCarId(order.getParentCarId()); + if (ObjectUtil.isEmpty(cars)) { + throw new RuntimeException("订单购物车不存在"); + } + return cars; + } + + /** + * 判断历史中是否已有待审核或已完成售后,作为旧客户端整单申请的兼容边界。 + * + * @param afterSales 订单全部售后单 + * @return {@code true} 表示已有有效金额或商品占用 + */ + private boolean hasEffectiveRefund(List afterSales) { + return afterSales.stream().anyMatch(item -> List.of(HardSaleStatusEnum.APPLY.getStatus(), + HardSaleStatusEnum.COMPLETE.getStatus()).contains(item.getStatus())); + } + + /** + * 旧客户端首次申请仍保存整单商品快照,退款金额以订单实付为准而不是客户端价格。 + * + * @param order 原订单 + * @param originalCars 原订单购物车行 + * @return 覆盖全部原商品的售后明细 + */ + private List buildLegacyWholeOrderRefundCars(HardOrderEntity order, List originalCars) { + Map allocations = buildProductAllocations(order, originalCars); + return allocations.values().stream().map(item -> toAfterSaleCar(item, item.getOriginalCount(), item.getAllocatedPrice())).toList(); + } + + /** + * 后台整单退款在已有部分退款后生成剩余数量或重量商品明细,避免重复恢复已退库存。 + * + * @param order 原订单 + * @param originalCars 原订单购物车行 + * @return 尚未被待审核或已完成售后占用的商品明细 + */ + private List buildRemainingRefundCars(HardOrderEntity order, List originalCars) { + Map allocations = buildProductAllocations(order, originalCars); + Map occupiedCars = occupiedCarMap(order.getParentCarId(), + List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())); + List result = new ArrayList<>(); + for (HardAppRefundProductVo product : allocations.values()) { + HardAfterSaleCarEntity occupiedCar = occupiedCars.get(product.getProductId()); + int occupiedCount = occupiedCar == null ? 0 : safeCount(occupiedCar.getProductCount()); + int remainingCount = Math.max(0, product.getOriginalCount() - occupiedCount); + BigDecimal occupiedWeight = occupiedCar == null || occupiedCar.getProductWeight() == null + ? BigDecimal.ZERO : occupiedCar.getProductWeight(); + BigDecimal remainingWeight = product.getOriginalWeight() == null ? BigDecimal.ZERO + : product.getOriginalWeight().subtract(occupiedWeight).max(BigDecimal.ZERO); + int occupiedPrice = occupiedCar == null || occupiedCar.getTotalPrice() == null + ? 0 : Math.toIntExact(occupiedCar.getTotalPrice()); + int remainingPrice = Math.max(0, product.getAllocatedPrice() - occupiedPrice); + boolean hasRemainingQuantity = ObjectUtil.equals(product.getProductNature(), HardProductNatureEnum.DEFAULT.getType()) + ? remainingCount > 0 : remainingWeight.compareTo(BigDecimal.ZERO) > 0; + // 分摊金额为零的赠送或全额优惠商品仍需要随整单剩余退款恢复库存,因此这里只按数量或重量判断。 + if (!hasRemainingQuantity) { + continue; + } + result.add(toAfterSaleCar(product, remainingCount, remainingWeight, remainingPrice)); + } + return result; + } + + /** + * 校验 App 选择并根据历史累计份数计算本次各商品退款额度,最后一份自动吸收舍入差额。 + * + * @param order 原订单 + * @param originalCars 原订单购物车行 + * @param requestItems 客户端仅提交的商品 ID 与整数份数 + * @return 服务端核定并可直接持久化的售后商品快照 + */ + private List buildAppRefundCars(HardOrderEntity order, List originalCars, + List requestItems) { + Map allocations = buildProductAllocations(order, originalCars); + Map occupiedCounts = occupiedCountMap(order.getParentCarId(), + List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())); + Set selectedProductIds = new HashSet<>(); + List result = new ArrayList<>(); + for (HardAppRefundItemDto requestItem : requestItems) { + if (!selectedProductIds.add(requestItem.getProductId())) { + throw new RuntimeException("同一商品不能重复提交"); + } + HardAppRefundProductVo product = allocations.get(requestItem.getProductId()); + if (product == null) { + throw new RuntimeException("退款商品不属于当前订单"); + } + if (!ObjectUtil.equals(product.getProductNature(), HardProductNatureEnum.DEFAULT.getType())) { + throw new RuntimeException("称重商品暂不支持在线逐份退款"); + } + int requestCount = requestItem.getProductCount() == null ? 0 : requestItem.getProductCount(); + int occupiedCount = occupiedCounts.getOrDefault(product.getProductId(), 0); + if (requestCount <= 0 || occupiedCount + requestCount > product.getOriginalCount()) { + throw new RuntimeException("退款商品<" + product.getProductName() + ">数量超出可退范围"); + } + int beforePrice = cumulativeProductPrice(product, occupiedCount); + int afterPrice = cumulativeProductPrice(product, occupiedCount + requestCount); + int currentPrice = afterPrice - beforePrice; + if (currentPrice < 0) { + throw new RuntimeException("退款商品<" + product.getProductName() + ">退款金额异常"); + } + // 赠品或全额优惠商品的分摊额可以为零;允许其与正金额商品一同申请,确保审核后仍能按所选份数恢复库存。 + result.add(toAfterSaleCar(product, requestCount, currentPrice)); + } + if (result.isEmpty()) { + throw new RuntimeException("请选择需要退款的商品"); + } + return result; + } + + /** + * 按商品 ID 汇总购物车行,并使用累计快照占比把整单实付金额分配到各商品;最终累计值固定等于实付金额。 + * + * @param order 原订单 + * @param originalCars 原订单购物车行 + * @return 保持商品 ID 升序的分摊结果 + */ + private Map buildProductAllocations(HardOrderEntity order, List originalCars) { + Map products = new TreeMap<>(); + for (HardCarEntity car : originalCars) { + if (car.getProductId() == null) { + throw new RuntimeException("订单商品数据异常"); + } + HardAppRefundProductVo product = products.computeIfAbsent(car.getProductId(), productId -> { + HardAppRefundProductVo item = new HardAppRefundProductVo(); + item.setProductId(productId); + item.setProductName(car.getProductName()); + item.setProductPic(car.getProductPic()); + item.setProductNature(car.getProductNature()); + item.setProductPrice(car.getProductPrice()); + item.setOriginalCount(0); + item.setOriginalWeight(BigDecimal.ZERO); + item.setOriginalTotalPrice(0L); + return item; + }); + product.setOriginalCount(Math.addExact(product.getOriginalCount(), safeCount(car.getProductCount()))); + if (car.getProductWeight() != null) { + product.setOriginalWeight(product.getOriginalWeight().add(car.getProductWeight())); + } + product.setOriginalTotalPrice(Math.addExact(product.getOriginalTotalPrice(), snapshotTotalPrice(car))); + } + long snapshotTotal = products.values().stream().map(HardAppRefundProductVo::getOriginalTotalPrice) + .filter(Objects::nonNull).mapToLong(Long::longValue).sum(); + if (snapshotTotal <= 0) { + throw new RuntimeException("订单商品金额快照异常"); + } + long cumulativeSnapshot = 0L; + int previousAllocated = 0; + for (HardAppRefundProductVo product : products.values()) { + cumulativeSnapshot = Math.addExact(cumulativeSnapshot, product.getOriginalTotalPrice()); + int cumulativeAllocated = proportionalPrice(safePrice(order.getPayPrice()), cumulativeSnapshot, snapshotTotal); + product.setAllocatedPrice(cumulativeAllocated - previousAllocated); + previousAllocated = cumulativeAllocated; + } + return products; + } + + /** + * 计算单个商品累计退款份数对应的累计金额,累计到最后一份时必定等于该商品完整分摊额。 + * + * @param product 商品分摊信息 + * @param cumulativeCount 已退和本次申请合计份数 + * @return 累计退款金额,单位为分 + */ + private int cumulativeProductPrice(HardAppRefundProductVo product, int cumulativeCount) { + if (product.getOriginalCount() <= 0) { + throw new RuntimeException("订单商品数量异常"); + } + int limitedCount = Math.min(Math.max(cumulativeCount, 0), product.getOriginalCount()); + return proportionalPrice(product.getAllocatedPrice(), limitedCount, product.getOriginalCount()); + } + + /** + * 使用十进制 HALF_UP 舍入计算累计比例金额,避免浮点数造成分币误差。 + * + * @param totalPrice 待分摊总金额,单位为分 + * @param cumulativeWeight 当前累计权重 + * @param totalWeight 总权重 + * @return 累计分摊金额,单位为分 + */ + private int proportionalPrice(int totalPrice, long cumulativeWeight, long totalWeight) { + if (totalWeight <= 0) { + throw new RuntimeException("退款分摊权重异常"); + } + return BigDecimal.valueOf(totalPrice).multiply(BigDecimal.valueOf(cumulativeWeight)) + .divide(BigDecimal.valueOf(totalWeight), 0, RoundingMode.HALF_UP).intValueExact(); + } + + /** + * 把服务端商品分摊结果转换为持久化售后明细,不接受客户端名称、图片或金额。 + * + * @param product 服务端商品分摊信息 + * @param count 本次退款份数 + * @param refundPrice 本次退款金额,单位为分 + * @return 售后商品实体 + */ + private HardAfterSaleCarEntity toAfterSaleCar(HardAppRefundProductVo product, int count, int refundPrice) { + return toAfterSaleCar(product, count, product.getOriginalWeight(), refundPrice); + } + + /** + * 把商品剩余数量或重量转换为售后明细,后台整单退剩余时据此避免重复恢复已退库存。 + * + * @param product 服务端商品分摊信息 + * @param count 本次退款份数 + * @param weight 本次退款重量,单位为千克 + * @param refundPrice 本次退款金额,单位为分 + * @return 售后商品实体 + */ + private HardAfterSaleCarEntity toAfterSaleCar(HardAppRefundProductVo product, int count, BigDecimal weight, int refundPrice) { + HardAfterSaleCarEntity car = new HardAfterSaleCarEntity(); + car.setProductId(product.getProductId()); + car.setProductName(product.getProductName()); + car.setProductPic(product.getProductPic()); + car.setProductNature(product.getProductNature()); + car.setProductPrice(product.getProductPrice() == null ? null : product.getProductPrice().longValue()); + car.setProductCount(count); + car.setProductWeight(weight); + car.setTotalPrice((long) refundPrice); + return car; + } + + /** + * 查询指定状态占用的完整售后商品汇总,供数量、重量和累计分摊金额共同参与剩余额度计算。 + * + * @param parentCarId 原订单父购物车 ID + * @param statuses 计入占用的售后状态 + * @return 商品 ID 到占用明细的映射 + */ + private Map occupiedCarMap(Long parentCarId, List statuses) { + return hardAfterSaleCarService.getOccupiedByCarParentId(parentCarId, statuses).stream() + .filter(item -> item.getProductId() != null) + .collect(Collectors.toMap(HardAfterSaleCarEntity::getProductId, item -> item, + (left, right) -> left, LinkedHashMap::new)); + } + + /** + * 查询指定售后状态占用的商品份数并按商品 ID 建立映射。 + * + * @param parentCarId 原订单父购物车 ID + * @param statuses 计入占用的售后状态 + * @return 商品 ID 到占用份数的映射 + */ + private Map occupiedCountMap(Long parentCarId, List statuses) { + return hardAfterSaleCarService.getOccupiedByCarParentId(parentCarId, statuses).stream() + .filter(item -> item.getProductId() != null) + .collect(Collectors.toMap(HardAfterSaleCarEntity::getProductId, + item -> safeCount(item.getProductCount()), Integer::sum, LinkedHashMap::new)); + } + + /** + * 汇总指定状态售后单的服务端核定金额。 + * + * @param afterSales 订单售后记录 + * @param status 需要汇总的售后状态编码 + * @return 累计退款金额,单位为分 + */ + private int sumRefundPrice(List afterSales, String status) { + return afterSales.stream().filter(item -> ObjectUtil.equals(item.getStatus(), status)) + .map(HardAfterSaleEntity::getRefundPrice).filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + } + + /** + * 根据全部售后记录统一刷新订单累计金额和聚合状态;部分退款不改变原订单履约状态。 + * + * @param orderId 原订单 ID + */ + private void recomputeOrderRefundState(Long orderId) { + HardOrderEntity order = hardOrderService.getById(orderId); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + List afterSales = listOrderAfterSales(orderId); + int completedPrice = sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus()); + boolean hasPending = afterSales.stream().anyMatch(item -> ObjectUtil.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus())); + HardAfterSaleEntity latest = afterSales.stream().max(Comparator.comparing(HardAfterSaleEntity::getId)).orElse(null); + String refundStatus; + if (hasPending) { + refundStatus = HardRefundStatusEnum.APPLY.getStatus(); + } else if (completedPrice > 0) { + refundStatus = HardRefundStatusEnum.SUCCESS.getStatus(); + } else if (latest != null && ObjectUtil.equals(latest.getStatus(), HardSaleStatusEnum.SELLER_DISAGREE.getStatus())) { + refundStatus = HardRefundStatusEnum.REJECT.getStatus(); + } else { + refundStatus = HardRefundStatusEnum.NONE.getStatus(); + } + String originalOrderStatus = resolveOriginalOrderStatus(afterSales, order.getOrderStatus()); + String orderStatus = completedPrice >= safePrice(order.getPayPrice()) && safePrice(order.getPayPrice()) > 0 + ? HardOrderStatusEnum.REFUND.getType() : originalOrderStatus; + LocalDateTime latestRefundTime = afterSales.stream() + .filter(item -> ObjectUtil.equals(item.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())) + .map(item -> item.getRefundTime() != null ? item.getRefundTime() : item.getAuditTime()) + .filter(Objects::nonNull).max(LocalDateTime::compareTo).orElse(null); + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(HardOrderEntity::getId, orderId) + .set(HardOrderEntity::getOrderStatus, orderStatus) + .set(HardOrderEntity::getRefundStatus, refundStatus) + .set(HardOrderEntity::getRefundPrice, completedPrice) + .set(HardOrderEntity::getRefundTime, latestRefundTime) + .set(HardOrderEntity::getApplyCancelTime, + latest != null && ObjectUtil.equals(latest.getStatus(), HardSaleStatusEnum.BUYER_CANCEL.getStatus()) + ? LocalDateTime.now() : null); + hardOrderService.update(wrapper); + } + + /** + * 从最早售后日志恢复申请前的订单履约状态,兼容历史逻辑曾把订单状态直接写成退款。 + * + * @param afterSales 订单售后记录 + * @param fallbackStatus 无可用日志时的当前状态 + * @return 退款流程之外应保持的订单状态 + */ + private String resolveOriginalOrderStatus(List afterSales, String fallbackStatus) { + for (HardAfterSaleEntity afterSale : afterSales) { + HardAfterSaleLogEntity firstLog = hardAfterSaleLogMapper.getFirst(afterSale.getId()); + if (firstLog != null && firstLog.getBeforeStatus() != null + && ObjectUtil.notEqual(firstLog.getBeforeStatus(), HardOrderStatusEnum.REFUND.getType())) { + return firstLog.getBeforeStatus(); + } + } + return fallbackStatus; + } + + /** + * 统一记录售后状态流转,确保每个入口使用相同的订单前后状态语义。 + * + * @param afterSaleId 售后单 ID + * @param userId 操作用户 ID + * @param operateType 操作类型协议编码 + * @param beforeStatus 操作前订单或售后状态 + * @param afterStatus 操作后订单或售后状态 + * @param content 展示文案 + */ + private void insertAfterSaleLog(Long afterSaleId, Long userId, String operateType, String beforeStatus, + String afterStatus, String content) { + HardAfterSaleLogEntity logEntity = HardAfterSaleLogEntity.builder().afterSaleId(afterSaleId).userId(userId) + .operateType(operateType).beforeStatus(beforeStatus).afterStatus(afterStatus).content(content).build(); + hardAfterSaleLogMapper.insert(logEntity); + } + + /** + * 将申请时保存的售后商品快照转换为库存服务需要的最小字段集合。 + * + * @param refundCars 售后商品快照 + * @return 仅包含商品 ID、性质、份数和重量的库存明细 + */ + private List toInventoryCars(List refundCars) { + return refundCars.stream().map(item -> HardCarEntity.builder().productId(item.getProductId()) + .productNature(item.getProductNature()).productCount(item.getProductCount()) + .productWeight(item.getProductWeight()).build()).toList(); + } + + /** + * 在同一订单的分布式锁内执行退款操作,锁失败时不进入任何持久化或支付调用。 + * + * @param orderId 原订单 ID + * @param action 持锁期间执行的业务动作 + * @param 动作返回类型 + * @return 业务动作返回值 + */ + private T withOrderRefundLock(Long orderId, Supplier action) { + LockInfo lock = lockTemplate.lock(REFUND_ORDER_LOCK_PREFIX + orderId, REFUND_ORDER_LOCK_EXPIRE_MILLIS, + REFUND_ORDER_LOCK_WAIT_MILLIS); + if (lock == null) { + throw new RuntimeException("订单退款正在处理中,请稍后重试"); + } + try { + return action.get(); + } finally { + lockTemplate.releaseLock(lock); + } + } + + /** + * 将可空金额转换为非负分值,空值按零处理。 + * + * @param price 金额,单位为分 + * @return 非负金额 + */ + private int safePrice(Integer price) { + return price == null ? 0 : Math.max(price, 0); + } + + /** + * 将可空商品份数转换为非负整数,空值按零处理。 + * + * @param count 商品份数 + * @return 非负商品份数 + */ + private int safeCount(Integer count) { + return count == null ? 0 : Math.max(count, 0); + } + + /** + * 优先使用购物车持久化总价快照;历史空值才按单价乘数量回退计算。 + * + * @param car 原订单购物车行 + * @return 非负快照金额,单位为分 + */ + private long snapshotTotalPrice(HardCarEntity car) { + if (car.getTotalPrice() != null && car.getTotalPrice() >= 0) { + return car.getTotalPrice(); + } + return Math.multiplyExact((long) safePrice(car.getProductPrice()), safeCount(car.getProductCount())); + } + private HardAfterSaleEntity validateAfterSaleAuditable(Long id) { HardAfterSaleEntity afterSale = baseMapper.selectById(id); if (afterSale == null) throw new RuntimeException("售后单不存在"); @@ -571,11 +1237,42 @@ public class HardAfterSaleServiceImpl extends ServiceImpl getSelf().adminRefundInTransaction(dto, userId)); + } + + /** + * 后台整单退款在订单行锁内只退剩余金额;首次退款仍保持原接口的整单语义。 + * + * @param dto 后台退款表单 + * @param userId 后台操作用户 ID + * @return {@code true} 表示退款执行成功 + */ + @Transactional(rollbackFor = Exception.class) + public Boolean adminRefundInTransaction(HardAdminRefundDto dto, Long userId) { + HardOrderEntity lockedOrder = requireRefundableOrderForUpdate(dto.getOrderId()); // 校验订单是否可以退款 HardOrderVo order = validateOrderCanRefund(dto.getOrderId()); + List afterSales = listOrderAfterSales(order.getId()); + if (afterSales.stream().anyMatch(item -> ObjectUtil.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) { + throw new RuntimeException("当前订单已有退款申请待审核,请先处理后再退款"); + } + int remainingRefundPrice = safePrice(order.getPayPrice()) - sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus()); + if (remainingRefundPrice <= 0) { + throw new RuntimeException("订单已全部退款"); + } + HardPayTypeEnum payType = HardPayTypeEnum.valueOfByType(lockedOrder.getPayType()); + HardOrderTypeEnum orderType = HardOrderTypeEnum.valueOfByType(lockedOrder.getOrderType()); + boolean simplePay = isSimplePay(payType, orderType); + List remainingCars = List.of(); + if (!simplePay) { + remainingCars = buildRemainingRefundCars(lockedOrder, getOriginalCars(lockedOrder)); + int remainingCarPrice = remainingCars.stream().map(HardAfterSaleCarEntity::getTotalPrice) + .filter(Objects::nonNull).mapToInt(Math::toIntExact).sum(); + if (remainingCars.isEmpty() || remainingCarPrice != remainingRefundPrice) { + throw new RuntimeException("历史退款商品明细不完整,请通过后台核对后处理"); + } + } // 添加售后订单 HardAfterSaleEntity saveObj = BeanUtils.toBean(dto, HardAfterSaleEntity.class); String refundCode = "hard_" + order.getId(); @@ -583,7 +1280,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl getSelf().adminReinforceRefundInTransaction(dto, userId)); + } + + /** + * 后台按商品退款保留原请求结构,并在订单锁内执行校验、库存恢复和支付退款。 + * + * @param dto 后台退款表单与商品明细 + * @param userId 后台操作用户 ID + * @return {@code true} 表示退款执行成功 + */ + @Transactional(rollbackFor = Exception.class) + public Boolean adminReinforceRefundInTransaction(HardAdminRefundReinforceDto dto, Long userId) { + requireRefundableOrderForUpdate(dto.getOrderId()); // 校验订单是否可以退款 HardOrderVo order = validateOrderCanRefund(dto.getOrderId()); + if (listOrderAfterSales(order.getId()).stream() + .anyMatch(item -> ObjectUtil.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) { + throw new RuntimeException("当前订单已有退款申请待审核,请先处理后再退款"); + } // 判断是否快速支付 HardPayTypeEnum hardPayType = HardPayTypeEnum.valueOfByType(order.getPayType()); @@ -673,7 +1392,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl getSelf().directRefundInTransaction(orderId, reason, user.getId(), true)); } catch (Exception e) { String msg = String.format("|%s|第三方退款错误,dto|%s|", TAG, JSONUtil.toJsonStr(orderId), e); - log.error(msg, e.getMessage()); - // 抛出错误 - throw new RuntimeException(e.getMessage()); + log.error(msg, e); + throw new RuntimeException(e.getMessage(), e); } - return true; } + /** + * 消费协议直退。该入口不限制支付时间,但与其他退款入口共用订单级并发保护和剩余额度计算。 + * + * @param orderId 待退款订单 ID + * @param reason 退款原因,原样保存到售后申请与审核说明 + * @return {@code true} 表示订单剩余可退金额已退款 + */ @Override public Boolean adRefund(Long orderId, String reason) { - HardOrderEntity orderDetail = hardOrderService.getById(orderId); - if (ObjectUtil.isEmpty(orderDetail)) { - throw new RuntimeException("订单不存在"); - } - orderDetail = validateOrderCanRefund(orderDetail.getId()); - if (ObjectUtil.isEmpty(orderDetail)) { - throw new RuntimeException("订单已退款"); - } try { - // 添加售后订单 - String refundCode = "hard|ad_" + orderDetail.getId(); - HardAfterSaleEntity saveObj = new HardAfterSaleEntity(); - saveObj.setType(HardSaleTypeEnum.AFTER_SALE.getType()); - saveObj.setStatus(HardSaleStatusEnum.COMPLETE.getStatus()); - saveObj.setNo(refundCode); - saveObj.setWay("10"); - saveObj.setOrderId(orderDetail.getId()); - saveObj.setRefundPrice(orderDetail.getPayPrice()); - saveObj.setUserId(orderDetail.getUserId()); - saveObj.setAuditUserId(orderDetail.getUserId()); - saveObj.setAuditTime(LocalDateTime.now()); - saveObj.setApplyReason(reason); - saveObj.setAuditReason(reason); - saveObj.setApplyDescription(reason); - this.save(saveObj); - // 添加售后进度 - HardAfterSaleLogEntity saveLogObj = HardAfterSaleLogEntity.builder() - .afterSaleId(saveObj.getId()) - .userId(orderDetail.getUserId()) - .operateType(HardAfterSaleOperateTypeEnum.ADMIN_REFUND.getType()) - .beforeStatus(orderDetail.getOrderStatus()) - .afterStatus(HardSaleStatusEnum.COMPLETE.getStatus()) - .content(HardSaleStatusEnum.COMPLETE.getContent()) - .build(); - hardAfterSaleLogMapper.insert(saveLogObj); - // 执行退款 - getSelf().refund(saveObj.getId(), "hard|ad"); + return withOrderRefundLock(orderId, + () -> getSelf().directRefundInTransaction(orderId, reason, null, false)); } catch (Exception e) { String msg = String.format("退款错误,%s", e.getMessage()); - log.error(msg, e.getMessage()); - // 抛出错误 - throw new RuntimeException(e.getMessage()); + log.error(msg, e); + throw new RuntimeException(e.getMessage(), e); } + } + + /** + * 在订单行锁与本地事务内完成消费端直接退款。已有部分退款时只退剩余金额,并只恢复尚未占用的商品库存。 + * + * @param orderId 待退款订单 ID + * @param reason 退款原因,允许为空并沿用历史接口语义 + * @param auditUserId 审核操作人 ID;为空时使用订单付款人 ID + * @param checkTimeLimit 是否校验支付后四小时的消费机退款期限 + * @return {@code true} 表示退款、售后记录和库存处理均已执行 + */ + @Transactional(rollbackFor = Exception.class) + public Boolean directRefundInTransaction(Long orderId, String reason, Long auditUserId, boolean checkTimeLimit) { + HardOrderEntity order = requireRefundableOrderForUpdate(orderId); + List afterSales = listOrderAfterSales(orderId); + if (afterSales.stream().anyMatch(item -> ObjectUtil.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) { + throw new RuntimeException("当前订单已有退款申请待审核,请先处理后再退款"); + } + int remainingRefundPrice = safePrice(order.getPayPrice()) + - sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus()); + if (remainingRefundPrice <= 0) { + throw new RuntimeException("订单已全部退款"); + } + if (checkTimeLimit && order.getPayTime() != null && order.getPayTime().plusHours(4).isBefore(LocalDateTime.now())) { + throw new RuntimeException("订单已超过4个小时,不能退款"); + } + + HardPayTypeEnum payType = HardPayTypeEnum.valueOfByType(order.getPayType()); + HardOrderTypeEnum orderType = HardOrderTypeEnum.valueOfByType(order.getOrderType()); + boolean simplePay = isSimplePay(payType, orderType); + List remainingCars = List.of(); + if (!simplePay) { + remainingCars = buildRemainingRefundCars(order, getOriginalCars(order)); + int remainingCarPrice = remainingCars.stream().map(HardAfterSaleCarEntity::getTotalPrice) + .filter(Objects::nonNull).mapToInt(Math::toIntExact).sum(); + if (remainingCars.isEmpty() || remainingCarPrice != remainingRefundPrice) { + throw new RuntimeException("历史退款商品明细不完整,请通过后台核对后处理"); + } + } + + Long operatorId = auditUserId == null ? order.getUserId() : auditUserId; + HardAfterSaleEntity afterSale = new HardAfterSaleEntity(); + afterSale.setType(HardSaleTypeEnum.AFTER_SALE.getType()); + afterSale.setStatus(HardSaleStatusEnum.COMPLETE.getStatus()); + afterSale.setNo("hard|ad_" + order.getId()); + afterSale.setWay("10"); + afterSale.setOrderId(order.getId()); + afterSale.setOrderNo(String.valueOf(order.getId())); + afterSale.setRefundPrice(remainingRefundPrice); + afterSale.setUserId(order.getUserId()); + afterSale.setAuditUserId(operatorId); + afterSale.setAuditTime(LocalDateTime.now()); + afterSale.setApplyReason(reason); + afterSale.setAuditReason(reason); + afterSale.setApplyDescription(reason); + baseMapper.insert(afterSale); + if (!simplePay) { + hardAfterSaleCarService.create(remainingCars, order, afterSale); + } + insertAfterSaleLog(afterSale.getId(), order.getUserId(), HardAfterSaleOperateTypeEnum.ADMIN_REFUND.getType(), + order.getOrderStatus(), HardSaleStatusEnum.COMPLETE.getStatus(), HardSaleStatusEnum.COMPLETE.getContent()); + if (!simplePay) { + hardOrderService.fastenerInventory(order, toInventoryCars(remainingCars), YesNoEnum.NO); + } + getSelf().refund(afterSale.getId(), "hard|ad"); + recomputeOrderRefundState(order.getId()); return true; } + /** + * 判断订单是否属于既有无完整购物车的简单支付范围。 + * + * @param payType 支付方式枚举,未知值按非简单支付处理并由后续购物车校验报错 + * @param orderType 订单类型枚举,未知值按非简单支付处理 + * @return {@code true} 表示退款不依赖商品购物车明细 + */ + private boolean isSimplePay(HardPayTypeEnum payType, HardOrderTypeEnum orderType) { + return HardPayTypeEnum.isSimplePayList.contains(payType) + || HardOrderTypeEnum.isSimplePayList.contains(orderType); + } + + /** + * 获取当前 Spring 代理实例,确保类内调用的事务方法仍经过事务拦截器。 + * + * @return 当前售后服务的 Spring 代理 + */ private HardAfterSaleServiceImpl getSelf() { return SpringUtil.getBean(getClass()); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index 8879bb66..338bfa58 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -3379,6 +3379,9 @@ public class HardOrderServiceImpl extends ServiceImpl 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 list = carList.stream().map(v -> { CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item(); @@ -3407,6 +3410,9 @@ public class HardOrderServiceImpl extends ServiceImpl 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 carList = hardCarService.getByParentCarId(order.getParentCarId()); if (ObjectUtil.isEmpty(carList)) throw new RuntimeException("扣减库存错误,购物车不存在"); + if (ObjectUtil.isNotEmpty(refundCarList)) { + carList = filterRefundInventoryCars(carList, refundCarList); + } List list = carList.stream().map(v -> { DeductionSpuStockDto entity = new DeductionSpuStockDto(); entity.setHardSpuId(v.getProductId()); @@ -3528,6 +3537,9 @@ public class HardOrderServiceImpl extends ServiceImpl 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 filterRefundInventoryCars(List originalCars, List refundCars) { + Map originalCarMap = originalCars.stream().filter(item -> item.getProductId() != null) + .collect(Collectors.toMap(HardCarEntity::getProductId, item -> item, (left, right) -> left, LinkedHashMap::new)); + Map 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) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleCarMapper.xml index 4859c1a7..33ffb43b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleCarMapper.xml @@ -60,4 +60,51 @@ group by a.product_id - \ No newline at end of file + + + + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index 10a3fc4d..240fa0b8 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -3,6 +3,15 @@ + + + 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 + + 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' @@ -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' + ) diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdRemoterRefundDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdRemoterRefundDto.java index de7cd8a8..1ff88ef2 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdRemoterRefundDto.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/ThirdRemoterRefundDto.java @@ -4,23 +4,33 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; import jakarta.validation.constraints.NotNull; import lombok.Data; +/** + * 跨服务第三方支付退款参数。 + * + *

同一支付订单允许多次部分退款时,调用方必须传入不同的售后单 ID 生成平台幂等请求号。

+ */ @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; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/pay/wechat/ThirdMiniWechatRefundDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/pay/wechat/ThirdMiniWechatRefundDto.java index 1a8c1785..28a89054 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/pay/wechat/ThirdMiniWechatRefundDto.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/appplus/pay/wechat/ThirdMiniWechatRefundDto.java @@ -15,12 +15,18 @@ import lombok.NoArgsConstructor; @NoArgsConstructor public class ThirdMiniWechatRefundDto { + /** 原支付订单号,用于定位第三方交易。 */ @NotEmpty private String payNo; /** - * 为null就全额退款 + * 本次退款金额,单位为分;为空时兼容历史全额退款。 */ private Integer refundPrice; + /** + * 本次售后单 ID,用作平台退款幂等请求号;为空时兼容历史固定请求号。 + */ + private Long afterSaleId; + } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java index 623e440f..d02795d6 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImpl.java @@ -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(); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java index 50054cd1..c5261a81 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java @@ -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 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 { diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java index 6e791f36..514da37b 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImpl.java @@ -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(); diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/constant/user/UserIdentityTypeEnum.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/constant/user/UserIdentityTypeEnum.java index 7d4fc26a..b504967b 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/constant/user/UserIdentityTypeEnum.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/constant/user/UserIdentityTypeEnum.java @@ -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; /** - * 用户身份 + * 用户身份协议枚举。 + *

身份编码会写入用户身份表并由 PC、App 等调用端传输,已有编码不得随意变更。

*/ -@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()) diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java index 10a4427b..eb9b87f6 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java @@ -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; + /** * 新密码 */ diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserRelationCreateUserDto.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserRelationCreateUserDto.java index df9040d2..71d6542c 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserRelationCreateUserDto.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserRelationCreateUserDto.java @@ -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() { diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserTeacherDto.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserTeacherDto.java new file mode 100644 index 00000000..14110d1b --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserTeacherDto.java @@ -0,0 +1,19 @@ +package com.bsd.pjcloud.admin.api.dto.user; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 老师身份扩展信息。 + *

老师扩展信息与学生扩展信息独立传输,当前仅保存老师学号。

+ */ +@Data +@Schema(description = "老师身份扩展信息") +public class SysUserTeacherDto { + + /** + * 老师学号,由老师用户表单或老师 Excel 模板提供。 + */ + @Schema(description = "老师学号") + private String teacherNo; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/user/SysUserTeacherEntity.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/user/SysUserTeacherEntity.java new file mode 100644 index 00000000..a274e34e --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/user/SysUserTeacherEntity.java @@ -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; + +/** + * 老师用户扩展信息表实体。 + *

以用户 ID 关联系统用户,仅承载老师身份专属的学号,避免复用学生扩展表。

+ */ +@Data +@TenantTable +@TableName("sys_user_teacher") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "老师用户扩展信息表") +public class SysUserTeacherEntity extends Model { + + /** + * 系统用户 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; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/TeacherUserExcelVO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/TeacherUserExcelVO.java new file mode 100644 index 00000000..d0500a44 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/TeacherUserExcelVO.java @@ -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; + +/** + * 老师用户导入模板对应的数据模型。 + *

模板中的手机号作为登录账号,“学号”写入老师扩展表,不复用学生扩展数据。

+ */ +@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; + + /** + * 老师模板中的学号,写入老师扩展表保存。 + *

长度允许 2 至 60 个字符,且不能包含中文。

+ */ + @NotBlank(message = "学号不能为空") + @Size(min = 2, max = 60, message = "学号长度必须介于2到60个字符之间") + @Pattern(regexp = "^[^\\u4e00-\\u9fa5]+$", message = "学号不能包含中文") + @ExcelProperty("学号") + private String teacherNo; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/UserVO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/UserVO.java index 172a44b7..d2ee612e 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/UserVO.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/UserVO.java @@ -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; + /** * 昵称 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/pom.xml b/pjcloud-upms/pjcloud-upms-biz/pom.xml index be91180d..d21b51e6 100644 --- a/pjcloud-upms/pjcloud-upms-biz/pom.xml +++ b/pjcloud-upms/pjcloud-upms-biz/pom.xml @@ -33,6 +33,11 @@ pjcloud 通用用户权限管理系统业务处理模块 + + + com.bsd + pjcloud-common-async-task + com.bsd pjcloud-common-encrypt-api diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index db667188..c484d926 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -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 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 excelVOList, BindingResult bindingResult) { - return userService.importUser(excelVOList, bindingResult); + public R importUser(@RequestExcel List 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 studentUserExcelList, BindingResult bindingResult) { - return userService.importStudentUser(studentUserExcelList, bindingResult); + public R importStudentUser(@RequestExcel List studentUserExcelList, BindingResult bindingResult) { + return userImportExportService.importStudentUser(studentUserExcelList, bindingResult); } /** - * 导入用户及 Excel 中内嵌的人脸图片。 + * 导入老师用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 + *

模板中的手机号作为登录账号,学号写入老师扩展表,老师身份单独保存为 TEACHER。

+ * + * @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 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 excelList, BindingResult bindingResult) { - return userService.importUserFace(file, excelList, bindingResult); + public R importUserFace(@RequestParam("file") MultipartFile file, @RequestExcel List excelList, BindingResult bindingResult) { + return userImportExportService.importUserFace(file, excelList, bindingResult); } /** diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java index a204e6cb..c6b29034 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java @@ -107,9 +107,9 @@ public class SysUserFaceController { } /** - * 当前用户为本人或有效被监护人提交人脸审核。 + * 当前用户为本人或有效被监护人提交人脸,是否审核由提交人角色决定。 */ - @Operation(summary = "提交本人或被监护人人脸", description = "仅可为本人或有效被监护人提交,所有图片必须进入审核流程") + @Operation(summary = "提交本人或被监护人人脸", description = "仅可为本人或有效被监护人提交,拥有指定角色时进入审核流程,否则直接通过") @SysLog("提交本人或被监护人人脸") @ApiCryptoAes @PostMapping("/app/guardian-ward") diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/apple/AppleIdentityTokenVerifier.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/apple/AppleIdentityTokenVerifier.java index 0b0fde49..5ef16abb 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/apple/AppleIdentityTokenVerifier.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/handler/apple/AppleIdentityTokenVerifier.java @@ -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(); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/user/SysUserTeacherMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/user/SysUserTeacherMapper.java new file mode 100644 index 00000000..b66ce968 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/user/SysUserTeacherMapper.java @@ -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 { +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java index 1984b73d..7b376ead 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java @@ -21,7 +21,10 @@ public interface SysUserFaceService extends IService { Long create(SysUserFaceEntity sysUserFace); /** - * 当前用户为本人或有效被监护人提交人脸,并强制进入审核流程。 + * 当前用户为本人或有效被监护人提交人脸,并根据提交人角色决定是否进入审核流程。 + * + * @param dto 目标用户及人脸图片 + * @return 新增的人脸记录ID列表 */ List createGuardianWardFaces(SysUserFaceGuardianWardCreateDto dto); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserImportExportService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserImportExportService.java new file mode 100644 index 00000000..3960055b --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserImportExportService.java @@ -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; + +/** + * 用户模块导入导出服务。 + *

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

+ */ +public interface SysUserImportExportService { + + /** + * 按查询条件和数据权限生成后台用户导出数据。 + * + * @param userDTO 用户筛选条件 + * @param ids 指定导出的用户 ID;为空时按筛选条件导出 + * @return 可由 Excel 响应转换器直接输出的用户列表 + */ + List listUser(UserDTO userDTO, Long[] ids); + + /** + * 导入后台用户基础信息;同步完成整份 Excel 校验,任一行错误均不创建任务,校验通过后异步统一写入。 + * + * @param excelVOList Excel 解析出的后台用户数据 + * @param bindingResult Excel 通用校验结果,错误目标包含原始行号 + * @return 校验失败时返回对应行号和原因;校验通过时返回可供轮询的异步任务进度 + */ + R importUser(List excelVOList, BindingResult bindingResult); + + /** + * 导入学生用户、学生扩展信息及监护关系;同步完成整表校验,任一行错误均不创建任务,校验通过后异步统一写入。 + * + * @param studentUserExcelList Excel 解析出的学生数据 + * @param bindingResult Excel 通用校验结果,错误目标包含原始行号 + * @return 校验失败时返回对应行号和原因;校验通过时返回可供轮询的异步任务进度 + */ + R importStudentUser(List studentUserExcelList, BindingResult bindingResult); + + /** + * 导入老师用户,并为其保存默认身份和老师身份;同步完成整表校验,任一行错误均不创建任务,校验通过后异步统一写入。 + * + * @param teacherUserExcelList Excel 解析出的老师数据 + * @param bindingResult Excel 通用校验结果,错误目标包含原始行号 + * @return 校验失败时返回对应行号和原因;校验通过时返回可供轮询的异步任务进度 + */ + R importTeacherUser(List teacherUserExcelList, BindingResult bindingResult); + + /** + * 导入后台用户及 Excel 中内嵌的人脸图片;同步完成整表校验和图片复制,任一行错误均不创建任务,校验通过后异步统一写入。 + * + * @param file 原始 Excel 文件,用于读取内嵌图片 + * @param excelList Excel 解析出的用户数据 + * @param bindingResult Excel 通用校验结果,错误目标包含原始行号 + * @return 校验失败时返回对应行号和原因;校验通过时返回可供轮询的异步任务进度 + */ + R importUserFace(MultipartFile file, List excelList, BindingResult bindingResult); +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java index 36da87b2..84380c10 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java @@ -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 { */ String generateUniqueSysUserUsername(); - /** - * 查询全部的用户 - */ - List listUser(UserDTO userDTO, Long[] ids); - - /** - * excel 导入用户 - */ - R importUser(List excelVOList, BindingResult bindingResult); - - /** - * 导入学生用户 - */ - R importStudentUser(List studentUserExcelList, BindingResult bindingResult); - - R importUserFace(MultipartFile file, List excelList, BindingResult bindingResult); - /** * 注册用户 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserTeacherService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserTeacherService.java new file mode 100644 index 00000000..db6d92e3 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserTeacherService.java @@ -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 { + + /** + * 判断当前租户中老师学号是否已被其他用户使用。 + * + * @param userId 编辑时排除的当前用户 ID;新增时为空 + * @param teacherNo 待校验的老师学号 + * @return 存在其他相同老师学号时返回 {@code true} + */ + boolean existsTeacherNo(Long userId, String teacherNo); +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java index 5073bc28..731d3797 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java @@ -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 applyUserRoles = sysRoleService.findRolesByUserId(applyUserId); + return ObjectUtil.isNotEmpty(applyUserRoles) && applyUserRoles.stream() + .anyMatch(role -> ObjectUtil.equals(role.getRoleCode(), CommonRoleConstant.ADD_FACE_NEED_CEHCK)); + } + /** * 查询当前监护人名下可选择的有效被监护人,并仅返回页面展示所需字段。 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java new file mode 100644 index 00000000..7e483093 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java @@ -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; + +/** + * 用户模块导入导出服务实现。 + *

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

+ */ +@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 listUser(UserDTO userDTO, Long[] ids) { + List userList = sysUserMapper.selectVoListByScope(userDTO, ids, DataScope.of()); + return userList.stream().map(userVO -> { + UserExcelVO excelVO = new UserExcelVO(); + BeanUtils.copyProperties(userVO, excelVO); + excelVO.setRoleNameList(userVO.getRoleList().stream().map(SysRole::getRoleName).collect(Collectors.joining(StrUtil.COMMA))); + excelVO.setPostNameList(userVO.getPostList().stream().map(SysPost::getPostName).collect(Collectors.joining(StrUtil.COMMA))); + if (ObjectUtil.isNotEmpty(userVO.getStaffingType())) { + UserStaffingTypeEnum staffingType = UserStaffingTypeEnum.valueOfByType(userVO.getStaffingType()); + if (staffingType != null) { + excelVO.setStaffingType(staffingType.getDescription()); + } + } + return excelVO; + }).toList(); + } + + /** + * 导入后台用户基础信息,并以 Excel 行号汇总业务校验错误;整表无错误后才统一写入。 + * + * @param excelVOList Excel 解析出的后台用户数据 + * @param bindingResult Excel 通用校验结果 + * @return 导入结果;失败时返回对应行号和原因 + */ + @Override + public R importUser(List excelVOList, BindingResult bindingResult) { + List errorMessageList = getErrorMessageList(bindingResult); + if (CollUtil.isEmpty(excelVOList)) { + return CollUtil.isNotEmpty(errorMessageList) ? R.failed(errorMessageList) : R.failed("Excel内容为空"); + } + DepartmentIndex departmentIndex = new DepartmentIndex(sysDeptService.list()); + List roleList = sysRoleService.list(); + List postList = sysPostService.list(); + Map usernameCountMap = countNonBlankValues(excelVOList.stream().map(UserExcelVO::getUsername).toList()); + Map importContextMap = new HashMap<>(); + for (UserExcelVO excel : excelVOList) { + Set errorMessages = new HashSet<>(); + boolean usernameExists = sysUserService.exists(Wrappers.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 roleNames = StrUtil.split(excel.getRoleNameList(), StrUtil.COMMA); + List 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 postNames = StrUtil.split(excel.getPostNameList(), StrUtil.COMMA); + List 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 studentUserExcelList, BindingResult bindingResult) { + List 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 guardianPhones = studentUserExcelList.stream().map(StudentUserExcelVO::getGuardianPhone) + .filter(StrUtil::isNotBlank).distinct().toList(); + Map guardianUserMap = guardianPhones.isEmpty() ? Map.of() : sysUserService.list( + Wrappers.lambdaQuery().in(SysUser::getPhone, guardianPhones) + .eq(SysUser::getProcessStatus, TaskCheckStatusEnum.RESULT.getCode())) + .stream().collect(Collectors.toMap(SysUser::getPhone, Function.identity(), (first, second) -> first)); + + Map> 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 distinctStudentNos = studentUserExcelList.stream().map(StudentUserExcelVO::getStudentNo) + .filter(StrUtil::isNotBlank).distinct().toList(); + List distinctSchoolNames = studentUserExcelList.stream().map(StudentUserExcelVO::getSchoolName) + .filter(StrUtil::isNotBlank).distinct().toList(); + Map> existingSchoolStudentNoMap = distinctSchoolNames.isEmpty() || distinctStudentNos.isEmpty() + ? Map.of() : sysUserStudentMapper.selectList(Wrappers.lambdaQuery() + .in(SysUserStudentEntity::getSchoolName, distinctSchoolNames) + .in(SysUserStudentEntity::getStudentNo, distinctStudentNos)) + .stream().collect(Collectors.groupingBy(SysUserStudentEntity::getSchoolName, + Collectors.mapping(SysUserStudentEntity::getStudentNo, Collectors.toSet()))); + + List studentRoleIds = resolveIdentityImportRoleIds(studentUserExcelList, errorMessageList, "学生"); + if (studentRoleIds == null) { + return R.failed(errorMessageList); + } + SysUserRelationTypeEntity guardianWardRelationType = sysUserRelationTypeService.getOne( + Wrappers.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 errorMessages = new LinkedHashSet<>(); + if (StrUtil.isBlank(excel.getUsername())) { + errorMessages.add("请输入学生姓名"); + } + if (StrUtil.isBlank(excel.getStudentNo())) { + errorMessages.add("请输入学号"); + } else { + if (StrUtil.isNotBlank(excel.getSchoolName())) { + Map 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 teacherUserExcelList, BindingResult bindingResult) { + List 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 teacherNoCountMap = countNonBlankValues(teacherUserExcelList.stream().map(TeacherUserExcelVO::getTeacherNo).toList()); + Map phoneCountMap = countNonBlankValues(teacherUserExcelList.stream().map(TeacherUserExcelVO::getPhone).toList()); + List teacherNos = teacherNoCountMap.keySet().stream().toList(); + List phones = phoneCountMap.keySet().stream().toList(); + Set existingTeacherNos = teacherNos.isEmpty() ? Set.of() : sysUserTeacherMapper.selectList( + Wrappers.lambdaQuery().in(SysUserTeacherEntity::getTeacherNo, teacherNos)) + .stream().map(SysUserTeacherEntity::getTeacherNo).collect(Collectors.toSet()); + Set existingPhones = phones.isEmpty() ? Set.of() : sysUserService.list( + Wrappers.lambdaQuery().in(SysUser::getPhone, phones)) + .stream().map(SysUser::getPhone).collect(Collectors.toSet()); + Set existingUsernames = phones.isEmpty() ? Set.of() : sysUserService.list( + Wrappers.lambdaQuery().in(SysUser::getUsername, phones)) + .stream().map(SysUser::getUsername).collect(Collectors.toSet()); + + DepartmentIndex departmentIndex = new DepartmentIndex(sysDeptService.list()); + Map departmentMap = new HashMap<>(); + + List teacherRoleIds = resolveIdentityImportRoleIds(teacherUserExcelList, errorMessageList, "老师"); + if (teacherRoleIds == null) { + return R.failed(errorMessageList); + } + for (TeacherUserExcelVO excel : teacherUserExcelList) { + Set 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 excelList, BindingResult bindingResult) { + try { + List errorMessageList = getErrorMessageList(bindingResult); + if (CollUtil.isEmpty(excelList)) { + return CollUtil.isNotEmpty(errorMessageList) ? R.failed(errorMessageList) : R.failed("Excel内容为空"); + } + DepartmentIndex departmentIndex = new DepartmentIndex(sysDeptService.list()); + List roles = sysRoleService.list(); + List users = sysUserService.list(); + String walletAutoCreateRole = ParamResolver.getStr("WALLET_AUTO_CREATE"); + String defaultUserRole = ParamResolver.getStr("USER_DEFAULT_ROLE"); + List roleIds = roles.stream().filter(role -> ObjectUtil.equals(role.getRoleCode(), walletAutoCreateRole) + || ObjectUtil.equals(role.getRoleCode(), defaultUserRole)).map(SysRole::getRoleId).toList(); + List phones = excelList.stream().map(UserExcelFaceVo::getMobile).filter(StrUtil::isNotBlank).toList(); + Map pictureDataMap = ExcelTool.getPictureFromExcel(file, 1); + Map images = pictureDataMap == null ? Map.of() : pictureDataMap.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getData())); + Map departmentMap = new HashMap<>(); + for (UserExcelFaceVo excel : excelList) { + Set 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); + } + } + + /** + * 统一解析用户导入模板中的部门名称或部门路径。 + *

单个部门名只有在当前租户内唯一时才可使用;多段路径按最后一段定位目标部门,并向上逐级核对父部门, + * 因此既支持完整路径,也允许省略目标部门之上的更高层组织。

+ * + * @param departmentValue Excel 中填写的部门名称或路径,路径可使用 {@code /}、{@code \} 或 {@code ->} 分隔 + * @param departmentIndex 当前租户全部有效部门构建的查询索引 + * @param errorMessages 当前 Excel 行的错误集合;解析失败时追加具体原因 + * @return 唯一匹配的目标部门;未找到或存在歧义时返回 {@code null} + */ + private SysDept resolveDepartment(String departmentValue, DepartmentIndex departmentIndex, Set errorMessages) { + if (StrUtil.isBlank(departmentValue)) { + errorMessages.add("部门不能为空"); + return null; + } + String normalizedValue = StrUtil.trim(departmentValue); + List 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 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 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 pathSegments, Map 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 departmentsById; + + /** 按去除首尾空格后的部门名称分组,用于识别不存在和同名歧义。 */ + private final Map> departmentsByName; + + /** + * 使用当前租户查询出的全部有效部门构建导入索引,后续仅用于部门匹配查询。 + * + * @param departments 当前租户的有效部门;为空时按空部门树处理 + */ + private DepartmentIndex(List departments) { + List 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 roles; + + /** 当前 Excel 行已匹配的岗位列表。 */ + private final List posts; + + /** + * 保存当前 Excel 行的关联对象解析结果,避免统一写入前重复查询。 + * + * @param department 当前行唯一匹配的部门 + * @param roles 当前行已匹配的角色列表 + * @param posts 当前行已匹配的岗位列表 + */ + private BackendUserImportContext(SysDept department, List roles, List 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 roles, List 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 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 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 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 resolveIdentityImportRoleIds(List rows, List errorMessageList, String identityName) { + List roleCodes = List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET); + List roles = Optional.ofNullable(sysRoleService.findRolesByCodes(roleCodes)).orElseGet(List::of); + Map roleIdMap = roles.stream().collect(Collectors.toMap(SysRole::getRoleCode, SysRole::getRoleId, (first, second) -> first)); + List 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 countNonBlankValues(List 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 getErrorMessageList(BindingResult bindingResult) { + Object target = bindingResult == null ? null : bindingResult.getTarget(); + if (target instanceof List targetList) { + return (List) targetList; + } + return new ArrayList<>(); + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index 87d559ca..69e422f4 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -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 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 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 impl */ private final RemoteFlowEngineService remoteFlowEngineService; - private final SysFileService sysFileService; - private final FlowUtil flowUtil; /** @@ -614,273 +582,9 @@ public class SysUserServiceImpl extends ServiceImpl impl return this.list(Wrappers.query().lambda().eq(SysUser::getDeptId, parentId)); } - /** - * 查询全部的用户 - * - * @param userDTO 查询条件 - * @param ids ids 用户列表 - * @return list - */ - @Override - public List listUser(UserDTO userDTO, Long[] ids) { - // 根据数据权限查询全部的用户信息 - List voList = baseMapper.selectVoListByScope(userDTO, ids, DataScope.of()); - // 转换成execl 对象输出 - List 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 excelVOList, BindingResult bindingResult) { - PjcloudUser user = SecurityUtils.getUser(); - // 通用校验获取失败的数据 - List errorMessageList = (List) bindingResult.getTarget(); - List deptList = sysDeptService.list(); - List roleList = sysRoleService.list(); - List postList = sysPostService.list(); - // 执行数据插入操作 组装 UserDto - for (UserExcelVO excel : excelVOList) { - // 个性化校验逻辑 - List userList = this.list(); - Set 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 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 roleNameList = StrUtil.split(excel.getRoleNameList(), StrUtil.COMMA); - List 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 postNameList = StrUtil.split(excel.getPostNameList(), StrUtil.COMMA); - List 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)); - } - - /** - * 导入学生用户。 - *

先汇总校验错误并保留 Excel 行号,校验通过的行再创建学生用户、学生身份扩展和监护关系。

- * - * @param studentUserExcelList Excel 中通过通用校验的学生数据 - * @param bindingResult Excel 通用校验产生的错误信息 - * @return 导入结果;存在错误时返回对应行号和错误原因 - */ - @Override - public R importStudentUser(List studentUserExcelList, BindingResult bindingResult) { - List errorMessageList = (List) 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 guardianPhoneList = studentUserExcelList.stream() - .map(StudentUserExcelVO::getGuardianPhone) - .filter(StrUtil::isNotBlank) - .distinct() - .toList(); - Map guardianUserMap = guardianPhoneList.isEmpty() ? Map.of() : this.list( - Wrappers.lambdaQuery() - .in(SysUser::getPhone, guardianPhoneList) - .eq(SysUser::getProcessStatus, TaskCheckStatusEnum.RESULT.getCode())) - .stream() - .collect(Collectors.toMap(SysUser::getPhone, user -> user, (first, second) -> first)); - - // 学号只要求在同一学校内唯一,按“学校名称 + 学号”统计 Excel 重复并批量查询已有学生。 - List studentNoList = studentUserExcelList.stream() - .map(StudentUserExcelVO::getStudentNo) - .filter(StrUtil::isNotBlank) - .toList(); - Map> 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 distinctStudentNoList = studentNoList.stream().distinct().toList(); - List distinctSchoolNameList = studentUserExcelList.stream() - .map(StudentUserExcelVO::getSchoolName) - .filter(StrUtil::isNotBlank) - .distinct() - .toList(); - Map> existingSchoolStudentNoMap = distinctSchoolNameList.isEmpty() || distinctStudentNoList.isEmpty() - ? Map.of() : sysUserStudentMapper.selectList(Wrappers.lambdaQuery() - .in(SysUserStudentEntity::getSchoolName, distinctSchoolNameList) - .in(SysUserStudentEntity::getStudentNo, distinctStudentNoList)) - .stream() - .collect(Collectors.groupingBy(SysUserStudentEntity::getSchoolName, - Collectors.mapping(SysUserStudentEntity::getStudentNo, Collectors.toSet()))); - - // 学生导入固定绑定普通用户和自动创建钱包角色,不能依赖可能被租户调整的默认角色参数。 - List studentRoleCodes = List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET); - Map studentRoleIdMap = sysRoleService.findRolesByCodes(studentRoleCodes).stream().collect(Collectors.toMap(SysRole::getRoleCode, SysRole::getRoleId, (first, second) -> first)); - List 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 studentRoleIds = studentRoleCodes.stream().map(studentRoleIdMap::get).toList(); - - // 监护关系属于可配置业务数据,导入前必须确认当前租户已配置并启用 GUARDIAN_WARD。 - SysUserRelationTypeEntity guardianWardRelationType = sysUserRelationTypeService.getOne( - Wrappers.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 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 currentSchoolStudentNoCountMap = schoolStudentNoCountMap.getOrDefault(excel.getSchoolName(), Map.of()); - if (currentSchoolStudentNoCountMap.getOrDefault(excel.getStudentNo(), 0L) > 1) { - errorMsg.add("学生,学校名称:" + excel.getSchoolName() + ",学号:" + excel.getStudentNo() + ",在excel中重复"); - } - Set 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 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 登录账号。 - *

随机账号满足现有用户名长度和字符约束,并在返回前查询数据库规避已有账号冲突。

+ *

该能力同时由独立的用户名生成接口和学生导入使用,因此保留在用户核心服务。

* * @return 未被占用的随机数字账号 */ @@ -895,178 +599,6 @@ public class SysUserServiceImpl extends ServiceImpl impl throw new IllegalStateException("自动生成用户账号失败,请重试"); } - @Override - public R importUserFace(MultipartFile file, List excelList, BindingResult bindingResult) { - try { - if (ObjectUtil.isEmpty(excelList)) { - R.failed("excel内容为空"); - } - // 校验导入的内容 - List errorMessageList = (List) bindingResult.getTarget(); - List deptNames = excelList.stream().map(UserExcelFaceVo::getDeptName).distinct().filter(ObjectUtil::isNotEmpty).toList(); - // 部门 - List sysDeptList = sysDeptService.selectByNames(deptNames); - // 角色 - List roleList = sysRoleService.list(); - // 用户 - List userList = this.list(); - String walletAutoCreate = ParamResolver.getStr("WALLET_AUTO_CREATE"); - String userDefaultRole = ParamResolver.getStr("USER_DEFAULT_ROLE"); - List roleIds = roleList.stream().filter(v -> ObjectUtil.equals(v.getRoleCode(), walletAutoCreate) || ObjectUtil.equals(v.getRoleCode(), userDefaultRole)).map(SysRole::getRoleId).toList(); - // 用户手机号重复判断 - List userPoneList = excelList.stream().map(UserExcelFaceVo::getMobile).filter(ObjectUtil::isNotEmpty).toList(); - // 用户名重复判断 - List usernameList = excelList.stream().map(UserExcelFaceVo::getUsername).filter(ObjectUtil::isNotEmpty).toList(); - // 读取图片 - Map images = ExcelTool.getPictureFromExcel(file, 1); - for (UserExcelFaceVo v : excelList) { - Long deptId = null; - Set 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 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 deptOptional, List roleCollList, List 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 postIdList = postCollList.stream().map(SysPost::getPostId).collect(Collectors.toList()); - userDTO.setPost(postIdList); - // 根据角色名称查询角色ID - List roleIdList = roleCollList.stream().map(SysRole::getRoleId).collect(Collectors.toList()); - userDTO.setRole(roleIdList); - // 插入用户 - this.saveUser(userDTO); - } - - public void insertExcelUser(UserExcelFaceVo excel, Long deptId, List 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()); - } - } - /** * 注册用户 赋予用户默认角色 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java index 46e8477b..217a715d 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java @@ -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 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 userIds) { if (CollUtil.isEmpty(userIds)) { @@ -107,9 +154,15 @@ public class SysUserIdentityServiceImpl extends ServiceImpllambdaQuery().in(SysUserStudentEntity::getUserId, userIds)); + sysUserTeacherService.remove(Wrappers.lambdaQuery().in(SysUserTeacherEntity::getUserId, userIds)); remove(Wrappers.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 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()); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserTeacherServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserTeacherServiceImpl.java new file mode 100644 index 00000000..79d30c94 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserTeacherServiceImpl.java @@ -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 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.lambdaQuery() + .eq(SysUserTeacherEntity::getTeacherNo, teacherNo.trim()) + .ne(userId != null, SysUserTeacherEntity::getUserId, userId)); + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/teacher_import.xlsx b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/teacher_import.xlsx new file mode 100644 index 00000000..7876aca4 Binary files /dev/null and b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/teacher_import.xlsx differ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java index af631cee..5b770ec1 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java @@ -264,8 +264,11 @@ public class WalletRechargeServiceImpl extends ServiceImpl