diff --git a/README_REDIS_TENANT.md b/README_REDIS_TENANT.md new file mode 100644 index 000000000..93be10fe8 --- /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 000000000..1d1e19090 --- /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 000000000..8111da270 --- /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 000000000..2199740e5 --- /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 000000000..24ad79eb8 --- /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 000000000..fb3cbd098 --- /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 000000000..99bac3c18 --- /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 000000000..358d78ffa --- /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 000000000..ae64e038c --- /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 000000000..c540e5486 --- /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 000000000..ea57776e0 --- /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 000000000..029e70401 --- /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 000000000..eabc61d12 --- /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 000000000..ab73c5e8c --- /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 000000000..4fc128c41 --- /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 000000000..16dfa6472 --- /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 aa59cd924..b4ee9fdd5 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 fcdcbe596..ddab5aefd 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 @@ -35,10 +35,24 @@ public interface CommonRoleConstant { // 智能柜管理员 String HARD_DOOR_ADMIN_USER = "HARD_DOOR_ADMIN"; - /** 线上食堂商户(移动端:商品/商家/报餐统计) */ + /** + * 线上食堂商户(移动端:商品/商家/报餐统计) + */ String ONLINE_CANTEEN_MCH = "ONLINE_CANTEEN_MCH"; - /** 食堂骑手(移动端配送) */ + /** + * 食堂骑手(移动端配送) + */ String CANTEEN_RIDER = "CANTEEN_RIDER"; + /** + * 添加用户需要审批 + */ + String ADD_USER_NEED_CEHCK = "ADD_USER_NEED_CEHCK"; + + /** + * 添加用户人脸需要审批 + */ + String ADD_FACE_NEED_CEHCK = "ADD_FACE_NEED_CEHCK"; + } diff --git a/pjcloud-common/pom.xml b/pjcloud-common/pom.xml index 268b4ca67..eb2486a0f 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 000000000..9116c82fb --- /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 000000000..c3942c03d --- /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 c0def7721..b00587c2f 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 000000000..2699614f6 --- /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 000000000..d4ce9b537 --- /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/hard/HardApiController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java index 6f6b731e0..02d14e947 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java @@ -56,13 +56,21 @@ public class HardApiController { private final HardOrderTotalService hardOrderTotalService; private final ReservationMenuQueryService reservationMenuQueryService; private final VerificationRecordService verificationRecordService; + /** * 食堂、超市 Android 终端心跳状态服务。 */ private final HardTerminalStatusService hardTerminalStatusService; + /** + * 将 Android 终端唯一标识绑定到服务端设备,并在短时间内拦截相同参数的重复请求。 + * + * @param dto 服务端设备 ID 与 Android 终端唯一标识 + * @return 绑定完成后的设备配置 + */ @PostMapping("/bind") @Operation(summary = "绑定服务端设备") + @Idempotent(expireTime = 3, key = "'/hard-api/ad/bind:' + #dto.getId() + ':' + #dto.getUniqueCode()", info = "正在绑定中,请勿重复操作") public R bind(@RequestBody HardBindDto dto) { return R.ok(hardManagerService.bind(dto)); } 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 63d882bc2..639db0906 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 8b57cbb67..5a3743977 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 26d102f66..d88ad49bc 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 779316aae..2151d4275 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 5a5cf740a..1c4a02bc0 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 515ddf1dd..11ecfed6a 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 中,用于区分同为食堂预约的在线商品订单。 + */ + private static final String DAILY_MENU_CART_SCENE = "DAILY_MENU"; private final HardAfterSaleLogMapper hardAfterSaleLogMapper; private final HardOrderService hardOrderService; @@ -81,6 +103,9 @@ public class HardAfterSaleServiceImpl extends ServiceImpl pageJoin(Page page, HardAfterSaleDto dto) { @@ -145,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; + } + /** * 售后读取沿用订单查看权限,避免代订订单通过售后接口越权暴露。 */ @@ -202,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("取消售后单失败,售后单状态不是【待审核】"); @@ -220,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()); @@ -322,11 +509,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundRes = thirdAppPlusPayService.remoterRefund(refundDto); @@ -343,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() @@ -397,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() @@ -429,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; } + /** + * 校验本次退款商品属于原订单,且本次数量加历史成功退款数量不超过下单数量。 + * + * @param order 待退款订单 + * @param refundCarList 本次选择的退款商品及数量 + */ public void validateOrderCarCanRefund(HardOrderEntity order, List 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())) { @@ -485,17 +680,517 @@ public class HardAfterSaleServiceImpl extends ServiceImpl ObjectUtil.equal(v.getId(), refundCar.getId())).findFirst().orElse(null); + // 售后明细 ID 是退款记录自身 ID,不能与原购物车 ID 比较;使用服务端订单商品 ID 防止重复超额退款。 + HardAfterSaleCarEntity refundSuccessCar = refundSuccessCarList.stream() + .filter(v -> ObjectUtil.equal(v.getProductId(), orderCar.getProductId())) + .findFirst() + .orElse(null); if (ObjectUtil.isNotEmpty(refundSuccessCar)) { refundCount += refundSuccessCar.getProductCount(); } } - if (orderCar.getProductCount() < refundCount) { + int originalCount = orderCarList.stream().filter(item -> 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("售后单不存在"); @@ -542,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(); @@ -554,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()); @@ -609,16 +1361,23 @@ public class HardAfterSaleServiceImpl extends ServiceImpl computerCarList = dto.getCarList().stream().map((item) -> { - HardCreateCarDto entity = new HardCreateCarDto(); - entity.setParentCarId(item.getParentCarId()); - entity.setProductId(item.getProductId()); - entity.setProductNature(HardProductNatureEnum.valueOfByType(item.getProductNature())); - entity.setProductCount(item.getProductCount()); - entity.setProductWeight(item.getProductWeight()); - return entity; - }).toList(); - refundPrice = hardCarService.computerCarTotalPrice(HardTypeEnum.valueOfByType(order.getSysType()), HardOrderTypeEnum.valueOfByType(order.getOrderType()), computerCarList); + if (isDailyMenuOrder(order)) { + refundPrice = calculateDailyMenuRefundPrice(order, dto.getCarList()); + } else { + List computerCarList = dto.getCarList().stream().map((item) -> { + HardCreateCarDto entity = new HardCreateCarDto(); + entity.setParentCarId(item.getParentCarId()); + entity.setProductId(item.getProductId()); + entity.setProductNature(HardProductNatureEnum.valueOfByType(item.getProductNature())); + entity.setProductCount(item.getProductCount()); + entity.setProductWeight(item.getProductWeight()); + return entity; + }).toList(); + refundPrice = hardCarService.computerCarTotalPrice(HardTypeEnum.valueOfByType(order.getSysType()), HardOrderTypeEnum.valueOfByType(order.getOrderType()), computerCarList); + } + } + if (refundPrice <= 0) { + throw new RuntimeException("退款金额必须大于0"); } saveObj.setType(HardSaleTypeEnum.AFTER_SALE.getType()); @@ -633,7 +1392,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCarList) { + List orderCarList = hardCarService.getByParentCarId(order.getParentCarId()); + if (ObjectUtil.isEmpty(orderCarList)) { + throw new RuntimeException("订单购物车不存在"); + } + int refundPrice = 0; + try { + for (HardAfterSaleCarEntity refundCar : refundCarList) { + int refundCount = refundCar.getProductCount() == null ? 0 : refundCar.getProductCount(); + if (refundCount <= 0) { + continue; + } + HardCarEntity orderCar = orderCarList.stream() + .filter(item -> ObjectUtil.equal(item.getId(), refundCar.getId())) + .findFirst() + .orElseThrow(() -> new RuntimeException("退款商品不存在")); + if (orderCar.getProductPrice() == null || orderCar.getProductPrice() < 0) { + throw new RuntimeException("退款商品价格异常"); + } + refundPrice = Math.addExact(refundPrice, Math.multiplyExact(orderCar.getProductPrice(), refundCount)); + } + } catch (ArithmeticException exception) { + throw new RuntimeException("退款金额超出计算范围", exception); + } + return refundPrice; + } + + /** + * 消费机限时退款。订单级锁覆盖校验、售后落库、库存恢复和支付退款,避免与 App 申请或后台退款并发。 + * + * @param orderId 待退款订单 ID + * @param reason 退款原因,原样保存到售后申请与审核说明 + * @param user 当前消费机登录用户,用于记录审核操作人 + * @return {@code true} 表示订单剩余可退金额已退款 */ @Override public Boolean adTimeLintRefund(Long orderId, String reason, PjcloudUser user) { try { - HardOrderEntity orderDetail = hardOrderService.getById(orderId); - if (ObjectUtil.isEmpty(orderDetail)) throw new RuntimeException("订单不存在"); - orderDetail = validateOrderCanRefund(orderDetail.getId()); - if (ObjectUtil.isEmpty(orderDetail)) throw new RuntimeException("订单已退款"); - // 超过#{hours}小时不能退款 - int hours = 4; - if (ObjectUtil.isNotEmpty(orderDetail.getPayTime())) { - if (orderDetail.getPayTime().plusHours(hours).isBefore(LocalDateTime.now())) { - throw new RuntimeException("订单已超过" + hours + "个小时,不能退款"); - } - } - // 添加售后订单 - 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(user.getId()); - 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"); - // 退库存 - hardOrderService.fastenerInventory(orderDetail, YesNoEnum.NO); + return withOrderRefundLock(orderId, + () -> 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 8879bb66f..338bfa58f 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 4859c1a72..33ffb43bd 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 10a3fc4dc..240fa0b8c 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 de7cd8a80..1ff88ef27 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 1a8c17856..28a890547 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-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/jg/JgDeviceRegistrationDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/jg/JgDeviceRegistrationDto.java new file mode 100644 index 000000000..e267c1526 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/jg/JgDeviceRegistrationDto.java @@ -0,0 +1,18 @@ +package com.bsd.pjcloud.thirdparty.dto.jg; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 极光设备绑定或解绑请求,只允许客户端提交当前设备的 registrationId。 + */ +@Data +public class JgDeviceRegistrationDto { + + /** + * 极光 SDK 为当前安装实例分配的设备标识;为空表示设备尚未完成极光注册。 + */ + @NotBlank(message = "极光设备registrationId不能为空") + private String registrationId; + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/JgConfig.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/JgConfig.java index e92793fac..7c23bf7a8 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/JgConfig.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/JgConfig.java @@ -4,43 +4,72 @@ import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import java.util.List; + +/** + * 极光推送配置,统一承载消息推送和用户设备 alias 管理参数。 + */ @Component @ConfigurationProperties(prefix = "jg") @Data public class JgConfig { - + /** + * 极光推送应用及设备绑定配置。 + */ private JgPush jgPush; - + /** + * 极光推送应用配置和 alias 设备容量策略。 + */ @Data public static class JgPush { /** - * 极光推送key + * 极光应用 AppKey,由极光控制台分配。 */ private String appKey; /** - * 是否生产模式 + * 是否按生产 APNs 环境发送 iOS 通知。 */ private Boolean isProd; /** - * 极光推送secret + * 极光应用 MasterSecret,仅供服务端调用极光 API。 */ private String masterSecret; /** - * 通知默认大图标 + * Android 通知默认大图标地址。 */ private String largeIcon; /** - * 通知默认小图标 + * Android 通知默认小图标地址。 */ private String smallIconUri; + /** + * 设备最后在线时间过期阈值,单位天;严格早于该日期的设备才会清理。 + */ + private Integer staleDays = 30; + + /** + * 普通账号绑定完成后的最大设备数,默认保留一个极光硬上限余量。 + */ + private Integer maxBindings = 9; + + /** + * 测试账号绑定完成后的最大设备数,用于更积极地清理频繁重装产生的旧设备。 + */ + private Integer testMaxBindings = 3; + + /** + * 使用测试设备容量策略的用户ID列表,ID来源于系统用户主键。 + */ + private List testUserIds = List.of(); + } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/initconfig/JgConfiguration.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/initconfig/JgConfiguration.java index 527f0d26e..6b397c94a 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/initconfig/JgConfiguration.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/initconfig/JgConfiguration.java @@ -1,26 +1,37 @@ package com.bsd.pjcloud.thirdparty.config.initconfig; +import cn.jiguang.sdk.api.DeviceApi; import cn.jiguang.sdk.api.PushApi; -import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.config.JgConfig; -import com.github.binarywang.wxpay.config.WxPayConfig; -import com.github.binarywang.wxpay.service.WxPayService; -import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl; import feign.Logger; -import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -@Slf4j +/** + * 极光服务端 SDK Bean 配置,消息推送与设备管理共用同一套应用凭据。 + */ @Configuration public class JgConfiguration { + /** + * 极光应用配置,来源于 {@code jg.jg-push} 配置项。 + */ private final JgConfig jgConfig; + /** + * 创建极光 SDK 配置类。 + * + * @param jgConfig 极光应用配置 + */ public JgConfiguration(JgConfig jgConfig) { this.jgConfig = jgConfig; } + /** + * 构建极光消息推送客户端。 + * + * @return 使用现有 AppKey 和 MasterSecret 初始化的推送客户端 + */ @Bean public PushApi initJgPush() { JgConfig.JgPush jgPush = this.jgConfig.getJgPush(); @@ -31,4 +42,19 @@ public class JgConfiguration { .build(); } + /** + * 构建极光设备管理客户端,用于查询、绑定和精确解绑 alias 设备。 + * + * @return 使用现有 AppKey 和 MasterSecret 初始化的设备客户端 + */ + @Bean + public DeviceApi initJgDevice() { + JgConfig.JgPush jgPush = this.jgConfig.getJgPush(); + return new DeviceApi.Builder() + .setLoggerLevel(Logger.Level.FULL) + .setAppKey(jgPush.getAppKey()) + .setMasterSecret(jgPush.getMasterSecret()) + .build(); + } + } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/jg/ThirdJgDeviceController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/jg/ThirdJgDeviceController.java new file mode 100644 index 000000000..866ccd94c --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/jg/ThirdJgDeviceController.java @@ -0,0 +1,62 @@ +package com.bsd.pjcloud.thirdparty.controller.jg; + +import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.thirdparty.dto.jg.JgDeviceRegistrationDto; +import com.bsd.pjcloud.thirdparty.service.ThirdJgDeviceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * App 极光设备管理接口,用户 alias 始终取自当前 Token,禁止客户端指定其他用户。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/jg-push/device") +@Tag(description = "jg-push-device", name = "极光设备管理") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class ThirdJgDeviceController { + + /** + * 极光用户设备管理服务。 + */ + private final ThirdJgDeviceService thirdJgDeviceService; + + /** + * 将当前 App 安装实例绑定到当前登录用户,并在绑定前清理旧设备。 + * + * @param dto 当前设备 registrationId + * @return 无业务数据的成功响应 + */ + @Operation(summary = "绑定当前极光设备") + @ApiCryptoAes + @PostMapping("/bind") + public R bind(@Valid @RequestBody JgDeviceRegistrationDto dto) { + thirdJgDeviceService.bind(SecurityUtils.getUser().getId(), dto.getRegistrationId()); + return R.ok(); + } + + /** + * 仅解除当前 App 安装实例与当前登录用户的 alias 关系。 + * + * @param dto 当前设备 registrationId + * @return 无业务数据的成功响应 + */ + @Operation(summary = "解绑当前极光设备") + @ApiCryptoAes + @PostMapping("/unbind") + public R unbind(@Valid @RequestBody JgDeviceRegistrationDto dto) { + thirdJgDeviceService.unbind(SecurityUtils.getUser().getId(), dto.getRegistrationId()); + return R.ok(); + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppJgPushJob.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppJgPushJob.java index 2978926e3..3e126e34a 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppJgPushJob.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppJgPushJob.java @@ -1,26 +1,35 @@ package com.bsd.pjcloud.thirdparty.job; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.thirdparty.config.JgConfig; import com.bsd.pjcloud.thirdparty.dto.jg.JgPusDto; import com.bsd.pjcloud.thirdparty.service.ThirdJgPushService; -import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import java.util.List; - +/** + * App 极光推送定时任务,测试推送对象与设备清理策略共用同一份配置名单。 + */ @Component @Slf4j @RequiredArgsConstructor public class ThirdAppJgPushJob { + /** + * 极光消息推送服务。 + */ private final ThirdJgPushService thirdJgPushService; + /** + * 用户远程服务,供后续恢复全量用户推送任务时读取用户范围。 + */ private final RemoteUserService remoteUserService; - private final List testUserIdList = List.of(2029755089492066305L, 2034824566572941314L); + /** + * 极光配置,提供统一维护的测试用户ID列表。 + */ + private final JgConfig jgConfig; // @PostConstruct @@ -30,11 +39,17 @@ public class ThirdAppJgPushJob { //// pushToAllUsers("晚餐提醒", "晚餐时间就要到了,请尽快预约哦!!"); // } + /** + * 向配置中的测试用户发送定时提醒,避免测试名单在任务和设备策略中重复维护。 + * + * @param title 通知标题 + * @param subtitle 通知副标题 + */ private void pushToAllUsers(String title, String subtitle) { JgPusDto jgPusDto = new JgPusDto(); jgPusDto.setTitle(title); jgPusDto.setSubtitle(subtitle); - jgPusDto.setUserIds(testUserIdList); + jgPusDto.setUserIds(jgConfig.getJgPush().getTestUserIds()); thirdJgPushService.push(jgPusDto); } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdJgDeviceService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdJgDeviceService.java new file mode 100644 index 000000000..f9b64d6cc --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdJgDeviceService.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.thirdparty.service; + +/** + * 极光用户设备管理服务,负责服务端 alias 绑定、容量清理和精确解绑。 + */ +public interface ThirdJgDeviceService { + + /** + * 清理用户 alias 下的过期或超量设备后,绑定当前设备。 + * + * @param userId 当前认证用户ID,同时作为极光 alias + * @param registrationId 当前安装实例的极光设备标识 + */ + void bind(Long userId, String registrationId); + + /** + * 仅解除当前用户 alias 与指定设备的关系,不影响该用户的其他设备。 + * + * @param userId 当前认证用户ID,同时作为极光 alias + * @param registrationId 当前安装实例的极光设备标识 + */ + void unbind(Long userId, String registrationId); + +} 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 623e440f8..d02795d64 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 50054cd15..c5261a810 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 6e791f361..514da37b4 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-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/jg/ThirdJgDeviceServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/jg/ThirdJgDeviceServiceImpl.java new file mode 100644 index 000000000..8a9af6f46 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/jg/ThirdJgDeviceServiceImpl.java @@ -0,0 +1,516 @@ +package com.bsd.pjcloud.thirdparty.service.impl.jg; + +import cn.hutool.core.util.StrUtil; +import cn.jiguang.sdk.api.DeviceApi; +import cn.jiguang.sdk.bean.device.AliasDeleteParam; +import cn.jiguang.sdk.bean.device.AliasGetResult; +import cn.jiguang.sdk.bean.device.DeviceSetParam; +import cn.jiguang.sdk.exception.ApiErrorException; +import com.bsd.pjcloud.common.core.exception.CheckedException; +import com.bsd.pjcloud.thirdparty.config.JgConfig; +import com.bsd.pjcloud.thirdparty.service.ThirdJgDeviceService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; + +/** + * 极光用户设备管理实现,通过服务端 Device API 控制 alias 的设备数量并处理并发绑定。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ThirdJgDeviceServiceImpl implements ThirdJgDeviceService { + + /** + * 极光设备管理日志标签,日志中只输出脱敏 registrationId。 + */ + private static final String TAG = "极光设备管理"; + + /** + * 按用户 alias 隔离的 Redis 锁键前缀,租户命名空间由现有 Redis 基础设施处理。 + */ + private static final String ALIAS_LOCK_PREFIX = "thirdparty:jg-push:alias-lock:"; + + /** + * 极光官方 alias 设备数超过上限的业务错误码,仅该错误允许清理一台后重试绑定。 + */ + private static final int ALIAS_BINDING_LIMIT_ERROR_CODE = 7015; + + /** + * 单次绑定允许的最大清理轮数,避免极光返回数据不收敛时无限循环。 + */ + private static final int MAX_CLEANUP_ROUNDS = 20; + + /** + * 普通账号默认最大设备数,给极光 10 台硬上限保留一个位置。 + */ + private static final int DEFAULT_MAX_BINDINGS = 9; + + /** + * 测试账号默认最大设备数,限制频繁安装测试产生的历史设备。 + */ + private static final int DEFAULT_TEST_MAX_BINDINGS = 3; + + /** + * 默认过期清理阈值,单位天。 + */ + private static final int DEFAULT_STALE_DAYS = 30; + + /** + * 极光官方默认 alias 设备硬上限。 + */ + private static final int JG_ALIAS_HARD_LIMIT = 10; + + /** + * 同一 alias 获取分布式锁的最长等待时间。 + */ + private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(3); + + /** + * 未获取到锁时的重试间隔,避免高频访问 Redis。 + */ + private static final Duration LOCK_RETRY_INTERVAL = Duration.ofMillis(100); + + /** + * alias 设备操作锁租期,覆盖正常的极光多轮查询和删除调用。 + */ + private static final Duration LOCK_TTL = Duration.ofSeconds(60); + + /** + * 设备在线日期的业务时区,避免部署服务器使用 UTC 时改变 30 天清理边界。 + */ + private static final ZoneId BUSINESS_ZONE_ID = ZoneId.of("Asia/Shanghai"); + + /** + * 设备排序规则:在线日期为空的异常设备最旧,其余按日期和 registrationId 稳定排序。 + */ + private static final Comparator OLDEST_DEVICE_FIRST = Comparator + .comparing(AliasGetResult.Data::getLastOnlineDate, Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparing(AliasGetResult.Data::getRegistrationId, Comparator.nullsFirst(Comparator.naturalOrder())); + + /** + * 仅当锁值仍属于当前请求时释放锁,防止租期结束后误删后继请求的锁。 + */ + private static final DefaultRedisScript UNLOCK_SCRIPT = new DefaultRedisScript<>( + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", Long.class); + + /** + * 极光设备 API,负责查询、绑定及精确解绑 alias。 + */ + private final DeviceApi deviceApi; + + /** + * 极光应用及设备容量配置。 + */ + private final JgConfig jgConfig; + + /** + * Redis 字符串模板,用于同一用户多设备并发登录时串行执行清理和绑定。 + */ + private final StringRedisTemplate stringRedisTemplate; + + /** + * 清理用户 alias 下的过期或超量设备后绑定当前设备。 + * + * @param userId 当前认证用户ID,同时作为极光 alias + * @param registrationId 当前安装实例的极光设备标识 + */ + @Override + public void bind(Long userId, String registrationId) { + validateRequest(userId, registrationId); + String normalizedRegistrationId = registrationId.trim(); + executeWithAliasLock(userId, () -> { + try { + bindUnderLock(userId, normalizedRegistrationId); + } catch (ApiErrorException exception) { + logJgError("设备绑定流程失败", userId, normalizedRegistrationId, exception); + throw exception; + } + return null; + }); + } + + /** + * 精确解除当前用户 alias 和当前设备的关系,其他登录设备保持不变。 + * + * @param userId 当前认证用户ID,同时作为极光 alias + * @param registrationId 当前安装实例的极光设备标识 + */ + @Override + public void unbind(Long userId, String registrationId) { + validateRequest(userId, registrationId); + String normalizedRegistrationId = registrationId.trim(); + executeWithAliasLock(userId, () -> { + try { + deleteDevices(String.valueOf(userId), List.of(normalizedRegistrationId)); + } catch (ApiErrorException exception) { + logJgError("设备解绑流程失败", userId, normalizedRegistrationId, exception); + throw exception; + } + log.info("|{}|当前设备解绑完成,userId:|{}|,registrationId:|{}|", TAG, userId, maskRegistrationId(normalizedRegistrationId)); + return null; + }); + } + + /** + * 在已持有 alias 锁的前提下执行清理与绑定,并仅对 7015 上限错误做一次恢复重试。 + * + * @param userId 当前认证用户ID + * @param registrationId 当前设备标识 + */ + private void bindUnderLock(Long userId, String registrationId) { + String alias = String.valueOf(userId); + int maxBindings = resolveMaxBindings(userId); + CleanupResult cleanupResult = cleanupBeforeBind(userId, alias, registrationId, maxBindings); + int recoveryRemovedCount = 0; + + try { + setAlias(alias, registrationId); + } catch (ApiErrorException exception) { + if (!Objects.equals(extractErrorCode(exception), ALIAS_BINDING_LIMIT_ERROR_CODE)) { + throw exception; + } + AliasGetResult.Data oldestDevice = queryAliasDevices(alias).stream() + .filter(device -> !Objects.equals(registrationId, device.getRegistrationId())) + .min(OLDEST_DEVICE_FIRST) + .orElse(null); + if (oldestDevice == null) { + log.warn("|{}|极光返回7015但无可清理的非当前设备,userId:|{}|,registrationId:|{}|", + TAG, userId, maskRegistrationId(registrationId)); + throw exception; + } + deleteDevices(alias, List.of(oldestDevice.getRegistrationId())); + recoveryRemovedCount = 1; + log.warn("|{}|极光返回7015,额外清理最旧设备后重试,userId:|{}|,registrationId:|{}|,removedRegistrationId:|{}|", + TAG, userId, maskRegistrationId(registrationId), maskRegistrationId(oldestDevice.getRegistrationId())); + setAlias(alias, registrationId); + } + + int estimatedAfterCount = (cleanupResult.currentDeviceBound() + ? cleanupResult.remainingVisibleCount() + : cleanupResult.remainingVisibleCount() + 1) - recoveryRemovedCount; + log.info("|{}|设备绑定完成,userId:|{}|,registrationId:|{}|,visibleBefore:|{}|,visibleAfterCleanup:|{}|,estimatedAfterBind:|{}|,removed:|{}|,maxBindings:|{}|", + TAG, userId, maskRegistrationId(registrationId), cleanupResult.initialVisibleCount(), + cleanupResult.remainingVisibleCount(), Math.max(estimatedAfterCount, 0), + cleanupResult.removedCount() + recoveryRemovedCount, maxBindings); + } + + /** + * 按“过期优先、超量其次”多轮清理 alias,每轮删除后重新查询以处理极光最多返回 10 台的限制。 + * + * @param userId 当前认证用户ID + * @param alias 极光 alias + * @param registrationId 当前设备标识 + * @param maxBindings 绑定完成后的设备上限 + * @return 清理统计和绑定前最终可见设备状态 + */ + private CleanupResult cleanupBeforeBind(Long userId, String alias, String registrationId, int maxBindings) { + int staleDays = positiveOrDefault(jgConfig.getJgPush().getStaleDays(), DEFAULT_STALE_DAYS); + LocalDate staleCutoff = LocalDate.now(BUSINESS_ZONE_ID).minusDays(staleDays); + int maxOtherDevices = Math.max(0, maxBindings - 1); + int initialVisibleCount = -1; + int removedCount = 0; + Set previousIdsAfterDelete = null; + + for (int round = 1; round <= MAX_CLEANUP_ROUNDS; round++) { + List devices = queryAliasDevices(alias); + if (initialVisibleCount < 0) { + initialVisibleCount = devices.size(); + } + Set currentIds = registrationIds(devices); + if (previousIdsAfterDelete != null && previousIdsAfterDelete.equals(currentIds)) { + log.warn("|{}|清理后设备列表未变化,userId:|{}|,round:|{}|,visibleCount:|{}|", TAG, userId, round, devices.size()); + throw new CheckedException("极光旧设备清理未生效,请稍后重试"); + } + + boolean currentDeviceBound = currentIds.contains(registrationId); + List otherDevices = devices.stream() + .filter(device -> !Objects.equals(registrationId, device.getRegistrationId())) + .sorted(OLDEST_DEVICE_FIRST) + .toList(); + List staleRegistrationIds = otherDevices.stream() + .filter(device -> device.getLastOnlineDate() == null || device.getLastOnlineDate().isBefore(staleCutoff)) + .map(AliasGetResult.Data::getRegistrationId) + .filter(StrUtil::isNotBlank) + .toList(); + if (!staleRegistrationIds.isEmpty()) { + deleteDevices(alias, staleRegistrationIds); + removedCount += staleRegistrationIds.size(); + previousIdsAfterDelete = currentIds; + log.info("|{}|清理过期设备,userId:|{}|,round:|{}|,removed:|{}|,staleCutoff:|{}|", + TAG, userId, round, staleRegistrationIds.size(), staleCutoff); + continue; + } + + if (otherDevices.size() > maxOtherDevices) { + int removeCount = otherDevices.size() - maxOtherDevices; + List overflowRegistrationIds = otherDevices.stream() + .limit(removeCount) + .map(AliasGetResult.Data::getRegistrationId) + .filter(StrUtil::isNotBlank) + .toList(); + if (overflowRegistrationIds.isEmpty()) { + throw new CheckedException("极光超量设备缺少有效标识,无法清理"); + } + deleteDevices(alias, overflowRegistrationIds); + removedCount += overflowRegistrationIds.size(); + previousIdsAfterDelete = currentIds; + log.info("|{}|清理超量设备,userId:|{}|,round:|{}|,removed:|{}|,maxOtherDevices:|{}|", + TAG, userId, round, overflowRegistrationIds.size(), maxOtherDevices); + continue; + } + + return new CleanupResult(Math.max(initialVisibleCount, 0), devices.size(), removedCount, currentDeviceBound); + } + + log.warn("|{}|设备清理超过最大轮数,userId:|{}|,registrationId:|{}|,maxRounds:|{}|", + TAG, userId, maskRegistrationId(registrationId), MAX_CLEANUP_ROUNDS); + throw new CheckedException("极光旧设备清理次数超过限制,请稍后重试"); + } + + /** + * 查询 alias 当前可见设备并过滤无效、重复记录;极光默认最多返回 10 条。 + * + * @param alias 当前用户 alias + * @return registrationId 唯一的设备列表 + */ + private List queryAliasDevices(String alias) { + AliasGetResult result = deviceApi.getAlias(alias); + if (result == null || result.getData() == null) { + return List.of(); + } + Set seen = new HashSet<>(); + List devices = new ArrayList<>(); + for (AliasGetResult.Data device : result.getData()) { + if (device != null && StrUtil.isNotBlank(device.getRegistrationId()) && seen.add(device.getRegistrationId())) { + devices.add(device); + } + } + return devices; + } + + /** + * 将当前设备 alias 更新为登录用户ID。 + * + * @param alias 当前用户 alias + * @param registrationId 当前设备标识 + */ + private void setAlias(String alias, String registrationId) { + DeviceSetParam param = new DeviceSetParam(); + param.setAlias(alias); + deviceApi.setDevice(registrationId, param); + } + + /** + * 从指定 alias 批量移除设备关系,不会删除整个 alias 或影响未列出的设备。 + * + * @param alias 当前用户 alias + * @param registrationIds 需要解除关系的设备标识 + */ + private void deleteDevices(String alias, List registrationIds) { + List validRegistrationIds = registrationIds.stream().filter(StrUtil::isNotBlank).distinct().toList(); + if (validRegistrationIds.isEmpty()) { + return; + } + AliasDeleteParam.RegistrationIds registrationIdParam = new AliasDeleteParam.RegistrationIds(); + registrationIdParam.setRemove(validRegistrationIds); + AliasDeleteParam deleteParam = new AliasDeleteParam(); + deleteParam.setRegistrationIds(registrationIdParam); + deviceApi.deleteAliases(alias, deleteParam); + } + + /** + * 在当前用户 alias 的 Redis 锁中执行远程设备操作,同一账号串行、不同账号并行。 + * + * @param userId 当前认证用户ID + * @param operation 持锁期间执行的设备操作 + * @param 操作返回类型 + * @return 设备操作返回值 + */ + private T executeWithAliasLock(Long userId, Supplier operation) { + String lockKey = ALIAS_LOCK_PREFIX + userId; + String lockToken = UUID.randomUUID().toString(); + long deadline = System.nanoTime() + LOCK_WAIT_TIMEOUT.toNanos(); + boolean acquired = false; + while (!acquired && System.nanoTime() < deadline) { + Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockToken, LOCK_TTL); + acquired = Boolean.TRUE.equals(result); + if (!acquired) { + long remainingNanos = deadline - System.nanoTime(); + if (remainingNanos <= 0) { + break; + } + waitBeforeRetry(remainingNanos); + } + } + if (!acquired) { + throw new CheckedException("推送设备正在同步,请稍后重试"); + } + try { + return operation.get(); + } finally { + releaseLock(lockKey, lockToken); + } + } + + /** + * 等待下一次锁获取尝试;最后一次等待不超过三秒总期限,线程中断时恢复中断标志。 + * + * @param remainingNanos 距离锁等待截止时间的剩余纳秒数 + */ + private void waitBeforeRetry(long remainingNanos) { + try { + long waitNanos = Math.min(LOCK_RETRY_INTERVAL.toNanos(), remainingNanos); + long waitMillis = waitNanos / 1_000_000L; + int waitExtraNanos = (int) (waitNanos % 1_000_000L); + Thread.sleep(waitMillis, waitExtraNanos); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new CheckedException("推送设备同步已中断"); + } + } + + /** + * 使用随机令牌校验脚本释放 alias 锁,释放异常只记录日志,不覆盖设备操作结果。 + * + * @param lockKey alias 锁键 + * @param lockToken 当前请求锁令牌 + */ + private void releaseLock(String lockKey, String lockToken) { + try { + Long released = stringRedisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), lockToken); + if (!Objects.equals(released, 1L)) { + log.warn("|{}|alias锁未由当前请求释放,lockKey:|{}|,result:|{}|", TAG, lockKey, released); + } + } catch (Exception exception) { + log.warn("|{}|alias锁释放失败,lockKey:|{}|,error:|{}|", TAG, lockKey, exception.getMessage()); + } + } + + /** + * 校验服务调用参数,避免空用户或空设备标识进入极光 API。 + * + * @param userId 当前认证用户ID + * @param registrationId 当前设备标识 + */ + private void validateRequest(Long userId, String registrationId) { + if (userId == null) { + throw new CheckedException("当前登录用户无效"); + } + if (StrUtil.isBlank(registrationId)) { + throw new CheckedException("极光设备registrationId不能为空"); + } + } + + /** + * 根据测试用户配置解析最终容量,并强制限制在极光硬上限以内。 + * + * @param userId 当前认证用户ID + * @return 绑定完成后的最大设备数 + */ + private int resolveMaxBindings(Long userId) { + JgConfig.JgPush pushConfig = jgConfig.getJgPush(); + int normalLimit = Math.min(positiveOrDefault(pushConfig.getMaxBindings(), DEFAULT_MAX_BINDINGS), JG_ALIAS_HARD_LIMIT - 1); + List testUserIds = pushConfig.getTestUserIds(); + if (testUserIds != null && testUserIds.contains(userId)) { + return Math.min(positiveOrDefault(pushConfig.getTestMaxBindings(), DEFAULT_TEST_MAX_BINDINGS), normalLimit); + } + return normalLimit; + } + + /** + * 对可配置正整数提供安全默认值,防止空值或非法负数破坏清理策略。 + * + * @param configuredValue 配置中心读取值 + * @param defaultValue 内置默认值 + * @return 有效正整数 + */ + private int positiveOrDefault(Integer configuredValue, int defaultValue) { + return configuredValue != null && configuredValue > 0 ? configuredValue : defaultValue; + } + + /** + * 提取设备列表中的 registrationId 集合,用于判断当前设备和检测清理是否收敛。 + * + * @param devices 极光 alias 可见设备 + * @return 去重后的设备标识集合 + */ + private Set registrationIds(List devices) { + Set registrationIds = new HashSet<>(); + for (AliasGetResult.Data device : devices) { + if (device != null && StrUtil.isNotBlank(device.getRegistrationId())) { + registrationIds.add(device.getRegistrationId()); + } + } + return registrationIds; + } + + /** + * 从极光 SDK 异常中读取业务错误码,结构不完整时返回空值。 + * + * @param exception 极光 API 异常 + * @return 极光业务错误码,无法读取时为 {@code null} + */ + private Integer extractErrorCode(ApiErrorException exception) { + if (exception == null || exception.getApiError() == null || exception.getApiError().getError() == null) { + return null; + } + return exception.getApiError().getError().getCode(); + } + + /** + * 记录不含完整 registrationId 的极光错误,便于区分容量、鉴权和网络问题。 + * + * @param action 失败操作 + * @param userId 当前认证用户ID + * @param registrationId 当前设备标识 + * @param exception 极光 API 异常 + */ + private void logJgError(String action, Long userId, String registrationId, ApiErrorException exception) { + log.warn("|{}|{},userId:|{}|,registrationId:|{}|,code:|{}|,httpStatus:|{}|", + TAG, action, userId, maskRegistrationId(registrationId), extractErrorCode(exception), exception.getStats()); + } + + /** + * 脱敏极光设备标识,仅保留首尾四位用于定位同一设备。 + * + * @param registrationId 原始极光设备标识 + * @return 不可直接用于推送的脱敏标识 + */ + private String maskRegistrationId(String registrationId) { + if (StrUtil.isBlank(registrationId)) { + return ""; + } + String value = registrationId.trim(); + if (value.length() <= 8) { + return "****"; + } + return value.substring(0, 4) + "****" + value.substring(value.length() - 4); + } + + /** + * 单次绑定前清理结果,用于记录可见设备数量和当前设备是否已绑定。 + * + * @param initialVisibleCount 首轮查询可见设备数,最多为极光默认返回上限 10 + * @param remainingVisibleCount 清理结束时可见设备数 + * @param removedCount 本次累计解除的旧设备数量 + * @param currentDeviceBound 清理结束时当前设备是否已经绑定该 alias + */ + private record CleanupResult(int initialVisibleCount, int remainingVisibleCount, int removedCount, boolean currentDeviceBound) { + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/application.yml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/application.yml index a8d0317b0..9e005146a 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/application.yml +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/application.yml @@ -227,4 +227,10 @@ jg: master-secret: 2a253b41ac1eaa96558c56ef large-icon: https://public-com.zianxin.com/logo/60x60.png small-icon-uri: https://public-com.zianxin.com/logo/320x320.png - is-prod: true \ No newline at end of file + is-prod: true + stale-days: 30 + max-bindings: 9 + test-max-bindings: 3 + test-user-ids: + - 2029755089492066305 + - 2034824566572941314 diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/jg/ThirdJgDeviceServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/jg/ThirdJgDeviceServiceImplTest.java new file mode 100644 index 000000000..ffd59ce17 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/jg/ThirdJgDeviceServiceImplTest.java @@ -0,0 +1,365 @@ +package com.bsd.pjcloud.thirdparty.service.impl.jg; + +import cn.jiguang.sdk.api.DeviceApi; +import cn.jiguang.sdk.bean.device.AliasDeleteParam; +import cn.jiguang.sdk.bean.device.AliasGetResult; +import cn.jiguang.sdk.bean.device.DeviceSetParam; +import cn.jiguang.sdk.exception.ApiErrorException; +import com.bsd.pjcloud.thirdparty.config.JgConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.script.RedisScript; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 极光用户设备管理单元测试,使用模拟 DeviceApi 验证清理、容量、重试和精确解绑规则。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdJgDeviceServiceImplTest { + + /** + * 普通测试用户ID,不在测试账号配置名单中。 + */ + private static final Long NORMAL_USER_ID = 1001L; + + /** + * 测试账号用户ID,绑定完成后只允许保留三台设备。 + */ + private static final Long TEST_USER_ID = 2001L; + + /** + * 当前设备标识,所有清理场景均需保证不会删除该值。 + */ + private static final String CURRENT_REGISTRATION_ID = "current-registration-id"; + + /** + * 设备在线日期采用的业务时区,与生产清理逻辑保持一致。 + */ + private static final ZoneId BUSINESS_ZONE_ID = ZoneId.of("Asia/Shanghai"); + + /** + * 极光设备管理 SDK 模拟对象。 + */ + @Mock + private DeviceApi deviceApi; + + /** + * Redis 模板模拟对象,用于验证锁获取和安全释放。 + */ + @Mock + private StringRedisTemplate stringRedisTemplate; + + /** + * Redis ValueOperations 模拟对象,用于控制锁获取结果。 + */ + @Mock + private ValueOperations valueOperations; + + /** + * 每个测试使用的设备容量配置。 + */ + private JgConfig jgConfig; + + /** + * 被测试的极光设备管理服务。 + */ + private ThirdJgDeviceServiceImpl service; + + /** + * 初始化普通账号九台、测试账号三台及三十天过期策略,并默认让 Redis 锁立即成功。 + */ + @BeforeEach + void setUp() { + JgConfig.JgPush jgPush = new JgConfig.JgPush(); + jgPush.setStaleDays(30); + jgPush.setMaxBindings(9); + jgPush.setTestMaxBindings(3); + jgPush.setTestUserIds(List.of(TEST_USER_ID)); + jgConfig = new JgConfig(); + jgConfig.setJgPush(jgPush); + + lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + lenient().when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true); + service = new ThirdJgDeviceServiceImpl(deviceApi, jgConfig, stringRedisTemplate); + } + + /** + * 最后在线日期严格早于三十天边界时应删除,边界当天不删除。 + */ + @Test + void bindShouldDeleteOnlyDevicesStrictlyOlderThanThirtyDays() { + LocalDate cutoff = LocalDate.now(BUSINESS_ZONE_ID).minusDays(30); + when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID))) + .thenReturn(aliasResult(List.of(device("expired-device", cutoff.minusDays(1)), device("boundary-device", cutoff)))) + .thenReturn(aliasResult(List.of(device("boundary-device", cutoff)))); + + service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID); + + assertEquals(List.of("expired-device"), captureRemovedRegistrationIds().get(0)); + verify(deviceApi).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class)); + } + + /** + * 极光未返回最后在线日期的异常设备应按最旧设备优先清理。 + */ + @Test + void bindShouldTreatMissingLastOnlineDateAsOldest() { + when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID))) + .thenReturn(aliasResult(List.of(device("missing-date-device", null)))) + .thenReturn(aliasResult(List.of())); + + service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID); + + assertEquals(List.of("missing-date-device"), captureRemovedRegistrationIds().get(0)); + } + + /** + * 普通账号绑定新设备前最多保留八台旧设备,绑定完成后估算总量为九台。 + */ + @Test + void bindShouldKeepAtMostEightOldDevicesForNormalUser() { + List tenDevices = recentDevices("normal", 10); + when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID))) + .thenReturn(aliasResult(tenDevices)) + .thenReturn(aliasResult(tenDevices.subList(2, 10))); + + service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID); + + assertEquals(List.of("normal-0", "normal-1"), captureRemovedRegistrationIds().get(0)); + verify(deviceApi).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class)); + } + + /** + * 测试账号绑定新设备前最多保留两台旧设备,绑定完成后最多三台。 + */ + @Test + void bindShouldKeepAtMostTwoOldDevicesForTestUser() { + List fourDevices = recentDevices("test", 4); + when(deviceApi.getAlias(String.valueOf(TEST_USER_ID))) + .thenReturn(aliasResult(fourDevices)) + .thenReturn(aliasResult(fourDevices.subList(2, 4))); + + service.bind(TEST_USER_ID, CURRENT_REGISTRATION_ID); + + assertEquals(List.of("test-0", "test-1"), captureRemovedRegistrationIds().get(0)); + verify(deviceApi).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class)); + } + + /** + * 当前设备即使日期为空也不得进入过期或超量删除列表。 + */ + @Test + void bindShouldNeverDeleteCurrentDevice() { + List devices = new ArrayList<>(); + devices.add(device(CURRENT_REGISTRATION_ID, null)); + devices.addAll(recentDevices("other", 9)); + List afterCleanup = new ArrayList<>(); + afterCleanup.add(device(CURRENT_REGISTRATION_ID, null)); + afterCleanup.addAll(recentDevices("other", 8).subList(1, 8)); + afterCleanup.add(device("other-8", LocalDate.now(BUSINESS_ZONE_ID))); + when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID))) + .thenReturn(aliasResult(devices)) + .thenReturn(aliasResult(afterCleanup)); + + service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID); + + List removed = captureRemovedRegistrationIds().stream().flatMap(List::stream).toList(); + assertTrue(!removed.contains(CURRENT_REGISTRATION_ID)); + assertEquals(List.of("other-0"), removed); + } + + /** + * 当前设备已经绑定且总量未超限时不清理其他设备,重复绑定保持幂等。 + */ + @Test + void bindShouldBeIdempotentWhenCurrentDeviceAlreadyBound() { + List devices = new ArrayList<>(); + devices.add(device(CURRENT_REGISTRATION_ID, LocalDate.now(BUSINESS_ZONE_ID))); + devices.addAll(recentDevices("other", 8)); + when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID))).thenReturn(aliasResult(devices)); + + service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID); + + verify(deviceApi, never()).deleteAliases(anyString(), any(AliasDeleteParam.class)); + verify(deviceApi).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class)); + } + + /** + * alias 实际超过十台且查询只能返回十台时,应在删除后重新查询直至旧设备降到八台。 + */ + @Test + void bindShouldCleanAcrossMultipleTenItemQueries() { + LocalDate cutoff = LocalDate.now(BUSINESS_ZONE_ID).minusDays(30); + List firstTen = recentDevices("device", 10); + firstTen.set(0, device("expired-device", cutoff.minusDays(1))); + List nextTen = recentDevices("hidden", 10); + List finalEight = nextTen.subList(2, 10); + when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID))) + .thenReturn(aliasResult(firstTen)) + .thenReturn(aliasResult(nextTen)) + .thenReturn(aliasResult(finalEight)); + + service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID); + + List> removals = captureRemovedRegistrationIds(); + assertEquals(2, removals.size()); + assertEquals(List.of("expired-device"), removals.get(0)); + assertEquals(List.of("hidden-0", "hidden-1"), removals.get(1)); + } + + /** + * 退出解绑只应移除请求中的当前 registrationId,不得调用删除整个 alias 的接口。 + */ + @Test + void unbindShouldRemoveOnlyCurrentRegistrationId() { + service.unbind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID); + + assertEquals(List.of(CURRENT_REGISTRATION_ID), captureRemovedRegistrationIds().get(0)); + verify(deviceApi, never()).deleteAlias(anyString()); + } + + /** + * 极光返回官方 7015 上限错误时,应额外删除最旧非当前设备并只重试一次。 + */ + @Test + void bindShouldRemoveOneOldestDeviceAndRetryOnceFor7015() { + List initial = recentDevices("initial", 8); + List limitSnapshot = List.of( + device("oldest-device", LocalDate.now(BUSINESS_ZONE_ID).minusDays(10)), + device("newer-device", LocalDate.now(BUSINESS_ZONE_ID).minusDays(1))); + when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID))) + .thenReturn(aliasResult(initial)) + .thenReturn(aliasResult(limitSnapshot)); + doThrow(apiError(7015)).doNothing().when(deviceApi).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class)); + + service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID); + + assertEquals(List.of("oldest-device"), captureRemovedRegistrationIds().get(0)); + verify(deviceApi, times(2)).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class)); + } + + /** + * 鉴权、参数或其他极光错误不得通过误删旧设备恢复。 + */ + @Test + void bindShouldNotDeleteDevicesForNonLimitError() { + when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID))).thenReturn(aliasResult(recentDevices("device", 8))); + doThrow(apiError(7004)).when(deviceApi).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class)); + + assertThrows(ApiErrorException.class, () -> service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID)); + + verify(deviceApi, never()).deleteAliases(anyString(), any(AliasDeleteParam.class)); + verify(deviceApi, times(1)).setDevice(eq(CURRENT_REGISTRATION_ID), any(DeviceSetParam.class)); + } + + /** + * 首次锁竞争失败时应等待后重试,并在完成后使用 Lua 脚本安全释放锁。 + */ + @Test + void bindShouldRetryLockAndReleaseItWithTokenScript() { + when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false, true); + when(deviceApi.getAlias(String.valueOf(NORMAL_USER_ID))).thenReturn(aliasResult(List.of())); + + service.bind(NORMAL_USER_ID, CURRENT_REGISTRATION_ID); + + ArgumentCaptor lockTokenCaptor = ArgumentCaptor.forClass(String.class); + verify(valueOperations, times(2)).setIfAbsent(eq("thirdparty:jg-push:alias-lock:" + NORMAL_USER_ID), lockTokenCaptor.capture(), eq(Duration.ofSeconds(60))); + ArgumentCaptor unlockTokenCaptor = ArgumentCaptor.forClass(Object.class); + verify(stringRedisTemplate).execute(any(RedisScript.class), eq(List.of("thirdparty:jg-push:alias-lock:" + NORMAL_USER_ID)), unlockTokenCaptor.capture()); + assertEquals(lockTokenCaptor.getAllValues().get(1), unlockTokenCaptor.getValue()); + } + + /** + * 构建按传入日期排序的极光 alias 设备记录。 + * + * @param registrationId 设备标识 + * @param lastOnlineDate 最后在线日期,可为空 + * @return 极光设备记录 + */ + private AliasGetResult.Data device(String registrationId, LocalDate lastOnlineDate) { + AliasGetResult.Data data = new AliasGetResult.Data(); + data.setRegistrationId(registrationId); + data.setLastOnlineDate(lastOnlineDate); + return data; + } + + /** + * 构建指定数量的近期在线设备,索引越小日期越旧,便于断言删除顺序。 + * + * @param prefix registrationId 前缀 + * @param count 设备数量 + * @return 按日期从旧到新排列的设备列表 + */ + private List recentDevices(String prefix, int count) { + List devices = new ArrayList<>(); + LocalDate firstDate = LocalDate.now(BUSINESS_ZONE_ID).minusDays(Math.min(count, 20)); + for (int index = 0; index < count; index++) { + devices.add(device(prefix + "-" + index, firstDate.plusDays(index))); + } + return devices; + } + + /** + * 构建极光查询 alias 的返回对象。 + * + * @param devices 可见设备列表 + * @return 极光 alias 查询结果 + */ + private AliasGetResult aliasResult(List devices) { + AliasGetResult result = new AliasGetResult(); + result.setData(devices); + return result; + } + + /** + * 构建带指定业务错误码的极光 SDK 异常。 + * + * @param code 极光业务错误码 + * @return 可由 DeviceApi 模拟抛出的异常 + */ + private ApiErrorException apiError(int code) { + ApiErrorException.ApiError.Error error = new ApiErrorException.ApiError.Error(); + error.setCode(code); + error.setMessage("mock error"); + ApiErrorException.ApiError apiError = new ApiErrorException.ApiError(); + apiError.setError(error); + return new ApiErrorException(400, apiError); + } + + /** + * 捕获所有批量解绑请求中的 registrationId,供测试断言删除范围和顺序。 + * + * @return 每次极光删除调用对应的设备标识列表 + */ + private List> captureRemovedRegistrationIds() { + ArgumentCaptor captor = ArgumentCaptor.forClass(AliasDeleteParam.class); + verify(deviceApi, org.mockito.Mockito.atLeastOnce()).deleteAliases(anyString(), captor.capture()); + return captor.getAllValues().stream().map(param -> param.getRegistrationIds().getRemove()).toList(); + } + +} 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 7d4fc26ac..b504967bf 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/SysUserDeleteDto.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserDeleteDto.java new file mode 100644 index 000000000..3db286107 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserDeleteDto.java @@ -0,0 +1,27 @@ +package com.bsd.pjcloud.admin.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 删除用户请求项。 + *

流程信息来自用户列表快照,后端删除时仍以数据库中的实时审批状态和流程ID为准。

+ */ +@Data +@Schema(description = "删除用户请求项") +public class SysUserDeleteDto { + + /** + * 待删除的系统用户ID。 + */ + @NotNull(message = "用户ID不能为空") + @Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private Long userId; + + /** + * 前端列表中的审批流程实例ID快照;允许为空,不作为后端取消流程的可信数据源。 + */ + @Schema(description = "审批流程实例ID快照") + private String processId; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceGuardianWardCreateDto.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceGuardianWardCreateDto.java index 1f948a89d..f78ee34d8 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceGuardianWardCreateDto.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysUserFaceGuardianWardCreateDto.java @@ -10,14 +10,14 @@ import lombok.Data; import java.util.List; /** - * 监护人代被监护人提交人脸审核。 + * 当前用户为本人或有效被监护人提交人脸审核。 */ @Data -@Schema(description = "监护人代被监护人提交人脸审核") +@Schema(description = "为本人或有效被监护人提交人脸审核") public class SysUserFaceGuardianWardCreateDto { - @NotNull(message = "被监护人不能为空") - @Schema(description = "被监护人用户ID") + @NotNull(message = "目标用户不能为空") + @Schema(description = "本人或有效被监护人用户ID") private Long wardUserId; @NotEmpty(message = "请上传人脸图片") 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 10a4427b0..eb9b87f65 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 df9040d22..71d6542c7 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 000000000..14110d1ba --- /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 000000000..a274e34eb --- /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/feign/RemoteUserRelationService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserRelationService.java index 063c72bb5..50cddcdb3 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserRelationService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserRelationService.java @@ -24,4 +24,14 @@ public interface RemoteUserRelationService { @NoToken @GetMapping("/sysUserRelation/inner/guardian-ward/user-ids") R> listActiveGuardianWardUserIds(@RequestParam("guardianUserId") Long guardianUserId); + + /** + * 查询指定用户所在完整关系图谱中的其他用户 ID。 + * + * @param userId 当前用户 ID + * @return 同一关系图谱中的其他用户 ID 列表,不包含当前用户自身 + */ + @NoToken + @GetMapping("/sysUserRelation/inner/related-user/user-ids") + R> listRelatedUserIds(@RequestParam("userId") Long userId); } diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/GuardianUserExcelVO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/GuardianUserExcelVO.java new file mode 100644 index 000000000..273151f54 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/GuardianUserExcelVO.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.admin.api.vo; + +import com.alibaba.excel.annotation.ExcelIgnore; +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import com.bsd.pjcloud.common.excel.annotation.ExcelLine; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +/** + * 学生家长用户导入模板对应的数据模型。 + *

手机号同时作为登录账号和初始密码;姓名允许为空,导入时按“手机号 + 学生家长”生成默认姓名。

+ */ +@Data +@ColumnWidth(30) +public class GuardianUserExcelVO { + + /** Excel 原始行号,仅用于向调用端定位导入错误,不参与持久化。 */ + @ExcelLine + @ExcelIgnore + private Long lineNum; + + /** 学生家长姓名;为空时由导入服务按手机号生成默认姓名。 */ + @ExcelProperty("姓名") + private String name; + + /** 学生家长手机号,同时作为登录账号、默认身份手机号和初始密码。 */ + @NotBlank(message = "手机号不能为空") + @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") + @ExcelProperty("手机号") + private String phone; + + /** 所属部门名称或路径,必须匹配当前租户已有部门。 */ + @NotBlank(message = "部门不能为空") + @ExcelProperty("部门") + private String deptName; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceGuardianWardWithdrawVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceGuardianWardWithdrawVo.java index 275e0b5c7..1320886fa 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceGuardianWardWithdrawVo.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceGuardianWardWithdrawVo.java @@ -4,22 +4,22 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; /** - * 被监护人人脸审核撤回结果。 + * 本人或被监护人人脸审核撤回结果。 */ @Data -@Schema(description = "被监护人人脸审核撤回结果") +@Schema(description = "本人或被监护人人脸审核撤回结果") public class SysUserFaceGuardianWardWithdrawVo { /** - * 被监护人用户ID + * 目标用户ID */ - @Schema(description = "被监护人用户ID") + @Schema(description = "本人或有效被监护人用户ID") private Long wardUserId; /** - * 被监护人姓名 + * 目标用户姓名 */ - @Schema(description = "被监护人姓名") + @Schema(description = "本人或有效被监护人姓名") private String wardName; /** 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 000000000..d0500a440 --- /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 172a44b77..d2ee612e1 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 be91180d0..d21b51e61 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 1f63663ee..fd1055989 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 @@ -27,6 +27,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.admin.api.dto.ForgotPasswordDTO; +import com.bsd.pjcloud.admin.api.dto.SysUserDeleteDto; import com.bsd.pjcloud.admin.api.dto.UserDTO; import com.bsd.pjcloud.admin.api.dto.UserDeptSimpleInfo; import com.bsd.pjcloud.admin.api.dto.UserInfo; @@ -45,6 +46,7 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.excel.annotation.RequestExcel; import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; +import com.bsd.pjcloud.common.idempotent.annotation.Idempotent; import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.annotation.HasPermission; import com.bsd.pjcloud.common.security.annotation.Inner; @@ -93,6 +95,9 @@ public class SysUserController { private final SysUserService userService; + /** 用户 Excel 导入导出服务,统一承接用户模块的文件转换与导入编排。 */ + private final SysUserImportExportService userImportExportService; + private final RemoterVisitorService remoterVisitorService; private final SysDeptService sysDeptService; @@ -247,17 +252,24 @@ public class SysUserController { } /** - * 删除用户信息 + * 删除用户信息;审核中的用户会先取消当前审批流程。 * - * @param ids ID - * @return R + * @param users 待删除用户及其前端流程快照 + * @return 删除结果;请求为空或删除前置校验失败时返回失败信息 */ @SysLog("删除用户信息") @DeleteMapping @HasPermission("sys_user_del") - @Operation(summary = "删除用户", description = "根据ID删除用户") - public R userDel(@RequestBody Long[] ids) { - return R.ok(userService.deleteUserByIds(ids)); + @Operation(summary = "删除用户", description = "根据ID删除用户,审核中的用户会先取消审批流程") + public R userDel(@Valid @RequestBody List users) { + if (CollUtil.isEmpty(users)) { + return R.failed("请选择要删除的用户"); + } + if (users.stream().anyMatch(user -> user == null || user.getUserId() == null)) { + return R.failed("用户ID不能为空"); + } + Long[] userIds = users.stream().map(SysUserDeleteDto::getUserId).distinct().toArray(Long[]::new); + return R.ok(userService.deleteUserByIds(userIds)); } /** @@ -389,40 +401,88 @@ public class SysUserController { } /** - * 导出excel 表格 + * 按当前数据权限导出用户 Excel。 * - * @param userDTO 查询条件 - * @return + * @param userDTO 用户筛选条件 + * @return 可由响应转换器直接写入 Excel 的用户数据 */ @ResponseExcel @PostMapping("/export") @HasPermission("sys_user_export") - public List export(@RequestBody UserDTO userDTO) { - return userService.listUser(userDTO, null); + public List 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 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 */ @PostMapping("/import-student") @HasPermission("sys_student_user_export") - public R importStudentUser(@RequestExcel List studentUserExcelList, BindingResult bindingResult) { - return userService.importStudentUser(studentUserExcelList, bindingResult); + @Idempotent(expireTime = 5, key = "'/user/import-student:' + #studentUserExcelList.hashCode() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()", info = "正在导入中,请勿重复操作") + public R importStudentUser(@RequestExcel List studentUserExcelList, BindingResult bindingResult) { + return userImportExportService.importStudentUser(studentUserExcelList, bindingResult); } + /** + * 导入学生家长用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 + *

手机号和部门必填;姓名为空时按“手机号 + 学生家长”生成,手机号同时作为登录账号和初始密码。

+ * + * @param guardianUserExcelList Excel 中解析出的学生家长用户数据 + * @param bindingResult Excel 通用校验产生的错误信息 + * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 + */ + @PostMapping("/import-guardian") + @HasPermission("sys_guardian_user_export") + @Idempotent(expireTime = 5, key = "'/user/import-guardian:' + #guardianUserExcelList.hashCode() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()", info = "正在导入中,请勿重复操作") + public R importGuardianUser(@RequestExcel(ignoreEmptyRow = true) List guardianUserExcelList, BindingResult bindingResult) { + return userImportExportService.importGuardianUser(guardianUserExcelList, bindingResult); + } + + /** + * 导入老师用户,接口同步校验整份 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 校验失败时包含校验或图片处理错误;校验通过时包含异步任务 ID 和初始进度 + */ @PostMapping("/import-face") @HasPermission("sys_user_export") - public R importUserFace(@RequestParam("file") MultipartFile file, @RequestExcel List excelList, BindingResult bindingResult) { - return userService.importUserFace(file, excelList, bindingResult); + @Idempotent(expireTime = 5, key = "'/user/import-face:' + #file.getSize() + ':' + #excelList.hashCode() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()", info = "正在导入中,请勿重复操作") + public R importUserFace(@RequestParam("file") MultipartFile file, @RequestExcel List 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 bc1323cee..c6b290340 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,10 +107,10 @@ public class SysUserFaceController { } /** - * 监护人代被监护人提交人脸审核。 + * 当前用户为本人或有效被监护人提交人脸,是否审核由提交人角色决定。 */ - @Operation(summary = "监护人提交被监护人人脸", description = "仅监护关系中的监护人可提交,所有图片必须进入审核流程") - @SysLog("监护人提交被监护人人脸") + @Operation(summary = "提交本人或被监护人人脸", description = "仅可为本人或有效被监护人提交,拥有指定角色时进入审核流程,否则直接通过") + @SysLog("提交本人或被监护人人脸") @ApiCryptoAes @PostMapping("/app/guardian-ward") @Idempotent(expireTime = 10, info = "正在处理中,请勿重复操作") @@ -129,9 +129,9 @@ public class SysUserFaceController { } /** - * 分页查询当前监护人名下被监护人的人脸审核记录。 + * 分页查询当前用户的有效被监护人,或显式指定的本人/有效被监护人人脸审核记录。 */ - @Operation(summary = "查询被监护人人脸审核记录", description = "查询当前用户全部有效被监护人的审核记录,并返回被监护人姓名") + @Operation(summary = "查询本人或被监护人人脸审核记录", description = "不指定目标时查询全部有效被监护人,指定目标时允许本人或有效被监护人") @ApiCryptoAes @GetMapping("/app/guardian-ward/page") public R> pageGuardianWardFaces(@ParameterObject Page page, @RequestParam(required = false) Long wardUserId, @RequestParam(required = false) String processStatus) { @@ -139,9 +139,9 @@ public class SysUserFaceController { } /** - * 查询当前监护人名下被监护人的人脸审核详情。 + * 查询当前用户本人或其有效被监护人的人脸审核详情。 */ - @Operation(summary = "查询被监护人人脸审核详情", description = "仅被监护人的有效监护人可以查看") + @Operation(summary = "查询本人或被监护人人脸审核详情", description = "仅本人或目标用户的有效监护人可以查看") @ApiCryptoAes @GetMapping("/app/guardian-ward/detail") public R getGuardianWardFaceDetail(@RequestParam Long id) { @@ -149,10 +149,10 @@ public class SysUserFaceController { } /** - * 撤回并删除当前监护人名下被监护人仍在审核中的人脸记录。 + * 撤回并删除当前用户本人或其有效被监护人仍在审核中的人脸记录。 */ - @Operation(summary = "撤回被监护人人脸审核", description = "仅审核中的记录可以撤回,撤回后删除旧记录并返回原图供重新编辑") - @SysLog("撤回被监护人人脸审核") + @Operation(summary = "撤回本人或被监护人人脸审核", description = "仅审核中的记录可以撤回,撤回后删除旧记录并返回原图供重新编辑") + @SysLog("撤回本人或被监护人人脸审核") @ApiCryptoAes @PutMapping("/app/guardian-ward/cancel") @Idempotent(expireTime = 3, info = "正在处理中,请勿重复操作") diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java index ee5e8a819..1ac15b4b7 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java @@ -63,6 +63,18 @@ public class SysUserRelationController { return R.ok(sysUserRelationService.listActiveGuardianWardUserIds(guardianUserId)); } + /** + * 内部查询指定用户所在完整关系图谱中的其他用户 ID。 + * + * @param userId 当前用户 ID + * @return 同一关系图谱中的其他用户 ID 列表,不包含当前用户自身 + */ + @Inner + @GetMapping("/inner/related-user/user-ids") + public R> listRelatedUserIds(@RequestParam Long userId) { + return R.ok(sysUserRelationService.listRelatedUserIds(userId)); + } + /** * 查询用户关系图谱 */ 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 0b0fde49c..5ef16abb7 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 000000000..b66ce968c --- /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 fb2657cd1..7b376eadc 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); @@ -36,17 +39,17 @@ public interface SysUserFaceService extends IService { List listStatusCounts(List userIds); /** - * 分页查询当前监护人名下全部或指定被监护人的人脸审核记录。 + * 分页查询当前用户的有效被监护人,或显式指定的本人/有效被监护人人脸审核记录。 */ Page pageGuardianWardFaces(Page page, Long wardUserId, String processStatus); /** - * 查询当前监护人名下被监护人的人脸审核详情。 + * 查询当前用户本人或其有效被监护人的人脸审核详情。 */ SysUserFaceVo getGuardianWardFaceDetail(Long id); /** - * 撤回并删除当前监护人名下被监护人仍在审核中的人脸记录。 + * 撤回并删除当前用户本人或其有效被监护人仍在审核中的人脸记录。 */ SysUserFaceGuardianWardWithdrawVo cancelGuardianWardFace(Long id); 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 000000000..f6a1f0c36 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserImportExportService.java @@ -0,0 +1,75 @@ +package com.bsd.pjcloud.admin.service; + +import com.bsd.pjcloud.admin.api.dto.UserDTO; +import com.bsd.pjcloud.admin.api.vo.GuardianUserExcelVO; +import com.bsd.pjcloud.admin.api.vo.StudentUserExcelVO; +import com.bsd.pjcloud.admin.api.vo.TeacherUserExcelVO; +import com.bsd.pjcloud.admin.api.vo.UserExcelFaceVo; +import com.bsd.pjcloud.admin.api.vo.UserExcelVO; +import com.bsd.pjcloud.common.core.util.R; +import org.springframework.validation.BindingResult; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 用户模块导入导出服务。 + *

统一承接后台用户、学生、学生家长、老师和人脸 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 guardianUserExcelList Excel 解析出的学生家长数据 + * @param bindingResult Excel 通用校验结果,错误目标包含原始行号 + * @return 校验失败时返回对应行号和原因;校验通过时返回可供轮询的异步任务进度 + */ + R importGuardianUser(List guardianUserExcelList, 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/SysUserRelationService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java index db3304404..b347d3f8a 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java @@ -37,6 +37,14 @@ public interface SysUserRelationService extends IService */ List listActiveGuardianWardUserIds(Long guardianUserId); + /** + * 查询指定用户所在完整关系图谱中的其他用户 ID。 + * + * @param userId 当前用户 ID + * @return 同一关系图谱中的其他用户 ID 列表,不包含当前用户自身 + */ + List listRelatedUserIds(Long userId); + /** * 查询指定监护人当前有效的被监护人选择项。 * 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 6e2394041..84380c101 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; /** @@ -64,10 +61,10 @@ public interface SysUserService extends IService { IPage innerPage(Page page, UserDTO userDTO); /** - * 删除用户 + * 删除指定用户,并在删除前取消其中仍处于审核中的审批流程。 * - * @param ids 用户 - * @return boolean + * @param ids 待删除的用户ID数组,不能为空 + * @return 用户及其关联数据进入删除流程后返回 {@code true} */ Boolean deleteUserByIds(Long[] ids); @@ -106,23 +103,6 @@ public interface SysUserService extends IService { */ 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 000000000..db6d92e38 --- /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 1b2233575..731d37971 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; @@ -72,9 +74,9 @@ public class SysUserFaceServiceImpl extends ServiceImpl GUARDIAN_WARD_VISIBLE_STATUSES = List.of( + private static final List APP_RELATION_FACE_VISIBLE_STATUSES = List.of( TaskCheckStatusEnum.PADDING.getCode(), TaskCheckStatusEnum.RESULT.getCode(), TaskCheckStatusEnum.REJECT.getCode() @@ -90,6 +92,12 @@ public class SysUserFaceServiceImpl extends ServiceImpl(dto.getFaceImgs()).size() != dto.getFaceImgs().size()) { throw new RuntimeException("请勿重复提交同一张人脸图片"); } - SysUser guardianUser = sysUserService.getById(loginUser.getId()); - if (guardianUser == null) { - throw new RuntimeException("监护人用户不存在"); + SysUser applyUser = sysUserService.getById(loginUser.getId()); + if (applyUser == null) { + throw new RuntimeException("当前用户不存在"); } if (sysUserService.getById(dto.getWardUserId()) == null) { - throw new RuntimeException("被监护人用户不存在"); + throw new RuntimeException("目标用户不存在"); } - if (!sysUserRelationService.hasActiveGuardianWardRelation(loginUser.getId(), dto.getWardUserId())) { - throw new RuntimeException("当前用户不是该用户的有效监护人,无法代为上传人脸"); + validateSelfOrGuardianWardUser(loginUser.getId(), dto.getWardUserId()); + boolean needFaceApproval = needFaceApproval(loginUser.getId()); + if (needFaceApproval && sysDeptService.getById(applyUser.getDeptId()) == null) { + throw new RuntimeException("当前用户所属部门不存在,无法发起人脸审核"); } - if (sysDeptService.getById(guardianUser.getDeptId()) == null) { - throw new RuntimeException("监护人所属部门不存在,无法发起人脸审核"); - } - validateGuardianWardFaceLimit(dto.getWardUserId(), dto.getFaceImgs().size()); + validateAppRelationFaceLimit(dto.getWardUserId(), dto.getFaceImgs().size()); List faceIds = new ArrayList<>(); for (String faceImg : dto.getFaceImgs()) { SysUserFaceEntity userFace = new SysUserFaceEntity(); userFace.setUserId(dto.getWardUserId()); userFace.setFaceImg(faceImg); - userFace.setProcessStatus(TaskCheckStatusEnum.PADDING.getCode()); + if (needFaceApproval) { + userFace.setProcessStatus(TaskCheckStatusEnum.PADDING.getCode()); + } else { + userFace.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode()); + userFace.setProcessTime(LocalDateTime.now()); + } baseMapper.insert(userFace); updateFaceAutoCode(userFace); - getSelf().applyFlow(guardianUser, userFace); + if (needFaceApproval) { + getSelf().applyFlow(applyUser, userFace); + } faceIds.add(userFace.getId()); } + if (!needFaceApproval) { + sendFaceMq(List.of(dto.getWardUserId()), null); + } return faceIds; } + /** + * 判断指定提交人上传人脸时是否必须审批;审批规则只认专用角色,不再从其他管理身份推断。 + * + * @param applyUserId 人脸提交人的用户ID + * @return 拥有添加人脸审批角色时返回 {@code true},否则返回 {@code false} + */ + private boolean needFaceApproval(Long applyUserId) { + List applyUserRoles = sysRoleService.findRolesByUserId(applyUserId); + return ObjectUtil.isNotEmpty(applyUserRoles) && applyUserRoles.stream() + .anyMatch(role -> ObjectUtil.equals(role.getRoleCode(), CommonRoleConstant.ADD_FACE_NEED_CEHCK)); + } + /** * 查询当前监护人名下可选择的有效被监护人,并仅返回页面展示所需字段。 * @@ -203,37 +232,35 @@ public class SysUserFaceServiceImpl extends ServiceImpl pageGuardianWardFaces(Page page, Long wardUserId, String processStatus) { - Long guardianUserId = SecurityUtils.getUser().getId(); - List wardUserIds = sysUserRelationService.listActiveGuardianWardUserIds(guardianUserId); - if (wardUserIds.isEmpty()) { - return new Page<>(page.getCurrent(), page.getSize()); - } - if (wardUserId != null && !wardUserIds.contains(wardUserId)) { - throw new RuntimeException("当前用户不是该用户的有效监护人,无权查看人脸审核记录"); - } + Long loginUserId = SecurityUtils.getUser().getId(); + List wardUserIds = sysUserRelationService.listActiveGuardianWardUserIds(loginUserId); SysUserFaceDto query = new SysUserFaceDto(); if (wardUserId == null) { + if (wardUserIds.isEmpty()) { + return new Page<>(page.getCurrent(), page.getSize()); + } query.setUserIds(wardUserIds); } else { + validateSelfOrGuardianWardUser(loginUserId, wardUserId); query.setUserId(wardUserId); } if (StrUtil.isNotBlank(processStatus)) { - if (!GUARDIAN_WARD_VISIBLE_STATUSES.contains(processStatus)) { + if (!APP_RELATION_FACE_VISIBLE_STATUSES.contains(processStatus)) { throw new RuntimeException("不支持查询该人脸审核状态"); } query.setProcessStatus(processStatus); } else { - query.setProcessStatuses(GUARDIAN_WARD_VISIBLE_STATUSES); + query.setProcessStatuses(APP_RELATION_FACE_VISIBLE_STATUSES); } return baseMapper.pageJoin(page, query); } /** - * 查询当前监护人名下被监护人的人脸审核详情。 + * 查询当前用户本人或其有效被监护人的人脸审核详情。 * * @param id 人脸审核记录ID * @return 人脸审核详情 @@ -244,15 +271,15 @@ public class SysUserFaceServiceImpl extends ServiceImpl wardUserIds = sysUserRelationService.listActiveGuardianWardUserIds(guardianUserId); - if (!wardUserIds.contains(wardUserId)) { - throw new RuntimeException("当前用户不是该用户的有效监护人,无权查看或操作人脸审核记录"); + private void validateSelfOrGuardianWardUser(Long loginUserId, Long targetUserId) { + if (Objects.equals(loginUserId, targetUserId)) { + return; + } + if (!sysUserRelationService.hasActiveGuardianWardRelation(loginUserId, targetUserId)) { + throw new RuntimeException("仅可查看或操作本人及有效被监护人的人脸审核记录"); } } @@ -312,11 +344,14 @@ public class SysUserFaceServiceImpl extends ServiceImpl() - .eq(SysUserFaceEntity::getUserId, wardUserId) + .eq(SysUserFaceEntity::getUserId, targetUserId) .in(SysUserFaceEntity::getProcessStatus, TaskCheckStatusEnum.PADDING.getCode(), TaskCheckStatusEnum.RESULT.getCode())); if (count + uploadCount > 3) { 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 000000000..02dafddd8 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java @@ -0,0 +1,916 @@ +package com.bsd.pjcloud.admin.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Validator; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.bsd.pjcloud.admin.api.constant.UserStaffingTypeEnum; +import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum; +import com.bsd.pjcloud.admin.api.constant.user.UserRelationTypeEnum; +import com.bsd.pjcloud.admin.api.dto.UserDTO; +import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationCreateUserDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.entity.SysDept; +import com.bsd.pjcloud.admin.api.entity.SysPost; +import com.bsd.pjcloud.admin.api.entity.SysRole; +import com.bsd.pjcloud.admin.api.entity.SysUser; +import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity; +import com.bsd.pjcloud.admin.api.entity.user.SysUserRelationTypeEntity; +import com.bsd.pjcloud.admin.api.entity.user.SysUserStudentEntity; +import com.bsd.pjcloud.admin.api.entity.user.SysUserTeacherEntity; +import com.bsd.pjcloud.admin.api.util.PersonNameUtils; +import com.bsd.pjcloud.admin.api.vo.GuardianUserExcelVO; +import com.bsd.pjcloud.admin.api.vo.StudentUserExcelVO; +import com.bsd.pjcloud.admin.api.vo.TeacherUserExcelVO; +import com.bsd.pjcloud.admin.api.vo.UserExcelFaceVo; +import com.bsd.pjcloud.admin.api.vo.UserExcelVO; +import com.bsd.pjcloud.admin.api.vo.UserVO; +import com.bsd.pjcloud.admin.mapper.SysUserMapper; +import com.bsd.pjcloud.admin.mapper.user.SysUserStudentMapper; +import com.bsd.pjcloud.admin.mapper.user.SysUserTeacherMapper; +import com.bsd.pjcloud.admin.service.SysDeptService; +import com.bsd.pjcloud.admin.service.SysFileService; +import com.bsd.pjcloud.admin.service.SysPostService; +import com.bsd.pjcloud.admin.service.SysRoleService; +import com.bsd.pjcloud.admin.service.SysUserFaceService; +import com.bsd.pjcloud.admin.service.SysUserImportExportService; +import com.bsd.pjcloud.admin.service.SysUserRelationService; +import com.bsd.pjcloud.admin.service.SysUserRelationTypeService; +import com.bsd.pjcloud.admin.service.SysUserService; +import com.bsd.pjcloud.admin.utils.excel.ExcelTool; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.core.constant.CommonConstants; +import com.bsd.pjcloud.common.core.constant.CommonRoleConstant; +import com.bsd.pjcloud.common.core.exception.ErrorCodes; +import com.bsd.pjcloud.common.core.util.MsgUtils; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.resolver.ParamResolver; +import com.bsd.pjcloud.common.excel.vo.ErrorMessage; +import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.PictureData; +import org.springframework.beans.BeanUtils; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.stereotype.Service; +import org.springframework.validation.BindingResult; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 用户模块导入导出服务实现。 + *

本类只负责 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 GUARDIAN_USER_IMPORT_REDIS_KEY = "user:import:guardian"; + + /** 老师用户导入任务的业务 Redis Key 命名空间。 */ + private static final String TEACHER_USER_IMPORT_REDIS_KEY = "user:import:teacher"; + + /** 带人脸图片的后台用户导入任务业务 Redis Key 命名空间。 */ + private static final String FACE_USER_IMPORT_REDIS_KEY = "user:import:face"; + + /** 用户查询 Mapper,用于应用数据权限生成导出数据。 */ + private final SysUserMapper sysUserMapper; + + /** 用户核心服务,用于用户查询、账号生成和完整用户创建。 */ + private final SysUserService sysUserService; + + /** 部门服务,用于加载当前租户的部门树并解析模板中的部门名称或路径。 */ + private final SysDeptService sysDeptService; + + /** 角色服务,用于解析普通导入角色和身份导入固定角色。 */ + private final SysRoleService sysRoleService; + + /** 岗位服务,用于解析普通用户导入模板中的岗位名称。 */ + private final SysPostService sysPostService; + + /** 学生 Mapper,用于批量检查同一学校下的重复学号。 */ + private final SysUserStudentMapper sysUserStudentMapper; + + /** 老师 Mapper,用于批量检查当前租户中已存在的老师学号。 */ + private final SysUserTeacherMapper sysUserTeacherMapper; + + /** 用户关系类型服务,用于获取当前租户启用的监护关系类型。 */ + private final SysUserRelationTypeService sysUserRelationTypeService; + + /** 用户关系服务,用于在同一事务入口创建学生用户及监护关系。 */ + private final SysUserRelationService sysUserRelationService; + + /** 文件服务,用于保存人脸导入模板中提取出的图片。 */ + private final SysFileService sysFileService; + + /** 用户人脸服务,用于按现有人脸审批规则创建人脸记录。 */ + private final SysUserFaceService sysUserFaceService; + + /** 通用异步事务任务执行器,用于在同步整表校验通过后后台统一落库并报告进度。 */ + private final AsyncTaskExecutorService asyncTaskExecutorService; + + /** + * 按查询条件和数据权限生成后台用户导出数据。 + * + * @param userDTO 用户筛选条件 + * @param ids 指定导出的用户 ID;为空时按筛选条件导出 + * @return 可由 Excel 响应转换器直接输出的用户列表 + */ + @Override + public List 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 guardianUserExcelList Excel 解析出的学生家长数据 + * @param bindingResult Excel 通用校验结果 + * @return 导入结果;失败时返回对应行号和原因 + */ + @Override + public R importGuardianUser(List guardianUserExcelList, BindingResult bindingResult) { + List errorMessageList = getErrorMessageList(bindingResult); + if (CollUtil.isEmpty(guardianUserExcelList)) { + return CollUtil.isNotEmpty(errorMessageList) ? R.failed(errorMessageList) : R.failed("Excel内容为空"); + } + guardianUserExcelList.forEach(excel -> { + excel.setName(PersonNameUtils.normalize(excel.getName())); + excel.setPhone(StrUtil.trim(excel.getPhone())); + excel.setDeptName(StrUtil.trim(excel.getDeptName())); + }); + + Map phoneCountMap = countNonBlankValues(guardianUserExcelList.stream() + .map(GuardianUserExcelVO::getPhone).toList()); + List phones = phoneCountMap.keySet().stream().toList(); + 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 guardianRoleIds = resolveIdentityImportRoleIds(guardianUserExcelList, errorMessageList, "学生家长"); + if (guardianRoleIds == null) { + return R.failed(errorMessageList); + } + for (GuardianUserExcelVO excel : guardianUserExcelList) { + Set errorMessages = new LinkedHashSet<>(); + if (StrUtil.isBlank(excel.getName())) { + excel.setName(excel.getPhone() + "学生家长"); + } else if (!PersonNameUtils.isValid(excel.getName())) { + errorMessages.add("姓名仅支持中文,或英文(单词间可用空格),且不能超过32个字符"); + } + if (StrUtil.isBlank(excel.getPhone())) { + errorMessages.add("手机号不能为空"); + } else { + if (!Validator.isMobile(excel.getPhone())) { + errorMessages.add("手机号格式不正确"); + } + if (phoneCountMap.getOrDefault(excel.getPhone(), 0L) > 1) { + errorMessages.add("手机号:" + excel.getPhone() + ",在Excel中重复"); + } + if (existingPhones.contains(excel.getPhone())) { + errorMessages.add("手机号:" + excel.getPhone() + ",在系统中已存在"); + } + if (existingUsernames.contains(excel.getPhone())) { + errorMessages.add("手机号:" + excel.getPhone() + ",在系统中已作为登录账号使用"); + } + } + SysDept department = resolveDepartment(excel.getDeptName(), departmentIndex, errorMessages); + if (CollUtil.isNotEmpty(errorMessages)) { + errorMessageList.add(new ErrorMessage(excel.getLineNum(), errorMessages)); + continue; + } + departmentMap.put(excel, department); + } + if (CollUtil.isNotEmpty(errorMessageList)) { + return R.failed(errorMessageList); + } + AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(GUARDIAN_USER_IMPORT_REDIS_KEY, "学生家长用户导入", + guardianUserExcelList.size(), reporter -> guardianUserExcelList.forEach(excel -> { + insertGuardianUser(excel, departmentMap.get(excel), guardianRoleIds); + reporter.advance("正在导入学生家长用户:" + excel.getName()); + })); + return R.ok(progress, "Excel校验通过,已开始后台导入"); + } + + /** + * 导入老师用户,模板手机号作为登录账号、学号写入老师扩展表,整表校验通过后统一写入。 + * + * @param teacherUserExcelList Excel 解析出的老师数据 + * @param bindingResult Excel 通用校验结果 + * @return 导入结果;失败时返回对应行号和原因 + */ + @Override + public R importTeacherUser(List 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 insertGuardianUser(GuardianUserExcelVO 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())); + user.setRole(roleIds); + sysUserService.saveUser(user); + } + + /** + * 创建老师用户;模板手机号作为登录账号,学号写入老师扩展表,老师身份与学生扩展数据完全分离。 + * + * @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 GuardianUserExcelVO guardian ? guardian.getLineNum() + : row instanceof TeacherUserExcelVO teacher ? teacher.getLineNum() : null; + errorMessageList.add(new ErrorMessage(lineNumber, Set.of(error))); + } + return null; + } + + /** + * 统计非空模板值在当前 Excel 中的出现次数。 + * + * @param values 待统计的模板列值 + * @return 按去除空值后的原始字符串分组的次数 + */ + private Map 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 6fd86ac42..69e422f41 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,19 +68,19 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.data.resolver.ParamResolver; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; -import com.bsd.pjcloud.common.excel.vo.ErrorMessage; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.flow.task.api.feign.RemoteFlowEngineService; import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum; import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto; import com.bsd.pjcloud.flow.task.dto.TaskDto; +import com.bsd.pjcloud.flow.task.dto.TaskParamDto; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; import cn.hutool.extra.spring.SpringUtil; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.poi.ss.usermodel.PictureData; import org.springframework.beans.BeanUtils; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; @@ -96,17 +88,13 @@ import org.springframework.cache.annotation.CacheEvict; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Lazy; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.validation.BindingResult; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.multipart.MultipartFile; -import java.io.ByteArrayInputStream; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.CompletableFuture; @@ -127,16 +115,12 @@ public class SysUserServiceImpl extends ServiceImpl impl private static final int RANDOM_USERNAME_MAX_ATTEMPTS = 10; - private static final int STUDENT_NO_MAX_LENGTH = 255; - private static final PasswordEncoder ENCODER = new BCryptPasswordEncoder(); private final SysMenuService sysMenuService; private final SysRoleService sysRoleService; - private final SysPostService sysPostService; - private final SysDeptService sysDeptService; private final SysUserTagService sysUserTagService; @@ -144,35 +128,26 @@ public class SysUserServiceImpl extends ServiceImpl impl private final SysUserIdentityService sysUserIdentityService; /** - * 学生导入需要复用关系服务的事务入口;延迟注入用于解除关系服务反向依赖用户服务形成的循环依赖。 + * 用户关系服务,用于删除用户时同步清理关联关系;延迟注入用于解除关系服务反向依赖用户服务形成的循环依赖。 */ @Lazy private final SysUserRelationService sysUserRelationService; - /** - * 用于按业务编码获取当前租户启用的监护关系类型。 - */ - private final SysUserRelationTypeService sysUserRelationTypeService; - - @Lazy - private final SysUserFaceService sysUserFaceService; private final SysUserFaceMapper sysUserFaceMapper; private final SysUserRoleMapper sysUserRoleMapper; private final SysUserPostMapper sysUserPostMapper; - /** - * 学生导入前批量查询已存在学号,避免在循环中逐行访问数据库。 - */ - private final SysUserStudentMapper sysUserStudentMapper; - private final CacheManager cacheManager; private final StringRedisTemplate redisTemplate; private final RemoteWalletService remoteWalletService; - private final SysFileService sysFileService; + /** + * 审批流程远程服务,用于在删除审核中用户前终止对应流程实例。 + */ + private final RemoteFlowEngineService remoteFlowEngineService; private final FlowUtil flowUtil; @@ -222,15 +197,17 @@ public class SysUserServiceImpl extends ServiceImpl impl sysUserIdentityService.validateDto(userDto); SysUser sysUser = new SysUser(); BeanUtils.copyProperties(userDto, sysUser); - // 判断是否为部门管理员 + // 是否进入审批由当前操作人的专用角色控制,不能再通过部门管理员身份推断。 PjcloudUser applyUser = SecurityUtils.getUser(); - boolean isDeptAdmin = false; + boolean needApproval = false; if (skipAudit) { sysUser.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode()); sysUser.setProcessTime(LocalDateTime.now()); } else if (null != applyUser) { - isDeptAdmin = sysRoleService.isDeptAdmin(applyUser.getId()); - if (isDeptAdmin) { + List applyUserRoles = sysRoleService.findRolesByUserId(applyUser.getId()); + needApproval = CollUtil.isNotEmpty(applyUserRoles) && applyUserRoles.stream() + .anyMatch(role -> ObjectUtil.equals(role.getRoleCode(), CommonRoleConstant.ADD_USER_NEED_CEHCK)); + if (needApproval) { List defaultRoles = sysRoleService.findRolesByCodes(List.of(CommonRoleConstant.DEFAULT_USER, CommonRoleConstant.AUTO_CREATE_WALLET)); if (ObjectUtil.isEmpty(defaultRoles)) { userDto.setRole(null); @@ -280,7 +257,7 @@ public class SysUserServiceImpl extends ServiceImpl impl return userRole; }).forEach(sysUserRoleMapper::insert); - if (isDeptAdmin && !skipAudit) { + if (needApproval) { getSelf().applyFlow(sysUser); } else { // 事务提交前其他连接无法读取新用户,因此仅发布事件,由监听器在提交成功后异步创建钱包。 @@ -403,16 +380,23 @@ public class SysUserServiceImpl extends ServiceImpl impl } /** - * 删除用户 + * 删除用户,并在删除前终止仍处于审核中的用户审批流程。 + *

审批状态和流程ID以数据库实时数据为准;流程取消失败时抛出异常并停止本次删除。

* * @param ids 用户ID 列表 - * @return Boolean + * @return 所有用户及其关联数据进入删除流程后返回 {@code true} */ @Override @Transactional(rollbackFor = Exception.class) public Boolean deleteUserByIds(Long[] ids) { + if (ArrayUtil.isEmpty(ids)) { + throw new RuntimeException("请选择要删除的用户"); + } List userIds = CollUtil.toList(ids); List userList = baseMapper.selectBatchIds(userIds); + if (userList.size() != userIds.stream().distinct().count()) { + throw new RuntimeException("部分用户不存在或已删除,请刷新后重试"); + } // 判断是否可以删除 userList.forEach(v -> { String errorMsg = remoteWalletService.isDel(v.getUserId()).getData(); @@ -421,6 +405,27 @@ public class SysUserServiceImpl extends ServiceImpl impl } }); + // 只终止数据库中仍为审核中的流程;前端携带的流程ID可能已经过期,不能作为删除依据。 + List pendingProcessIds = new ArrayList<>(); + for (SysUser user : userList) { + if (!ObjectUtil.equals(user.getProcessStatus(), TaskCheckStatusEnum.PADDING.getCode())) { + continue; + } + if (StrUtil.isBlank(user.getProcessId())) { + throw new RuntimeException("审核中的用户缺少流程ID,无法删除"); + } + pendingProcessIds.add(user.getProcessId()); + } + if (CollUtil.isNotEmpty(pendingProcessIds)) { + TaskParamDto taskParam = new TaskParamDto(); + taskParam.setProcessInstanceIdList(pendingProcessIds.stream().distinct().toList()); + R stopResult = remoteFlowEngineService.stopProcessInstance(taskParam); + if (stopResult == null || !stopResult.isOk()) { + String errorMessage = stopResult == null ? null : stopResult.getMsg(); + throw new RuntimeException(StrUtil.blankToDefault(errorMessage, "取消用户审批流程失败")); + } + } + // 删除 spring cache Cache cache = cacheManager.getCache(CacheConstants.USER_DETAILS); for (SysUser sysUser : userList) { @@ -435,9 +440,9 @@ public class SysUserServiceImpl extends ServiceImpl impl this.removeBatchByIds(userIds); // 删除钱包 CompletableFuture.runAsync(() -> { - remoteWalletService.delWallet(List.of(ids)); + remoteWalletService.delWallet(userIds); }); - for (Long userId : ids) { + for (Long userId : userIds) { remoteWalletService.userChange(userId, Boolean.TRUE); } @@ -577,273 +582,9 @@ public class SysUserServiceImpl extends ServiceImpl 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 未被占用的随机数字账号 */ @@ -858,177 +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+", "")); - userDTO.setName(excel.getName().replaceAll("\\s+", "")); - userDTO.setNickname(excel.getName().replaceAll("\\s+", "")); - userDTO.setPhone(excel.getMobile().replaceAll("\\s+", "")); - userDTO.setDeptId(deptId); - userDTO.setStaffingType(excel.getStaffingType()); - // 批量导入初始密码为手机号 - userDTO.setPasswordModifyTime(LocalDateTime.now()); - userDTO.setPassword(userDTO.getPhone().replaceAll("\\s+", "")); - // 根据角色名称查询角色ID - userDTO.setRole(roleList); - // 插入用户 - Long usrId = this.saveUser(userDTO); - userDTO.setUserId(usrId); - // 更新人脸 - if (ObjectUtil.isNotEmpty(face)) { - insertExcelFace(userDTO, face); - } - } - - public void insertExcelFace(UserDTO user, PictureData face) { - try { - // 查询用户 - MultipartFile multipartFile = new MockMultipartFile("file", UUID.randomUUID() + "_face.png", "image/png", new ByteArrayInputStream(face.getData())); - Map data = (Map) sysFileService.uploadFile(multipartFile, null, null, null).getData(); - String url = data.get("url").toString(); - SysUserFaceEntity updateObj = new SysUserFaceEntity(); - updateObj.setUserId(user.getUserId()); - updateObj.setFaceImg(url); - sysUserFaceService.create(updateObj); - } catch (Exception e) { - e.printStackTrace(); - log.error("|{}|获取人脸图片错误error:|{}|", TAG, e.getMessage()); - } - } - /** * 注册用户 赋予用户默认角色 * 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 46e8477b5..217a715d7 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/SysUserRelationServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java index b9fb90a26..2254d0b56 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java @@ -136,6 +136,19 @@ public class SysUserRelationServiceImpl extends ServiceImpl listRelatedUserIds(Long userId) { + return collectRelationGraph(userId).userIds.stream() + .filter(relatedUserId -> !Objects.equals(userId, relatedUserId)) + .toList(); + } + /** * 查询指定监护人当前有效的被监护人,并按关系记录顺序返回展示信息。 * 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 000000000..79d30c944 --- /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/guardian_import.xlsx b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/guardian_import.xlsx new file mode 100644 index 000000000..809c7d544 Binary files /dev/null and b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/guardian_import.xlsx differ 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 000000000..7876aca4c Binary files /dev/null and b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/teacher_import.xlsx differ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImplTest.java b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImplTest.java index 66cf1f99c..0670d2350 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImplTest.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImplTest.java @@ -89,6 +89,24 @@ class SysUserRelationServiceImplTest { assertFalse(service.hasActiveGuardianWardRelation(GUARDIAN_USER_ID, 999L)); } + /** + * 验证关系人查询覆盖完整连通图,并排除当前用户和重复用户。 + */ + @Test + void shouldListDistinctUsersFromCompleteRelationGraph() { + SysUserRelationServiceImpl service = spy( + new SysUserRelationServiceImpl(mock(SysUserService.class), mock(SysUserRelationTypeService.class))); + SysUserRelationEntity outgoingRelation = createRelation(2079104982669385730L, FIRST_WARD_USER_ID); + SysUserRelationEntity incomingRelation = createRelation(2079129636280758273L, FIRST_WARD_USER_ID); + incomingRelation.setSubjectUserId(SECOND_WARD_USER_ID); + doReturn(List.of(outgoingRelation), List.of(outgoingRelation, incomingRelation), List.of(incomingRelation)) + .when(service).list(org.mockito.ArgumentMatchers.>any()); + + List relatedUserIds = service.listRelatedUserIds(GUARDIAN_USER_ID); + + assertEquals(List.of(FIRST_WARD_USER_ID, SECOND_WARD_USER_ID), relatedUserIds); + } + private SysUserRelationEntity createRelation(Long relationId, Long wardUserId) { SysUserRelationEntity relation = new SysUserRelationEntity(); relation.setRelationId(relationId); diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java index a9049225e..b28991931 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java @@ -1,5 +1,6 @@ package com.bsd.pjcloud.wallet.api.dto; +import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface; import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; @@ -10,7 +11,7 @@ import lombok.Data; import java.util.List; @Data -public class WalletActiveDto extends WalletActiveEntity { +public class WalletActiveDto extends WalletActiveEntity implements BaseIsIncludeTreeInterface { @Schema(description = "数量") @Max(200) @@ -21,10 +22,14 @@ public class WalletActiveDto extends WalletActiveEntity { @NotNull(message = "商户id不能为空") private Long merchantId; + private Long merchantIdList; + private Long walletTypeId; private List userIds; + private Boolean isInclude; + private List deptIds; /** diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java index 2a02ac96d..7273b8bee 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java @@ -3,23 +3,33 @@ package com.bsd.pjcloud.wallet.api.vo.recharge.app; import lombok.Data; /** - * 被监护人饭卡列表项,仅返回移动端展示所需的脱敏信息。 + * 用户业务摘要列表项,兼容被监护人代办与本人及关系人列表,仅返回移动端展示所需的脱敏信息。 */ @Data public class GuardianWardRechargeItemVo { /** - * 被监护人用户ID,仅用于后续受保护的业务请求,不在列表界面直接展示。 + * 摘要对应的用户 ID,仅用于后续受保护的业务请求,不在列表界面直接展示。 */ private Long wardUserId; /** - * 被监护人展示名称。 + * 是否为当前登录用户本人。 + */ + private Boolean currentUser; + + /** + * 当前登录用户是否可以按有效监护关系为该用户代办业务。 + */ + private Boolean guardianWard; + + /** + * 用户展示名称。 */ private String wardName; /** - * 被监护人头像地址。 + * 用户头像地址。 */ private String wardAvatar; diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java index 8ac43c3cb..2e6654d14 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java @@ -75,6 +75,19 @@ public class WalletRechargeController { return R.ok(walletRechargeService.listGuardianWards(user.getId())); } + /** + * 查询当前用户本人及同一完整关系图谱中全部关系人的业务摘要。 + * + * @return 本人及关系人的饭卡、人脸和预约摘要 + */ + @Operation(summary = "查询本人及关系人列表", description = "查询当前登录用户本人及同一完整关系图谱中全部关系人的业务摘要") + @ApiCryptoAes + @GetMapping("/app/related-user/list") + public R> relatedUserList() { + PjcloudUser user = SecurityUtils.getUser(); + return R.ok(walletRechargeService.listRelatedUsers(user.getId())); + } + @Operation(summary = "查询监护代充值上下文", description = "返回目标饭卡、有效充值套餐和目标食堂支付平台") @ApiCryptoAes @GetMapping("/app/guardian-ward/context") diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java index 0f0e0679c..85918f4aa 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java @@ -35,8 +35,17 @@ public interface WalletActiveMapper extends PjcloudBaseMapper { */ List listGuardianWards(Long guardianUserId); + /** + * 查询当前用户本人及同一完整关系图谱中全部关系人的饭卡、人脸和预约摘要。 + * + * @param currentUserId 当前登录用户 ID + * @return 本人及关系人摘要列表,本人固定排在首位 + */ + List listRelatedUsers(Long currentUserId); + /** * 查询被监护人的代充值上下文。 * 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 074da342c..5b770ec12 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 @@ -51,9 +51,11 @@ import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; /** @@ -83,13 +85,13 @@ public class WalletRechargeServiceImpl extends ServiceImpl listRelatedUsers(Long currentUserId) { + List relatedUserIds = listRelatedUserIds(currentUserId); + List wardUserIds = listActiveWardUserIds(currentUserId); + LinkedHashSet targetUserIds = new LinkedHashSet<>(); + targetUserIds.add(currentUserId); + targetUserIds.addAll(relatedUserIds); + return listUserBusinessItems(List.copyOf(targetUserIds), Set.copyOf(wardUserIds), currentUserId); + } + + /** + * 按指定用户顺序聚合饭卡、人脸和待核销预约,并补充本人及监护代办身份。 + * + * @param userIds 需要聚合的用户 ID,返回顺序与此列表保持一致 + * @param wardUserIds 当前用户可代办的有效被监护人 ID + * @param currentUserId 当前登录用户 ID;旧监护列表传空,不标记本人 + * @return 用户业务摘要列表 + */ + private List listUserBusinessItems(List userIds, Set wardUserIds, Long currentUserId) { R> userResult; try { - userResult = remoteUserService.getInnerUserById(wardUserIds); + userResult = remoteUserService.getInnerUserById(userIds); } catch (Exception exception) { - throw new RuntimeException("查询被监护人信息失败", exception); + throw new RuntimeException("查询关系人信息失败", exception); } if (userResult == null || !userResult.isOk() || userResult.getData() == null) { - throw new RuntimeException("查询被监护人信息失败"); + throw new RuntimeException("查询关系人信息失败"); } Map userMap = new LinkedHashMap<>(); userResult.getData().forEach(user -> userMap.put(user.getUserId(), user)); - Map faceCountMap = listWardFaceStatusCounts(wardUserIds); - Map reservationCountMap = listWardPendingReservationCounts(wardUserIds); - return wardUserIds.stream().map(wardUserId -> { - GuardianWardRechargeItemVo item = buildWardItem(userMap.get(wardUserId), walletManageService.getWallet(wardUserId, WalletTypeEnum.EAT)); - SysUserFaceStatusCountVo faceCount = faceCountMap.get(wardUserId); + Map faceCountMap = listUserFaceStatusCounts(userIds); + Map reservationCountMap = listUserPendingReservationCounts(userIds); + return userIds.stream().map(userId -> { + GuardianWardRechargeItemVo item = buildWardItem(userMap.get(userId), walletManageService.getWallet(userId, WalletTypeEnum.EAT)); + item.setCurrentUser(Objects.equals(currentUserId, userId)); + item.setGuardianWard(wardUserIds.contains(userId)); + SysUserFaceStatusCountVo faceCount = faceCountMap.get(userId); item.setAvailableFaceCount(faceCount == null ? 0L : ObjectUtil.defaultIfNull(faceCount.getAvailableCount(), 0L)); item.setPendingFaceCount(faceCount == null ? 0L : ObjectUtil.defaultIfNull(faceCount.getPendingCount(), 0L)); - HardPendingReservationCountVo reservationCount = reservationCountMap.get(wardUserId); + HardPendingReservationCountVo reservationCount = reservationCountMap.get(userId); item.setPendingReservationCount(reservationCount == null ? 0L : ObjectUtil.defaultIfNull(reservationCount.getPendingCount(), 0L)); return item; }).toList(); @@ -232,8 +264,11 @@ public class WalletRechargeServiceImpl extends ServiceImpl listWardFaceStatusCounts(List wardUserIds) { - R> result; + private List listRelatedUserIds(Long currentUserId) { + R> result; try { - result = remoteUserFaceService.listStatusCounts(wardUserIds); + result = remoteUserRelationService.listRelatedUserIds(currentUserId); } catch (Exception exception) { - throw new RuntimeException("查询被监护人人脸情况失败", exception); + throw new RuntimeException("查询用户关系失败", exception); } if (result == null || !result.isOk() || result.getData() == null) { - throw new RuntimeException("查询被监护人人脸情况失败"); + throw new RuntimeException("查询用户关系失败"); + } + return result.getData().stream().filter(Objects::nonNull).distinct().toList(); + } + + /** + * 人脸统计失败时拒绝返回不完整列表,避免把未知状态误展示为零。 + * + * @param userIds 需要统计人脸状态的用户 ID + * @return 按用户 ID 索引的人脸状态统计 + */ + private Map listUserFaceStatusCounts(List userIds) { + R> result; + try { + result = remoteUserFaceService.listStatusCounts(userIds); + } catch (Exception exception) { + throw new RuntimeException("查询用户人脸情况失败", exception); + } + if (result == null || !result.isOk() || result.getData() == null) { + throw new RuntimeException("查询用户人脸情况失败"); } Map countMap = new LinkedHashMap<>(); result.getData().stream().filter(Objects::nonNull).filter(item -> item.getUserId() != null).forEach(item -> countMap.put(item.getUserId(), item)); @@ -287,16 +344,19 @@ public class WalletRechargeServiceImpl extends ServiceImpl listWardPendingReservationCounts(List wardUserIds) { + private Map listUserPendingReservationCounts(List userIds) { R> result; try { - result = remoteHardOrderService.listPendingReservationCounts(wardUserIds); + result = remoteHardOrderService.listPendingReservationCounts(userIds); } catch (Exception exception) { - throw new RuntimeException("查询被监护人待核销预约失败", exception); + throw new RuntimeException("查询用户待核销预约失败", exception); } if (result == null || !result.isOk() || result.getData() == null) { - throw new RuntimeException("查询被监护人待核销预约失败"); + throw new RuntimeException("查询用户待核销预约失败"); } Map countMap = new LinkedHashMap<>(); result.getData().stream().filter(Objects::nonNull).filter(item -> item.getUserId() != null).forEach(item -> countMap.put(item.getUserId(), item));