test(avatar): verify recovery with PostgreSQL transactions

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-12 16:16:15 +08:00
parent c48b09d3a0
commit bc2592c978

View file

@ -0,0 +1,423 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse;
import com.iflytek.skillhub.infra.jpa.NamespaceJpaRepository;
import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository;
import com.iflytek.skillhub.infra.jpa.SkillJpaRepository;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.AvatarModerationGateway;
import com.iflytek.skillhub.service.AvatarModerationProvider;
import com.iflytek.skillhub.service.AvatarReviewSettingsService;
import com.iflytek.skillhub.service.SkillAvatarAppService;
import com.iflytek.skillhub.service.SkillAvatarImageProcessor;
import com.iflytek.skillhub.service.SkillAvatarUrls;
import com.iflytek.skillhub.listener.SkillAvatarScopeReviewListener;
import com.iflytek.skillhub.task.SkillAvatarMachineRecoveryTask;
import com.iflytek.skillhub.task.SkillAvatarScopeReviewRecoveryTask;
import com.iflytek.skillhub.storage.ObjectStorageService;
import java.io.ByteArrayInputStream;
import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
/** Real PostgreSQL recovery transactions with controlled policy, storage and external provider. */
@DataJpaTest(showSql = false)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
@Testcontainers
@Import({SkillAvatarAppService.class, SkillAvatarMachineRecoveryTask.class,
SkillAvatarScopeReviewListener.class, SkillAvatarScopeReviewRecoveryTask.class,
com.iflytek.skillhub.task.SkillAvatarCleanupTask.class})
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class SkillAvatarRecoveryPostgresTest {
@Container private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void postgres(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
registry.add("spring.flyway.enabled", () -> true);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
@Autowired private SkillJpaRepository skills;
@Autowired private NamespaceJpaRepository namespaces;
@Autowired private SkillAvatarCandidateJpaRepository candidates;
@Autowired private SkillAvatarAppService avatars;
@Autowired private SkillAvatarMachineRecoveryTask machineRecovery;
@Autowired private SkillAvatarScopeReviewRecoveryTask scopeRecovery;
@Autowired private PlatformTransactionManager transactions;
@Autowired private JdbcTemplate jdbc;
@Autowired private com.iflytek.skillhub.task.SkillAvatarCleanupTask cleanup;
@Autowired private com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository users;
@MockBean private ObjectStorageService storage;
@MockBean private SkillAvatarImageProcessor imageProcessor;
@MockBean private AvatarReviewSettingsService settings;
@MockBean private AvatarModerationGateway moderation;
@MockBean private VisibilityChecker visibility;
@MockBean private SkillQueryService skillQueries;
@MockBean private AuditLogService audit;
@MockBean private RequestIdAccessor requestIds;
@BeforeEach
void providerAndStorage() {
if (((com.iflytek.skillhub.domain.user.UserAccountRepository) users).findById("owner").isEmpty()) {
users.saveAndFlush(new com.iflytek.skillhub.domain.user.UserAccount("owner", "owner", null, null));
}
byte[] image = UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
when(storage.getObject(anyString())).thenAnswer(ignored -> new ByteArrayInputStream(image));
when(moderation.requireConfiguredProvider()).thenReturn("xfyun");
when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(AvatarModerationProvider.Verdict.PASS);
when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(true, false, 1, null));
}
@Test
void ordinaryCommittedPendingUploadIsRecoveredByScheduler() {
Long skillId = skill(false);
byte[] image = UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
when(imageProcessor.normalize(any())).thenAnswer(call -> call.getArgument(0));
Long candidateId = avatars.submit(namespaceSlug(skillId), "skill", "owner", image,
new AuditRequestContext(null, null)).candidateId();
machineRecovery.recover();
assertThat(candidates.findById(candidateId).orElseThrow().getMachineStatus())
.isEqualTo(SkillAvatarCandidate.MachineStatus.PENDING);
assertThat(readSkill(skillId).getActiveAvatarId()).isNull();
setTime(candidateId, "created_at", Instant.now().minusSeconds(660));
Instant createdAt = candidates.findById(candidateId).orElseThrow().getCreatedAt();
assertThat(createdAt).isBefore(Instant.now().minusSeconds(600));
assertThat(abandoned(createdAt.minusSeconds(1), Instant.now())).extracting(SkillAvatarCandidate::getId).doesNotContain(candidateId);
assertThat(abandoned(createdAt, Instant.now())).extracting(SkillAvatarCandidate::getId).contains(candidateId);
machineRecovery.recover();
assertThat(candidates.findById(candidateId).orElseThrow().getMachineStatus())
.isEqualTo(SkillAvatarCandidate.MachineStatus.PASSED);
assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(candidateId);
assertThat(readSkill(skillId).getPendingAvatarId()).isNull();
}
@Test
void expiredLeaseIsReclaimedAndLateFirstAttemptCannotOverwriteSecond() throws Exception {
Long skillId = skill(false);
Long candidateId = pending(skillId);
CountDownLatch providerEntered = new CountDownLatch(1);
CountDownLatch releaseFirst = new CountDownLatch(1);
AtomicInteger calls = new AtomicInteger();
when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenAnswer(ignored -> {
if (calls.incrementAndGet() == 1) {
providerEntered.countDown();
assertThat(releaseFirst.await(20, TimeUnit.SECONDS)).isTrue();
return AvatarModerationProvider.Verdict.BLOCK;
}
return AvatarModerationProvider.Verdict.PASS;
});
try (var pool = Executors.newFixedThreadPool(2)) {
var first = pool.submit(() -> avatars.processMachine(candidateId, new AuditRequestContext(null, null)));
try {
assertThat(providerEntered.await(10, TimeUnit.SECONDS)).isTrue();
var otherSession = pool.submit(() -> new TransactionTemplate(transactions).execute(status -> {
jdbc.execute("SET LOCAL lock_timeout = '2s'");
return skills.findLockedById(skillId).orElseThrow().getId();
}));
assertThat(otherSession.get(5, TimeUnit.SECONDS)).isEqualTo(skillId);
setTime(candidateId, "machine_lease_until", Instant.now().minusSeconds(1));
Instant lease = candidates.findById(candidateId).orElseThrow().getMachineLeaseUntil();
assertThat(lease).isBefore(Instant.now());
assertThat(abandoned(Instant.now().minusSeconds(600), lease.minusSeconds(1)))
.extracting(SkillAvatarCandidate::getId).doesNotContain(candidateId);
assertThat(abandoned(Instant.now().minusSeconds(600), lease))
.extracting(SkillAvatarCandidate::getId).contains(candidateId);
machineRecovery.recover();
assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(2);
} finally {
releaseFirst.countDown();
}
assertThat(first.get(10, TimeUnit.SECONDS).machineStatus()).isEqualTo("PASSED");
assertThat(calls.get()).isEqualTo(2);
}
assertThat(candidates.findById(candidateId).orElseThrow().getMachineStatus())
.isEqualTo(SkillAvatarCandidate.MachineStatus.PASSED);
assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(candidateId);
}
@Test
void missingScopeListenerIsCompensatedWithoutPublishingPrivateImage() {
Long skillId = skill(true);
Long oldAvatarId = readSkill(skillId).getActiveAvatarId();
when(storage.getObject("old/" + skillId)).thenThrow(new IllegalStateException("temporary storage outage"));
scopeRecovery.recover();
assertThat(readSkill(skillId).getPendingAvatarId()).isNull();
assertThat(SkillAvatarUrls.current(readSkill(skillId), "test")).isNull();
org.mockito.Mockito.doAnswer(ignored -> new ByteArrayInputStream(new byte[] {1, 2, 3}))
.when(storage).getObject("old/" + skillId);
scopeRecovery.recover();
Skill recovered = readSkill(skillId);
assertThat(recovered.getActiveAvatarId()).isNotEqualTo(oldAvatarId);
assertThat(recovered.getActiveAvatarAudience()).isEqualTo(SkillVisibility.PUBLIC);
assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(skillId)).hasSize(2);
assertThat(candidates.findById(recovered.getActiveAvatarId()).orElseThrow().isScopeReview()).isTrue();
}
@Test
void committedScopeCandidateRecoversWithCooldownAndOwnerRetryAfterThreeAutomaticAttempts() {
// Independent candidates: a manually repeated ERROR cannot be applied to an already active PASS.
for (AvatarModerationProvider.Verdict manualVerdict : new AvatarModerationProvider.Verdict[] {
AvatarModerationProvider.Verdict.ERROR, AvatarModerationProvider.Verdict.PASS}) {
Long skillId = skill(true);
Long candidateId = avatars.prepareScopeReview(skillId, null).candidateId();
assertThat(candidates.findById(candidateId).orElseThrow().isScopeReview()).isTrue();
assertThat(candidates.findById(candidateId).orElseThrow().getScopeReviewVersionId()).isNull();
assertThat(abandoned(Instant.now().plusSeconds(660), Instant.now().plusSeconds(660)))
.extracting(SkillAvatarCandidate::getId).doesNotContain(candidateId);
setTime(candidateId, "created_at", Instant.now().minusSeconds(660));
assertThat(candidates.findById(candidateId).orElseThrow().getCreatedAt()).isBefore(Instant.now().minusSeconds(600));
org.mockito.Mockito.clearInvocations(moderation);
when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(AvatarModerationProvider.Verdict.ERROR);
scopeRecovery.recover();
SkillAvatarCandidate first = candidates.findById(candidateId).orElseThrow();
assertThat(first.getMachineAttemptCount()).isEqualTo(1);
assertThat(first.getMachineNextRetryAt()).isBetween(Instant.now().plusSeconds(3500), Instant.now().plusSeconds(3601));
assertThatThrownBy(() -> avatars.processMachine(candidateId, new AuditRequestContext(null, null)))
.isInstanceOf(DomainConflictException.class);
scopeRecovery.recover();
assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(1);
for (int attempt = 2; attempt <= 3; attempt++) {
setTime(candidateId, "machine_next_retry_at", Instant.now().minusSeconds(1));
scopeRecovery.recover();
assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(attempt);
}
avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "owner");
assertThatThrownBy(() -> avatars.processMachine(candidateId, new AuditRequestContext(null, null)))
.isInstanceOf(DomainConflictException.class);
assertThatThrownBy(() -> avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "stranger"))
.isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class);
org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(3)).moderate(eq("xfyun"), any(byte[].class));
setTime(candidateId, "machine_next_retry_at", Instant.now().minusSeconds(1));
scopeRecovery.recover();
scopeRecovery.recover();
assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(3);
org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(3)).moderate(eq("xfyun"), any(byte[].class));
assertThat(readSkill(skillId).getPendingAvatarId()).isEqualTo(candidateId);
assertThat(SkillAvatarUrls.current(readSkill(skillId), "test")).isNull();
when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(manualVerdict);
avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "owner");
var retried = avatars.processMachine(candidateId, new AuditRequestContext(null, null));
assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(4);
org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(4)).moderate(eq("xfyun"), any(byte[].class));
if (manualVerdict == AvatarModerationProvider.Verdict.PASS) {
assertThat(retried.avatarUrl()).isNotNull();
assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(candidateId);
assertThat(readSkill(skillId).getPendingAvatarId()).isNull();
assertThatThrownBy(() -> avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "owner"))
.isInstanceOf(DomainConflictException.class);
} else {
assertThat(retried.avatarUrl()).isNull();
assertThat(readSkill(skillId).getPendingAvatarId()).isEqualTo(candidateId);
assertThat(candidates.findById(candidateId).orElseThrow().getMachineNextRetryAt())
.isAfter(Instant.now().plusSeconds(3500));
}
assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(skillId)).hasSize(2);
}
}
@Test
void identicalImageReusesOnlyMatchingProviderAndManualPolicy() {
byte[] image = UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
when(imageProcessor.normalize(any())).thenAnswer(call -> call.getArgument(0));
Long sourceId = skill(false);
String sourceNamespace = namespaceSlug(sourceId);
Long sourceCandidate = avatars.submit(sourceNamespace, "skill", "owner", image, new AuditRequestContext(null, null)).candidateId();
avatars.processMachine(sourceCandidate, new AuditRequestContext(null, null));
Long sameId = skill(false);
var same = avatars.submit(namespaceSlug(sameId), "skill", "owner", image, new AuditRequestContext(null, null));
assertThat(same.machineStatus()).isEqualTo("PASSED");
assertThat(same.avatarUrl()).isNotNull();
when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(true, true, 1, null));
Long manualId = skill(false);
var manual = avatars.submit(namespaceSlug(manualId), "skill", "owner", image, new AuditRequestContext(null, null));
assertThat(manual.machineStatus()).isEqualTo("PENDING");
assertThat(manual.avatarUrl()).isNull();
when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(true, false, 1, null));
when(moderation.requireConfiguredProvider()).thenReturn("aliyun");
Long otherProviderId = skill(false);
var other = avatars.submit(namespaceSlug(otherProviderId), "skill", "owner", image, new AuditRequestContext(null, null));
assertThat(other.machineStatus()).isEqualTo("PENDING");
assertThat(other.avatarUrl()).isNull();
}
@Test
void blockedAndProviderFailureNeverExposeAnActiveAvatar() {
for (AvatarModerationProvider.Verdict verdict : new AvatarModerationProvider.Verdict[] {
AvatarModerationProvider.Verdict.BLOCK, AvatarModerationProvider.Verdict.ERROR}) {
Long skillId = skill(false);
Long candidateId = pending(skillId);
when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(verdict);
var result = avatars.processMachine(candidateId, new AuditRequestContext(null, null));
assertThat(result.machineStatus()).isEqualTo(verdict == AvatarModerationProvider.Verdict.BLOCK ? "BLOCKED" : "ERROR");
assertThat(result.avatarUrl()).isNull();
assertThat(readSkill(skillId).getActiveAvatarId()).isNull();
assertThat(readSkill(skillId).getPendingAvatarId()).isEqualTo(candidateId);
}
Long skillId = skill(false);
Long candidateId = pending(skillId);
when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenThrow(new IllegalStateException("provider unavailable"));
assertThat(avatars.processMachine(candidateId, new AuditRequestContext(null, null)).machineStatus()).isEqualTo("ERROR");
assertThat(readSkill(skillId).getActiveAvatarId()).isNull();
}
@Test
void globalReviewDoesNotAcceptTeamAdministratorRole() {
Long skillId = skill(false);
Skill skill = readSkill(skillId);
new TransactionTemplate(transactions).executeWithoutResult(status -> {
Namespace ns = ((com.iflytek.skillhub.domain.namespace.NamespaceRepository) namespaces).findById(skill.getNamespaceId()).orElseThrow();
ns.setType(com.iflytek.skillhub.domain.namespace.NamespaceType.GLOBAL);
});
when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(false, true, 1, null));
when(imageProcessor.normalize(any())).thenReturn(new byte[] {5, 6});
Long candidateId = avatars.submit(namespaceSlug(skillId), "skill", "owner", new byte[] {5, 6}, new AuditRequestContext(null, null)).candidateId();
assertThatThrownBy(() -> avatars.decide(namespaceSlug(skillId), "skill", candidateId, true, "reviewed", "reviewer",
java.util.Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.ADMIN),
java.util.Set.of(), new AuditRequestContext(null, null)))
.isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class);
assertThat(readSkill(skillId).getActiveAvatarId()).isNull();
var approved = avatars.decide(namespaceSlug(skillId), "skill", candidateId, true, "reviewed", "owner",
java.util.Map.of(), java.util.Set.of("SUPER_ADMIN"), new AuditRequestContext(null, null));
assertThat(approved.avatarUrl()).isNotNull();
}
@Test
void expiredImageCleanupRetriesFailureAndPreservesHistoryAndActiveObject() {
Long skillId = skill(true);
SkillAvatarCandidate active = candidates.findById(readSkill(skillId).getActiveAvatarId()).orElseThrow();
Long rejectedId = new TransactionTemplate(transactions).execute(status -> {
SkillAvatarCandidate rejected = new SkillAvatarCandidate(skillId, "owner", "rejected/" + skillId,
"rejected-sha", SkillVisibility.PUBLIC, false, true, null);
rejected.reject("owner", "keep this textual review history");
return candidates.saveAndFlush(rejected).getId();
});
SkillAvatarCandidate rejected = candidates.findById(rejectedId).orElseThrow();
assertThat(candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(rejected.getDiscardAt().minusSeconds(1)))
.extracting(SkillAvatarCandidate::getId).doesNotContain(rejectedId);
assertThat(candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(rejected.getDiscardAt()))
.extracting(SkillAvatarCandidate::getId).doesNotContain(rejectedId);
assertThat(candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(rejected.getDiscardAt().plusSeconds(1)))
.extracting(SkillAvatarCandidate::getId).contains(rejectedId);
assertThat(rejected.getDiscardAt()).isBetween(Instant.now().plusSeconds(30L * 86400 - 60),
Instant.now().plusSeconds(30L * 86400 + 1));
cleanup.removeExpiredImages();
org.mockito.Mockito.verify(storage, org.mockito.Mockito.never()).deleteObject("rejected/" + skillId);
new TransactionTemplate(transactions).executeWithoutResult(status -> {
SkillAvatarCandidate expired = candidates.findById(rejectedId).orElseThrow();
org.springframework.test.util.ReflectionTestUtils.setField(expired, "discardAt", Instant.now().minusSeconds(1));
});
org.mockito.Mockito.doThrow(new IllegalStateException("temporary object store outage"))
.doNothing().when(storage).deleteObject("rejected/" + skillId);
assertThatThrownBy(cleanup::removeExpiredImages).isInstanceOf(IllegalStateException.class);
assertThat(candidates.findById(rejectedId).orElseThrow().getObjectKey()).isEqualTo("rejected/" + skillId);
cleanup.removeExpiredImages();
SkillAvatarCandidate retained = candidates.findById(rejectedId).orElseThrow();
assertThat(retained.getObjectKey()).isNull();
assertThat(retained.getReviewReason()).isEqualTo("keep this textual review history");
assertThat(retained.getReviewedBy()).isEqualTo("owner");
assertThat(retained.getStatus()).isEqualTo(SkillAvatarCandidate.Status.REJECTED);
assertThat(candidates.findById(active.getId()).orElseThrow().getObjectKey()).isEqualTo(active.getObjectKey());
org.mockito.Mockito.verify(storage, org.mockito.Mockito.never()).deleteObject(active.getObjectKey());
}
private java.util.List<SkillAvatarCandidate> abandoned(Instant staleBefore, Instant now) {
return candidates.findAbandonedMachineCandidates(0L, SkillAvatarCandidate.Status.PENDING,
SkillAvatarCandidate.MachineStatus.PENDING, SkillAvatarCandidate.MachineStatus.PROCESSING,
staleBefore, now, org.springframework.data.domain.PageRequest.of(0, 100));
}
// Hibernate binds these timestamp-without-zone columns in UTC; use the same calendar in JDBC fixtures.
private void setTime(Long candidateId, String column, Instant value) {
jdbc.update(connection -> {
var statement = connection.prepareStatement("UPDATE skill_avatar_candidate SET " + column + " = ? WHERE id = ?");
statement.setTimestamp(1, java.sql.Timestamp.from(value),
java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")));
statement.setLong(2, candidateId);
return statement;
});
}
private Skill readSkill(Long id) {
return ((com.iflytek.skillhub.domain.skill.SkillRepository) skills).findById(id).orElseThrow();
}
private String namespaceSlug(Long skillId) {
return ((com.iflytek.skillhub.domain.namespace.NamespaceRepository) namespaces).findById(readSkill(skillId).getNamespaceId()).orElseThrow().getSlug();
}
private Long skill(boolean expanded) {
return new TransactionTemplate(transactions).execute(status -> {
String suffix = UUID.randomUUID().toString().substring(0, 8);
Namespace ns = namespaces.saveAndFlush(new Namespace("avatar-" + suffix, "Avatar recovery", "owner"));
Skill skill = skills.saveAndFlush(new Skill(ns.getId(), "skill", "owner", expanded ? SkillVisibility.PRIVATE : SkillVisibility.PUBLIC));
if (expanded) {
SkillAvatarCandidate old = new SkillAvatarCandidate(skill.getId(), "owner", "old/" + skill.getId(), "old-sha-" + suffix,
SkillVisibility.PRIVATE, false, false, null);
old.activate();
candidates.saveAndFlush(old);
skill.activateAvatar(old.getId(), SkillVisibility.PRIVATE);
skill.setVisibility(SkillVisibility.PUBLIC);
skills.saveAndFlush(skill);
}
return skill.getId();
});
}
private Long pending(Long skillId) {
return new TransactionTemplate(transactions).execute(status -> {
SkillAvatarCandidate candidate = new SkillAvatarCandidate(skillId, "owner", "pending/" + skillId,
"sha-" + skillId, SkillVisibility.PUBLIC, true, false, "xfyun");
candidates.saveAndFlush(candidate);
Skill skill = skills.findLockedById(skillId).orElseThrow();
skill.setPendingAvatarId(candidate.getId());
return candidate.getId();
});
}
}