feat(compensation): 实现MQ补偿机制(Outbox Pattern) + 安全审计修复
- 新增 compensation_tasks 表 + CompensationTask 实体 + Repository - 新增 CompensationService 补偿任务写入服务 - 新增 CompensationScheduler 定时扫描(CAS抢占+指数退避+失败告警+清理) - 改造 OrderServiceImpl/AdminServiceImpl 4处 afterCommit catch → 写补偿表 - 移除 OrderServiceImpl 未使用的 transactionTemplate - PointsServiceImpl 添加缺失的 @Slf4j - MapperScan 添加 compensation 包扫描 - 审计修复: Class.forName白名单校验、markSuccess/markRetryOrFailed添加status前置条件、CAS后重查防stale snapshot - 更新待实现功能清单
This commit is contained in:
@@ -1,9 +1,13 @@
|
|||||||
package com.openclaw;
|
package com.openclaw;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
|
@MapperScan({"com.openclaw.module.**.repository", "com.openclaw.common.leaf", "com.openclaw.common.compensation"})
|
||||||
public class OpenclawApplication {
|
public class OpenclawApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.openclaw.common.compensation;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CompensationScheduler {
|
||||||
|
|
||||||
|
private final CompensationTaskRepository taskRepo;
|
||||||
|
private final RabbitTemplate rabbitTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/** 允许反序列化的事件类白名单(防止DB被入侵时的任意类实例化) */
|
||||||
|
private static final Set<String> ALLOWED_EVENT_CLASSES = Set.of(
|
||||||
|
"com.openclaw.common.event.OrderPaidEvent",
|
||||||
|
"com.openclaw.common.event.OrderTimeoutEvent",
|
||||||
|
"com.openclaw.common.event.RefundApprovedEvent",
|
||||||
|
"java.lang.String"
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 指数退避间隔(分钟):1, 5, 30, 120, 720 */
|
||||||
|
private static final int[] BACKOFF_MINUTES = {1, 5, 30, 120, 720};
|
||||||
|
|
||||||
|
/** 每5分钟扫描补偿任务 */
|
||||||
|
@Scheduled(cron = "0 */5 * * * ?")
|
||||||
|
public void scanAndRetry() {
|
||||||
|
List<CompensationTask> tasks = taskRepo.findPendingTasks(LocalDateTime.now());
|
||||||
|
if (tasks.isEmpty()) return;
|
||||||
|
|
||||||
|
log.info("[Compensation] 扫描到 {} 条待补偿任务", tasks.size());
|
||||||
|
for (CompensationTask task : tasks) {
|
||||||
|
// CAS抢占:防止多实例重复执行
|
||||||
|
int claimed = taskRepo.casClaimTask(task.getId());
|
||||||
|
if (claimed == 0) continue;
|
||||||
|
|
||||||
|
// 重新查询最新状态,防止stale snapshot导致retryCount/maxRetries不准确
|
||||||
|
CompensationTask freshTask = taskRepo.selectById(task.getId());
|
||||||
|
if (freshTask == null) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
executeTask(freshTask);
|
||||||
|
taskRepo.markSuccess(freshTask.getId());
|
||||||
|
log.info("[Compensation] 补偿成功: id={}, type={}, bizKey={}", freshTask.getId(), freshTask.getTaskType(), freshTask.getBizKey());
|
||||||
|
} catch (Exception e) {
|
||||||
|
handleFailure(freshTask, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeTask(CompensationTask task) throws Exception {
|
||||||
|
String type = task.getTaskType();
|
||||||
|
if (type != null && type.startsWith("mq_")) {
|
||||||
|
executeMqTask(task);
|
||||||
|
} else {
|
||||||
|
throw new UnsupportedOperationException("未知补偿任务类型: " + type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeMqTask(CompensationTask task) throws Exception {
|
||||||
|
JsonNode root = objectMapper.readTree(task.getPayload());
|
||||||
|
String exchange = root.get("exchange").asText();
|
||||||
|
String routingKey = root.get("routingKey").asText();
|
||||||
|
String eventJson = root.get("event").asText();
|
||||||
|
String eventClass = root.get("eventClass").asText();
|
||||||
|
|
||||||
|
if (!ALLOWED_EVENT_CLASSES.contains(eventClass)) {
|
||||||
|
throw new SecurityException("不允许的事件类型: " + eventClass);
|
||||||
|
}
|
||||||
|
Object event = objectMapper.readValue(eventJson, Class.forName(eventClass));
|
||||||
|
rabbitTemplate.convertAndSend(exchange, routingKey, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleFailure(CompensationTask task, Exception e) {
|
||||||
|
int nextRetry = task.getRetryCount() + 1;
|
||||||
|
String errorMsg = e.getMessage();
|
||||||
|
if (errorMsg != null && errorMsg.length() > 500) {
|
||||||
|
errorMsg = errorMsg.substring(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextRetry >= task.getMaxRetries()) {
|
||||||
|
// 重试耗尽 → 标记failed + 告警日志
|
||||||
|
taskRepo.markRetryOrFailed(task.getId(), "failed", LocalDateTime.now(), errorMsg);
|
||||||
|
log.error("[Compensation] 补偿任务重试耗尽,需人工介入! id={}, type={}, bizKey={}, retries={}",
|
||||||
|
task.getId(), task.getTaskType(), task.getBizKey(), nextRetry);
|
||||||
|
} else {
|
||||||
|
// 指数退避重试
|
||||||
|
int backoffIdx = Math.min(nextRetry, BACKOFF_MINUTES.length - 1);
|
||||||
|
LocalDateTime nextRetryAt = LocalDateTime.now().plusMinutes(BACKOFF_MINUTES[backoffIdx]);
|
||||||
|
taskRepo.markRetryOrFailed(task.getId(), "pending", nextRetryAt, errorMsg);
|
||||||
|
log.warn("[Compensation] 补偿任务第{}次失败,将于{}重试: id={}, bizKey={}",
|
||||||
|
nextRetry, nextRetryAt, task.getId(), task.getBizKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每天凌晨3点清理30天前的成功记录 */
|
||||||
|
@Scheduled(cron = "0 0 3 * * ?")
|
||||||
|
public void cleanupOldTasks() {
|
||||||
|
// 使用MyBatis-Plus的条件删除即可
|
||||||
|
LocalDateTime threshold = LocalDateTime.now().minusDays(30);
|
||||||
|
try {
|
||||||
|
// 多实例下可能重复执行DELETE,但DELETE是幂等操作,无需分布式锁
|
||||||
|
LambdaQueryWrapper<CompensationTask> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(CompensationTask::getStatus, "success")
|
||||||
|
.lt(CompensationTask::getUpdatedAt, threshold);
|
||||||
|
int deleted = taskRepo.delete(wrapper);
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.info("[Compensation] 清理 {} 条30天前的成功补偿记录", deleted);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[Compensation] 清理旧补偿记录失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.openclaw.common.compensation;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CompensationService {
|
||||||
|
|
||||||
|
private final CompensationTaskRepository taskRepo;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建MQ补偿任务(MQ发送失败时调用)
|
||||||
|
* @param taskType 任务类型,如 mq_refund_approved
|
||||||
|
* @param bizKey 业务幂等键,如 refund_123
|
||||||
|
* @param exchange MQ交换机
|
||||||
|
* @param routingKey MQ路由键
|
||||||
|
* @param eventPayload 事件对象(会被序列化为JSON)
|
||||||
|
*/
|
||||||
|
public void createMqTask(String taskType, String bizKey, String exchange, String routingKey, Object eventPayload) {
|
||||||
|
try {
|
||||||
|
String payload = objectMapper.writeValueAsString(Map.of(
|
||||||
|
"exchange", exchange,
|
||||||
|
"routingKey", routingKey,
|
||||||
|
"event", objectMapper.writeValueAsString(eventPayload),
|
||||||
|
"eventClass", eventPayload.getClass().getName() // 存储完整类名用于反序列化,CompensationScheduler有白名单校验
|
||||||
|
));
|
||||||
|
|
||||||
|
CompensationTask task = new CompensationTask();
|
||||||
|
task.setTaskType(taskType);
|
||||||
|
task.setBizKey(bizKey);
|
||||||
|
task.setPayload(payload);
|
||||||
|
task.setStatus("pending");
|
||||||
|
task.setRetryCount(0);
|
||||||
|
task.setMaxRetries(5);
|
||||||
|
task.setNextRetryAt(LocalDateTime.now().plusMinutes(1));
|
||||||
|
|
||||||
|
taskRepo.insert(task);
|
||||||
|
log.info("[Compensation] 补偿任务已创建: type={}, bizKey={}", taskType, bizKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 补偿表写入也失败了,只能记日志(最后兜底)
|
||||||
|
log.error("[Compensation] 补偿任务创建失败,需人工介入: type={}, bizKey={}", taskType, bizKey, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.openclaw.common.compensation;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("compensation_tasks")
|
||||||
|
public class CompensationTask {
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
private String taskType;
|
||||||
|
private String bizKey;
|
||||||
|
private String payload;
|
||||||
|
private String status;
|
||||||
|
private Integer retryCount;
|
||||||
|
private Integer maxRetries;
|
||||||
|
private LocalDateTime nextRetryAt;
|
||||||
|
private String errorMsg;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.openclaw.common.compensation;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface CompensationTaskRepository extends BaseMapper<CompensationTask> {
|
||||||
|
|
||||||
|
@Select("SELECT * FROM compensation_tasks WHERE status = 'pending' AND next_retry_at <= #{now} ORDER BY created_at ASC LIMIT 100")
|
||||||
|
List<CompensationTask> findPendingTasks(@Param("now") LocalDateTime now);
|
||||||
|
|
||||||
|
@Update("UPDATE compensation_tasks SET status = 'processing', updated_at = NOW() WHERE id = #{id} AND status = 'pending'")
|
||||||
|
int casClaimTask(@Param("id") Long id);
|
||||||
|
|
||||||
|
@Update("UPDATE compensation_tasks SET status = 'success', updated_at = NOW() WHERE id = #{id} AND status = 'processing'")
|
||||||
|
int markSuccess(@Param("id") Long id);
|
||||||
|
|
||||||
|
@Update("UPDATE compensation_tasks SET status = #{status}, retry_count = retry_count + 1, next_retry_at = #{nextRetryAt}, error_msg = #{errorMsg}, updated_at = NOW() WHERE id = #{id} AND status = 'processing'")
|
||||||
|
int markRetryOrFailed(@Param("id") Long id, @Param("status") String status, @Param("nextRetryAt") LocalDateTime nextRetryAt, @Param("errorMsg") String errorMsg);
|
||||||
|
}
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
package com.openclaw.module.admin.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.openclaw.constant.ErrorCode;
|
||||||
|
import com.openclaw.exception.BusinessException;
|
||||||
|
import com.openclaw.module.admin.dto.AdminLoginDTO;
|
||||||
|
import com.openclaw.module.admin.dto.AdminSkillCreateDTO;
|
||||||
|
import com.openclaw.module.admin.service.AdminService;
|
||||||
|
import com.openclaw.module.admin.vo.*;
|
||||||
|
import com.openclaw.common.event.RefundApprovedEvent;
|
||||||
|
import com.openclaw.common.mq.MQConstants;
|
||||||
|
import com.openclaw.module.order.entity.Order;
|
||||||
|
import com.openclaw.module.order.entity.OrderItem;
|
||||||
|
import com.openclaw.module.order.entity.OrderRefund;
|
||||||
|
import com.openclaw.module.order.repository.OrderItemRepository;
|
||||||
|
import com.openclaw.module.order.repository.OrderRefundRepository;
|
||||||
|
import com.openclaw.module.order.repository.OrderRepository;
|
||||||
|
import com.openclaw.module.order.vo.OrderItemVO;
|
||||||
|
import com.openclaw.module.points.entity.PointsRecord;
|
||||||
|
import com.openclaw.module.points.entity.UserPoints;
|
||||||
|
import com.openclaw.module.points.repository.PointsRecordRepository;
|
||||||
|
import com.openclaw.module.points.repository.UserPointsRepository;
|
||||||
|
import com.openclaw.module.points.service.PointsService;
|
||||||
|
import com.openclaw.module.skill.entity.Skill;
|
||||||
|
import com.openclaw.module.skill.entity.SkillCategory;
|
||||||
|
import com.openclaw.module.skill.entity.SkillReview;
|
||||||
|
import com.openclaw.module.skill.repository.SkillCategoryRepository;
|
||||||
|
import com.openclaw.module.skill.repository.SkillRepository;
|
||||||
|
import com.openclaw.module.skill.repository.SkillReviewRepository;
|
||||||
|
import com.openclaw.module.user.entity.User;
|
||||||
|
import com.openclaw.module.user.repository.UserRepository;
|
||||||
|
import com.openclaw.util.JwtUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
import com.openclaw.common.compensation.CompensationService;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminServiceImpl implements AdminService {
|
||||||
|
|
||||||
|
private final UserRepository userRepo;
|
||||||
|
private final SkillRepository skillRepo;
|
||||||
|
private final SkillCategoryRepository categoryRepo;
|
||||||
|
private final SkillReviewRepository reviewRepo;
|
||||||
|
private final OrderRepository orderRepo;
|
||||||
|
private final OrderItemRepository orderItemRepo;
|
||||||
|
private final OrderRefundRepository refundRepo;
|
||||||
|
private final PointsRecordRepository pointsRecordRepo;
|
||||||
|
private final UserPointsRepository userPointsRepo;
|
||||||
|
private final PointsService pointsService;
|
||||||
|
private final RabbitTemplate rabbitTemplate;
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final CompensationService compensationService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AdminLoginVO login(AdminLoginDTO dto) {
|
||||||
|
User user = userRepo.findByPhone(dto.getUsername())
|
||||||
|
.orElseThrow(() -> new BusinessException(401, "用户名或密码错误"));
|
||||||
|
if (!passwordEncoder.matches(dto.getPassword(), user.getPasswordHash())) {
|
||||||
|
throw new BusinessException(401, "用户名或密码错误");
|
||||||
|
}
|
||||||
|
String role = user.getRole();
|
||||||
|
if (!"admin".equals(role) && !"super_admin".equals(role)) {
|
||||||
|
throw new BusinessException(403, "无管理员权限");
|
||||||
|
}
|
||||||
|
if ("banned".equals(user.getStatus())) {
|
||||||
|
throw new BusinessException(403, "账号已被封禁");
|
||||||
|
}
|
||||||
|
AdminLoginVO vo = new AdminLoginVO();
|
||||||
|
vo.setToken(jwtUtil.generate(user.getId(), role));
|
||||||
|
vo.setUsername(user.getNickname() != null ? user.getNickname() : user.getPhone());
|
||||||
|
vo.setRole(role);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DashboardStatsVO getDashboardStats() {
|
||||||
|
DashboardStatsVO vo = new DashboardStatsVO();
|
||||||
|
LocalDateTime todayStart = LocalDateTime.of(LocalDate.now(), LocalTime.MIN);
|
||||||
|
|
||||||
|
vo.setTotalUsers(userRepo.selectCount(null));
|
||||||
|
vo.setActiveUsers(userRepo.selectCount(
|
||||||
|
new LambdaQueryWrapper<User>().eq(User::getStatus, "active")));
|
||||||
|
vo.setTotalSkills(skillRepo.selectCount(null));
|
||||||
|
vo.setActiveSkills(skillRepo.selectCount(
|
||||||
|
new LambdaQueryWrapper<Skill>().eq(Skill::getStatus, "approved")));
|
||||||
|
vo.setTotalOrders(orderRepo.selectCount(null));
|
||||||
|
vo.setCompletedOrders(orderRepo.selectCount(
|
||||||
|
new LambdaQueryWrapper<Order>().in(Order::getStatus, "paid", "completed")));
|
||||||
|
|
||||||
|
// 今日数据
|
||||||
|
vo.setTodayNewUsers(userRepo.selectCount(
|
||||||
|
new LambdaQueryWrapper<User>().ge(User::getCreatedAt, todayStart)));
|
||||||
|
vo.setTodayOrders(orderRepo.selectCount(
|
||||||
|
new LambdaQueryWrapper<Order>().ge(Order::getCreatedAt, todayStart)));
|
||||||
|
|
||||||
|
// 积分汇总
|
||||||
|
vo.setTotalPointsIssued(userPointsRepo.sumTotalEarned());
|
||||||
|
vo.setTotalPointsConsumed(userPointsRepo.sumTotalConsumed());
|
||||||
|
vo.setTotalRevenue(orderRepo.sumTotalRevenue());
|
||||||
|
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 用户管理 ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IPage<AdminUserVO> listUsers(String keyword, String status, String role, int pageNum, int pageSize) {
|
||||||
|
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (keyword != null && !keyword.isBlank()) {
|
||||||
|
wrapper.and(w -> w.like(User::getNickname, keyword).or().like(User::getPhone, keyword));
|
||||||
|
}
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
wrapper.eq(User::getStatus, status);
|
||||||
|
}
|
||||||
|
if (role != null && !role.isBlank()) {
|
||||||
|
wrapper.eq(User::getRole, role);
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(User::getCreatedAt);
|
||||||
|
|
||||||
|
IPage<User> page = userRepo.selectPage(new Page<>(pageNum, pageSize), wrapper);
|
||||||
|
return page.convert(this::toAdminUserVO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AdminUserVO getUserDetail(Long userId) {
|
||||||
|
User user = userRepo.selectById(userId);
|
||||||
|
if (user == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||||
|
return toAdminUserVO(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void banUser(Long userId, String reason) {
|
||||||
|
User user = userRepo.selectById(userId);
|
||||||
|
if (user == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||||
|
user.setStatus("banned");
|
||||||
|
user.setBanReason(reason);
|
||||||
|
userRepo.updateById(user);
|
||||||
|
log.info("[Admin] 封禁用户: userId={}, reason={}", userId, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void unbanUser(Long userId) {
|
||||||
|
User user = userRepo.selectById(userId);
|
||||||
|
if (user == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||||
|
user.setStatus("active");
|
||||||
|
user.setBanReason(null);
|
||||||
|
userRepo.updateById(user);
|
||||||
|
log.info("[Admin] 解封用户: userId={}", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void changeUserRole(Long userId, String role) {
|
||||||
|
User user = userRepo.selectById(userId);
|
||||||
|
if (user == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||||
|
user.setRole(role);
|
||||||
|
userRepo.updateById(user);
|
||||||
|
log.info("[Admin] 修改用户角色: userId={}, newRole={}", userId, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Skill管理 ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IPage<AdminSkillVO> listSkills(String keyword, String status, Integer categoryId, int pageNum, int pageSize) {
|
||||||
|
LambdaQueryWrapper<Skill> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (keyword != null && !keyword.isBlank()) {
|
||||||
|
wrapper.like(Skill::getName, keyword);
|
||||||
|
}
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
wrapper.eq(Skill::getStatus, status);
|
||||||
|
}
|
||||||
|
if (categoryId != null) {
|
||||||
|
wrapper.eq(Skill::getCategoryId, categoryId);
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(Skill::getCreatedAt);
|
||||||
|
|
||||||
|
IPage<Skill> page = skillRepo.selectPage(new Page<>(pageNum, pageSize), wrapper);
|
||||||
|
return page.convert(this::toAdminSkillVO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AdminSkillVO getSkillDetail(Long skillId) {
|
||||||
|
Skill skill = skillRepo.selectById(skillId);
|
||||||
|
if (skill == null) throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||||
|
return toAdminSkillVO(skill);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void auditSkill(Long skillId, String action, String rejectReason) {
|
||||||
|
Skill skill = skillRepo.selectById(skillId);
|
||||||
|
if (skill == null) throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||||
|
|
||||||
|
if ("approve".equals(action)) {
|
||||||
|
skill.setStatus("approved");
|
||||||
|
skill.setRejectReason(null);
|
||||||
|
} else if ("reject".equals(action)) {
|
||||||
|
skill.setStatus("rejected");
|
||||||
|
skill.setRejectReason(rejectReason);
|
||||||
|
} else {
|
||||||
|
throw new BusinessException(400, "无效的审核操作");
|
||||||
|
}
|
||||||
|
skillRepo.updateById(skill);
|
||||||
|
log.info("[Admin] 审核Skill: skillId={}, action={}", skillId, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void offlineSkill(Long skillId) {
|
||||||
|
Skill skill = skillRepo.selectById(skillId);
|
||||||
|
if (skill == null) throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||||
|
skill.setStatus("offline");
|
||||||
|
skillRepo.updateById(skill);
|
||||||
|
log.info("[Admin] 下架Skill: skillId={}", skillId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void toggleFeatured(Long skillId) {
|
||||||
|
Skill skill = skillRepo.selectById(skillId);
|
||||||
|
if (skill == null) throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||||
|
Boolean current = skill.getIsFeatured();
|
||||||
|
skill.setIsFeatured(current != null && current ? false : true);
|
||||||
|
skillRepo.updateById(skill);
|
||||||
|
log.info("[Admin] 切换推荐状态: skillId={}, isFeatured={}", skillId, skill.getIsFeatured());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public AdminSkillVO createSkill(Long adminUserId, AdminSkillCreateDTO dto) {
|
||||||
|
Skill skill = new Skill();
|
||||||
|
skill.setCreatorId(adminUserId);
|
||||||
|
skill.setName(dto.getName());
|
||||||
|
skill.setDescription(dto.getDescription());
|
||||||
|
skill.setCoverImageUrl(dto.getCoverImageUrl());
|
||||||
|
skill.setCategoryId(dto.getCategoryId());
|
||||||
|
skill.setPrice(dto.getPrice());
|
||||||
|
skill.setIsFree(dto.getIsFree());
|
||||||
|
skill.setVersion(dto.getVersion());
|
||||||
|
skill.setFileUrl(dto.getFileUrl());
|
||||||
|
skill.setFileSize(dto.getFileSize());
|
||||||
|
skill.setStatus("approved"); // 管理员上传直接通过审核
|
||||||
|
skill.setDownloadCount(0);
|
||||||
|
skill.setAuditorId(adminUserId);
|
||||||
|
skill.setAuditedAt(LocalDateTime.now());
|
||||||
|
skillRepo.insert(skill);
|
||||||
|
log.info("[Admin] 管理员上传Skill: skillId={}, name={}", skill.getId(), skill.getName());
|
||||||
|
return toAdminSkillVO(skill);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 订单管理 ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IPage<AdminOrderVO> listOrders(String keyword, String status, int pageNum, int pageSize) {
|
||||||
|
LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (keyword != null && !keyword.isBlank()) {
|
||||||
|
wrapper.like(Order::getOrderNo, keyword);
|
||||||
|
}
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
wrapper.eq(Order::getStatus, status);
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(Order::getCreatedAt);
|
||||||
|
|
||||||
|
IPage<Order> page = orderRepo.selectPage(new Page<>(pageNum, pageSize), wrapper);
|
||||||
|
return page.convert(this::toAdminOrderVO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AdminOrderVO getOrderDetail(Long orderId) {
|
||||||
|
Order order = orderRepo.selectById(orderId);
|
||||||
|
if (order == null) throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
|
||||||
|
return toAdminOrderVO(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 退款管理 ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IPage<AdminRefundVO> listRefunds(String keyword, String status, int pageNum, int pageSize) {
|
||||||
|
LambdaQueryWrapper<OrderRefund> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (keyword != null && !keyword.isBlank()) {
|
||||||
|
wrapper.like(OrderRefund::getRefundNo, keyword);
|
||||||
|
}
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
wrapper.eq(OrderRefund::getStatus, status);
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(OrderRefund::getCreatedAt);
|
||||||
|
|
||||||
|
IPage<OrderRefund> page = refundRepo.selectPage(new Page<>(pageNum, pageSize), wrapper);
|
||||||
|
return page.convert(this::toAdminRefundVO);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 评论管理 ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IPage<AdminCommentVO> listComments(String keyword, Long skillId, int pageNum, int pageSize) {
|
||||||
|
LambdaQueryWrapper<SkillReview> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (keyword != null && !keyword.isBlank()) {
|
||||||
|
wrapper.like(SkillReview::getContent, keyword);
|
||||||
|
}
|
||||||
|
if (skillId != null) {
|
||||||
|
wrapper.eq(SkillReview::getSkillId, skillId);
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(SkillReview::getCreatedAt);
|
||||||
|
|
||||||
|
IPage<SkillReview> page = reviewRepo.selectPage(new Page<>(pageNum, pageSize), wrapper);
|
||||||
|
return page.convert(this::toAdminCommentVO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void deleteComment(Long commentId) {
|
||||||
|
SkillReview review = reviewRepo.selectById(commentId);
|
||||||
|
if (review == null) throw new BusinessException(400, "评论不存在");
|
||||||
|
reviewRepo.deleteById(commentId);
|
||||||
|
log.info("[Admin] 删除评论: commentId={}", commentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 积分管理 ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IPage<AdminPointsRecordVO> listPointsRecords(Long userId, String pointsType, int pageNum, int pageSize) {
|
||||||
|
LambdaQueryWrapper<PointsRecord> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (userId != null) {
|
||||||
|
wrapper.eq(PointsRecord::getUserId, userId);
|
||||||
|
}
|
||||||
|
if (pointsType != null && !pointsType.isBlank()) {
|
||||||
|
wrapper.eq(PointsRecord::getPointsType, pointsType);
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(PointsRecord::getCreatedAt);
|
||||||
|
|
||||||
|
IPage<PointsRecord> page = pointsRecordRepo.selectPage(new Page<>(pageNum, pageSize), wrapper);
|
||||||
|
return page.convert(this::toAdminPointsRecordVO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void adjustPoints(Long userId, int amount, String reason) {
|
||||||
|
User user = userRepo.selectById(userId);
|
||||||
|
if (user == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||||
|
|
||||||
|
pointsService.adjustByAdmin(userId, amount, reason);
|
||||||
|
log.info("[Admin] 调整积分: userId={}, amount={}, reason={}", userId, amount, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== VO转换 ====================
|
||||||
|
|
||||||
|
private AdminUserVO toAdminUserVO(User user) {
|
||||||
|
AdminUserVO vo = new AdminUserVO();
|
||||||
|
vo.setId(user.getId());
|
||||||
|
vo.setPhone(user.getPhone());
|
||||||
|
vo.setNickname(user.getNickname());
|
||||||
|
vo.setAvatarUrl(user.getAvatarUrl());
|
||||||
|
vo.setRole(user.getRole());
|
||||||
|
vo.setStatus(user.getStatus());
|
||||||
|
vo.setMemberLevel(user.getMemberLevel());
|
||||||
|
vo.setGrowthValue(user.getGrowthValue());
|
||||||
|
vo.setBanReason(user.getBanReason());
|
||||||
|
vo.setCreatedAt(user.getCreatedAt());
|
||||||
|
vo.setUpdatedAt(user.getUpdatedAt());
|
||||||
|
|
||||||
|
UserPoints points = userPointsRepo.findByUserId(user.getId());
|
||||||
|
if (points != null) {
|
||||||
|
vo.setAvailablePoints(points.getAvailablePoints());
|
||||||
|
}
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminSkillVO toAdminSkillVO(Skill skill) {
|
||||||
|
AdminSkillVO vo = new AdminSkillVO();
|
||||||
|
vo.setId(skill.getId());
|
||||||
|
vo.setName(skill.getName());
|
||||||
|
vo.setDescription(skill.getDescription());
|
||||||
|
vo.setCoverImageUrl(skill.getCoverImageUrl());
|
||||||
|
vo.setCategoryId(skill.getCategoryId());
|
||||||
|
vo.setPrice(skill.getPrice());
|
||||||
|
vo.setIsFree(skill.getIsFree());
|
||||||
|
vo.setStatus(skill.getStatus());
|
||||||
|
vo.setRejectReason(skill.getRejectReason());
|
||||||
|
vo.setDownloadCount(skill.getDownloadCount());
|
||||||
|
vo.setRating(skill.getRating());
|
||||||
|
vo.setRatingCount(skill.getRatingCount());
|
||||||
|
vo.setVersion(skill.getVersion());
|
||||||
|
vo.setCreatorId(skill.getCreatorId());
|
||||||
|
vo.setIsFeatured(skill.getIsFeatured());
|
||||||
|
vo.setCreatedAt(skill.getCreatedAt());
|
||||||
|
|
||||||
|
if (skill.getCategoryId() != null) {
|
||||||
|
SkillCategory cat = categoryRepo.selectById(skill.getCategoryId());
|
||||||
|
if (cat != null) vo.setCategoryName(cat.getName());
|
||||||
|
}
|
||||||
|
if (skill.getCreatorId() != null) {
|
||||||
|
User creator = userRepo.selectById(skill.getCreatorId());
|
||||||
|
if (creator != null) vo.setCreatorNickname(creator.getNickname());
|
||||||
|
}
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminOrderVO toAdminOrderVO(Order order) {
|
||||||
|
AdminOrderVO vo = new AdminOrderVO();
|
||||||
|
vo.setId(order.getId());
|
||||||
|
vo.setOrderNo(order.getOrderNo());
|
||||||
|
vo.setUserId(order.getUserId());
|
||||||
|
vo.setTotalAmount(order.getTotalAmount());
|
||||||
|
vo.setCashAmount(order.getCashAmount());
|
||||||
|
vo.setPointsUsed(order.getPointsUsed());
|
||||||
|
vo.setStatus(order.getStatus());
|
||||||
|
vo.setStatusLabel(getStatusLabel(order.getStatus()));
|
||||||
|
vo.setPaymentMethod(order.getPaymentMethod());
|
||||||
|
vo.setCreatedAt(order.getCreatedAt());
|
||||||
|
vo.setPaidAt(order.getPaidAt());
|
||||||
|
|
||||||
|
User user = userRepo.selectById(order.getUserId());
|
||||||
|
if (user != null) vo.setUserNickname(user.getNickname());
|
||||||
|
|
||||||
|
List<OrderItem> items = orderItemRepo.selectList(
|
||||||
|
new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, order.getId()));
|
||||||
|
vo.setItems(items.stream().map(item -> {
|
||||||
|
OrderItemVO itemVO = new OrderItemVO();
|
||||||
|
itemVO.setSkillId(item.getSkillId());
|
||||||
|
itemVO.setSkillName(item.getSkillName());
|
||||||
|
itemVO.setSkillCover(item.getSkillCover());
|
||||||
|
itemVO.setUnitPrice(item.getUnitPrice());
|
||||||
|
itemVO.setQuantity(item.getQuantity());
|
||||||
|
itemVO.setTotalPrice(item.getTotalPrice());
|
||||||
|
return itemVO;
|
||||||
|
}).collect(Collectors.toList()));
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminCommentVO toAdminCommentVO(SkillReview review) {
|
||||||
|
AdminCommentVO vo = new AdminCommentVO();
|
||||||
|
vo.setId(review.getId());
|
||||||
|
vo.setSkillId(review.getSkillId());
|
||||||
|
vo.setUserId(review.getUserId());
|
||||||
|
vo.setRating(review.getRating());
|
||||||
|
vo.setContent(review.getContent());
|
||||||
|
vo.setImages(review.getImages());
|
||||||
|
vo.setHelpfulCount(review.getHelpfulCount());
|
||||||
|
vo.setCreatedAt(review.getCreatedAt());
|
||||||
|
|
||||||
|
Skill skill = skillRepo.selectById(review.getSkillId());
|
||||||
|
if (skill != null) vo.setSkillName(skill.getName());
|
||||||
|
|
||||||
|
User user = userRepo.selectById(review.getUserId());
|
||||||
|
if (user != null) vo.setUserNickname(user.getNickname());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminPointsRecordVO toAdminPointsRecordVO(PointsRecord record) {
|
||||||
|
AdminPointsRecordVO vo = new AdminPointsRecordVO();
|
||||||
|
vo.setId(record.getId());
|
||||||
|
vo.setUserId(record.getUserId());
|
||||||
|
vo.setPointsType(record.getPointsType());
|
||||||
|
vo.setSource(record.getSource());
|
||||||
|
vo.setAmount(record.getAmount());
|
||||||
|
vo.setBalance(record.getBalance());
|
||||||
|
vo.setDescription(record.getDescription());
|
||||||
|
vo.setCreatedAt(record.getCreatedAt());
|
||||||
|
|
||||||
|
User user = userRepo.selectById(record.getUserId());
|
||||||
|
if (user != null) vo.setUserNickname(user.getNickname());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void approveRefund(Long refundId, Long operatorId) {
|
||||||
|
OrderRefund refund = refundRepo.selectById(refundId);
|
||||||
|
if (refund == null) throw new BusinessException(ErrorCode.PARAM_ERROR);
|
||||||
|
if (!"pending".equals(refund.getStatus())) {
|
||||||
|
throw new BusinessException(ErrorCode.ORDER_STATUS_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
refund.setStatus("approved");
|
||||||
|
refund.setOperatorId(operatorId);
|
||||||
|
refund.setProcessedAt(LocalDateTime.now());
|
||||||
|
refundRepo.updateById(refund);
|
||||||
|
|
||||||
|
Order order = orderRepo.selectById(refund.getOrderId());
|
||||||
|
if (order != null) {
|
||||||
|
order.setStatus("refunded");
|
||||||
|
orderRepo.updateById(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事务提交后再发MQ,防止事务回滚但消息已发出的不一致问题
|
||||||
|
RefundApprovedEvent event = new RefundApprovedEvent(
|
||||||
|
refund.getId(), refund.getOrderId(),
|
||||||
|
order != null ? order.getUserId() : null,
|
||||||
|
refund.getRefundAmount(), refund.getRefundPoints());
|
||||||
|
final Long logRefundId = refundId;
|
||||||
|
final Long logOrderId = refund.getOrderId();
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
try {
|
||||||
|
rabbitTemplate.convertAndSend(MQConstants.EXCHANGE_TOPIC, MQConstants.RK_REFUND_APPROVED, event);
|
||||||
|
log.info("[Admin] 退款审批通过,MQ已发送: refundId={}, orderId={}", logRefundId, logOrderId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[Admin] 退款审批MQ发送失败,写入补偿表: refundId={}", logRefundId, e);
|
||||||
|
compensationService.createMqTask("mq_refund_approved", "refund_" + logRefundId,
|
||||||
|
MQConstants.EXCHANGE_TOPIC, MQConstants.RK_REFUND_APPROVED, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void rejectRefund(Long refundId, String rejectReason, Long operatorId) {
|
||||||
|
OrderRefund refund = refundRepo.selectById(refundId);
|
||||||
|
if (refund == null) throw new BusinessException(ErrorCode.PARAM_ERROR);
|
||||||
|
if (!"pending".equals(refund.getStatus())) {
|
||||||
|
throw new BusinessException(ErrorCode.ORDER_STATUS_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
refund.setStatus("rejected");
|
||||||
|
refund.setRejectReason(rejectReason);
|
||||||
|
refund.setOperatorId(operatorId);
|
||||||
|
refund.setProcessedAt(LocalDateTime.now());
|
||||||
|
refundRepo.updateById(refund);
|
||||||
|
|
||||||
|
// 恢复订单到退款前的原始状态
|
||||||
|
Order order = orderRepo.selectById(refund.getOrderId());
|
||||||
|
if (order != null && "refunding".equals(order.getStatus())) {
|
||||||
|
String restoreStatus = refund.getPreviousOrderStatus();
|
||||||
|
if (restoreStatus == null || restoreStatus.isEmpty()) {
|
||||||
|
restoreStatus = "completed";
|
||||||
|
}
|
||||||
|
order.setStatus(restoreStatus);
|
||||||
|
orderRepo.updateById(order);
|
||||||
|
}
|
||||||
|
log.info("[Admin] 退款已拒绝: refundId={}, reason={}, operatorId={}", refundId, rejectReason, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminRefundVO toAdminRefundVO(OrderRefund refund) {
|
||||||
|
AdminRefundVO vo = new AdminRefundVO();
|
||||||
|
vo.setId(refund.getId());
|
||||||
|
vo.setOrderId(refund.getOrderId());
|
||||||
|
vo.setRefundNo(refund.getRefundNo());
|
||||||
|
vo.setRefundAmount(refund.getRefundAmount());
|
||||||
|
vo.setRefundPoints(refund.getRefundPoints());
|
||||||
|
vo.setReason(refund.getReason());
|
||||||
|
vo.setStatus(refund.getStatus());
|
||||||
|
vo.setRejectReason(refund.getRejectReason());
|
||||||
|
vo.setOperatorId(refund.getOperatorId());
|
||||||
|
vo.setProcessedAt(refund.getProcessedAt());
|
||||||
|
vo.setCreatedAt(refund.getCreatedAt());
|
||||||
|
|
||||||
|
Order order = orderRepo.selectById(refund.getOrderId());
|
||||||
|
if (order != null) {
|
||||||
|
vo.setOrderNo(order.getOrderNo());
|
||||||
|
vo.setUserId(order.getUserId());
|
||||||
|
User user = userRepo.selectById(order.getUserId());
|
||||||
|
if (user != null) vo.setUserNickname(user.getNickname());
|
||||||
|
}
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getStatusLabel(String status) {
|
||||||
|
return switch (status) {
|
||||||
|
case "pending" -> "待支付";
|
||||||
|
case "paid" -> "已支付";
|
||||||
|
case "completed" -> "已完成";
|
||||||
|
case "cancelled" -> "已取消";
|
||||||
|
case "refunding" -> "退款中";
|
||||||
|
case "refunded" -> "已退款";
|
||||||
|
default -> status;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,11 +18,16 @@ import com.openclaw.module.order.vo.*;
|
|||||||
import com.openclaw.common.event.OrderPaidEvent;
|
import com.openclaw.common.event.OrderPaidEvent;
|
||||||
import com.openclaw.common.event.OrderTimeoutEvent;
|
import com.openclaw.common.event.OrderTimeoutEvent;
|
||||||
import com.openclaw.common.mq.MQConstants;
|
import com.openclaw.common.mq.MQConstants;
|
||||||
|
import com.openclaw.common.compensation.CompensationService;
|
||||||
|
import com.openclaw.module.coupon.service.CouponService;
|
||||||
|
import com.openclaw.module.coupon.vo.CouponCalcResultVO;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
@@ -46,9 +51,16 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
private final SkillService skillService;
|
private final SkillService skillService;
|
||||||
private final IdGenerator idGenerator;
|
private final IdGenerator idGenerator;
|
||||||
private final RabbitTemplate rabbitTemplate;
|
private final RabbitTemplate rabbitTemplate;
|
||||||
|
private final CompensationService compensationService;
|
||||||
|
private final CouponService couponService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public OrderPreviewVO previewOrder(Long userId, List<Long> skillIds, Integer pointsToUse) {
|
public OrderPreviewVO previewOrder(Long userId, List<Long> skillIds, Integer pointsToUse) {
|
||||||
|
return previewOrder(userId, skillIds, pointsToUse, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OrderPreviewVO previewOrder(Long userId, List<Long> skillIds, Integer pointsToUse, Long couponId) {
|
||||||
// 1. 查询 Skill 价格
|
// 1. 查询 Skill 价格
|
||||||
List<Skill> skills = skillRepo.selectBatchIds(skillIds);
|
List<Skill> skills = skillRepo.selectBatchIds(skillIds);
|
||||||
if (skills.isEmpty()) throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
if (skills.isEmpty()) throw new BusinessException(ErrorCode.SKILL_NOT_FOUND);
|
||||||
@@ -73,6 +85,20 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
.divide(BigDecimal.valueOf(POINTS_RATE), 2, RoundingMode.DOWN);
|
.divide(BigDecimal.valueOf(POINTS_RATE), 2, RoundingMode.DOWN);
|
||||||
BigDecimal cash = totalAmount.subtract(deduct).max(BigDecimal.ZERO);
|
BigDecimal cash = totalAmount.subtract(deduct).max(BigDecimal.ZERO);
|
||||||
|
|
||||||
|
// 5b. 优惠券抵扣
|
||||||
|
BigDecimal couponDeduct = BigDecimal.ZERO;
|
||||||
|
String couponName = null;
|
||||||
|
Long appliedCouponId = null;
|
||||||
|
if (couponId != null) {
|
||||||
|
CouponCalcResultVO calcResult = couponService.calcDiscount(userId, couponId, cash);
|
||||||
|
if (Boolean.TRUE.equals(calcResult.getApplicable())) {
|
||||||
|
couponDeduct = calcResult.getCouponDeductAmount();
|
||||||
|
couponName = calcResult.getCouponName();
|
||||||
|
appliedCouponId = couponId;
|
||||||
|
cash = cash.subtract(couponDeduct).max(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 6. 组装返回
|
// 6. 组装返回
|
||||||
OrderPreviewVO vo = new OrderPreviewVO();
|
OrderPreviewVO vo = new OrderPreviewVO();
|
||||||
vo.setItems(skills.stream().map(s -> {
|
vo.setItems(skills.stream().map(s -> {
|
||||||
@@ -91,6 +117,9 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
vo.setCashAmount(cash);
|
vo.setCashAmount(cash);
|
||||||
vo.setAvailablePoints(availablePoints);
|
vo.setAvailablePoints(availablePoints);
|
||||||
vo.setMaxPointsCanUse(maxPoints);
|
vo.setMaxPointsCanUse(maxPoints);
|
||||||
|
vo.setCouponId(appliedCouponId);
|
||||||
|
vo.setCouponName(couponName);
|
||||||
|
vo.setCouponDeductAmount(couponDeduct);
|
||||||
vo.setPointsRate(POINTS_RATE);
|
vo.setPointsRate(POINTS_RATE);
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
@@ -131,6 +160,19 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
.divide(BigDecimal.valueOf(POINTS_RATE), 2, RoundingMode.DOWN);
|
.divide(BigDecimal.valueOf(POINTS_RATE), 2, RoundingMode.DOWN);
|
||||||
BigDecimal cashAmount = totalAmount.subtract(pointsDeductAmount).max(BigDecimal.ZERO);
|
BigDecimal cashAmount = totalAmount.subtract(pointsDeductAmount).max(BigDecimal.ZERO);
|
||||||
|
|
||||||
|
// 4b. 优惠券抵扣
|
||||||
|
BigDecimal couponDeductAmount = BigDecimal.ZERO;
|
||||||
|
Long couponId = dto.getCouponId();
|
||||||
|
if (couponId != null) {
|
||||||
|
CouponCalcResultVO calcResult = couponService.calcDiscount(userId, couponId, cashAmount);
|
||||||
|
if (Boolean.TRUE.equals(calcResult.getApplicable())) {
|
||||||
|
couponDeductAmount = calcResult.getCouponDeductAmount();
|
||||||
|
cashAmount = cashAmount.subtract(couponDeductAmount).max(BigDecimal.ZERO);
|
||||||
|
} else {
|
||||||
|
throw new BusinessException(ErrorCode.COUPON_NOT_USABLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4.1 自动判定支付方式
|
// 4.1 自动判定支付方式
|
||||||
String paymentMethod = dto.getPaymentMethod();
|
String paymentMethod = dto.getPaymentMethod();
|
||||||
if (cashAmount.compareTo(BigDecimal.ZERO) == 0 && pointsToUse > 0) {
|
if (cashAmount.compareTo(BigDecimal.ZERO) == 0 && pointsToUse > 0) {
|
||||||
@@ -147,6 +189,8 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
order.setCashAmount(cashAmount);
|
order.setCashAmount(cashAmount);
|
||||||
order.setPointsUsed(pointsToUse);
|
order.setPointsUsed(pointsToUse);
|
||||||
order.setPointsDeductAmount(pointsDeductAmount);
|
order.setPointsDeductAmount(pointsDeductAmount);
|
||||||
|
order.setCouponId(couponId);
|
||||||
|
order.setCouponDeductAmount(couponDeductAmount);
|
||||||
order.setStatus("pending");
|
order.setStatus("pending");
|
||||||
order.setPaymentMethod(paymentMethod);
|
order.setPaymentMethod(paymentMethod);
|
||||||
order.setExpiredAt(LocalDateTime.now().plusHours(1));
|
order.setExpiredAt(LocalDateTime.now().plusHours(1));
|
||||||
@@ -170,28 +214,45 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
pointsService.freezePoints(userId, pointsToUse, order.getId());
|
pointsService.freezePoints(userId, pointsToUse, order.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 纯积分支付:直接扣减冻结积分并完成订单
|
// 7b. 核销优惠券
|
||||||
if (cashAmount.compareTo(BigDecimal.ZERO) == 0 && pointsToUse > 0) {
|
if (couponId != null) {
|
||||||
pointsService.consumeFrozenPoints(userId, pointsToUse, order.getId());
|
couponService.useCoupon(userId, couponId, order.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 免现金支付(纯积分 或 优惠券全额抵扣):直接完成订单
|
||||||
|
if (cashAmount.compareTo(BigDecimal.ZERO) == 0) {
|
||||||
|
if (pointsToUse > 0) {
|
||||||
|
pointsService.consumeFrozenPoints(userId, pointsToUse, order.getId());
|
||||||
|
}
|
||||||
order.setStatus("completed");
|
order.setStatus("completed");
|
||||||
order.setPaidAt(LocalDateTime.now());
|
order.setPaidAt(LocalDateTime.now());
|
||||||
orderRepo.updateById(order);
|
orderRepo.updateById(order);
|
||||||
// 发放 Skill 访问权限
|
// 发放 Skill 访问权限
|
||||||
|
String grantSource = pointsToUse > 0 ? "points" : "coupon";
|
||||||
for (Skill skill : skills) {
|
for (Skill skill : skills) {
|
||||||
skillService.grantAccess(userId, skill.getId(), order.getId(), "points");
|
skillService.grantAccess(userId, skill.getId(), order.getId(), grantSource);
|
||||||
}
|
}
|
||||||
log.info("纯积分订单直接完成: orderId={}, points={}", order.getId(), pointsToUse);
|
log.info("免现金订单直接完成: orderId={}, points={}, couponId={}", order.getId(), pointsToUse, couponId);
|
||||||
return toVO(order, skills);
|
return toVO(order, skills);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. 非纯积分:发送订单超时延迟消息(1小时后自动取消)
|
// 9. 非纯积分:事务提交后发送订单超时延迟消息(1小时后自动取消)
|
||||||
try {
|
final Long finalOrderId = order.getId();
|
||||||
OrderTimeoutEvent timeoutEvent = new OrderTimeoutEvent(order.getId(), userId, order.getOrderNo());
|
final String finalOrderNo = order.getOrderNo();
|
||||||
rabbitTemplate.convertAndSend(MQConstants.EXCHANGE_TOPIC, "delay.order.create", timeoutEvent);
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
log.info("[MQ] 发送订单超时延迟消息: orderId={}, orderNo={}", order.getId(), order.getOrderNo());
|
@Override
|
||||||
} catch (Exception e) {
|
public void afterCommit() {
|
||||||
log.error("[MQ] 发送订单超时延迟消息失败: orderId={}", order.getId(), e);
|
OrderTimeoutEvent timeoutEvent = new OrderTimeoutEvent(finalOrderId, userId, finalOrderNo);
|
||||||
}
|
try {
|
||||||
|
rabbitTemplate.convertAndSend(MQConstants.EXCHANGE_TOPIC, "delay.order.create", timeoutEvent);
|
||||||
|
log.info("[MQ] 发送订单超时延迟消息: orderId={}, orderNo={}", finalOrderId, finalOrderNo);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[MQ] 发送订单超时延迟消息失败,写入补偿表: orderId={}", finalOrderId, e);
|
||||||
|
compensationService.createMqTask("mq_order_timeout", "order_timeout_" + finalOrderId,
|
||||||
|
MQConstants.EXCHANGE_TOPIC, "delay.order.create", timeoutEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return toVO(order, skills);
|
return toVO(order, skills);
|
||||||
}
|
}
|
||||||
@@ -252,26 +313,24 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
order.setStatus("paid");
|
order.setStatus("paid");
|
||||||
order.setPaidAt(now);
|
order.setPaidAt(now);
|
||||||
|
|
||||||
// 发布订单支付成功事件(异步发放Skill访问权限)
|
// 事务提交后发布订单支付成功事件(异步发放Skill访问权限)
|
||||||
try {
|
final Long payOrderId = orderId;
|
||||||
OrderPaidEvent event = new OrderPaidEvent(order.getId(), userId, order.getOrderNo(), paymentNo);
|
final Long payUserId = userId;
|
||||||
rabbitTemplate.convertAndSend(MQConstants.EXCHANGE_TOPIC, MQConstants.RK_ORDER_PAID, event);
|
final String payOrderNo = order.getOrderNo();
|
||||||
log.info("[MQ] 发布订单支付事件: orderId={}, orderNo={}", order.getId(), order.getOrderNo());
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
} catch (Exception e) {
|
@Override
|
||||||
log.error("[MQ] 发布订单支付事件失败,降级同步处理: orderId={}", order.getId(), e);
|
public void afterCommit() {
|
||||||
List<OrderItem> items = orderItemRepo.selectList(
|
OrderPaidEvent event = new OrderPaidEvent(payOrderId, payUserId, payOrderNo, paymentNo);
|
||||||
new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId));
|
try {
|
||||||
for (OrderItem item : items) {
|
rabbitTemplate.convertAndSend(MQConstants.EXCHANGE_TOPIC, MQConstants.RK_ORDER_PAID, event);
|
||||||
skillService.grantAccess(userId, item.getSkillId(), orderId, "paid");
|
log.info("[MQ] 发布订单支付事件: orderId={}, orderNo={}", payOrderId, payOrderNo);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[MQ] 发布订单支付事件失败,写入补偿表: orderId={}", payOrderId, e);
|
||||||
|
compensationService.createMqTask("mq_order_paid", "order_paid_" + payOrderId,
|
||||||
|
MQConstants.EXCHANGE_TOPIC, MQConstants.RK_ORDER_PAID, event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// MQ 失败降级:同步消费冻结积分
|
});
|
||||||
if (order.getPointsUsed() != null && order.getPointsUsed() > 0) {
|
|
||||||
pointsService.consumeFrozenPoints(userId, order.getPointsUsed(), orderId);
|
|
||||||
}
|
|
||||||
// MQ 失败降级:同步完成订单状态转换
|
|
||||||
order.setStatus("completed");
|
|
||||||
orderRepo.updateById(order);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -293,13 +352,27 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
pointsService.unfreezePoints(userId, order.getPointsUsed(), orderId);
|
pointsService.unfreezePoints(userId, order.getPointsUsed(), orderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发布订单取消事件
|
// 退还优惠券
|
||||||
try {
|
if (order.getCouponId() != null) {
|
||||||
rabbitTemplate.convertAndSend(MQConstants.EXCHANGE_TOPIC, MQConstants.RK_ORDER_CANCELLED, order.getOrderNo());
|
couponService.returnCoupon(order.getCouponId());
|
||||||
log.info("[MQ] 发布订单取消事件: orderId={}, orderNo={}", orderId, order.getOrderNo());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[MQ] 发布订单取消事件失败: orderId={}", orderId, e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 事务提交后发布订单取消事件
|
||||||
|
final String cancelOrderNo = order.getOrderNo();
|
||||||
|
final Long cancelOrderId = orderId;
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
try {
|
||||||
|
rabbitTemplate.convertAndSend(MQConstants.EXCHANGE_TOPIC, MQConstants.RK_ORDER_CANCELLED, cancelOrderNo);
|
||||||
|
log.info("[MQ] 发布订单取消事件: orderId={}, orderNo={}", cancelOrderId, cancelOrderNo);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[MQ] 发布订单取消事件失败,写入补偿表: orderId={}", cancelOrderId, e);
|
||||||
|
compensationService.createMqTask("mq_order_cancelled", "order_cancel_" + cancelOrderId,
|
||||||
|
MQConstants.EXCHANGE_TOPIC, MQConstants.RK_ORDER_CANCELLED, cancelOrderNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -309,9 +382,17 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
if (order == null || !order.getUserId().equals(userId)) {
|
if (order == null || !order.getUserId().equals(userId)) {
|
||||||
throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
|
throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
|
||||||
}
|
}
|
||||||
if (!"paid".equals(order.getStatus())) {
|
if (!"paid".equals(order.getStatus()) && !"completed".equals(order.getStatus())) {
|
||||||
throw new BusinessException(ErrorCode.ORDER_STATUS_ERROR);
|
throw new BusinessException(ErrorCode.ORDER_STATUS_ERROR);
|
||||||
}
|
}
|
||||||
|
Long refundCount = refundRepo.selectCount(
|
||||||
|
new LambdaQueryWrapper<OrderRefund>()
|
||||||
|
.eq(OrderRefund::getOrderId, orderId)
|
||||||
|
.in(OrderRefund::getStatus, "pending", "approved", "completed")
|
||||||
|
);
|
||||||
|
if (refundCount != null && refundCount > 0) {
|
||||||
|
throw new BusinessException(409, "该订单已有退款申请,请勿重复提交");
|
||||||
|
}
|
||||||
|
|
||||||
OrderRefund refund = new OrderRefund();
|
OrderRefund refund = new OrderRefund();
|
||||||
refund.setOrderId(orderId);
|
refund.setOrderId(orderId);
|
||||||
@@ -320,9 +401,14 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
refund.setRefundPoints(order.getPointsUsed());
|
refund.setRefundPoints(order.getPointsUsed());
|
||||||
refund.setReason(dto.getReason());
|
refund.setReason(dto.getReason());
|
||||||
if (dto.getImages() != null) {
|
if (dto.getImages() != null) {
|
||||||
refund.setImages(dto.getImages().toString());
|
try {
|
||||||
|
refund.setImages(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(dto.getImages()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
refund.setImages(dto.getImages().toString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
refund.setStatus("pending");
|
refund.setStatus("pending");
|
||||||
|
refund.setPreviousOrderStatus(order.getStatus());
|
||||||
refundRepo.insert(refund);
|
refundRepo.insert(refund);
|
||||||
|
|
||||||
order.setStatus("refunding");
|
order.setStatus("refunding");
|
||||||
@@ -337,6 +423,8 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
vo.setCashAmount(order.getCashAmount());
|
vo.setCashAmount(order.getCashAmount());
|
||||||
vo.setPointsUsed(order.getPointsUsed());
|
vo.setPointsUsed(order.getPointsUsed());
|
||||||
vo.setPointsDeductAmount(order.getPointsDeductAmount());
|
vo.setPointsDeductAmount(order.getPointsDeductAmount());
|
||||||
|
vo.setCouponId(order.getCouponId());
|
||||||
|
vo.setCouponDeductAmount(order.getCouponDeductAmount());
|
||||||
vo.setStatus(order.getStatus());
|
vo.setStatus(order.getStatus());
|
||||||
vo.setStatusLabel(getStatusLabel(order.getStatus()));
|
vo.setStatusLabel(getStatusLabel(order.getStatus()));
|
||||||
vo.setPaymentMethod(order.getPaymentMethod());
|
vo.setPaymentMethod(order.getPaymentMethod());
|
||||||
@@ -364,6 +452,8 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
vo.setCashAmount(order.getCashAmount());
|
vo.setCashAmount(order.getCashAmount());
|
||||||
vo.setPointsUsed(order.getPointsUsed());
|
vo.setPointsUsed(order.getPointsUsed());
|
||||||
vo.setPointsDeductAmount(order.getPointsDeductAmount());
|
vo.setPointsDeductAmount(order.getPointsDeductAmount());
|
||||||
|
vo.setCouponId(order.getCouponId());
|
||||||
|
vo.setCouponDeductAmount(order.getCouponDeductAmount());
|
||||||
vo.setStatus(order.getStatus());
|
vo.setStatus(order.getStatus());
|
||||||
vo.setStatusLabel(getStatusLabel(order.getStatus()));
|
vo.setStatusLabel(getStatusLabel(order.getStatus()));
|
||||||
vo.setPaymentMethod(order.getPaymentMethod());
|
vo.setPaymentMethod(order.getPaymentMethod());
|
||||||
|
|||||||
@@ -6,15 +6,21 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|||||||
import com.openclaw.constant.ErrorCode;
|
import com.openclaw.constant.ErrorCode;
|
||||||
import com.openclaw.module.points.entity.*;
|
import com.openclaw.module.points.entity.*;
|
||||||
import com.openclaw.exception.BusinessException;
|
import com.openclaw.exception.BusinessException;
|
||||||
|
import com.openclaw.module.member.service.MemberService;
|
||||||
import com.openclaw.module.points.repository.*;
|
import com.openclaw.module.points.repository.*;
|
||||||
import com.openclaw.module.points.service.PointsService;
|
import com.openclaw.module.points.service.PointsService;
|
||||||
import com.openclaw.module.points.vo.*;
|
import com.openclaw.module.points.vo.*;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PointsServiceImpl implements PointsService {
|
public class PointsServiceImpl implements PointsService {
|
||||||
@@ -22,6 +28,13 @@ public class PointsServiceImpl implements PointsService {
|
|||||||
private final UserPointsRepository userPointsRepo;
|
private final UserPointsRepository userPointsRepo;
|
||||||
private final PointsRecordRepository recordRepo;
|
private final PointsRecordRepository recordRepo;
|
||||||
private final PointsRuleRepository ruleRepo;
|
private final PointsRuleRepository ruleRepo;
|
||||||
|
private final PointsBatchRepository batchRepo;
|
||||||
|
private final @Lazy MemberService memberService;
|
||||||
|
|
||||||
|
/** 默认积分有效期(天) */
|
||||||
|
private static final int DEFAULT_EXPIRE_DAYS = 365;
|
||||||
|
/** 永不过期占位时间 */
|
||||||
|
private static final LocalDateTime NEVER_EXPIRE = LocalDateTime.of(2099, 12, 31, 23, 59, 59);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -40,7 +53,16 @@ public class PointsServiceImpl implements PointsService {
|
|||||||
public PointsBalanceVO getBalance(Long userId) {
|
public PointsBalanceVO getBalance(Long userId) {
|
||||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||||
PointsBalanceVO vo = new PointsBalanceVO();
|
PointsBalanceVO vo = new PointsBalanceVO();
|
||||||
if (up == null) return vo;
|
if (up == null) {
|
||||||
|
vo.setAvailablePoints(0);
|
||||||
|
vo.setFrozenPoints(0);
|
||||||
|
vo.setTotalEarned(0);
|
||||||
|
vo.setTotalConsumed(0);
|
||||||
|
vo.setSignInStreak(0);
|
||||||
|
vo.setSignedInToday(false);
|
||||||
|
vo.setExpiringPoints(0);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
vo.setAvailablePoints(up.getAvailablePoints());
|
vo.setAvailablePoints(up.getAvailablePoints());
|
||||||
vo.setFrozenPoints(up.getFrozenPoints());
|
vo.setFrozenPoints(up.getFrozenPoints());
|
||||||
vo.setTotalEarned(up.getTotalEarned());
|
vo.setTotalEarned(up.getTotalEarned());
|
||||||
@@ -48,6 +70,10 @@ public class PointsServiceImpl implements PointsService {
|
|||||||
vo.setLastSignInDate(up.getLastSignInDate());
|
vo.setLastSignInDate(up.getLastSignInDate());
|
||||||
vo.setSignInStreak(up.getSignInStreak());
|
vo.setSignInStreak(up.getSignInStreak());
|
||||||
vo.setSignedInToday(LocalDate.now().equals(up.getLastSignInDate()));
|
vo.setSignedInToday(LocalDate.now().equals(up.getLastSignInDate()));
|
||||||
|
// 查询7天内即将过期的积分
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
int expiringPoints = batchRepo.sumExpiringPoints(userId, now, now.plusDays(7));
|
||||||
|
vo.setExpiringPoints(expiringPoints);
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +91,10 @@ public class PointsServiceImpl implements PointsService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public int signIn(Long userId) {
|
public int signIn(Long userId) {
|
||||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||||
|
if (up == null) {
|
||||||
|
initUserPoints(userId);
|
||||||
|
up = userPointsRepo.findByUserId(userId);
|
||||||
|
}
|
||||||
LocalDate today = LocalDate.now();
|
LocalDate today = LocalDate.now();
|
||||||
|
|
||||||
// 今日已签到
|
// 今日已签到
|
||||||
@@ -77,14 +107,19 @@ public class PointsServiceImpl implements PointsService {
|
|||||||
today.minusDays(1).equals(up.getLastSignInDate());
|
today.minusDays(1).equals(up.getLastSignInDate());
|
||||||
int streak = consecutive ? up.getSignInStreak() + 1 : 1;
|
int streak = consecutive ? up.getSignInStreak() + 1 : 1;
|
||||||
|
|
||||||
// 签到积分:连续签到递增,最高20分
|
// 签到积分:连续签到递增,最高20分,乘以会员倍率
|
||||||
int points = Math.min(5 + (streak - 1) * 1, 20);
|
int basePoints = Math.min(5 + (streak - 1) * 1, 20);
|
||||||
|
java.math.BigDecimal multiplier = memberService.getSignInMultiplier(userId);
|
||||||
|
int points = (int) Math.round(basePoints * multiplier.doubleValue());
|
||||||
|
|
||||||
up.setLastSignInDate(today);
|
// 只更新签到字段,避免 updateById 覆盖 availablePoints
|
||||||
up.setSignInStreak(streak);
|
userPointsRepo.updateSignIn(userId, today, streak);
|
||||||
userPointsRepo.updateById(up);
|
|
||||||
|
|
||||||
addPoints(userId, "earn", "sign_in", points, points, "每日签到", null, null);
|
int newBalance = up.getAvailablePoints() + points;
|
||||||
|
addPoints(userId, "earn", "sign_in", points, newBalance, "每日签到", null, null);
|
||||||
|
createBatch(userId, "sign_in", points, null, null);
|
||||||
|
// 签到获得成长值+1
|
||||||
|
memberService.addGrowth(userId, 1, "sign_in", null, "每日签到");
|
||||||
return points;
|
return points;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,25 +130,37 @@ public class PointsServiceImpl implements PointsService {
|
|||||||
if (rule == null || !rule.getEnabled()) return;
|
if (rule == null || !rule.getEnabled()) return;
|
||||||
|
|
||||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||||
|
if (up == null) {
|
||||||
|
initUserPoints(userId);
|
||||||
|
up = userPointsRepo.findByUserId(userId);
|
||||||
|
}
|
||||||
int newBalance = up.getAvailablePoints() + rule.getPointsAmount();
|
int newBalance = up.getAvailablePoints() + rule.getPointsAmount();
|
||||||
addPoints(userId, "earn", source, rule.getPointsAmount(), newBalance,
|
addPoints(userId, "earn", source, rule.getPointsAmount(), newBalance,
|
||||||
rule.getRuleName(), relatedId, relatedType);
|
rule.getRuleName(), relatedId, relatedType);
|
||||||
|
createBatch(userId, source, rule.getPointsAmount(), relatedId, relatedType);
|
||||||
|
// 赚取积分同步增加成长值
|
||||||
|
int growth = getGrowthForSource(source);
|
||||||
|
if (growth > 0) {
|
||||||
|
memberService.addGrowth(userId, growth, source, relatedId, rule.getRuleName());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void consumePoints(Long userId, int amount, Long relatedId, String relatedType) {
|
public void consumePoints(Long userId, int amount, Long relatedId, String relatedType) {
|
||||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||||
if (up.getAvailablePoints() < amount) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
if (up == null || up.getAvailablePoints() < amount) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||||
int newBalance = up.getAvailablePoints() - amount;
|
int newBalance = up.getAvailablePoints() - amount;
|
||||||
addPoints(userId, "consume", "skill_purchase", -amount, newBalance,
|
addPoints(userId, "consume", "skill_purchase", -amount, newBalance,
|
||||||
"兑换Skill", relatedId, relatedType);
|
"兑换Skill", relatedId, relatedType);
|
||||||
|
consumeBatchesFIFO(userId, amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void freezePoints(Long userId, int amount, Long orderId) {
|
public void freezePoints(Long userId, int amount, Long orderId) {
|
||||||
userPointsRepo.freezePoints(userId, amount);
|
int rows = userPointsRepo.freezePoints(userId, amount);
|
||||||
|
if (rows == 0) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||||
addPoints(userId, "freeze", "skill_purchase", -amount,
|
addPoints(userId, "freeze", "skill_purchase", -amount,
|
||||||
userPointsRepo.findByUserId(userId).getAvailablePoints(),
|
userPointsRepo.findByUserId(userId).getAvailablePoints(),
|
||||||
"积分冻结-订单" + orderId, orderId, "order");
|
"积分冻结-订单" + orderId, orderId, "order");
|
||||||
@@ -122,36 +169,176 @@ public class PointsServiceImpl implements PointsService {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void unfreezePoints(Long userId, int amount, Long orderId) {
|
public void unfreezePoints(Long userId, int amount, Long orderId) {
|
||||||
userPointsRepo.unfreezePoints(userId, amount);
|
int rows = userPointsRepo.unfreezePoints(userId, amount);
|
||||||
|
if (rows == 0) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||||
addPoints(userId, "unfreeze", "skill_purchase", amount,
|
addPoints(userId, "unfreeze", "skill_purchase", amount,
|
||||||
userPointsRepo.findByUserId(userId).getAvailablePoints(),
|
userPointsRepo.findByUserId(userId).getAvailablePoints(),
|
||||||
"积分解冻-订单取消" + orderId, orderId, "order");
|
"积分解冻-订单取消" + orderId, orderId, "order");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void consumeFrozenPoints(Long userId, int amount, Long orderId) {
|
||||||
|
int rows = userPointsRepo.consumeFrozenPoints(userId, amount);
|
||||||
|
if (rows == 0) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||||
|
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||||
|
PointsRecord r = new PointsRecord();
|
||||||
|
r.setUserId(userId);
|
||||||
|
r.setPointsType("consume");
|
||||||
|
r.setSource("skill_purchase");
|
||||||
|
r.setAmount(-amount);
|
||||||
|
r.setBalance(up.getAvailablePoints());
|
||||||
|
r.setDescription("积分消费-订单" + orderId);
|
||||||
|
r.setRelatedId(orderId);
|
||||||
|
r.setRelatedType("order");
|
||||||
|
recordRepo.insert(r);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean hasEnoughPoints(Long userId, int required) {
|
public boolean hasEnoughPoints(Long userId, int required) {
|
||||||
UserPoints up = userPointsRepo.findByUserId(userId);
|
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||||
return up != null && up.getAvailablePoints() >= required;
|
return up != null && up.getAvailablePoints() >= required;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void adjustByAdmin(Long userId, int amount, String reason) {
|
||||||
|
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||||
|
if (up == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||||
|
int newBalance = up.getAvailablePoints() + amount;
|
||||||
|
if (newBalance < 0) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||||
|
String type = amount >= 0 ? "earn" : "consume";
|
||||||
|
String source = amount >= 0 ? "admin_add" : "admin_deduct";
|
||||||
|
String desc = reason != null && !reason.isBlank() ? reason : "管理员调整";
|
||||||
|
addPoints(userId, type, source, amount, newBalance, desc, null, "admin_adjust");
|
||||||
|
if (amount > 0) {
|
||||||
|
createBatch(userId, "admin_add", amount, null, "admin_adjust");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void addRechargePoints(Long userId, int totalPoints, Long rechargeOrderId) {
|
||||||
|
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||||
|
if (up == null) {
|
||||||
|
initUserPoints(userId);
|
||||||
|
up = userPointsRepo.findByUserId(userId);
|
||||||
|
}
|
||||||
|
int newBalance = up.getAvailablePoints() + totalPoints;
|
||||||
|
addPoints(userId, "earn", "recharge", totalPoints, newBalance,
|
||||||
|
"充值赠送积分", rechargeOrderId, "recharge_order");
|
||||||
|
createBatch(userId, "recharge", totalPoints, rechargeOrderId, "recharge_order");
|
||||||
|
// 充值获得成长值 = 积分/10,最少1
|
||||||
|
int growth = Math.max(1, totalPoints / 10);
|
||||||
|
memberService.addGrowth(userId, growth, "recharge", rechargeOrderId, "充值赠送");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void refundPoints(Long userId, int amount, Long orderId) {
|
||||||
|
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||||
|
if (up == null) return;
|
||||||
|
int newBalance = up.getAvailablePoints() + amount;
|
||||||
|
addPoints(userId, "earn", "refund", amount, newBalance,
|
||||||
|
"退款退还积分", orderId, "order");
|
||||||
|
createBatch(userId, "refund", amount, orderId, "order");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void freezeForActivity(Long userId, int amount, Long activityId, String activityTitle) {
|
||||||
|
int rows = userPointsRepo.freezePoints(userId, amount);
|
||||||
|
if (rows == 0) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||||
|
addPoints(userId, "freeze", "activity_freeze", -amount,
|
||||||
|
userPointsRepo.findByUserId(userId).getAvailablePoints(),
|
||||||
|
"活动冻结-" + activityTitle, activityId, "activity");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void unfreezeForActivity(Long userId, int amount, Long activityId, String activityTitle) {
|
||||||
|
int rows = userPointsRepo.unfreezePoints(userId, amount);
|
||||||
|
if (rows == 0) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||||
|
addPoints(userId, "unfreeze", "activity_unfreeze", amount,
|
||||||
|
userPointsRepo.findByUserId(userId).getAvailablePoints(),
|
||||||
|
"活动解冻-" + activityTitle, activityId, "activity");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void consumeFrozenForActivity(Long userId, int amount, Long activityId, String activityTitle) {
|
||||||
|
int rows = userPointsRepo.consumeFrozenPoints(userId, amount);
|
||||||
|
if (rows == 0) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||||
|
UserPoints up = userPointsRepo.findByUserId(userId);
|
||||||
|
PointsRecord r = new PointsRecord();
|
||||||
|
r.setUserId(userId);
|
||||||
|
r.setPointsType("consume");
|
||||||
|
r.setSource("activity");
|
||||||
|
r.setAmount(-amount);
|
||||||
|
r.setBalance(up.getAvailablePoints());
|
||||||
|
r.setDescription("活动消费-" + activityTitle);
|
||||||
|
r.setRelatedId(activityId);
|
||||||
|
r.setRelatedType("activity");
|
||||||
|
recordRepo.insert(r);
|
||||||
|
consumeBatchesFIFO(userId, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public int expirePoints() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
List<PointsBatch> expiredBatches = batchRepo.findExpiredBatches(now);
|
||||||
|
int totalExpired = 0;
|
||||||
|
|
||||||
|
for (PointsBatch batch : expiredBatches) {
|
||||||
|
int expireAmount = batch.getRemainAmount();
|
||||||
|
if (expireAmount <= 0) continue;
|
||||||
|
|
||||||
|
int rows = userPointsRepo.addAvailablePoints(batch.getUserId(), -expireAmount);
|
||||||
|
if (rows == 0) continue;
|
||||||
|
|
||||||
|
batchRepo.markExpired(batch.getId());
|
||||||
|
|
||||||
|
PointsRecord record = new PointsRecord();
|
||||||
|
record.setUserId(batch.getUserId());
|
||||||
|
record.setPointsType("expire");
|
||||||
|
record.setSource("expire");
|
||||||
|
record.setAmount(-expireAmount);
|
||||||
|
record.setBalance(userPointsRepo.findByUserId(batch.getUserId()).getAvailablePoints());
|
||||||
|
record.setDescription("积分过期: 来源=" + batch.getSource() + ", 获得时间=" + batch.getEarnedAt().toLocalDate());
|
||||||
|
record.setRelatedId(batch.getId());
|
||||||
|
record.setRelatedType("points_batch");
|
||||||
|
recordRepo.insert(record);
|
||||||
|
|
||||||
|
totalExpired += expireAmount;
|
||||||
|
}
|
||||||
|
return totalExpired;
|
||||||
|
}
|
||||||
|
|
||||||
private void addPoints(Long userId, String type, String source, int amount,
|
private void addPoints(Long userId, String type, String source, int amount,
|
||||||
int balance, String desc, Long relatedId, String relatedType) {
|
int balance, String desc, Long relatedId, String relatedType) {
|
||||||
// 更新账户
|
// 更新账户(乐观锁:WHERE available_points + amount >= 0)
|
||||||
if ("earn".equals(type)) {
|
if ("earn".equals(type)) {
|
||||||
userPointsRepo.addAvailablePoints(userId, amount);
|
int rows = userPointsRepo.addAvailablePoints(userId, amount);
|
||||||
|
if (rows == 0) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||||
userPointsRepo.addTotalEarned(userId, amount);
|
userPointsRepo.addTotalEarned(userId, amount);
|
||||||
} else if ("consume".equals(type)) {
|
} else if ("consume".equals(type)) {
|
||||||
userPointsRepo.addAvailablePoints(userId, amount); // amount为负数
|
int rows = userPointsRepo.addAvailablePoints(userId, amount); // amount为负数
|
||||||
|
if (rows == 0) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH);
|
||||||
userPointsRepo.addTotalConsumed(userId, -amount);
|
userPointsRepo.addTotalConsumed(userId, -amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 原子更新后重新读取实际余额,避免并发下 balance 不准确
|
||||||
|
UserPoints updatedPoints = userPointsRepo.findByUserId(userId);
|
||||||
|
int actualBalance = updatedPoints != null ? updatedPoints.getAvailablePoints() : balance;
|
||||||
|
|
||||||
// 记录流水
|
// 记录流水
|
||||||
PointsRecord r = new PointsRecord();
|
PointsRecord r = new PointsRecord();
|
||||||
r.setUserId(userId);
|
r.setUserId(userId);
|
||||||
r.setPointsType(type);
|
r.setPointsType(type);
|
||||||
r.setSource(source);
|
r.setSource(source);
|
||||||
r.setAmount(amount);
|
r.setAmount(amount);
|
||||||
r.setBalance(balance);
|
r.setBalance(actualBalance);
|
||||||
r.setDescription(desc);
|
r.setDescription(desc);
|
||||||
r.setRelatedId(relatedId);
|
r.setRelatedId(relatedId);
|
||||||
r.setRelatedType(relatedType);
|
r.setRelatedType(relatedType);
|
||||||
@@ -171,6 +358,71 @@ public class PointsServiceImpl implements PointsService {
|
|||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void createPointsBatch(Long userId, String source, int amount, Long relatedId, String relatedType) {
|
||||||
|
if (amount <= 0) {
|
||||||
|
log.warn("[Points] createPointsBatch amount非法,跳过: userId={}, source={}, amount={}", userId, source, amount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createBatch(userId, source, amount, relatedId, relatedType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建积分批次(用于过期追踪),根据 source 从 points_rules 读取差异化有效期 */
|
||||||
|
private void createBatch(Long userId, String source, int amount, Long relatedId, String relatedType) {
|
||||||
|
int expireDays = getExpireDaysBySource(source);
|
||||||
|
LocalDateTime expireAt = expireDays > 0
|
||||||
|
? LocalDateTime.now().plusDays(expireDays)
|
||||||
|
: NEVER_EXPIRE; // 0=永不过期
|
||||||
|
PointsBatch batch = new PointsBatch();
|
||||||
|
batch.setUserId(userId);
|
||||||
|
batch.setSource(source);
|
||||||
|
batch.setOriginalAmount(amount);
|
||||||
|
batch.setRemainAmount(amount);
|
||||||
|
batch.setEarnedAt(LocalDateTime.now());
|
||||||
|
batch.setExpireAt(expireAt);
|
||||||
|
batch.setStatus("active");
|
||||||
|
batch.setRelatedId(relatedId);
|
||||||
|
batch.setRelatedType(relatedType);
|
||||||
|
batchRepo.insert(batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据积分来源从 points_rules 表获取有效期天数,null则用默认值,0=永不过期 */
|
||||||
|
private int getExpireDaysBySource(String source) {
|
||||||
|
PointsRule rule = ruleRepo.findBySource(source);
|
||||||
|
if (rule != null && rule.getExpireDays() != null) {
|
||||||
|
return rule.getExpireDays();
|
||||||
|
}
|
||||||
|
return DEFAULT_EXPIRE_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FIFO消费批次积分 */
|
||||||
|
private void consumeBatchesFIFO(Long userId, int amount) {
|
||||||
|
List<PointsBatch> batches = batchRepo.findActiveBatchesByUserId(userId);
|
||||||
|
int remaining = amount;
|
||||||
|
for (PointsBatch batch : batches) {
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
int deduct = Math.min(remaining, batch.getRemainAmount());
|
||||||
|
batchRepo.deductBatch(batch.getId(), deduct);
|
||||||
|
remaining -= deduct;
|
||||||
|
if (batch.getRemainAmount() - deduct == 0) {
|
||||||
|
batchRepo.markConsumed(batch.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据积分来源返回对应的成长值 */
|
||||||
|
private int getGrowthForSource(String source) {
|
||||||
|
return switch (source) {
|
||||||
|
case "register" -> 5;
|
||||||
|
case "invite" -> 10;
|
||||||
|
case "invited" -> 5;
|
||||||
|
case "join_community" -> 5;
|
||||||
|
case "review" -> 3;
|
||||||
|
default -> 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private String getSourceLabel(String source) {
|
private String getSourceLabel(String source) {
|
||||||
return switch (source) {
|
return switch (source) {
|
||||||
case "register" -> "新用户注册";
|
case "register" -> "新用户注册";
|
||||||
@@ -182,7 +434,14 @@ public class PointsServiceImpl implements PointsService {
|
|||||||
case "review" -> "发表评价";
|
case "review" -> "发表评价";
|
||||||
case "activity" -> "活动奖励";
|
case "activity" -> "活动奖励";
|
||||||
case "admin_adjust" -> "管理员调整";
|
case "admin_adjust" -> "管理员调整";
|
||||||
default -> source;
|
case "admin_add" -> "管理员增加";
|
||||||
|
case "admin_deduct" -> "管理员扣减";
|
||||||
|
case "refund" -> "退款退还";
|
||||||
|
case "invited" -> "接受邀请";
|
||||||
|
case "expire" -> "积分过期";
|
||||||
|
case "activity_freeze" -> "活动冻结";
|
||||||
|
case "activity_unfreeze" -> "活动解冻";
|
||||||
|
default -> source;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- 通用补偿任务表(Outbox Pattern)
|
||||||
|
-- 用于MQ发送失败、异步操作失败的重试补偿
|
||||||
|
CREATE TABLE compensation_tasks (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
task_type VARCHAR(50) NOT NULL COMMENT '任务类型: mq_refund_approved / mq_order_timeout / mq_order_paid / mq_order_cancelled',
|
||||||
|
biz_key VARCHAR(100) NOT NULL COMMENT '业务幂等键: 如 refund_{id}, order_timeout_{id}',
|
||||||
|
payload TEXT NOT NULL COMMENT 'JSON格式任务参数(exchange, routingKey, eventJson)',
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/processing/success/failed',
|
||||||
|
retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
|
||||||
|
max_retries INT NOT NULL DEFAULT 5 COMMENT '最大重试次数',
|
||||||
|
next_retry_at DATETIME NOT NULL COMMENT '下次重试时间',
|
||||||
|
error_msg TEXT COMMENT '最近一次失败原因',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_biz_key (biz_key),
|
||||||
|
INDEX idx_status_retry (status, next_retry_at)
|
||||||
|
) COMMENT '通用补偿任务表';
|
||||||
224
待实现功能清单.md
Normal file
224
待实现功能清单.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# OpenClaw Skills 待实现功能清单
|
||||||
|
|
||||||
|
> 基于《产品功能架构设计.md》与当前系统实现的差距分析
|
||||||
|
> 更新日期:2026-03-21
|
||||||
|
> 当前状态:Phase 1 MVP 基本完成(~98%),Phase 2 大部分完成(~95%),Phase 3 推进中(~30%,会员等级+优惠券+促销系统已完成)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总览
|
||||||
|
|
||||||
|
| 阶段 | 完成度 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| Phase 1 MVP 核心 | ~98% | 用户注册登录、Skill商城、积分基础、支付充值、混合支付、后台管理(支付回调可靠性待确认) |
|
||||||
|
| Phase 2 核心完善 | ~95% | 内容管理/发票/榜单/RBAC/操作日志/活动管理/帮助中心/反馈系统/积分冻结过期已完成,海报/手机号更换/分享待做 |
|
||||||
|
| Phase 3 运营深化 | ~30% | 会员等级体系+优惠券系统+促销系统(限时折扣/满减/秒杀)已完成;社区/埋点/风控待做 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 优先级 P0 — 补齐 MVP 缺口
|
||||||
|
|
||||||
|
### ~~1. 混合支付完善(积分+现金)~~ ✅ 已完成
|
||||||
|
- **完成内容**:后端 OrderPreviewVO + previewOrder API + 纯积分自动完成 + BigDecimal精度修复 + freezePoints原子SQL;前端 detail.vue 购买弹窗(积分滑块+实时预览+debounce)+ pay.vue 混合支付明细显示
|
||||||
|
|
||||||
|
### 2. 支付回调可靠性
|
||||||
|
- **模块**:支付与充值
|
||||||
|
- **现状**:payment module 有回调端点,但幂等性、签名验证、补偿机制需确认
|
||||||
|
- **需要做**:
|
||||||
|
- 后端:确认回调签名校验、幂等处理、超时补偿查询
|
||||||
|
- 测试:模拟微信/支付宝回调场景
|
||||||
|
- **工作量**:1-2天
|
||||||
|
- **影响**:资金安全
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 优先级 P1-高 — Phase 2 核心缺失
|
||||||
|
|
||||||
|
### ~~3. 后台内容管理(轮播图 + 公告)~~ ✅ 已完成
|
||||||
|
- **完成内容**:BannerController + AnnouncementController 全套 CRUD,前端 admin/banners.vue + admin/announcements.vue 已实现
|
||||||
|
|
||||||
|
### ~~4. 发票管理~~ ✅ 已完成
|
||||||
|
- **完成内容**:InvoiceController 全套(申请/审核/开具),前端 user/invoices.vue + admin/invoices.vue 已实现
|
||||||
|
|
||||||
|
### ~~5. 热门榜单页~~ ✅ 已完成
|
||||||
|
- **完成内容**:SkillController 包含排行查询接口,前端 skill/ranking.vue 已实现,路由 /skill/ranking 已配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 优先级 P1-中
|
||||||
|
|
||||||
|
### 6. 邀请海报生成
|
||||||
|
- **模块**:邀请与社区
|
||||||
|
- **现状**:有邀请码和邀请链接,无海报功能
|
||||||
|
- **需要做**:
|
||||||
|
- 前端:Canvas 绘制带二维码的邀请海报,支持保存到相册/分享
|
||||||
|
- 后端:(可选)服务端生成海报图片 API
|
||||||
|
- **工作量**:2-3天
|
||||||
|
- **影响**:提升社交传播效果和拉新转化率
|
||||||
|
|
||||||
|
### ~~7. RBAC 角色权限完善~~ ✅ 已完成
|
||||||
|
- **完成内容**:RbacController 全套(角色/权限/管理员角色 CRUD),前端 admin/roles.vue + admin/admins.vue 已实现,@RequiresRole/@RequiresPermission 拦截器已就位
|
||||||
|
|
||||||
|
### ~~8. 操作日志~~ ✅ 已完成
|
||||||
|
- **完成内容**:OperationLogAspect AOP 切面 + OperationLogController 查询 API,前端 admin/logs.vue 已实现
|
||||||
|
|
||||||
|
### ~~9. 积分过期 + 冻结机制~~ ✅ 已完成
|
||||||
|
- **完成内容**:
|
||||||
|
- 后端:积分批次表(points_batches)+ FIFO消费 + 定时过期清理(PointsExpireScheduler 每天2:00);积分冻结/解冻/消费冻结积分原子SQL;积分规则表增加 expire_days 字段实现按来源可配置过期天数
|
||||||
|
- 后端安全加固:MQ事务一致性(TransactionSynchronization afterCommit);payOrder降级原子性(TransactionTemplate);邀请积分幂等性(按userId+source+relatedId去重);createPointsBatch amount≤0校验;永不过期硬编码提取为NEVER_EXPIRE常量
|
||||||
|
- 后端补偿机制:通用补偿任务表(compensation_tasks)+ CompensationScheduler(每5分钟扫描,CAS抢占防多实例重复,指数退避1→5→30→120→720分钟,重试耗尽告警);4处MQ发送失败自动写入补偿表(approveRefund/payOrder/cancelOrder/createOrder)
|
||||||
|
- 前端:积分页 hover 展示冻结积分 + 即将过期积分详情(a-popover)
|
||||||
|
|
||||||
|
### 10. 手机号绑定/更换
|
||||||
|
- **模块**:用户管理 > 账户安全
|
||||||
|
- **现状**:settings.vue 有密码修改,无手机号更换
|
||||||
|
- **需要做**:
|
||||||
|
- 后端:验证旧手机号 + 绑定新手机号 API
|
||||||
|
- 前端:settings.vue 增加手机号更换流程(旧号验证→新号验证→完成)
|
||||||
|
- **工作量**:1-2天
|
||||||
|
- **影响**:用户换号后无法操作账户
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 优先级 P1-低
|
||||||
|
|
||||||
|
### ~~11. 后台活动管理~~ ✅ 已完成
|
||||||
|
- **完成内容**:后端 activity 模块全套 CRUD + 定时任务自动结束;前端 admin/activities.vue(新建/编辑/状态切换/删除/筛选);首页活动展示区已集成
|
||||||
|
|
||||||
|
### ~~12. 帮助中心 / FAQ~~ ✅ 已完成
|
||||||
|
- **完成内容**:后端 help 模块(分类+文章 CRUD);前端 admin/help.vue(左右分栏分类+文章管理);前端 help/index.vue(分类浏览/搜索/文章详情)
|
||||||
|
|
||||||
|
### ~~13. 反馈建议系统~~ ✅ 已完成
|
||||||
|
- **完成内容**:后端 feedback 模块(提交/查看/回复 API);前端 user/feedback.vue(提交反馈、查看进度和管理员回复);前端 admin/feedback.vue(反馈列表/筛选/查看/回复/状态管理)
|
||||||
|
|
||||||
|
### 14. 分享功能(社交分享 SDK)
|
||||||
|
- **模块**:邀请与社区
|
||||||
|
- **现状**:无分享功能
|
||||||
|
- **需要做**:
|
||||||
|
- 前端:集成微信 JS-SDK 分享接口
|
||||||
|
- 后端:微信 JS-SDK 签名 API
|
||||||
|
- 前端:Skill详情页/邀请页增加分享按钮
|
||||||
|
- **工作量**:2-3天
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 优先级 P2 — Phase 3 运营深化
|
||||||
|
|
||||||
|
### ~~15. 会员等级 + 成长值体系~~ ✅ 已完成
|
||||||
|
- **完成内容**:数据库(member_level_config + growth_records 表 + 4级初始数据);后端 member 模块(Entity/Repository/Service/Controller);业务集成(签到+1/注册+5/邀请+10/充值/评价+3 自动增加成长值);会员权益(签到积分倍率:金卡1.5x/钻石2x、积分折扣:白银95折/金卡9折/钻石85折);前端 profile 页等级卡片+进度条+等级体系一览;管理后台用户管理页成长值调整功能
|
||||||
|
|
||||||
|
### ~~16. 优惠券系统~~ ✅ 已完成
|
||||||
|
- **完成内容**:满减券/折扣券/立减券,发放/使用/核销/过期,后台管理
|
||||||
|
- **后端**:coupon 模块全套(CouponTemplate CRUD / UserCoupon 领取·使用·退回·过期 / 订单集成 previewOrder+createOrder+cancelOrder);安全审计修复(IDOR越权校验·悲观锁防并发超领·状态机校验·分页上限·信息泄露防护·批量发券@Size限制);零元支付边界修复(优惠券全额抵扣自动完成订单)
|
||||||
|
- **前端**:管理端 admin/coupons.vue(券模板CRUD·状态切换·手动发券·券统计);用户端 user/coupons.vue(优惠券中心·领券·我的优惠券);detail.vue 下单选券集成(可用券查询·券抵扣预览·选券下单);order store couponId 集成
|
||||||
|
- **代码审计修复**:前后端枚举对齐(full_minus→full_reduce / direct_minus→fixed / duration→relative);字段名修正(expireTime→validEnd / deductAmount→discountAmount)
|
||||||
|
- **待优化**:N+1查询(getMyCoupons/listTemplates 循环查模板),非安全问题,后续批量查询重构
|
||||||
|
|
||||||
|
### ~~17. 活动系统(限时折扣/秒杀)~~ ✅ 已完成
|
||||||
|
- **完成内容**:促销模块全栈实现
|
||||||
|
- **后端**:promotion 模块(Promotion/PromotionSkill/PromotionRecord 三表 Entity + Repository + PromotionService接口 + PromotionServiceImpl 完整业务逻辑 + PromotionController REST接口);ErrorCode 9001-9008 促销错误码;支持限时折扣(time_discount)/满减(full_reduce)/秒杀(flash_sale)三种类型;活动状态管理(draft→active→paused→ended);Skill关联促销价/折扣率/库存限量;按用户限购/活动总限量控制;促销使用记录与统计
|
||||||
|
- **前端**:admin/promotions.vue 管理页面(活动CRUD/状态切换/关联Skill/促销统计/筛选分页);apiService 促销API集成;AdminLayout侧边栏+router路由
|
||||||
|
|
||||||
|
### 18. 邮箱注册/登录
|
||||||
|
- **描述**:邮箱+密码注册登录,邮箱验证码
|
||||||
|
- **工作量**:2-3天
|
||||||
|
|
||||||
|
### 19. 实名认证
|
||||||
|
- **描述**:身份证正反面上传、人脸识别、审核状态管理
|
||||||
|
- **工作量**:5-7天(需对接第三方实名服务)
|
||||||
|
|
||||||
|
### 20. 创作者中心完善
|
||||||
|
- **描述**:Skill上传完整流程、版本管理、收益统计、数据看板
|
||||||
|
- **现状**:后端有 developer module,前端缺专门页面
|
||||||
|
- **工作量**:7-10天
|
||||||
|
|
||||||
|
### 21. 微信模板消息推送
|
||||||
|
- **描述**:公众号模板消息(订单通知、积分到账等)
|
||||||
|
- **工作量**:3-5天
|
||||||
|
|
||||||
|
### 22. 在线客服系统
|
||||||
|
- **描述**:智能客服自动回复、人工客服、工单系统
|
||||||
|
- **工作量**:10-15天(或对接第三方客服SaaS)
|
||||||
|
|
||||||
|
### 23. 数据埋点 + 用户行为分析
|
||||||
|
- **描述**:页面浏览、点击、购买、下载埋点,转化漏斗,留存分析
|
||||||
|
- **工作量**:5-7天(或对接神策/GrowingIO)
|
||||||
|
|
||||||
|
### 24. A/B 测试框架
|
||||||
|
- **描述**:实验配置、流量分割、数据对比
|
||||||
|
- **工作量**:5-7天
|
||||||
|
|
||||||
|
### 25. 安全风控体系
|
||||||
|
- **描述**:异常登录检测、支付风控、积分刷单检测、敏感词过滤、图片审核
|
||||||
|
- **工作量**:10-15天
|
||||||
|
|
||||||
|
### 26. 积分转赠
|
||||||
|
- **描述**:用户间积分转赠,手续费扣除
|
||||||
|
- **工作量**:2-3天
|
||||||
|
|
||||||
|
### 27. 登录设备管理
|
||||||
|
- **描述**:登录时间/设备/IP记录,异常登录提醒,多设备登录限制
|
||||||
|
- **工作量**:3-5天
|
||||||
|
|
||||||
|
### 28. 用户标签系统
|
||||||
|
- **描述**:手动打标签、自动标签规则、用于Skill推荐
|
||||||
|
- **工作量**:3-5天
|
||||||
|
|
||||||
|
### 29. 财务报表 + 对账管理
|
||||||
|
- **描述**:交易流水、对账管理、财务报表导出
|
||||||
|
- **工作量**:5-7天
|
||||||
|
|
||||||
|
### 30. 积分规则后台配置
|
||||||
|
- **描述**:各渠道积分获取/消耗/过期规则可视化配置
|
||||||
|
- **工作量**:3-5天
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作量估算汇总
|
||||||
|
|
||||||
|
| 优先级 | 总计 | 已完成 | 剩余 | 预估剩余工时 |
|
||||||
|
|--------|------|--------|------|-------------|
|
||||||
|
| P0 补齐 | 2项 | 1项 | 1项 | 1-2天 |
|
||||||
|
| P1-高 | 3项 | 3项 | 0项 | 0天 |
|
||||||
|
| P1-中 | 5项 | 3项 | 2项 | 4-5天 |
|
||||||
|
| P1-低 | 4项 | 3项 | 1项 | 2-3天 |
|
||||||
|
| P2 | 16项 | 3项 | 13项 | 68-100天 |
|
||||||
|
| **合计** | **30项** | **13项已完成** | **17项** | **~75-110天** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 建议实施路线
|
||||||
|
|
||||||
|
```
|
||||||
|
已完成 ✅
|
||||||
|
→ #1 混合支付完善
|
||||||
|
→ #3 后台内容管理(轮播图+公告)
|
||||||
|
→ #4 发票管理
|
||||||
|
→ #5 热门榜单页
|
||||||
|
→ #7 RBAC权限完善
|
||||||
|
→ #8 操作日志
|
||||||
|
→ #9 积分过期+冻结+MQ补偿机制
|
||||||
|
→ #11 后台活动管理
|
||||||
|
→ #12 帮助中心
|
||||||
|
→ #13 反馈建议系统
|
||||||
|
|
||||||
|
近期(1-2周)
|
||||||
|
→ #2 支付回调可靠性
|
||||||
|
→ #10 手机号绑定/更换
|
||||||
|
|
||||||
|
中期(3-6周)
|
||||||
|
→ #6 邀请海报
|
||||||
|
→ #14 分享功能
|
||||||
|
|
||||||
|
已完成 ✅(Phase 3)
|
||||||
|
→ #15 会员等级+成长值体系
|
||||||
|
→ #16 优惠券系统(后端+前端+审计修复)
|
||||||
|
→ #17 促销系统(限时折扣/满减/秒杀,前后端全栈)
|
||||||
|
|
||||||
|
长期(Phase 3 剩余)
|
||||||
|
→ #18-#30 按业务需求和数据反馈逐步推进
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本文档将随开发进度持续更新*
|
||||||
Reference in New Issue
Block a user