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.
112 lines
4.7 KiB
112 lines
4.7 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 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;
|
|
|
|
/**
|
|
* 若用户处于 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);
|
|
}
|
|
}
|