From a8aaf15bfb410a6a9675b299d5f421c7fc065d7c Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 21 Mar 2026 14:31:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(compensation):=20=E5=AE=9E=E7=8E=B0MQ?= =?UTF-8?q?=E8=A1=A5=E5=81=BF=E6=9C=BA=E5=88=B6(Outbox=20Pattern)=20+=20?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 - 更新待实现功能清单 --- .../com/openclaw/OpenclawApplication.java | 4 + .../compensation/CompensationScheduler.java | 126 ++++ .../compensation/CompensationService.java | 52 ++ .../common/compensation/CompensationTask.java | 22 + .../CompensationTaskRepository.java | 24 + .../admin/service/impl/AdminServiceImpl.java | 591 ++++++++++++++++++ .../order/service/impl/OrderServiceImpl.java | 170 +++-- .../service/impl/PointsServiceImpl.java | 289 ++++++++- .../V20240110__create_compensation_tasks.sql | 17 + 待实现功能清单.md | 224 +++++++ 10 files changed, 1464 insertions(+), 55 deletions(-) create mode 100644 openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationScheduler.java create mode 100644 openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationService.java create mode 100644 openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationTask.java create mode 100644 openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationTaskRepository.java create mode 100644 openclaw-backend/openclaw-backend/src/main/java/com/openclaw/module/admin/service/impl/AdminServiceImpl.java create mode 100644 openclaw-backend/openclaw-backend/src/main/resources/db/migration/V20240110__create_compensation_tasks.sql create mode 100644 待实现功能清单.md diff --git a/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/OpenclawApplication.java b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/OpenclawApplication.java index 294307d..90a3519 100644 --- a/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/OpenclawApplication.java +++ b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/OpenclawApplication.java @@ -1,9 +1,13 @@ package com.openclaw; +import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling +@MapperScan({"com.openclaw.module.**.repository", "com.openclaw.common.leaf", "com.openclaw.common.compensation"}) public class OpenclawApplication { public static void main(String[] args) { diff --git a/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationScheduler.java b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationScheduler.java new file mode 100644 index 0000000..96eeaca --- /dev/null +++ b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationScheduler.java @@ -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 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 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 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); + } + } +} diff --git a/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationService.java b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationService.java new file mode 100644 index 0000000..3c46909 --- /dev/null +++ b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationService.java @@ -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); + } + } +} diff --git a/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationTask.java b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationTask.java new file mode 100644 index 0000000..e97d0f7 --- /dev/null +++ b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationTask.java @@ -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; +} diff --git a/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationTaskRepository.java b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationTaskRepository.java new file mode 100644 index 0000000..1d24ace --- /dev/null +++ b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/common/compensation/CompensationTaskRepository.java @@ -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 { + + @Select("SELECT * FROM compensation_tasks WHERE status = 'pending' AND next_retry_at <= #{now} ORDER BY created_at ASC LIMIT 100") + List 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); +} diff --git a/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/module/admin/service/impl/AdminServiceImpl.java b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/module/admin/service/impl/AdminServiceImpl.java new file mode 100644 index 0000000..f389741 --- /dev/null +++ b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/module/admin/service/impl/AdminServiceImpl.java @@ -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().eq(User::getStatus, "active"))); + vo.setTotalSkills(skillRepo.selectCount(null)); + vo.setActiveSkills(skillRepo.selectCount( + new LambdaQueryWrapper().eq(Skill::getStatus, "approved"))); + vo.setTotalOrders(orderRepo.selectCount(null)); + vo.setCompletedOrders(orderRepo.selectCount( + new LambdaQueryWrapper().in(Order::getStatus, "paid", "completed"))); + + // 今日数据 + vo.setTodayNewUsers(userRepo.selectCount( + new LambdaQueryWrapper().ge(User::getCreatedAt, todayStart))); + vo.setTodayOrders(orderRepo.selectCount( + new LambdaQueryWrapper().ge(Order::getCreatedAt, todayStart))); + + // 积分汇总 + vo.setTotalPointsIssued(userPointsRepo.sumTotalEarned()); + vo.setTotalPointsConsumed(userPointsRepo.sumTotalConsumed()); + vo.setTotalRevenue(orderRepo.sumTotalRevenue()); + + return vo; + } + + // ==================== 用户管理 ==================== + + @Override + public IPage listUsers(String keyword, String status, String role, int pageNum, int pageSize) { + LambdaQueryWrapper 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 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 listSkills(String keyword, String status, Integer categoryId, int pageNum, int pageSize) { + LambdaQueryWrapper 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 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 listOrders(String keyword, String status, int pageNum, int pageSize) { + LambdaQueryWrapper 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 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 listRefunds(String keyword, String status, int pageNum, int pageSize) { + LambdaQueryWrapper 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 page = refundRepo.selectPage(new Page<>(pageNum, pageSize), wrapper); + return page.convert(this::toAdminRefundVO); + } + + // ==================== 评论管理 ==================== + + @Override + public IPage listComments(String keyword, Long skillId, int pageNum, int pageSize) { + LambdaQueryWrapper 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 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 listPointsRecords(Long userId, String pointsType, int pageNum, int pageSize) { + LambdaQueryWrapper 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 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 items = orderItemRepo.selectList( + new LambdaQueryWrapper().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; + }; + } +} diff --git a/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/module/order/service/impl/OrderServiceImpl.java b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/module/order/service/impl/OrderServiceImpl.java index 6a73133..b0ca72b 100644 --- a/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/module/order/service/impl/OrderServiceImpl.java +++ b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/module/order/service/impl/OrderServiceImpl.java @@ -18,11 +18,16 @@ import com.openclaw.module.order.vo.*; import com.openclaw.common.event.OrderPaidEvent; import com.openclaw.common.event.OrderTimeoutEvent; 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.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.stereotype.Service; 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.RoundingMode; @@ -46,9 +51,16 @@ public class OrderServiceImpl implements OrderService { private final SkillService skillService; private final IdGenerator idGenerator; private final RabbitTemplate rabbitTemplate; + private final CompensationService compensationService; + private final CouponService couponService; @Override public OrderPreviewVO previewOrder(Long userId, List skillIds, Integer pointsToUse) { + return previewOrder(userId, skillIds, pointsToUse, null); + } + + @Override + public OrderPreviewVO previewOrder(Long userId, List skillIds, Integer pointsToUse, Long couponId) { // 1. 查询 Skill 价格 List skills = skillRepo.selectBatchIds(skillIds); 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); 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. 组装返回 OrderPreviewVO vo = new OrderPreviewVO(); vo.setItems(skills.stream().map(s -> { @@ -91,6 +117,9 @@ public class OrderServiceImpl implements OrderService { vo.setCashAmount(cash); vo.setAvailablePoints(availablePoints); vo.setMaxPointsCanUse(maxPoints); + vo.setCouponId(appliedCouponId); + vo.setCouponName(couponName); + vo.setCouponDeductAmount(couponDeduct); vo.setPointsRate(POINTS_RATE); return vo; } @@ -131,6 +160,19 @@ public class OrderServiceImpl implements OrderService { .divide(BigDecimal.valueOf(POINTS_RATE), 2, RoundingMode.DOWN); 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 自动判定支付方式 String paymentMethod = dto.getPaymentMethod(); if (cashAmount.compareTo(BigDecimal.ZERO) == 0 && pointsToUse > 0) { @@ -147,6 +189,8 @@ public class OrderServiceImpl implements OrderService { order.setCashAmount(cashAmount); order.setPointsUsed(pointsToUse); order.setPointsDeductAmount(pointsDeductAmount); + order.setCouponId(couponId); + order.setCouponDeductAmount(couponDeductAmount); order.setStatus("pending"); order.setPaymentMethod(paymentMethod); order.setExpiredAt(LocalDateTime.now().plusHours(1)); @@ -170,28 +214,45 @@ public class OrderServiceImpl implements OrderService { pointsService.freezePoints(userId, pointsToUse, order.getId()); } - // 8. 纯积分支付:直接扣减冻结积分并完成订单 - if (cashAmount.compareTo(BigDecimal.ZERO) == 0 && pointsToUse > 0) { - pointsService.consumeFrozenPoints(userId, pointsToUse, order.getId()); + // 7b. 核销优惠券 + if (couponId != null) { + 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.setPaidAt(LocalDateTime.now()); orderRepo.updateById(order); // 发放 Skill 访问权限 + String grantSource = pointsToUse > 0 ? "points" : "coupon"; 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); } - // 9. 非纯积分:发送订单超时延迟消息(1小时后自动取消) - try { - OrderTimeoutEvent timeoutEvent = new OrderTimeoutEvent(order.getId(), userId, order.getOrderNo()); - rabbitTemplate.convertAndSend(MQConstants.EXCHANGE_TOPIC, "delay.order.create", timeoutEvent); - log.info("[MQ] 发送订单超时延迟消息: orderId={}, orderNo={}", order.getId(), order.getOrderNo()); - } catch (Exception e) { - log.error("[MQ] 发送订单超时延迟消息失败: orderId={}", order.getId(), e); - } + // 9. 非纯积分:事务提交后发送订单超时延迟消息(1小时后自动取消) + final Long finalOrderId = order.getId(); + final String finalOrderNo = order.getOrderNo(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + 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); } @@ -252,26 +313,24 @@ public class OrderServiceImpl implements OrderService { order.setStatus("paid"); order.setPaidAt(now); - // 发布订单支付成功事件(异步发放Skill访问权限) - try { - OrderPaidEvent event = new OrderPaidEvent(order.getId(), userId, order.getOrderNo(), paymentNo); - rabbitTemplate.convertAndSend(MQConstants.EXCHANGE_TOPIC, MQConstants.RK_ORDER_PAID, event); - log.info("[MQ] 发布订单支付事件: orderId={}, orderNo={}", order.getId(), order.getOrderNo()); - } catch (Exception e) { - log.error("[MQ] 发布订单支付事件失败,降级同步处理: orderId={}", order.getId(), e); - List items = orderItemRepo.selectList( - new LambdaQueryWrapper().eq(OrderItem::getOrderId, orderId)); - for (OrderItem item : items) { - skillService.grantAccess(userId, item.getSkillId(), orderId, "paid"); + // 事务提交后发布订单支付成功事件(异步发放Skill访问权限) + final Long payOrderId = orderId; + final Long payUserId = userId; + final String payOrderNo = order.getOrderNo(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + OrderPaidEvent event = new OrderPaidEvent(payOrderId, payUserId, payOrderNo, paymentNo); + try { + rabbitTemplate.convertAndSend(MQConstants.EXCHANGE_TOPIC, MQConstants.RK_ORDER_PAID, event); + 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 @@ -293,13 +352,27 @@ public class OrderServiceImpl implements OrderService { pointsService.unfreezePoints(userId, order.getPointsUsed(), orderId); } - // 发布订单取消事件 - try { - rabbitTemplate.convertAndSend(MQConstants.EXCHANGE_TOPIC, MQConstants.RK_ORDER_CANCELLED, order.getOrderNo()); - log.info("[MQ] 发布订单取消事件: orderId={}, orderNo={}", orderId, order.getOrderNo()); - } catch (Exception e) { - log.error("[MQ] 发布订单取消事件失败: orderId={}", orderId, e); + // 退还优惠券 + if (order.getCouponId() != null) { + couponService.returnCoupon(order.getCouponId()); } + + // 事务提交后发布订单取消事件 + 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 @@ -309,9 +382,17 @@ public class OrderServiceImpl implements OrderService { if (order == null || !order.getUserId().equals(userId)) { 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); } + Long refundCount = refundRepo.selectCount( + new LambdaQueryWrapper() + .eq(OrderRefund::getOrderId, orderId) + .in(OrderRefund::getStatus, "pending", "approved", "completed") + ); + if (refundCount != null && refundCount > 0) { + throw new BusinessException(409, "该订单已有退款申请,请勿重复提交"); + } OrderRefund refund = new OrderRefund(); refund.setOrderId(orderId); @@ -320,9 +401,14 @@ public class OrderServiceImpl implements OrderService { refund.setRefundPoints(order.getPointsUsed()); refund.setReason(dto.getReason()); 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.setPreviousOrderStatus(order.getStatus()); refundRepo.insert(refund); order.setStatus("refunding"); @@ -337,6 +423,8 @@ public class OrderServiceImpl implements OrderService { vo.setCashAmount(order.getCashAmount()); vo.setPointsUsed(order.getPointsUsed()); vo.setPointsDeductAmount(order.getPointsDeductAmount()); + vo.setCouponId(order.getCouponId()); + vo.setCouponDeductAmount(order.getCouponDeductAmount()); vo.setStatus(order.getStatus()); vo.setStatusLabel(getStatusLabel(order.getStatus())); vo.setPaymentMethod(order.getPaymentMethod()); @@ -364,6 +452,8 @@ public class OrderServiceImpl implements OrderService { vo.setCashAmount(order.getCashAmount()); vo.setPointsUsed(order.getPointsUsed()); vo.setPointsDeductAmount(order.getPointsDeductAmount()); + vo.setCouponId(order.getCouponId()); + vo.setCouponDeductAmount(order.getCouponDeductAmount()); vo.setStatus(order.getStatus()); vo.setStatusLabel(getStatusLabel(order.getStatus())); vo.setPaymentMethod(order.getPaymentMethod()); diff --git a/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/module/points/service/impl/PointsServiceImpl.java b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/module/points/service/impl/PointsServiceImpl.java index 0d65795..680ee66 100644 --- a/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/module/points/service/impl/PointsServiceImpl.java +++ b/openclaw-backend/openclaw-backend/src/main/java/com/openclaw/module/points/service/impl/PointsServiceImpl.java @@ -6,15 +6,21 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.openclaw.constant.ErrorCode; import com.openclaw.module.points.entity.*; import com.openclaw.exception.BusinessException; +import com.openclaw.module.member.service.MemberService; import com.openclaw.module.points.repository.*; import com.openclaw.module.points.service.PointsService; import com.openclaw.module.points.vo.*; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +@Slf4j @Service @RequiredArgsConstructor public class PointsServiceImpl implements PointsService { @@ -22,6 +28,13 @@ public class PointsServiceImpl implements PointsService { private final UserPointsRepository userPointsRepo; private final PointsRecordRepository recordRepo; 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 @Transactional @@ -40,7 +53,16 @@ public class PointsServiceImpl implements PointsService { public PointsBalanceVO getBalance(Long userId) { UserPoints up = userPointsRepo.findByUserId(userId); 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.setFrozenPoints(up.getFrozenPoints()); vo.setTotalEarned(up.getTotalEarned()); @@ -48,6 +70,10 @@ public class PointsServiceImpl implements PointsService { vo.setLastSignInDate(up.getLastSignInDate()); vo.setSignInStreak(up.getSignInStreak()); 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; } @@ -65,6 +91,10 @@ public class PointsServiceImpl implements PointsService { @Transactional public int signIn(Long userId) { UserPoints up = userPointsRepo.findByUserId(userId); + if (up == null) { + initUserPoints(userId); + up = userPointsRepo.findByUserId(userId); + } LocalDate today = LocalDate.now(); // 今日已签到 @@ -77,14 +107,19 @@ public class PointsServiceImpl implements PointsService { today.minusDays(1).equals(up.getLastSignInDate()); int streak = consecutive ? up.getSignInStreak() + 1 : 1; - // 签到积分:连续签到递增,最高20分 - int points = Math.min(5 + (streak - 1) * 1, 20); + // 签到积分:连续签到递增,最高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); - up.setSignInStreak(streak); - userPointsRepo.updateById(up); + // 只更新签到字段,避免 updateById 覆盖 availablePoints + userPointsRepo.updateSignIn(userId, today, streak); - 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; } @@ -95,25 +130,37 @@ public class PointsServiceImpl implements PointsService { if (rule == null || !rule.getEnabled()) return; UserPoints up = userPointsRepo.findByUserId(userId); + if (up == null) { + initUserPoints(userId); + up = userPointsRepo.findByUserId(userId); + } int newBalance = up.getAvailablePoints() + rule.getPointsAmount(); addPoints(userId, "earn", source, rule.getPointsAmount(), newBalance, 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 @Transactional public void consumePoints(Long userId, int amount, Long relatedId, String relatedType) { 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; addPoints(userId, "consume", "skill_purchase", -amount, newBalance, "兑换Skill", relatedId, relatedType); + consumeBatchesFIFO(userId, amount); } @Override @Transactional 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, userPointsRepo.findByUserId(userId).getAvailablePoints(), "积分冻结-订单" + orderId, orderId, "order"); @@ -122,36 +169,176 @@ public class PointsServiceImpl implements PointsService { @Override @Transactional 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, userPointsRepo.findByUserId(userId).getAvailablePoints(), "积分解冻-订单取消" + 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 public boolean hasEnoughPoints(Long userId, int required) { UserPoints up = userPointsRepo.findByUserId(userId); 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 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, int balance, String desc, Long relatedId, String relatedType) { - // 更新账户 + // 更新账户(乐观锁:WHERE available_points + amount >= 0) 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); } 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); } + // 原子更新后重新读取实际余额,避免并发下 balance 不准确 + UserPoints updatedPoints = userPointsRepo.findByUserId(userId); + int actualBalance = updatedPoints != null ? updatedPoints.getAvailablePoints() : balance; + // 记录流水 PointsRecord r = new PointsRecord(); r.setUserId(userId); r.setPointsType(type); r.setSource(source); r.setAmount(amount); - r.setBalance(balance); + r.setBalance(actualBalance); r.setDescription(desc); r.setRelatedId(relatedId); r.setRelatedType(relatedType); @@ -171,6 +358,71 @@ public class PointsServiceImpl implements PointsService { 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 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) { return switch (source) { case "register" -> "新用户注册"; @@ -182,7 +434,14 @@ public class PointsServiceImpl implements PointsService { case "review" -> "发表评价"; case "activity" -> "活动奖励"; 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; }; } } diff --git a/openclaw-backend/openclaw-backend/src/main/resources/db/migration/V20240110__create_compensation_tasks.sql b/openclaw-backend/openclaw-backend/src/main/resources/db/migration/V20240110__create_compensation_tasks.sql new file mode 100644 index 0000000..280a587 --- /dev/null +++ b/openclaw-backend/openclaw-backend/src/main/resources/db/migration/V20240110__create_compensation_tasks.sql @@ -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 '通用补偿任务表'; diff --git a/待实现功能清单.md b/待实现功能清单.md new file mode 100644 index 0000000..f074f06 --- /dev/null +++ b/待实现功能清单.md @@ -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 按业务需求和数据反馈逐步推进 +``` + +--- + +*本文档将随开发进度持续更新*