- 后端: JPQL构造器投影排除LONGTEXT大字段(uploadedImages/videoReferenceImages) - 后端: DTO层过滤非分镜图类型的base64内联resultUrl - 前端: 列表缩略图从video改为img loading=lazy,消除172并发请求 - 前端: download函数增加resultUrl懒加载(详情接口兜底) - 文档: 新增性能优化报告 docs/performance-optimization-report.md
924 lines
41 KiB
Java
924 lines
41 KiB
Java
package com.example.demo.service;
|
||
|
||
import java.time.LocalDateTime;
|
||
import java.util.Optional;
|
||
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import org.springframework.cache.Cache;
|
||
import org.springframework.cache.CacheManager;
|
||
import org.springframework.cache.annotation.Cacheable;
|
||
import org.springframework.context.annotation.Lazy;
|
||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||
import org.springframework.stereotype.Service;
|
||
import org.springframework.transaction.annotation.Propagation;
|
||
import org.springframework.transaction.annotation.Transactional;
|
||
|
||
import com.example.demo.config.CacheConfig;
|
||
import com.example.demo.model.MembershipLevel;
|
||
import com.example.demo.model.PointsFreezeRecord;
|
||
import com.example.demo.model.User;
|
||
import com.example.demo.model.UserMembership;
|
||
import com.example.demo.repository.MembershipLevelRepository;
|
||
import com.example.demo.repository.PointsFreezeRecordRepository;
|
||
import com.example.demo.repository.UserMembershipRepository;
|
||
import com.example.demo.repository.UserRepository;
|
||
import com.example.demo.util.UserIdGenerator;
|
||
|
||
@Service
|
||
public class UserService {
|
||
|
||
private static final Logger logger = LoggerFactory.getLogger(UserService.class);
|
||
|
||
private final UserRepository userRepository;
|
||
private final PasswordEncoder passwordEncoder;
|
||
private final PointsFreezeRecordRepository pointsFreezeRecordRepository;
|
||
private final com.example.demo.repository.OrderRepository orderRepository;
|
||
private final com.example.demo.repository.PaymentRepository paymentRepository;
|
||
private final CacheManager cacheManager;
|
||
private final MembershipLevelRepository membershipLevelRepository;
|
||
private final UserMembershipRepository userMembershipRepository;
|
||
|
||
public UserService(UserRepository userRepository, @Lazy PasswordEncoder passwordEncoder,
|
||
PointsFreezeRecordRepository pointsFreezeRecordRepository,
|
||
com.example.demo.repository.OrderRepository orderRepository,
|
||
com.example.demo.repository.PaymentRepository paymentRepository,
|
||
CacheManager cacheManager,
|
||
MembershipLevelRepository membershipLevelRepository,
|
||
UserMembershipRepository userMembershipRepository) {
|
||
this.userRepository = userRepository;
|
||
this.passwordEncoder = passwordEncoder;
|
||
this.pointsFreezeRecordRepository = pointsFreezeRecordRepository;
|
||
this.orderRepository = orderRepository;
|
||
this.paymentRepository = paymentRepository;
|
||
this.cacheManager = cacheManager;
|
||
this.membershipLevelRepository = membershipLevelRepository;
|
||
this.userMembershipRepository = userMembershipRepository;
|
||
}
|
||
|
||
@Transactional
|
||
public User register(String username, String email, String rawPassword) {
|
||
if (userRepository.existsByUsername(username)) {
|
||
throw new IllegalArgumentException("用户名已存在");
|
||
}
|
||
if (userRepository.existsByEmail(email)) {
|
||
throw new IllegalArgumentException("邮箱已被使用");
|
||
}
|
||
User user = new User();
|
||
// 生成唯一用户ID,重试最多10次确保唯一性
|
||
String userId = null;
|
||
for (int i = 0; i < 10; i++) {
|
||
userId = UserIdGenerator.generate();
|
||
if (!userRepository.existsByUserId(userId)) {
|
||
break;
|
||
}
|
||
logger.warn("用户ID冲突,重新生成: userId={}", userId);
|
||
}
|
||
user.setUserId(userId);
|
||
user.setUsername(username);
|
||
user.setEmail(email);
|
||
user.setPasswordHash(passwordEncoder.encode(rawPassword));
|
||
// 注册时默认为普通用户
|
||
user.setRole("ROLE_USER");
|
||
User savedUser = userRepository.save(user);
|
||
|
||
// 自动创建默认会员记录(标准会员,到期时间为1年后)
|
||
createDefaultMembership(savedUser);
|
||
|
||
return savedUser;
|
||
}
|
||
|
||
/**
|
||
* 为新用户创建默认会员记录
|
||
*/
|
||
private void createDefaultMembership(User user) {
|
||
try {
|
||
// 查找入门版会员等级(后端标记仍为 free)
|
||
Optional<MembershipLevel> freeLevel = membershipLevelRepository.findByName("free");
|
||
if (freeLevel.isEmpty()) {
|
||
logger.warn("未找到入门版会员等级(free),跳过创建会员记录");
|
||
return;
|
||
}
|
||
|
||
UserMembership membership = new UserMembership();
|
||
membership.setUserId(user.getId());
|
||
membership.setMembershipLevelId(freeLevel.get().getId());
|
||
membership.setStartDate(LocalDateTime.now());
|
||
membership.setEndDate(LocalDateTime.of(2099, 12, 31, 23, 59, 59)); // 入门版会员永久有效
|
||
membership.setStatus("ACTIVE");
|
||
membership.setAutoRenew(false);
|
||
membership.setCreatedAt(LocalDateTime.now());
|
||
|
||
userMembershipRepository.save(membership);
|
||
logger.info("✅ 为新用户创建默认会员记录: userId={}, level=入门版会员(永久有效)", user.getId());
|
||
} catch (Exception e) {
|
||
logger.error("创建默认会员记录失败: userId={}", user.getId(), e);
|
||
// 不抛出异常,允许用户注册成功
|
||
}
|
||
}
|
||
|
||
@Transactional(readOnly = true)
|
||
public java.util.List<User> findAll() {
|
||
return userRepository.findAll();
|
||
}
|
||
|
||
@Transactional(readOnly = true)
|
||
public User findById(Long id) {
|
||
return userRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("用户不存在"));
|
||
}
|
||
|
||
@Cacheable(value = CacheConfig.USER_CACHE, key = "#username", unless = "#result == null")
|
||
@Transactional(readOnly = true)
|
||
public User findByUsername(String username) {
|
||
return userRepository.findByUsername(username).orElseThrow(() -> new IllegalArgumentException("用户不存在"));
|
||
}
|
||
|
||
@Cacheable(value = CacheConfig.USER_CACHE, key = "#username", unless = "#result == null")
|
||
@Transactional(readOnly = true)
|
||
public User findByUsernameOrNull(String username) {
|
||
return userRepository.findByUsername(username).orElse(null);
|
||
}
|
||
|
||
@Transactional(readOnly = true)
|
||
public boolean existsByUserId(String userId) {
|
||
return userRepository.existsByUserId(userId);
|
||
}
|
||
|
||
|
||
@Transactional
|
||
public User create(String username, String email, String rawPassword) {
|
||
return register(username, email, rawPassword);
|
||
}
|
||
|
||
@Transactional
|
||
public User update(Long id, String username, String email, String rawPasswordNullable) {
|
||
return update(id, username, email, rawPasswordNullable, null);
|
||
}
|
||
|
||
@Transactional
|
||
public User update(Long id, String username, String email, String rawPasswordNullable, String role) {
|
||
User user = findById(id);
|
||
String oldUsername = user.getUsername();
|
||
if (!oldUsername.equals(username) && userRepository.existsByUsername(username)) {
|
||
throw new IllegalArgumentException("用户名已存在");
|
||
}
|
||
if (!user.getEmail().equals(email) && userRepository.existsByEmail(email)) {
|
||
throw new IllegalArgumentException("邮箱已被使用");
|
||
}
|
||
user.setUsername(username);
|
||
user.setEmail(email);
|
||
if (role != null && !role.isBlank()) {
|
||
user.setRole(role);
|
||
}
|
||
if (rawPasswordNullable != null && !rawPasswordNullable.isBlank()) {
|
||
user.setPasswordHash(rawPasswordNullable);
|
||
}
|
||
User savedUser = userRepository.save(user);
|
||
|
||
// 手动清除旧用户名和新用户名的缓存
|
||
evictUserCache(oldUsername);
|
||
if (!oldUsername.equals(username)) {
|
||
evictUserCache(username);
|
||
}
|
||
|
||
return savedUser;
|
||
}
|
||
|
||
/**
|
||
* 手动清除用户缓存
|
||
* 注意:使用 CacheManager 直接操作,避免同类内部调用 @CacheEvict 不生效的问题
|
||
*/
|
||
public void evictUserCache(String username) {
|
||
Cache cache = cacheManager.getCache(CacheConfig.USER_CACHE);
|
||
if (cache != null && username != null) {
|
||
cache.evict(username);
|
||
logger.debug("清除用户缓存: {}", username);
|
||
}
|
||
}
|
||
|
||
@Transactional
|
||
public void delete(Long id) {
|
||
// 先获取用户名用于清除缓存
|
||
userRepository.findById(id).ifPresent(user -> {
|
||
evictUserCache(user.getUsername());
|
||
});
|
||
userRepository.deleteById(id);
|
||
}
|
||
|
||
/**
|
||
* 检查密码是否匹配(加密比较)
|
||
*/
|
||
public boolean checkPassword(String rawPassword, String storedPassword) {
|
||
return passwordEncoder.matches(rawPassword, storedPassword);
|
||
}
|
||
|
||
/**
|
||
* 修改指定用户的密码
|
||
*
|
||
* 原密码为可选:
|
||
* - 如果提供了原密码,则验证原密码是否正确
|
||
* - 如果未提供原密码,则直接设置新密码
|
||
*/
|
||
@Transactional
|
||
public void changePassword(Long userId, String oldPassword, String newPassword) {
|
||
User user = findById(userId);
|
||
|
||
if (newPassword == null || newPassword.isBlank()) {
|
||
throw new IllegalArgumentException("新密码不能为空");
|
||
}
|
||
if (newPassword.length() < 8) {
|
||
throw new IllegalArgumentException("新密码长度不能少于8位");
|
||
}
|
||
// 验证密码必须包含英文字母和数字
|
||
if (!newPassword.matches(".*[a-zA-Z].*")) {
|
||
throw new IllegalArgumentException("新密码必须包含英文字母");
|
||
}
|
||
if (!newPassword.matches(".*[0-9].*")) {
|
||
throw new IllegalArgumentException("新密码必须包含数字");
|
||
}
|
||
|
||
String currentPasswordHash = user.getPasswordHash();
|
||
// 如果提供了原密码,则需要验证
|
||
if (oldPassword != null && !oldPassword.isBlank()) {
|
||
if (currentPasswordHash != null && !currentPasswordHash.isBlank()) {
|
||
if (!checkPassword(oldPassword, currentPasswordHash)) {
|
||
throw new IllegalArgumentException("原密码不正确");
|
||
}
|
||
}
|
||
}
|
||
// 如果未提供原密码,直接设置新密码
|
||
|
||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||
userRepository.save(user);
|
||
evictUserCache(user.getUsername()); // 清除缓存
|
||
}
|
||
|
||
/**
|
||
* 根据邮箱查找用户
|
||
*/
|
||
@Transactional(readOnly = true)
|
||
public User findByEmail(String email) {
|
||
return userRepository.findByEmail(email).orElse(null);
|
||
}
|
||
|
||
@Transactional(readOnly = true)
|
||
public User findByEmailOrNull(String email) {
|
||
return userRepository.findByEmail(email).orElse(null);
|
||
}
|
||
|
||
/**
|
||
* 根据手机号查找用户
|
||
*/
|
||
@Transactional(readOnly = true)
|
||
public User findByPhone(String phone) {
|
||
return userRepository.findByPhone(phone).orElse(null);
|
||
}
|
||
|
||
/**
|
||
* 保存用户
|
||
*/
|
||
@Transactional
|
||
public User save(User user) {
|
||
User savedUser = userRepository.save(user);
|
||
evictUserCache(user.getUsername()); // 清除缓存
|
||
return savedUser;
|
||
}
|
||
|
||
/**
|
||
* 更新用户活跃时间(不清除缓存,避免频繁清除影响性能)
|
||
* 此方法专门用于 UserActivityInterceptor,仅更新 lastActiveTime 字段
|
||
*/
|
||
@Transactional
|
||
public void updateLastActiveTime(String username) {
|
||
userRepository.findByUsername(username).ifPresent(user -> {
|
||
user.setLastActiveTime(java.time.LocalDateTime.now());
|
||
userRepository.save(user);
|
||
// 注意:不清除缓存,因为 lastActiveTime 不是缓存的关键数据
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 增加用户积分
|
||
*/
|
||
@Transactional
|
||
public User addPoints(Long userId, Integer points) {
|
||
java.util.Objects.requireNonNull(userId, "用户ID不能为空");
|
||
User user = userRepository.findById(userId)
|
||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||
|
||
int newPoints = user.getPoints() + points;
|
||
if (newPoints < 0) {
|
||
throw new RuntimeException("积分不能为负数");
|
||
}
|
||
|
||
user.setPoints(newPoints);
|
||
User savedUser = userRepository.save(user);
|
||
evictUserCache(user.getUsername()); // 清除缓存
|
||
return savedUser;
|
||
}
|
||
|
||
/**
|
||
* 减少用户积分
|
||
*/
|
||
@Transactional
|
||
public User deductPoints(Long userId, Integer points) {
|
||
User user = userRepository.findById(userId)
|
||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||
|
||
int newPoints = user.getPoints() - points;
|
||
if (newPoints < 0) {
|
||
throw new RuntimeException("积分不足");
|
||
}
|
||
|
||
user.setPoints(newPoints);
|
||
User savedUser = userRepository.save(user);
|
||
evictUserCache(user.getUsername()); // 清除缓存
|
||
return savedUser;
|
||
}
|
||
|
||
/**
|
||
* 冻结用户积分
|
||
* 修复:重试失败任务时需要重新冻结积分
|
||
*/
|
||
@Transactional
|
||
public PointsFreezeRecord freezePoints(String username, String taskId, PointsFreezeRecord.TaskType taskType, Integer points, String reason) {
|
||
// 检查是否已经存在相同 taskId 的冻结记录
|
||
Optional<PointsFreezeRecord> existingRecord = pointsFreezeRecordRepository.findByTaskId(taskId);
|
||
if (existingRecord.isPresent()) {
|
||
PointsFreezeRecord record = existingRecord.get();
|
||
|
||
// 只有状态为 FROZEN 时才跳过(真正的重复冻结)
|
||
if (record.getStatus() == PointsFreezeRecord.FreezeStatus.FROZEN) {
|
||
logger.info("积分已冻结,跳过重复冻结: taskId={}, status={}", taskId, record.getStatus());
|
||
return record;
|
||
}
|
||
|
||
// 如果状态是 DEDUCTED,说明任务已完成,不应该重试
|
||
if (record.getStatus() == PointsFreezeRecord.FreezeStatus.DEDUCTED) {
|
||
logger.warn("任务已完成并扣除积分,不允许重复冻结: taskId={}", taskId);
|
||
throw new RuntimeException("任务已完成,不允许重复提交: " + taskId);
|
||
}
|
||
|
||
// 如果状态是 RETURNED/EXPIRED,说明是重试任务,需要重新冻结
|
||
logger.info("检测到重试任务,旧记录状态: {}, 将创建新的冻结记录: taskId={}",
|
||
record.getStatus(), taskId);
|
||
// 继续执行下面的冻结逻辑
|
||
}
|
||
|
||
User user = userRepository.findByUsername(username)
|
||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||
|
||
// 检查可用积分是否足够
|
||
if (user.getAvailablePoints() < points) {
|
||
throw new RuntimeException("可用积分不足,当前可用积分: " + user.getAvailablePoints() + ",需要积分: " + points);
|
||
}
|
||
|
||
// 检查总积分是否足够(防止冻结积分过多导致总积分为负)
|
||
if (user.getPoints() < points) {
|
||
throw new RuntimeException("总积分不足,当前总积分: " + user.getPoints() + ",需要积分: " + points);
|
||
}
|
||
|
||
// 增加冻结积分
|
||
user.setFrozenPoints(user.getFrozenPoints() + points);
|
||
userRepository.save(user);
|
||
evictUserCache(username); // 清除缓存
|
||
|
||
// 创建新的冻结记录
|
||
PointsFreezeRecord record = new PointsFreezeRecord(username, taskId, taskType, points, reason);
|
||
record = pointsFreezeRecordRepository.save(record);
|
||
|
||
logger.info("积分冻结成功: taskId={}, username={}, points={}, reason={}",
|
||
taskId, username, points, reason);
|
||
|
||
return record;
|
||
}
|
||
|
||
/**
|
||
* 扣除冻结的积分(任务完成)
|
||
* 使用悲观锁防止并发重复扣除
|
||
* 使用 REQUIRES_NEW 传播行为,确保在一个独立的事务中执行,
|
||
* 这样即使抛出异常回滚,也不会影响调用者的事务(只要调用者捕获了异常)
|
||
*/
|
||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||
public void deductFrozenPoints(String taskId) {
|
||
// 使用悲观写锁查询,防止并发重复扣除
|
||
PointsFreezeRecord record = pointsFreezeRecordRepository.findByTaskIdWithLock(taskId)
|
||
.orElseThrow(() -> new RuntimeException("找不到冻结记录: " + taskId));
|
||
|
||
// 如果已经扣除过,直接返回,避免重复扣除(双重检查,悲观锁已经保证了并发安全)
|
||
if (record.getStatus() == PointsFreezeRecord.FreezeStatus.DEDUCTED) {
|
||
return;
|
||
}
|
||
|
||
if (record.getStatus() != PointsFreezeRecord.FreezeStatus.FROZEN) {
|
||
throw new RuntimeException("冻结记录状态不正确: " + record.getStatus() + ",期望状态: FROZEN");
|
||
}
|
||
|
||
User user = userRepository.findByUsername(record.getUsername())
|
||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||
|
||
// 减少总积分和冻结积分
|
||
user.setPoints(user.getPoints() - record.getFreezePoints());
|
||
user.setFrozenPoints(user.getFrozenPoints() - record.getFreezePoints());
|
||
userRepository.save(user);
|
||
evictUserCache(record.getUsername()); // 清除缓存
|
||
|
||
// 更新冻结记录状态
|
||
record.updateStatus(PointsFreezeRecord.FreezeStatus.DEDUCTED);
|
||
pointsFreezeRecordRepository.save(record);
|
||
}
|
||
|
||
/**
|
||
* 返还冻结的积分(任务失败)
|
||
* 使用悲观锁防止并发重复返还
|
||
* 使用 REQUIRES_NEW 传播行为,防止异常导致外部事务回滚
|
||
* 如果积分已经返还或扣除,则静默返回(幂等操作)
|
||
*/
|
||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||
public void returnFrozenPoints(String taskId) {
|
||
// 使用悲观写锁查询,防止并发重复返还
|
||
// 注意:只查询 FROZEN 状态的记录,如果找不到说明已经处理过
|
||
Optional<PointsFreezeRecord> recordOpt = pointsFreezeRecordRepository.findByTaskIdWithLock(taskId);
|
||
|
||
if (recordOpt.isEmpty()) {
|
||
// 找不到 FROZEN 状态的记录,说明已经返还或扣除过,静默返回(幂等操作)
|
||
logger.debug("找不到 FROZEN 状态的冻结记录,可能已处理过: taskId={}", taskId);
|
||
return;
|
||
}
|
||
|
||
PointsFreezeRecord record = recordOpt.get();
|
||
|
||
// 双重检查:如果状态不是 FROZEN,说明已经处理过,静默返回(幂等操作)
|
||
if (record.getStatus() != PointsFreezeRecord.FreezeStatus.FROZEN) {
|
||
logger.debug("积分记录状态为 {},跳过返还: taskId={}", record.getStatus(), taskId);
|
||
return;
|
||
}
|
||
|
||
User user = userRepository.findByUsername(record.getUsername())
|
||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||
|
||
// 减少冻结积分(总积分不变),确保不会为负数
|
||
user.setFrozenPoints(Math.max(0, user.getFrozenPoints() - record.getFreezePoints()));
|
||
userRepository.save(user);
|
||
evictUserCache(record.getUsername()); // 清除缓存
|
||
|
||
// 更新冻结记录状态
|
||
record.updateStatus(PointsFreezeRecord.FreezeStatus.RETURNED);
|
||
pointsFreezeRecordRepository.save(record);
|
||
}
|
||
|
||
/**
|
||
* 给用户增加积分
|
||
*/
|
||
@Transactional
|
||
public void addPoints(String username, Integer points) {
|
||
User user = userRepository.findByUsername(username)
|
||
.orElseThrow(() -> new RuntimeException("用户不存在: " + username));
|
||
|
||
user.setPoints(user.getPoints() + points);
|
||
userRepository.save(user);
|
||
evictUserCache(username); // 清除缓存
|
||
}
|
||
|
||
/**
|
||
* 设置用户积分
|
||
*/
|
||
@Transactional
|
||
public void setPoints(String username, Integer points) {
|
||
User user = userRepository.findByUsername(username)
|
||
.orElseThrow(() -> new RuntimeException("用户不存在: " + username));
|
||
|
||
user.setPoints(points);
|
||
userRepository.save(user);
|
||
evictUserCache(username); // 清除缓存
|
||
}
|
||
|
||
/**
|
||
* 获取用户可用积分
|
||
*/
|
||
@Transactional(readOnly = true)
|
||
public Integer getAvailablePoints(String username) {
|
||
User user = userRepository.findByUsername(username)
|
||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||
return user.getAvailablePoints();
|
||
}
|
||
|
||
/**
|
||
* 获取用户冻结积分
|
||
*/
|
||
@Transactional(readOnly = true)
|
||
public Integer getFrozenPoints(String username) {
|
||
User user = userRepository.findByUsername(username)
|
||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||
return user.getFrozenPoints();
|
||
}
|
||
|
||
/**
|
||
* 获取用户积分冻结记录
|
||
*/
|
||
@Transactional(readOnly = true)
|
||
public java.util.List<PointsFreezeRecord> getPointsFreezeRecords(String username) {
|
||
return pointsFreezeRecordRepository.findByUsernameOrderByCreatedAtDesc(username);
|
||
}
|
||
|
||
/**
|
||
* 处理过期的冻结记录
|
||
*/
|
||
@Transactional
|
||
public int processExpiredFrozenRecords() {
|
||
java.time.LocalDateTime expiredTime = java.time.LocalDateTime.now().minusHours(24);
|
||
java.util.List<PointsFreezeRecord> expiredRecords = pointsFreezeRecordRepository.findExpiredFrozenRecords(expiredTime);
|
||
|
||
int processedCount = 0;
|
||
for (PointsFreezeRecord record : expiredRecords) {
|
||
try {
|
||
// 返还过期冻结的积分
|
||
returnFrozenPoints(record.getTaskId());
|
||
processedCount++;
|
||
} catch (Exception e) {
|
||
logger.error("处理过期冻结记录失败: {}", record.getTaskId(), e);
|
||
}
|
||
}
|
||
|
||
return processedCount;
|
||
}
|
||
|
||
/**
|
||
* 修复所有用户的frozen_points字段,使其与实际FROZEN记录的积分总和一致
|
||
* 防止因为并发问题或异常导致frozen_points字段与实际冻结积分不匹配
|
||
*/
|
||
@Transactional
|
||
public int repairAllUsersFrozenPoints() {
|
||
int fixedCount = 0;
|
||
java.util.List<User> allUsers = userRepository.findAll();
|
||
for (User user : allUsers) {
|
||
try {
|
||
Integer actualFrozen = pointsFreezeRecordRepository.sumFrozenPointsByUsername(user.getUsername());
|
||
if (actualFrozen == null) actualFrozen = 0;
|
||
if (!actualFrozen.equals(user.getFrozenPoints())) {
|
||
logger.warn("修复用户冻结积分不一致: username={}, 记录值={}, 实际值={}",
|
||
user.getUsername(), user.getFrozenPoints(), actualFrozen);
|
||
user.setFrozenPoints(actualFrozen);
|
||
userRepository.save(user);
|
||
evictUserCache(user.getUsername());
|
||
fixedCount++;
|
||
}
|
||
} catch (Exception e) {
|
||
logger.error("修复用户冻结积分失败: username={}", user.getUsername(), e);
|
||
}
|
||
}
|
||
return fixedCount;
|
||
}
|
||
|
||
/**
|
||
* 获取用户积分
|
||
*/
|
||
@Transactional(readOnly = true)
|
||
public Integer getUserPoints(Long userId) {
|
||
User user = userRepository.findById(userId)
|
||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||
return user.getPoints();
|
||
}
|
||
|
||
/**
|
||
* 获取积分使用历史(充值和使用记录)
|
||
* 包括:订单充值记录和积分消耗记录
|
||
*/
|
||
@Transactional(readOnly = true)
|
||
public java.util.List<java.util.Map<String, Object>> getPointsHistory(String username, int page, int size) {
|
||
User user = userRepository.findByUsername(username)
|
||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||
|
||
java.util.List<java.util.Map<String, Object>> history = new java.util.ArrayList<>();
|
||
|
||
// 1. 获取成功支付的记录(充值记录)
|
||
// 注意:积分是在支付成功时通过 PaymentService.addPointsForPayment 添加的
|
||
// 所以应该从支付记录中获取充值记录,而不是从订单中获取
|
||
java.util.List<com.example.demo.model.Payment> allPayments = paymentRepository
|
||
.findByUserIdOrderByCreatedAtDesc(user.getId());
|
||
|
||
java.util.List<com.example.demo.model.Payment> successfulPayments = allPayments
|
||
.stream()
|
||
.filter(payment -> payment.getStatus() == com.example.demo.model.PaymentStatus.SUCCESS)
|
||
.collect(java.util.stream.Collectors.toList());
|
||
|
||
for (com.example.demo.model.Payment payment : successfulPayments) {
|
||
// 从支付记录中提取积分数量(使用与 PaymentService.addPointsForPayment 相同的逻辑)
|
||
Integer points = extractPointsFromPayment(payment);
|
||
|
||
// 即使没有提取到积分,也显示充值记录(可能金额不在套餐范围内,但用户确实支付了)
|
||
// 如果提取到积分,使用提取的积分;否则显示0积分(表示支付成功但未获得积分)
|
||
Integer displayPoints = (points != null && points > 0) ? points : 0;
|
||
|
||
java.util.Map<String, Object> record = new java.util.HashMap<>();
|
||
record.put("type", "充值");
|
||
String description = payment.getDescription() != null ? payment.getDescription() : "支付充值";
|
||
if (displayPoints == 0 && points == null) {
|
||
// 如果未提取到积分,在描述中说明
|
||
description = description + "(金额不在套餐范围内,未获得积分)";
|
||
}
|
||
record.put("description", description);
|
||
record.put("points", displayPoints);
|
||
record.put("time", payment.getPaidAt() != null ? payment.getPaidAt() : payment.getCreatedAt());
|
||
record.put("orderId", payment.getOrderId());
|
||
record.put("paymentId", payment.getId());
|
||
history.add(record);
|
||
}
|
||
|
||
// 2. 也检查已支付和已完成订单(作为补充,以防有订单但没有支付记录的情况)
|
||
java.util.List<com.example.demo.model.Order> paidOrders = new java.util.ArrayList<>();
|
||
// 包含PAID状态的订单
|
||
paidOrders.addAll(orderRepository.findByUserIdAndStatus(
|
||
user.getId(),
|
||
com.example.demo.model.OrderStatus.PAID
|
||
));
|
||
// 包含COMPLETED状态的订单
|
||
paidOrders.addAll(orderRepository.findByUserIdAndStatus(
|
||
user.getId(),
|
||
com.example.demo.model.OrderStatus.COMPLETED
|
||
));
|
||
|
||
for (com.example.demo.model.Order order : paidOrders) {
|
||
// 检查是否已经在支付记录中处理过(避免重复)
|
||
// 通过Payment关联的Order ID来匹配,而不是通过orderId字符串
|
||
boolean alreadyProcessed = successfulPayments.stream()
|
||
.anyMatch(p -> p.getOrder() != null && p.getOrder().getId().equals(order.getId()));
|
||
|
||
if (!alreadyProcessed) {
|
||
// 从订单描述或订单项中提取积分数量
|
||
Integer points = extractPointsFromOrder(order);
|
||
|
||
if (points != null && points > 0) {
|
||
java.util.Map<String, Object> record = new java.util.HashMap<>();
|
||
record.put("type", "充值");
|
||
record.put("description", "订单充值 - " + (order.getDescription() != null ? order.getDescription() : ""));
|
||
record.put("points", points);
|
||
record.put("time", order.getPaidAt() != null ? order.getPaidAt() : order.getCreatedAt());
|
||
record.put("orderNumber", order.getOrderNumber());
|
||
record.put("orderType", order.getOrderType() != null ? order.getOrderType().name() : "");
|
||
history.add(record);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 获取积分冻结记录(使用记录)- 只获取已扣除的记录
|
||
// 直接在数据库层面过滤DEDUCTED状态,避免加载所有记录到内存
|
||
java.util.List<PointsFreezeRecord> deductedRecords = pointsFreezeRecordRepository
|
||
.findByUsernameAndStatusOrderByCompletedAtDesc(username, PointsFreezeRecord.FreezeStatus.DEDUCTED);
|
||
|
||
for (PointsFreezeRecord record : deductedRecords) {
|
||
java.util.Map<String, Object> historyRecord = new java.util.HashMap<>();
|
||
historyRecord.put("type", "消耗");
|
||
historyRecord.put("description", record.getTaskType().getDescription() + " - " +
|
||
(record.getFreezeReason() != null ? record.getFreezeReason() : "任务消耗"));
|
||
historyRecord.put("points", -record.getFreezePoints()); // 负数表示消耗
|
||
historyRecord.put("time", record.getCompletedAt() != null ? record.getCompletedAt() : record.getCreatedAt());
|
||
historyRecord.put("taskId", record.getTaskId());
|
||
historyRecord.put("taskType", record.getTaskType().name());
|
||
history.add(historyRecord);
|
||
}
|
||
|
||
// 4. 按时间倒序排序
|
||
history.sort((a, b) -> {
|
||
java.time.LocalDateTime timeA = (java.time.LocalDateTime) a.get("time");
|
||
java.time.LocalDateTime timeB = (java.time.LocalDateTime) b.get("time");
|
||
return timeB.compareTo(timeA); // 倒序
|
||
});
|
||
|
||
// 5. 分页处理
|
||
int start = page * size;
|
||
int end = Math.min(start + size, history.size());
|
||
if (start >= history.size()) {
|
||
return new java.util.ArrayList<>();
|
||
}
|
||
return history.subList(start, end);
|
||
}
|
||
|
||
/**
|
||
* 从订单中提取积分数量
|
||
* 这里需要根据实际业务逻辑调整
|
||
* 假设订单描述或订单项中包含积分信息
|
||
*/
|
||
private Integer extractPointsFromOrder(com.example.demo.model.Order order) {
|
||
// 方法1:从订单描述中提取(如果描述包含积分信息)
|
||
if (order.getDescription() != null) {
|
||
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("(\\d+)积分");
|
||
java.util.regex.Matcher matcher = pattern.matcher(order.getDescription());
|
||
if (matcher.find()) {
|
||
return Integer.valueOf(matcher.group(1));
|
||
}
|
||
}
|
||
|
||
// 方法2:从订单项中提取(如果订单项名称包含积分信息)
|
||
if (order.getOrderItems() != null && !order.getOrderItems().isEmpty()) {
|
||
for (com.example.demo.model.OrderItem item : order.getOrderItems()) {
|
||
if (item.getProductName() != null) {
|
||
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("(\\d+)积分");
|
||
java.util.regex.Matcher matcher = pattern.matcher(item.getProductName());
|
||
if (matcher.find()) {
|
||
return Integer.valueOf(matcher.group(1));
|
||
}
|
||
// 如果是会员订阅,从数据库读取积分配置
|
||
if (item.getProductName().contains("标准版") || item.getProductName().contains("专业版")) {
|
||
// 从membership_levels表读取积分配置(禁止硬编码)
|
||
MembershipLevel standardLevel = membershipLevelRepository.findByName("standard")
|
||
.orElse(null);
|
||
MembershipLevel proLevel = membershipLevelRepository.findByName("professional")
|
||
.orElse(null);
|
||
|
||
if (item.getProductName().contains("标准版") && standardLevel != null) {
|
||
return standardLevel.getPointsBonus();
|
||
} else if (item.getProductName().contains("专业版") && proLevel != null) {
|
||
return proLevel.getPointsBonus();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 方法3:根据订单类型和金额计算积分(从数据库读取配置)
|
||
if (order.getOrderType() != null) {
|
||
if (order.getOrderType() == com.example.demo.model.OrderType.SUBSCRIPTION) {
|
||
// 订阅订单:根据金额计算积分(从数据库读取,禁止硬编码)
|
||
if (order.getTotalAmount() != null) {
|
||
// 使用OrderService的逻辑从数据库读取积分配置
|
||
try {
|
||
MembershipLevel standardLevel = membershipLevelRepository.findByName("standard")
|
||
.orElseThrow(() -> new IllegalStateException("数据库中缺少standard会员等级配置"));
|
||
MembershipLevel proLevel = membershipLevelRepository.findByName("professional")
|
||
.orElseThrow(() -> new IllegalStateException("数据库中缺少professional会员等级配置"));
|
||
|
||
int standardPrice = standardLevel.getPrice().intValue();
|
||
int standardPoints = standardLevel.getPointsBonus();
|
||
int proPrice = proLevel.getPrice().intValue();
|
||
int proPoints = proLevel.getPointsBonus();
|
||
|
||
int amountInt = order.getTotalAmount().intValue();
|
||
|
||
// 判断套餐类型(允许10%的价格浮动范围)
|
||
if (amountInt >= proPrice * 0.9 && amountInt <= proPrice * 1.1) {
|
||
return proPoints; // 专业版积分
|
||
} else if (amountInt >= standardPrice * 0.9 && amountInt <= standardPrice * 1.1) {
|
||
return standardPoints; // 标准版积分
|
||
} else if (amountInt >= proPrice) {
|
||
return proPoints;
|
||
} else if (amountInt >= standardPrice) {
|
||
return standardPoints;
|
||
}
|
||
} catch (Exception e) {
|
||
logger.error("从数据库读取会员等级配置失败: {}", e.getMessage(), e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 从支付记录中提取积分数量
|
||
* 使用与 PaymentService.addPointsForPayment 相同的逻辑
|
||
*/
|
||
private Integer extractPointsFromPayment(com.example.demo.model.Payment payment) {
|
||
if (payment == null) {
|
||
return null;
|
||
}
|
||
|
||
java.math.BigDecimal amount = payment.getAmount();
|
||
if (amount == null) {
|
||
logger.warn("支付记录 ID: {} 的金额为空", payment.getId());
|
||
return null;
|
||
}
|
||
|
||
String description = payment.getDescription() != null ? payment.getDescription() : "";
|
||
Integer pointsToAdd = 0;
|
||
|
||
// 从membership_levels表读取价格和积分配置(必须从数据库获取,禁止硬编码)
|
||
MembershipLevel standardLevel = membershipLevelRepository.findByName("standard")
|
||
.orElseThrow(() -> new IllegalStateException("数据库中缺少standard会员等级配置"));
|
||
MembershipLevel proLevel = membershipLevelRepository.findByName("professional")
|
||
.orElseThrow(() -> new IllegalStateException("数据库中缺少professional会员等级配置"));
|
||
|
||
int standardPrice = standardLevel.getPrice().intValue();
|
||
int standardPoints = standardLevel.getPointsBonus();
|
||
int proPrice = proLevel.getPrice().intValue();
|
||
int proPoints = proLevel.getPointsBonus();
|
||
|
||
// 优先从描述中识别套餐类型
|
||
if (description.contains("标准版") || description.contains("standard") ||
|
||
description.contains("Standard") || description.contains("STANDARD")) {
|
||
pointsToAdd = standardPoints;
|
||
} else if (description.contains("专业版") || description.contains("premium") ||
|
||
description.contains("Premium") || description.contains("PREMIUM") ||
|
||
description.contains("professional") || description.contains("Professional")) {
|
||
pointsToAdd = proPoints;
|
||
} else {
|
||
// 如果描述中没有套餐信息,根据金额判断
|
||
int amountInt = amount.intValue();
|
||
if (amountInt >= proPrice * 0.9 && amountInt <= proPrice * 1.1) {
|
||
pointsToAdd = proPoints;
|
||
} else if (amountInt >= standardPrice * 0.9 && amountInt <= standardPrice * 1.1) {
|
||
pointsToAdd = standardPoints;
|
||
} else if (amountInt >= proPrice) {
|
||
pointsToAdd = proPoints;
|
||
} else if (amountInt >= standardPrice) {
|
||
pointsToAdd = standardPoints;
|
||
}
|
||
}
|
||
|
||
return pointsToAdd > 0 ? pointsToAdd : null;
|
||
}
|
||
|
||
/**
|
||
* 统计在线用户数(最近N分钟内有活动的用户)
|
||
*
|
||
* @param minutes 活跃时间范围(分钟),默认10分钟
|
||
* @return 在线用户数量
|
||
*/
|
||
@Transactional(readOnly = true)
|
||
public long countOnlineUsers(int minutes) {
|
||
LocalDateTime activeAfter = LocalDateTime.now().minusMinutes(minutes);
|
||
return userRepository.countByLastActiveTimeAfter(activeAfter);
|
||
}
|
||
|
||
/**
|
||
* 统计在线用户数(默认10分钟内活跃)
|
||
*
|
||
* @return 在线用户数量
|
||
*/
|
||
@Transactional(readOnly = true)
|
||
public long countOnlineUsers() {
|
||
return countOnlineUsers(10);
|
||
}
|
||
|
||
/**
|
||
* 处理过期会员:付费会员过期同时清零积分,但保留会员等级
|
||
* 免费/入门会员不会过期(到期时间设为2099年)
|
||
* @return 处理的过期会员数量
|
||
*/
|
||
@Transactional
|
||
public int processExpiredMemberships(com.example.demo.repository.UserMembershipRepository userMembershipRepository) {
|
||
LocalDateTime now = LocalDateTime.now();
|
||
java.util.List<com.example.demo.model.UserMembership> expiredMemberships =
|
||
userMembershipRepository.findByStatusAndEndDateBefore("ACTIVE", now);
|
||
|
||
// 查找免费/入门版会员等级(后端标记为 free)
|
||
Optional<MembershipLevel> freeLevelOpt = membershipLevelRepository.findByName("free");
|
||
if (freeLevelOpt.isEmpty()) {
|
||
logger.error("未找到免费/入门版会员等级(free),无法处理过期会员");
|
||
return 0;
|
||
}
|
||
Long freeLevelId = freeLevelOpt.get().getId();
|
||
|
||
// 永久有效的到期时间(针对免费/入门会员)
|
||
LocalDateTime permanentEndDate = LocalDateTime.of(2099, 12, 31, 23, 59, 59);
|
||
|
||
int processedCount = 0;
|
||
for (com.example.demo.model.UserMembership membership : expiredMemberships) {
|
||
try {
|
||
// 跳过免费/入门版会员(他们不应该过期,但以防万一)
|
||
if (membership.getMembershipLevelId().equals(freeLevelId)) {
|
||
// 免费/入门版会员如果意外过期,直接延长到2099年
|
||
membership.setEndDate(permanentEndDate);
|
||
membership.setUpdatedAt(now);
|
||
userMembershipRepository.save(membership);
|
||
logger.info("✅ 免费/入门版会员到期时间已延长: userId={}", membership.getUserId());
|
||
continue;
|
||
}
|
||
|
||
// 🔥 付费会员过期:保留等级,只清零积分,不降级
|
||
// 不修改 membershipLevelId(保留原等级)
|
||
// 不修改 endDate(保留过期时间)
|
||
// 不修改 status(保持ACTIVE,前端根据endDate判断是否过期)
|
||
membership.setUpdatedAt(now);
|
||
userMembershipRepository.save(membership);
|
||
|
||
// 清零用户积分
|
||
java.util.Optional<User> userOpt = userRepository.findById(membership.getUserId());
|
||
if (userOpt.isPresent()) {
|
||
User user = userOpt.get();
|
||
int oldPoints = user.getPoints();
|
||
if (oldPoints > 0) {
|
||
user.setPoints(0);
|
||
user.setFrozenPoints(0); // 同时清零冻结积分
|
||
userRepository.save(user);
|
||
logger.info("✅ 付费会员过期积分清零: userId={}, 原积分={}", membership.getUserId(), oldPoints);
|
||
}
|
||
}
|
||
|
||
processedCount++;
|
||
logger.info("✅ 付费会员过期已处理(保留等级,积分清零): userId={}, 过期时间={}",
|
||
membership.getUserId(), membership.getEndDate());
|
||
} catch (Exception e) {
|
||
logger.error("处理过期会员失败: userId={}", membership.getUserId(), e);
|
||
}
|
||
}
|
||
|
||
if (processedCount > 0) {
|
||
logger.info("✅ 共处理 {} 个过期付费会员,已清零积分但保留会员等级", processedCount);
|
||
}
|
||
|
||
return processedCount;
|
||
}
|
||
}
|