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