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 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; /** * 注销完成工人(独立事务)。 * *

将到期的 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; /** * 若用户处于 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()); // 完成 + 脱敏审计各写一次 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); } }