You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

116 lines
4.9 KiB

package com.mydreamjob.deletion;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.dataexport.PersonalDataExportCleanupService;
import com.mydreamjob.salarycalculator.SalaryHistoryCleanupService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 注销完成工人(独立事务)。
*
* <p>将到期的 PENDING 一次性转为 COMPLETED 并执行用户 PII 脱敏和审计。
* 通过 REQUIRES_NEW 独立事务 + @Version 乐观锁,保证定时任务与懒转换并发时
* 只有一个事务完成状态迁移;并发冲突时另一方直接返回 false 且不影响外层请求。幂等安全。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AccountDeletionCompletionWorker {
private static final String PHONE_PREFIX = "deleted_";
private final AccountDeletionRepository accountDeletionRepository;
private final UserRepository userRepository;
private final AuditService auditService;
private final AccountDeletionPhoneHasher phoneHasher;
private final PersonalDataExportCleanupService dataExportCleanupService;
private final SalaryHistoryCleanupService salaryHistoryCleanupService;
/**
* 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。
*
* @return 本调用是否实际完成了迁移(去重判据);未到期/非 PENDING/已被它事务完成则返回 false
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean completeIfExpired(Long userId) {
AccountDeletion rec = accountDeletionRepository.findByUserId(userId).orElse(null);
if (rec == null || rec.getStatus() != DeletionStatus.PENDING) {
return false;
}
LocalDateTime now = LocalDateTime.now();
if (rec.getEffectiveAt() == null || rec.getEffectiveAt().isAfter(now)) {
return false;
}
try {
// 先记录原手机号墓碑(HMAC),再脱敏;幂等:已脱敏则不会再进入
beforeDesensitize(rec);
rec.setStatus(DeletionStatus.COMPLETED);
rec.setCompletedAt(now);
accountDeletionRepository.saveAndFlush(rec); // @Version 门控:并发时后到者抛异常
desensitize(rec.getUserId());
// 注销完成:清理该用户的个人导出文件并作废未完成任务
dataExportCleanupService.purgeForDeletedUser(rec.getUserId());
salaryHistoryCleanupService.purgeForDeletedUser(rec.getUserId());
// 完成 + 脱敏审计各写一次
auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION",
rec.getId(), "account deletion completed; userId=" + userId
+ "; PENDING->COMPLETED", null, null, null);
auditService.log(userId, "ACCOUNT_DATA_DESENSITIZED", "USER",
userId, "user pii desensitized; userId=" + userId, null, null, null);
return true;
} catch (ObjectOptimisticLockingFailureException e) {
log.info("Deletion already completed concurrently for userId={}", userId);
return false;
}
}
/**
* 脱敏前:用服务端密钥对原手机号计算 HMAC-SHA256 摘要并写入注销记录,
* 作为"原手机号禁止重新注册"的墓碑。只在首次完成时执行,幂等安全。
*/
private void beforeDesensitize(AccountDeletion rec) {
User u = userRepository.findById(rec.getUserId()).orElse(null);
if (u == null) {
return;
}
String originalPhone = u.getPhone();
if (originalPhone == null || originalPhone.isBlank()
|| originalPhone.startsWith(PHONE_PREFIX)) {
return;
}
rec.setDeletedPhoneHash(phoneHasher.hash(originalPhone));
}
/**
* 对 users 表做 PII 脱敏(幂等):phone 改为 deleted_{id} 保证唯一,
* 其余敏感字段置空,is_active=false。不物理删除任何业务数据。
*/
private void desensitize(Long userId) {
User u = userRepository.findById(userId).orElse(null);
if (u == null) {
return;
}
// 幂等:已脱敏则跳过
if (Boolean.FALSE.equals(u.getActive()) && u.getPhone() != null
&& u.getPhone().startsWith(PHONE_PREFIX)) {
return;
}
u.setPhone(PHONE_PREFIX + userId);
u.setEmail(null);
u.setNickname("deleted");
u.setAvatarUrl(null);
u.setPasswordHash(null);
u.setActive(false);
userRepository.saveAndFlush(u);
}
}