mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
fix(avatar): recover pending reviews and cover moderation flows
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
b67ea7ddb8
commit
17a18b9319
40 changed files with 2293 additions and 55 deletions
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
团队范围由团队管理员审核,全局范围由平台管理员审核。头像只能由技能所有者提交、替换或删除;管理员可审核和撤下,不可代传。新申请使旧待审申请失效;旧审核决定不能覆盖新头像。被拒绝、替换、删除的候选图片私有保留 30 天后清理,文字审核记录继续保留。头像撤下后显示首字母兜底。
|
||||
|
||||
技能发布后若可见范围扩大,旧头像会立即从新增受众的详情中隐藏,系统从私有存储复制同一图片为新的候选,按目标范围重新走当前审核策略。已有相同目标范围的待审申请不会被覆盖;新旧候选使用不同对象键,避免旧文件到期清理影响当前头像。自动建候选失败时旧头像仍保持隐藏;每五分钟分批检查并补建候选。若候选已建但机器读取或审核失败,状态记为 `ERROR`,一小时冷却后自动重试;同一候选自动审核最多调用三次(含首次),到达上限后由所有者在冷却结束后主动重试或重新提交。普通上传在机器调用前宕机留下的 `PENDING` 候选,以及机器处理中宕机留下的 `PROCESSING` 候选,十分钟后也由补偿任务重领。机器调用前后分别使用短事务核验候选指针,外部调用期间不持有数据库锁;过期尝试的迟到结果不会激活头像。当前发布版已驳回的自动申请不会反复入队,历史发布版的驳回不阻止新发布。服务日志仅记录技能或候选 ID 和异常类型,不记录可能包含凭证的供应商请求信息。`PRIVATE` 头像及候选图片只有技能所有者可以读取。
|
||||
|
||||
## 部署配置
|
||||
|
||||
在部署密钥配置中指定唯一供应商及其凭证,然后才可从后台开启机器审核。Spring 属性如下;将值映射到部署 Secret/环境变量时请遵循当前部署平台的配置方式,不要写入仓库或后台数据库:
|
||||
|
|
@ -20,7 +22,7 @@
|
|||
|
||||
后台只显示两个启停开关,不回显供应商或密钥。阿里云适配器使用上海地域的 `profilePhotoCheck`,通过短期上传令牌把私有候选送到官方 OSS 临时对象,再调用同步审核;不会公开候选 URL。官方返回 `RiskLevel=none/low` 通过,`medium/high` 保持私有并允许人工复核。把 `medium` 归为阻止是本产品策略。响应非 200、空结果或未知值均不放行。机器接口可能按量计费,开启前由部署方确认供应商账号与费用。
|
||||
|
||||
讯飞官方图片审核接口要求把 `accessKeySecret` 放在请求 URL 参数中。应用不会记录完整 URL 或异常原文,但反向代理、出口代理、链路追踪及第三方日志仍可能记录该 URL。部署方使用讯飞适配器前,必须确认相关网络组件不采集请求 URL,限制凭证权限并建立轮换机制。若无法满足,先不要开启该适配器。
|
||||
讯飞官方图片审核接口要求把 `accessKeySecret` 放在请求 URL 参数中。应用不会记录完整 URL 或异常原文,但 Java HTTP 客户端调试日志、反向代理、出口代理、链路追踪及第三方日志仍可能记录该 URL。部署方使用讯飞适配器前,必须确认相关组件不采集请求 URL,限制凭证权限并建立轮换机制。若无法满足,先不要开启该适配器。
|
||||
|
||||
## 与原 Issue 的差异
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@
|
|||
<groupId>com.aliyun.oss</groupId>
|
||||
<artifactId>aliyun-sdk-oss</artifactId>
|
||||
<version>3.16.3</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>commons-logging</groupId>
|
||||
<artifactId>commons-logging</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
|
|
|||
|
|
@ -130,8 +130,10 @@ public class SkillController extends BaseApiController {
|
|||
.filter(SkillSuiteReferenceResponse::currentSkillEntry)
|
||||
.toList(),
|
||||
memberOfSuites,
|
||||
SkillAvatarUrls.current(detail.activeAvatarId(), detail.activeAvatarAudience(),
|
||||
SkillVisibility.valueOf(detail.visibility()), namespace, detail.slug())
|
||||
SkillVisibility.PRIVATE == SkillVisibility.valueOf(detail.visibility())
|
||||
&& !detail.ownerId().equals(userId) ? null
|
||||
: SkillAvatarUrls.current(detail.activeAvatarId(), detail.activeAvatarAudience(),
|
||||
SkillVisibility.valueOf(detail.visibility()), namespace, detail.slug())
|
||||
);
|
||||
|
||||
return ok("response.success.read", response);
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@ package com.iflytek.skillhub.dto;
|
|||
|
||||
public record SkillAvatarStatusResponse(String avatarUrl, Long candidateId, String candidateStatus,
|
||||
String machineStatus, String reviewStatus, String reviewReason,
|
||||
String candidateUrl, String namespace, String slug) {}
|
||||
String candidateUrl, String namespace, String slug,
|
||||
java.time.Instant machineNextRetryAt, boolean automaticRetryExhausted) {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
package com.iflytek.skillhub.listener;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
|
||||
import com.iflytek.skillhub.dto.SkillAvatarStatusResponse;
|
||||
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||
import com.iflytek.skillhub.service.SkillAvatarAppService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
@Component
|
||||
public class SkillAvatarScopeReviewListener {
|
||||
private static final Logger log = LoggerFactory.getLogger(SkillAvatarScopeReviewListener.class);
|
||||
private final SkillAvatarAppService avatars;
|
||||
|
||||
public SkillAvatarScopeReviewListener(SkillAvatarAppService avatars) {
|
||||
this.avatars = avatars;
|
||||
}
|
||||
|
||||
@Async("skillhubEventExecutor")
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void onSkillPublished(SkillPublishedEvent event) {
|
||||
reviewScope(event.skillId(), event.publisherId());
|
||||
}
|
||||
|
||||
public void reviewScope(Long skillId, String publisherId) {
|
||||
try {
|
||||
SkillAvatarStatusResponse candidate = avatars.prepareScopeReview(skillId, publisherId);
|
||||
if (candidate != null && ("PENDING".equals(candidate.machineStatus())
|
||||
|| "PROCESSING".equals(candidate.machineStatus())
|
||||
|| "ERROR".equals(candidate.machineStatus()))) {
|
||||
avatars.processMachine(candidate.candidateId(), new AuditRequestContext(null, null));
|
||||
}
|
||||
} catch (Exception failure) {
|
||||
// Provider errors can include credential-bearing request URLs. Never log their message or stack here.
|
||||
log.warn("Avatar scope review failed for skill {}, failureType={}",
|
||||
skillId, failure.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,10 @@ import java.util.Set;
|
|||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
|
|
@ -55,13 +59,14 @@ public class SkillAvatarAppService {
|
|||
private final AuditLogService audit;
|
||||
private final RequestIdAccessor requestIds;
|
||||
private final EntityManager entityManager;
|
||||
private final TransactionTemplate machineTransactions;
|
||||
|
||||
public SkillAvatarAppService(SkillJpaRepository skills, NamespaceRepository namespaces,
|
||||
SkillAvatarCandidateJpaRepository candidates, ObjectStorageService storage,
|
||||
SkillAvatarImageProcessor imageProcessor, AvatarReviewSettingsService settings,
|
||||
AvatarModerationGateway moderation, VisibilityChecker visibility,
|
||||
SkillQueryService skillQueries, AuditLogService audit, RequestIdAccessor requestIds,
|
||||
EntityManager entityManager) {
|
||||
EntityManager entityManager, PlatformTransactionManager transactionManager) {
|
||||
this.skills = skills;
|
||||
this.namespaces = namespaces;
|
||||
this.candidates = candidates;
|
||||
|
|
@ -74,6 +79,8 @@ public class SkillAvatarAppService {
|
|||
this.audit = audit;
|
||||
this.requestIds = requestIds;
|
||||
this.entityManager = entityManager;
|
||||
this.machineTransactions = new TransactionTemplate(transactionManager);
|
||||
this.machineTransactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -82,6 +89,52 @@ public class SkillAvatarAppService {
|
|||
Skill skill = lockedSkill(namespaceSlug, slug);
|
||||
requireOwner(skill, actor);
|
||||
byte[] normalized = imageProcessor.normalize(image);
|
||||
return createCandidate(skill, namespaceSlug, actor, actor, "SKILL_AVATAR_SUBMIT", normalized, context);
|
||||
}
|
||||
|
||||
/** Reassesses the existing image when publication expands its audience. */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public SkillAvatarStatusResponse prepareScopeReview(Long skillId, String publisher) {
|
||||
Skill skill = skills.findLockedById(skillId).orElseThrow(() -> notFound());
|
||||
if (skill.getActiveAvatarId() == null || skill.getActiveAvatarAudience() == null
|
||||
|| SkillAvatarUrls.current(skill, namespaceSlug(skill)) != null) {
|
||||
return null;
|
||||
}
|
||||
if (skill.getPendingAvatarId() != null) {
|
||||
SkillAvatarCandidate pending = candidates.findById(skill.getPendingAvatarId()).orElse(null);
|
||||
if (pending != null && pending.getStatus() == SkillAvatarCandidate.Status.PENDING
|
||||
&& pending.getAudience() == skill.getVisibility()) {
|
||||
Instant now = Instant.now();
|
||||
boolean abandonedBeforeClaim = pending.getMachineStatus() == SkillAvatarCandidate.MachineStatus.PENDING
|
||||
&& pending.getCreatedAt() != null
|
||||
&& !pending.getCreatedAt().plusSeconds(600).isAfter(now);
|
||||
return ((pending.canRetryMachine(now) && pending.getMachineAttemptCount() < 3) || abandonedBeforeClaim)
|
||||
? status(skill, pending, namespaceSlug(skill)) : null;
|
||||
}
|
||||
}
|
||||
if (candidates.existsBySkillIdAndAudienceAndScopeReviewVersionIdAndStatus(skill.getId(),
|
||||
skill.getVisibility(), skill.getLatestVersionId(), SkillAvatarCandidate.Status.REJECTED)) {
|
||||
return null;
|
||||
}
|
||||
SkillAvatarCandidate active = findCandidate(skill.getActiveAvatarId());
|
||||
if (active.getStatus() != SkillAvatarCandidate.Status.ACTIVE || active.getObjectKey() == null) {
|
||||
return null;
|
||||
}
|
||||
byte[] image;
|
||||
try (InputStream input = storage.getObject(active.getObjectKey())) {
|
||||
image = input.readAllBytes();
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Cannot read private avatar candidate", failure);
|
||||
}
|
||||
// A fresh object key keeps 30-day candidate cleanup from deleting the active image.
|
||||
return createCandidate(skill, namespaceSlug(skill), skill.getOwnerId(),
|
||||
publisher == null ? skill.getOwnerId() : publisher, "SKILL_AVATAR_SCOPE_REVIEW", image,
|
||||
new AuditRequestContext(null, null));
|
||||
}
|
||||
|
||||
private SkillAvatarStatusResponse createCandidate(Skill skill, String namespaceSlug, String submittedBy,
|
||||
String auditActor, String action, byte[] normalized,
|
||||
AuditRequestContext context) {
|
||||
AvatarReviewSettingsResponse policy = settings.current();
|
||||
boolean privateOnly = skill.getVisibility() == SkillVisibility.PRIVATE;
|
||||
boolean machineEnabled = !privateOnly && policy.machineEnabled();
|
||||
|
|
@ -101,8 +154,11 @@ public class SkillAvatarAppService {
|
|||
if (skill.getPendingAvatarId() != null) {
|
||||
candidates.findById(skill.getPendingAvatarId()).ifPresent(SkillAvatarCandidate::supersede);
|
||||
}
|
||||
SkillAvatarCandidate candidate = new SkillAvatarCandidate(skill.getId(), actor, key, sha256,
|
||||
SkillAvatarCandidate candidate = new SkillAvatarCandidate(skill.getId(), submittedBy, key, sha256,
|
||||
skill.getVisibility(), machineEnabled, manualEnabled, provider);
|
||||
if ("SKILL_AVATAR_SCOPE_REVIEW".equals(action)) {
|
||||
candidate.markScopeReviewVersion(skill.getLatestVersionId());
|
||||
}
|
||||
if (machineEnabled) {
|
||||
// Only the machine verdict is reusable. Every new audience has its own human decision.
|
||||
boolean reusedPass = !candidates.findByContentSha256AndMachineProviderAndMachineEnabledTrueAndManualEnabledAndMachineStatus(
|
||||
|
|
@ -123,46 +179,56 @@ public class SkillAvatarAppService {
|
|||
|| candidate.getMachineStatus() == SkillAvatarCandidate.MachineStatus.PASSED && !manualEnabled) {
|
||||
activate(skill, candidate);
|
||||
}
|
||||
log(actor, "SKILL_AVATAR_SUBMIT", candidate.getId(), context,
|
||||
log(auditActor, action, candidate.getId(), context,
|
||||
AuditDetail.builder().put("skillId", skill.getId()).put("audience", candidate.getAudience().name()).build());
|
||||
return status(skill, candidate, namespaceSlug);
|
||||
}
|
||||
|
||||
/** Runs outside submit's transaction; result application locks the Skill before changing pointers. */
|
||||
@Transactional
|
||||
/** Claims and finishes in short transactions; the external call holds no database lock. */
|
||||
public SkillAvatarStatusResponse processMachine(long candidateId, AuditRequestContext context) {
|
||||
SkillAvatarCandidate candidate = findCandidate(candidateId);
|
||||
if (candidate.getStatus() != SkillAvatarCandidate.Status.PENDING
|
||||
|| candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PENDING
|
||||
&& candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.ERROR) {
|
||||
throw new DomainConflictException("error.skill.avatar.notPending");
|
||||
}
|
||||
byte[] bytes;
|
||||
try (InputStream input = storage.getObject(candidate.getObjectKey())) {
|
||||
bytes = input.readAllBytes();
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Cannot read private avatar candidate", failure);
|
||||
}
|
||||
AvatarModerationProvider.Verdict verdict = moderation.moderate(candidate.getMachineProvider(), bytes);
|
||||
Skill skill = skills.findLockedById(candidate.getSkillId()).orElseThrow(() -> notFound());
|
||||
entityManager.refresh(candidate);
|
||||
if (!Objects.equals(skill.getPendingAvatarId(), candidateId)
|
||||
|| candidate.getStatus() != SkillAvatarCandidate.Status.PENDING) {
|
||||
return status(skill, candidate, namespaceSlug(skill));
|
||||
}
|
||||
candidate.machineResult(switch (verdict) {
|
||||
case PASS -> SkillAvatarCandidate.MachineStatus.PASSED;
|
||||
case BLOCK -> SkillAvatarCandidate.MachineStatus.BLOCKED;
|
||||
case ERROR -> SkillAvatarCandidate.MachineStatus.ERROR;
|
||||
MachineClaim claim = machineTransactions.execute(ignored -> {
|
||||
SkillAvatarCandidate candidate = findCandidate(candidateId);
|
||||
Skill skill = skills.findLockedById(candidate.getSkillId()).orElseThrow(() -> notFound());
|
||||
entityManager.refresh(candidate);
|
||||
String attemptId = UUID.randomUUID().toString();
|
||||
Instant now = Instant.now();
|
||||
if (!Objects.equals(skill.getPendingAvatarId(), candidateId)
|
||||
|| !candidate.claimMachineAttempt(attemptId, now, now.plusSeconds(600))) {
|
||||
throw new DomainConflictException("error.skill.avatar.notPending");
|
||||
}
|
||||
return new MachineClaim(candidate.getObjectKey(), candidate.getMachineProvider(), attemptId);
|
||||
});
|
||||
if (verdict == AvatarModerationProvider.Verdict.PASS && !candidate.isManualEnabled()) {
|
||||
activate(skill, candidate);
|
||||
AvatarModerationProvider.Verdict verdict;
|
||||
try (InputStream input = storage.getObject(claim.objectKey())) {
|
||||
verdict = moderation.moderate(claim.provider(), input.readAllBytes());
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
// Leave an explicit retriable state. Storage and provider exceptions can contain secret URLs.
|
||||
verdict = AvatarModerationProvider.Verdict.ERROR;
|
||||
}
|
||||
log(candidate.getSubmittedBy(), "SKILL_AVATAR_MACHINE_RESULT", candidate.getId(), context,
|
||||
AuditDetail.of("verdict", verdict.name()));
|
||||
return status(skill, candidate, namespaceSlug(skill));
|
||||
AvatarModerationProvider.Verdict result = verdict;
|
||||
return machineTransactions.execute(ignored -> {
|
||||
SkillAvatarCandidate candidate = findCandidate(candidateId);
|
||||
Skill skill = skills.findLockedById(candidate.getSkillId()).orElseThrow(() -> notFound());
|
||||
entityManager.refresh(candidate);
|
||||
if (!Objects.equals(skill.getPendingAvatarId(), candidateId)
|
||||
|| !candidate.completeMachineAttempt(claim.attemptId(), switch (result) {
|
||||
case PASS -> SkillAvatarCandidate.MachineStatus.PASSED;
|
||||
case BLOCK -> SkillAvatarCandidate.MachineStatus.BLOCKED;
|
||||
case ERROR -> SkillAvatarCandidate.MachineStatus.ERROR;
|
||||
})) {
|
||||
return status(skill, candidate, namespaceSlug(skill));
|
||||
}
|
||||
if (result == AvatarModerationProvider.Verdict.PASS && !candidate.isManualEnabled()) {
|
||||
activate(skill, candidate);
|
||||
}
|
||||
log(candidate.getSubmittedBy(), "SKILL_AVATAR_MACHINE_RESULT", candidate.getId(), context,
|
||||
AuditDetail.of("verdict", result.name()));
|
||||
return status(skill, candidate, namespaceSlug(skill));
|
||||
});
|
||||
}
|
||||
|
||||
private record MachineClaim(String objectKey, String provider, String attemptId) {}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SkillAvatarStatusResponse ownerStatus(String namespaceSlug, String slug, String actor) {
|
||||
Skill skill = findSkill(namespaceSlug, slug);
|
||||
|
|
@ -177,6 +243,7 @@ public class SkillAvatarAppService {
|
|||
skillQueries.getSkillDetail(namespaceSlug, slug, actor, namespaceRoles == null ? Map.of() : namespaceRoles);
|
||||
Skill skill = findSkill(namespaceSlug, slug);
|
||||
if (!visibility.canAccess(skill, actor, namespaceRoles, platformRoles)
|
||||
|| skill.getVisibility() == SkillVisibility.PRIVATE && !skill.getOwnerId().equals(actor)
|
||||
|| SkillAvatarUrls.current(skill, namespaceSlug) == null) {
|
||||
throw notFound();
|
||||
}
|
||||
|
|
@ -193,6 +260,7 @@ public class SkillAvatarAppService {
|
|||
Skill skill = findSkill(namespaceSlug, slug);
|
||||
SkillAvatarCandidate candidate = findCandidate(candidateId);
|
||||
if (!skill.getId().equals(candidate.getSkillId()) || candidate.getObjectKey() == null
|
||||
|| candidate.getAudience() == SkillVisibility.PRIVATE && !skill.getOwnerId().equals(actor)
|
||||
|| !skill.getOwnerId().equals(actor) && !canReview(skill, actor, namespaceRoles, platformRoles)) {
|
||||
throw notFound();
|
||||
}
|
||||
|
|
@ -283,6 +351,7 @@ public class SkillAvatarAppService {
|
|||
SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.ReviewStatus.APPEAL_PENDING).stream())
|
||||
.filter(candidate -> candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.ERROR)
|
||||
.filter(candidate -> candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PENDING)
|
||||
.filter(candidate -> candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PROCESSING)
|
||||
.filter(candidate -> ((SkillRepository) skills).findById(candidate.getSkillId())
|
||||
.filter(skill -> canReview(skill, actor, namespaceRoles, platformRoles)).isPresent())
|
||||
.map(candidate -> {
|
||||
|
|
@ -366,7 +435,9 @@ public class SkillAvatarAppService {
|
|||
candidate == null ? null : candidate.getReviewReason(),
|
||||
candidate == null || candidate.getObjectKey() == null ? null
|
||||
: SkillAvatarUrls.candidate(namespaceSlug, skill, candidate.getId()),
|
||||
namespaceSlug, skill.getSlug());
|
||||
namespaceSlug, skill.getSlug(),
|
||||
candidate == null ? null : candidate.getMachineNextRetryAt(),
|
||||
candidate != null && candidate.getMachineAttemptCount() >= 3);
|
||||
}
|
||||
|
||||
private String sha256(byte[] image) {
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ public class SkillSearchAppService {
|
|||
normalizeLabelSlugs(labelSlugs),
|
||||
requireInstallableLatest
|
||||
));
|
||||
List<SkillSummaryResponse> pageItems = mapVisibleSkillSummaries(result.skillIds());
|
||||
List<SkillSummaryResponse> pageItems = mapVisibleSkillSummaries(result.skillIds(), scope.userId());
|
||||
return new SearchResponse(pageItems, result.total(), page, size);
|
||||
}
|
||||
|
||||
|
|
@ -202,7 +202,7 @@ public class SkillSearchAppService {
|
|||
.toList();
|
||||
}
|
||||
|
||||
private List<SkillSummaryResponse> mapVisibleSkillSummaries(List<Long> skillIds) {
|
||||
private List<SkillSummaryResponse> mapVisibleSkillSummaries(List<Long> skillIds, String userId) {
|
||||
if (skillIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
|
@ -235,7 +235,8 @@ public class SkillSearchAppService {
|
|||
skill,
|
||||
namespaceSlugsById,
|
||||
ownersById,
|
||||
projectionsBySkillId.get(skill.getId())
|
||||
projectionsBySkillId.get(skill.getId()),
|
||||
userId
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
|
@ -244,7 +245,8 @@ public class SkillSearchAppService {
|
|||
Skill skill,
|
||||
Map<Long, String> namespaceSlugsById,
|
||||
Map<String, UserAccount> ownersById,
|
||||
SkillLifecycleProjectionService.Projection projection) {
|
||||
SkillLifecycleProjectionService.Projection projection,
|
||||
String userId) {
|
||||
String namespaceSlug = namespaceSlugsById.get(skill.getNamespaceId());
|
||||
UserAccount owner = ownersById.get(skill.getOwnerId());
|
||||
SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion();
|
||||
|
|
@ -275,7 +277,8 @@ public class SkillSearchAppService {
|
|||
? complianceSnapshotProjectionService.fromParsedMetadataJson(headlineVersion.parsedMetadataJson())
|
||||
: null,
|
||||
null,
|
||||
SkillAvatarUrls.current(skill, namespaceSlug)
|
||||
skill.getVisibility() == com.iflytek.skillhub.domain.skill.SkillVisibility.PRIVATE
|
||||
&& !skill.getOwnerId().equals(userId) ? null : SkillAvatarUrls.current(skill, namespaceSlug)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
package com.iflytek.skillhub.task;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate;
|
||||
import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository;
|
||||
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||
import com.iflytek.skillhub.service.SkillAvatarAppService;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Recovers ordinary uploads abandoned before or during machine moderation. */
|
||||
@Component
|
||||
public class SkillAvatarMachineRecoveryTask {
|
||||
private static final Logger log = LoggerFactory.getLogger(SkillAvatarMachineRecoveryTask.class);
|
||||
private final SkillAvatarCandidateJpaRepository candidates;
|
||||
private final SkillAvatarAppService avatars;
|
||||
private final AtomicLong lastScannedId = new AtomicLong();
|
||||
|
||||
public SkillAvatarMachineRecoveryTask(SkillAvatarCandidateJpaRepository candidates, SkillAvatarAppService avatars) {
|
||||
this.candidates = candidates;
|
||||
this.avatars = avatars;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 300000)
|
||||
public void recover() {
|
||||
List<SkillAvatarCandidate> batch = findAfter(lastScannedId.get());
|
||||
if (batch.isEmpty() && lastScannedId.get() != 0L) {
|
||||
lastScannedId.set(0L);
|
||||
batch = findAfter(0L);
|
||||
}
|
||||
for (SkillAvatarCandidate candidate : batch) {
|
||||
lastScannedId.set(candidate.getId());
|
||||
try {
|
||||
avatars.processMachine(candidate.getId(), new AuditRequestContext(null, null));
|
||||
} catch (Exception failure) {
|
||||
// Provider exceptions may contain credential-bearing URLs; log no message or stack.
|
||||
log.warn("Avatar machine recovery failed for candidate {}, failureType={}",
|
||||
candidate.getId(), failure.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<SkillAvatarCandidate> findAfter(Long afterId) {
|
||||
Instant now = Instant.now();
|
||||
return candidates.findAbandonedMachineCandidates(afterId, SkillAvatarCandidate.Status.PENDING,
|
||||
SkillAvatarCandidate.MachineStatus.PENDING, SkillAvatarCandidate.MachineStatus.PROCESSING,
|
||||
now.minusSeconds(600), now, PageRequest.of(0, 100));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.iflytek.skillhub.task;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.infra.jpa.SkillJpaRepository;
|
||||
import com.iflytek.skillhub.listener.SkillAvatarScopeReviewListener;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Recovers scope reviews lost after publication because private storage was unavailable. */
|
||||
@Component
|
||||
public class SkillAvatarScopeReviewRecoveryTask {
|
||||
private final SkillJpaRepository skills;
|
||||
private final SkillAvatarScopeReviewListener listener;
|
||||
private final AtomicLong lastScannedId = new AtomicLong();
|
||||
|
||||
public SkillAvatarScopeReviewRecoveryTask(SkillJpaRepository skills, SkillAvatarScopeReviewListener listener) {
|
||||
this.skills = skills;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 300000)
|
||||
public void recover() {
|
||||
List<Skill> batch = findAfter(lastScannedId.get());
|
||||
if (batch.isEmpty() && lastScannedId.get() != 0L) {
|
||||
lastScannedId.set(0L);
|
||||
batch = findAfter(0L);
|
||||
}
|
||||
for (var skill : batch) {
|
||||
lastScannedId.set(skill.getId());
|
||||
listener.reviewScope(skill.getId(), null);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Skill> findAfter(Long afterId) {
|
||||
Instant now = Instant.now();
|
||||
return skills.findScopeReviewRecoveryCandidates(afterId, SkillStatus.ACTIVE, SkillVisibility.PRIVATE,
|
||||
SkillVisibility.NAMESPACE_ONLY, SkillVisibility.PUBLIC,
|
||||
SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.MachineStatus.PENDING,
|
||||
SkillAvatarCandidate.MachineStatus.PROCESSING, SkillAvatarCandidate.MachineStatus.ERROR,
|
||||
3, SkillAvatarCandidate.Status.REJECTED, now.minusSeconds(600), now, PageRequest.of(0, 100));
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,12 @@ CREATE TABLE skill_avatar_candidate (
|
|||
machine_enabled BOOLEAN NOT NULL,
|
||||
manual_enabled BOOLEAN NOT NULL,
|
||||
machine_provider VARCHAR(32),
|
||||
scope_review_version_id BIGINT,
|
||||
scope_review BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
machine_attempt_id VARCHAR(36),
|
||||
machine_lease_until TIMESTAMP,
|
||||
machine_next_retry_at TIMESTAMP,
|
||||
machine_attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
machine_status VARCHAR(32) NOT NULL,
|
||||
review_status VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
package com.iflytek.skillhub.listener;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
|
||||
import com.iflytek.skillhub.service.SkillAvatarAppService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import static org.mockito.Mockito.timeout;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.after;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class SkillAvatarScopeReviewListenerTest {
|
||||
@Autowired private ApplicationEventPublisher events;
|
||||
@Autowired private PlatformTransactionManager transactions;
|
||||
@MockBean private SkillAvatarAppService avatars;
|
||||
|
||||
@Test
|
||||
void committedPublicationTriggersScopeReview() {
|
||||
new TransactionTemplate(transactions).executeWithoutResult(ignored -> {
|
||||
events.publishEvent(new SkillPublishedEvent(912345L, 1L, "publisher"));
|
||||
verify(avatars, never()).prepareScopeReview(912345L, "publisher");
|
||||
});
|
||||
|
||||
verify(avatars, timeout(5000)).prepareScopeReview(912345L, "publisher");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledBackPublicationDoesNotTriggerScopeReview() {
|
||||
new TransactionTemplate(transactions).executeWithoutResult(status -> {
|
||||
events.publishEvent(new SkillPublishedEvent(912346L, 1L, "publisher"));
|
||||
status.setRollbackOnly();
|
||||
});
|
||||
|
||||
verify(avatars, after(1000).never()).prepareScopeReview(912346L, "publisher");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
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.infra.jpa.SkillAvatarCandidateJpaRepository;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@ActiveProfiles("test")
|
||||
class SkillAvatarMachineRecoveryQueryTest {
|
||||
@Autowired private EntityManager entities;
|
||||
@Autowired private SkillAvatarCandidateJpaRepository candidates;
|
||||
|
||||
@Test
|
||||
void selectsOnlyCurrentOrdinaryCandidatesAfterAbandonmentDeadline() {
|
||||
Namespace namespace = new Namespace("machine-recovery", "Machine recovery", "owner");
|
||||
entities.persist(namespace);
|
||||
SkillAvatarCandidate pending = candidate(namespace.getId(), "pending", false);
|
||||
SkillAvatarCandidate processing = candidate(namespace.getId(), "processing", true);
|
||||
SkillAvatarCandidate superseded = candidate(namespace.getId(), "superseded", false);
|
||||
SkillAvatarCandidate scoped = candidate(namespace.getId(), "scope", false);
|
||||
scoped.markScopeReviewVersion(null);
|
||||
Skill supersededSkill = entities.createQuery("SELECT s FROM Skill s WHERE s.id = :id", Skill.class)
|
||||
.setParameter("id", superseded.getSkillId()).getSingleResult();
|
||||
supersededSkill.setPendingAvatarId(null);
|
||||
entities.flush();
|
||||
entities.clear();
|
||||
|
||||
Instant now = Instant.now();
|
||||
assertThat(recoverable(now)).isEmpty();
|
||||
assertThat(recoverable(now.plusSeconds(601))).extracting(SkillAvatarCandidate::getId)
|
||||
.containsExactly(pending.getId(), processing.getId());
|
||||
assertThat(recoverable(now.plusSeconds(601), pending.getId()))
|
||||
.extracting(SkillAvatarCandidate::getId).containsExactly(processing.getId());
|
||||
}
|
||||
|
||||
private SkillAvatarCandidate candidate(Long namespaceId, String slug, boolean claim) {
|
||||
Skill skill = new Skill(namespaceId, slug, "owner", SkillVisibility.PUBLIC);
|
||||
entities.persist(skill);
|
||||
entities.flush();
|
||||
SkillAvatarCandidate candidate = new SkillAvatarCandidate(skill.getId(), "owner", slug + ".png",
|
||||
"sha", SkillVisibility.PUBLIC, true, false, "xfyun");
|
||||
if (claim) {
|
||||
Instant now = Instant.now();
|
||||
candidate.claimMachineAttempt("attempt", now, now.plusSeconds(600));
|
||||
}
|
||||
entities.persist(candidate);
|
||||
entities.flush();
|
||||
skill.setPendingAvatarId(candidate.getId());
|
||||
entities.flush();
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private java.util.List<SkillAvatarCandidate> recoverable(Instant now) {
|
||||
return recoverable(now, 0L);
|
||||
}
|
||||
|
||||
private java.util.List<SkillAvatarCandidate> recoverable(Instant now, Long afterId) {
|
||||
return candidates.findAbandonedMachineCandidates(afterId, SkillAvatarCandidate.Status.PENDING,
|
||||
SkillAvatarCandidate.MachineStatus.PENDING, SkillAvatarCandidate.MachineStatus.PROCESSING,
|
||||
now.minusSeconds(600), now, PageRequest.of(0, 100));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.infra.jpa.SkillJpaRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@ActiveProfiles("test")
|
||||
class SkillAvatarScopeRecoveryQueryTest {
|
||||
@Autowired private TestEntityManager entities;
|
||||
@Autowired private SkillJpaRepository skills;
|
||||
|
||||
@Test
|
||||
void selectsMissingAndErroredReviewsButSkipsCurrentRejectedCandidate() {
|
||||
Namespace namespace = entities.persistAndFlush(new Namespace("scope-recovery", "Scope recovery", "owner"));
|
||||
Skill needsReview = expandedSkill(namespace.getId(), "needs-review");
|
||||
Skill rejected = expandedSkill(namespace.getId(), "rejected");
|
||||
Skill oldRejection = expandedSkill(namespace.getId(), "old-rejection");
|
||||
Skill retry = expandedSkill(namespace.getId(), "retry");
|
||||
Skill fresh = expandedSkill(namespace.getId(), "fresh");
|
||||
Skill processing = expandedSkill(namespace.getId(), "processing");
|
||||
SkillAvatarCandidate rejectedCandidate = new SkillAvatarCandidate(rejected.getId(), "owner", "old/rejected.png",
|
||||
"sha", SkillVisibility.PUBLIC, false, true, null);
|
||||
rejectedCandidate.markScopeReviewVersion(4L);
|
||||
rejectedCandidate.reject("reviewer", "blocked");
|
||||
entities.persistAndFlush(rejectedCandidate);
|
||||
SkillAvatarCandidate historical = new SkillAvatarCandidate(oldRejection.getId(), "owner", "old/historical.png",
|
||||
"sha", SkillVisibility.PUBLIC, false, true, null);
|
||||
historical.markScopeReviewVersion(3L);
|
||||
historical.reject("reviewer", "blocked on older publication");
|
||||
entities.persistAndFlush(historical);
|
||||
SkillAvatarCandidate errored = new SkillAvatarCandidate(retry.getId(), "owner", "old/error.png", "sha",
|
||||
SkillVisibility.PUBLIC, true, false, "xfyun");
|
||||
errored.machineResult(SkillAvatarCandidate.MachineStatus.ERROR);
|
||||
entities.persistAndFlush(errored);
|
||||
retry.setPendingAvatarId(errored.getId());
|
||||
entities.persistAndFlush(retry);
|
||||
SkillAvatarCandidate freshCandidate = new SkillAvatarCandidate(fresh.getId(), "owner", "old/fresh.png", "sha",
|
||||
SkillVisibility.PUBLIC, true, false, "xfyun");
|
||||
entities.persistAndFlush(freshCandidate);
|
||||
fresh.setPendingAvatarId(freshCandidate.getId());
|
||||
entities.persistAndFlush(fresh);
|
||||
SkillAvatarCandidate processingCandidate = new SkillAvatarCandidate(processing.getId(), "owner",
|
||||
"old/processing.png", "sha", SkillVisibility.PUBLIC, true, false, "xfyun");
|
||||
Instant claimTime = Instant.now();
|
||||
processingCandidate.claimMachineAttempt("first-attempt", claimTime, claimTime.plusSeconds(600));
|
||||
entities.persistAndFlush(processingCandidate);
|
||||
processing.setPendingAvatarId(processingCandidate.getId());
|
||||
entities.persistAndFlush(processing);
|
||||
entities.clear();
|
||||
|
||||
Instant now = Instant.now();
|
||||
var results = recoverable(now);
|
||||
|
||||
assertThat(results).extracting(Skill::getId)
|
||||
.containsExactly(needsReview.getId(), oldRejection.getId(), retry.getId());
|
||||
assertThat(recoverable(now.plusSeconds(601))).extracting(Skill::getId)
|
||||
.containsExactly(needsReview.getId(), oldRejection.getId(), retry.getId(),
|
||||
fresh.getId(), processing.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cooledDownErrorIsNotRetriedOnConsecutiveTicksAndStopsAfterThreeAttempts() {
|
||||
Namespace namespace = entities.persistAndFlush(new Namespace("scope-cooldown", "Scope cooldown", "owner"));
|
||||
Skill cooling = expandedSkill(namespace.getId(), "cooling");
|
||||
Skill exhausted = expandedSkill(namespace.getId(), "exhausted");
|
||||
SkillAvatarCandidate first = errorCandidate(cooling, 1);
|
||||
SkillAvatarCandidate third = errorCandidate(exhausted, 3);
|
||||
cooling.setPendingAvatarId(first.getId());
|
||||
exhausted.setPendingAvatarId(third.getId());
|
||||
entities.persistAndFlush(cooling);
|
||||
entities.persistAndFlush(exhausted);
|
||||
entities.clear();
|
||||
|
||||
Instant now = Instant.now();
|
||||
assertThat(recoverable(now)).isEmpty();
|
||||
assertThat(recoverable(now.plusSeconds(300))).isEmpty();
|
||||
assertThat(recoverable(now.plusSeconds(3601))).extracting(Skill::getId)
|
||||
.containsExactly(cooling.getId());
|
||||
}
|
||||
|
||||
private SkillAvatarCandidate errorCandidate(Skill skill, int attempts) {
|
||||
SkillAvatarCandidate candidate = new SkillAvatarCandidate(skill.getId(), "owner", "old/error.png",
|
||||
"sha", SkillVisibility.PUBLIC, true, false, "xfyun");
|
||||
for (int n = 0; n < attempts; n++) {
|
||||
Instant at = Instant.now().plusSeconds(n * 3601L);
|
||||
candidate.claimMachineAttempt("attempt-" + n, at, at.plusSeconds(600));
|
||||
candidate.completeMachineAttempt("attempt-" + n, SkillAvatarCandidate.MachineStatus.ERROR);
|
||||
}
|
||||
return entities.persistAndFlush(candidate);
|
||||
}
|
||||
|
||||
private java.util.List<Skill> recoverable(Instant now) {
|
||||
return skills.findScopeReviewRecoveryCandidates(0L, SkillStatus.ACTIVE,
|
||||
SkillVisibility.PRIVATE, SkillVisibility.NAMESPACE_ONLY, SkillVisibility.PUBLIC,
|
||||
SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.MachineStatus.PENDING,
|
||||
SkillAvatarCandidate.MachineStatus.PROCESSING, SkillAvatarCandidate.MachineStatus.ERROR, 3,
|
||||
SkillAvatarCandidate.Status.REJECTED, now.minusSeconds(600), now, PageRequest.of(0, 100));
|
||||
}
|
||||
|
||||
private Skill expandedSkill(Long namespaceId, String slug) {
|
||||
Skill skill = entities.persistAndFlush(new Skill(namespaceId, slug, "owner", SkillVisibility.PRIVATE));
|
||||
SkillAvatarCandidate active = new SkillAvatarCandidate(skill.getId(), "owner", "old/" + slug + ".png",
|
||||
"sha", SkillVisibility.PRIVATE, false, false, null);
|
||||
active.activate();
|
||||
entities.persistAndFlush(active);
|
||||
skill.activateAvatar(active.getId(), SkillVisibility.PRIVATE);
|
||||
skill.setLatestVersionId(4L);
|
||||
skill.setVisibility(SkillVisibility.PUBLIC);
|
||||
entities.persistAndFlush(skill);
|
||||
return skill;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.auth.settings.SystemSetting;
|
||||
import com.iflytek.skillhub.auth.settings.SystemSettingRepository;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
|
||||
import com.iflytek.skillhub.dto.AvatarReviewSettingsUpdateRequest;
|
||||
import com.iflytek.skillhub.observability.RequestIdAccessor;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AvatarReviewSettingsServiceTest {
|
||||
@Mock private SystemSettingRepository repository;
|
||||
@Mock private AvatarModerationGateway gateway;
|
||||
@Mock private AuditLogService audit;
|
||||
@Mock private RequestIdAccessor requestIds;
|
||||
private AvatarReviewSettingsService service;
|
||||
private SystemSetting setting;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
service = new AvatarReviewSettingsService(repository, gateway, audit, requestIds);
|
||||
setting = new SystemSetting(AvatarReviewSettingsService.SETTING_KEY,
|
||||
Map.of("machineEnabled", false, "manualEnabled", true));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({ "false,false", "false,true", "true,false", "true,true" })
|
||||
void updatesAllFourPolicies(boolean machine, boolean manual) {
|
||||
when(repository.findBySettingKey(AvatarReviewSettingsService.SETTING_KEY)).thenReturn(Optional.of(setting));
|
||||
if (machine) when(gateway.requireConfiguredProvider()).thenReturn("xfyun");
|
||||
var updated = service.update(new AvatarReviewSettingsUpdateRequest(machine, manual, 0), "admin",
|
||||
new AuditRequestContext(null, null));
|
||||
assertThat(updated.machineEnabled()).isEqualTo(machine);
|
||||
assertThat(updated.manualEnabled()).isEqualTo(manual);
|
||||
verify(repository).saveAndFlush(setting);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cannotEnableMachineReviewWithoutProvider() {
|
||||
when(gateway.requireConfiguredProvider()).thenThrow(new DomainBadRequestException("providerUnavailable"));
|
||||
assertThatThrownBy(() -> service.update(new AvatarReviewSettingsUpdateRequest(true, false, 0), "admin",
|
||||
new AuditRequestContext(null, null))).isInstanceOf(DomainBadRequestException.class);
|
||||
verify(repository, never()).saveAndFlush(setting);
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleVersionDoesNotOverwriteSettings() {
|
||||
when(repository.findBySettingKey(AvatarReviewSettingsService.SETTING_KEY)).thenReturn(Optional.of(setting));
|
||||
assertThatThrownBy(() -> service.update(new AvatarReviewSettingsUpdateRequest(false, false, 1), "admin",
|
||||
new AuditRequestContext(null, null))).isInstanceOf(DomainConflictException.class);
|
||||
verify(repository, never()).saveAndFlush(setting);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,395 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse;
|
||||
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.infra.jpa.SkillAvatarCandidateJpaRepository;
|
||||
import com.iflytek.skillhub.infra.jpa.SkillJpaRepository;
|
||||
import com.iflytek.skillhub.observability.RequestIdAccessor;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.SimpleTransactionStatus;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SkillAvatarAppServiceTest {
|
||||
@Mock private SkillJpaRepository skills;
|
||||
@Mock private NamespaceRepository namespaces;
|
||||
@Mock private SkillAvatarCandidateJpaRepository candidates;
|
||||
@Mock private ObjectStorageService storage;
|
||||
@Mock private SkillAvatarImageProcessor imageProcessor;
|
||||
@Mock private AvatarReviewSettingsService settings;
|
||||
@Mock private AvatarModerationGateway moderation;
|
||||
@Mock private VisibilityChecker visibility;
|
||||
@Mock private SkillQueryService skillQueries;
|
||||
@Mock private AuditLogService audit;
|
||||
@Mock private RequestIdAccessor requestIds;
|
||||
@Mock private EntityManager entityManager;
|
||||
@Mock private PlatformTransactionManager transactionManager;
|
||||
private SkillAvatarAppService service;
|
||||
private Namespace namespace;
|
||||
private Skill skill;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
service = new SkillAvatarAppService(skills, namespaces, candidates, storage, imageProcessor,
|
||||
settings, moderation, visibility, skillQueries, audit, requestIds, entityManager, transactionManager);
|
||||
org.mockito.Mockito.lenient().when(transactionManager.getTransaction(any()))
|
||||
.thenAnswer(ignored -> new SimpleTransactionStatus());
|
||||
namespace = new Namespace("team", "Team", "owner");
|
||||
ReflectionTestUtils.setField(namespace, "id", 10L);
|
||||
ReflectionTestUtils.setField(namespace, "type", NamespaceType.TEAM);
|
||||
skill = new Skill(10L, "foo", "owner", SkillVisibility.PUBLIC);
|
||||
ReflectionTestUtils.setField(skill, "id", 11L);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearTransactionSynchronization() {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void privateAvatarCannotBeReadByNamespaceAdmin() {
|
||||
skill.setVisibility(SkillVisibility.PRIVATE);
|
||||
skill.activateAvatar(21L, SkillVisibility.PRIVATE);
|
||||
when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace));
|
||||
when(skills.findByNamespaceIdAndSlug(10L, "foo")).thenReturn(List.of(skill));
|
||||
when(visibility.canAccess(eq(skill), eq("admin"), any(), any())).thenReturn(true);
|
||||
assertThatThrownBy(() -> service.readActive("team", "foo", "admin",
|
||||
Map.of(10L, NamespaceRole.ADMIN), Set.of()))
|
||||
.isInstanceOf(DomainNotFoundException.class);
|
||||
verifyNoInteractions(storage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void privateCandidateCannotBeReadByNamespaceAdmin() {
|
||||
when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace));
|
||||
when(skills.findByNamespaceIdAndSlug(10L, "foo")).thenReturn(List.of(skill));
|
||||
SkillAvatarCandidate candidate = new SkillAvatarCandidate(11L, "owner", "avatar/private.png", "abc",
|
||||
SkillVisibility.PRIVATE, false, false, null);
|
||||
ReflectionTestUtils.setField(candidate, "id", 21L);
|
||||
when(candidates.findById(21L)).thenReturn(Optional.of(candidate));
|
||||
assertThatThrownBy(() -> service.readCandidate("team", "foo", 21L, "admin",
|
||||
Map.of(10L, NamespaceRole.ADMIN), Set.of()))
|
||||
.isInstanceOf(DomainNotFoundException.class);
|
||||
verifyNoInteractions(storage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expandingVisibilityCreatesNewCandidateWithDistinctObjectKey() {
|
||||
skill.activateAvatar(21L, SkillVisibility.PRIVATE);
|
||||
skill.setLatestVersionId(4L);
|
||||
SkillAvatarCandidate old = new SkillAvatarCandidate(11L, "owner", "avatar/private.png", "abc",
|
||||
SkillVisibility.PRIVATE, false, false, null);
|
||||
old.activate();
|
||||
ReflectionTestUtils.setField(old, "id", 21L);
|
||||
when(skills.findLockedById(11L)).thenReturn(Optional.of(skill));
|
||||
when(namespaces.findById(10L)).thenReturn(Optional.of(namespace));
|
||||
when(candidates.findById(21L)).thenReturn(Optional.of(old));
|
||||
when(storage.getObject("avatar/private.png")).thenReturn(new ByteArrayInputStream(new byte[] { 1 }));
|
||||
when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(false, true, 1, null));
|
||||
when(candidates.saveAndFlush(any())).thenAnswer(invocation -> {
|
||||
SkillAvatarCandidate created = invocation.getArgument(0);
|
||||
ReflectionTestUtils.setField(created, "id", 22L);
|
||||
return created;
|
||||
});
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
|
||||
var result = service.prepareScopeReview(11L, "publisher");
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(result.candidateStatus()).isEqualTo("PENDING");
|
||||
org.assertj.core.api.Assertions.assertThat(result.avatarUrl()).isNull();
|
||||
org.assertj.core.api.Assertions.assertThat(skill.getPendingAvatarId()).isEqualTo(22L);
|
||||
org.assertj.core.api.Assertions.assertThat(old.getStatus()).isEqualTo(SkillAvatarCandidate.Status.ACTIVE);
|
||||
org.mockito.ArgumentCaptor<String> key = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(storage).putObject(key.capture(), any(), eq(1L), eq("image/png"));
|
||||
org.assertj.core.api.Assertions.assertThat(key.getValue()).isNotEqualTo("avatar/private.png");
|
||||
org.mockito.ArgumentCaptor<SkillAvatarCandidate> created =
|
||||
org.mockito.ArgumentCaptor.forClass(SkillAvatarCandidate.class);
|
||||
verify(candidates).saveAndFlush(created.capture());
|
||||
org.assertj.core.api.Assertions.assertThat(created.getValue().getScopeReviewVersionId()).isEqualTo(4L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expandingVisibilityKeepsExistingTargetScopeCandidate() {
|
||||
skill.activateAvatar(21L, SkillVisibility.PRIVATE);
|
||||
skill.setPendingAvatarId(22L);
|
||||
SkillAvatarCandidate pending = new SkillAvatarCandidate(11L, "owner", "avatar/new.png", "abc",
|
||||
SkillVisibility.PUBLIC, false, true, null);
|
||||
ReflectionTestUtils.setField(pending, "id", 22L);
|
||||
when(skills.findLockedById(11L)).thenReturn(Optional.of(skill));
|
||||
when(namespaces.findById(10L)).thenReturn(Optional.of(namespace));
|
||||
when(candidates.findById(22L)).thenReturn(Optional.of(pending));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(service.prepareScopeReview(11L, "publisher")).isNull();
|
||||
verifyNoInteractions(storage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void storageFailureLeavesOldAvatarHiddenAndLaterRecoveryCanCreateCandidate() {
|
||||
skill.activateAvatar(21L, SkillVisibility.PRIVATE);
|
||||
SkillAvatarCandidate old = new SkillAvatarCandidate(11L, "owner", "avatar/private.png", "abc",
|
||||
SkillVisibility.PRIVATE, false, false, null);
|
||||
old.activate();
|
||||
ReflectionTestUtils.setField(old, "id", 21L);
|
||||
when(skills.findLockedById(11L)).thenReturn(Optional.of(skill));
|
||||
when(namespaces.findById(10L)).thenReturn(Optional.of(namespace));
|
||||
when(candidates.findById(21L)).thenReturn(Optional.of(old));
|
||||
when(storage.getObject("avatar/private.png"))
|
||||
.thenThrow(new IllegalStateException("temporary storage outage"))
|
||||
.thenReturn(new ByteArrayInputStream(new byte[] { 1 }));
|
||||
when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(false, true, 1, null));
|
||||
when(candidates.saveAndFlush(any())).thenAnswer(invocation -> {
|
||||
SkillAvatarCandidate created = invocation.getArgument(0);
|
||||
ReflectionTestUtils.setField(created, "id", 22L);
|
||||
return created;
|
||||
});
|
||||
|
||||
assertThatThrownBy(() -> service.prepareScopeReview(11L, "publisher"))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
org.assertj.core.api.Assertions.assertThat(SkillAvatarUrls.current(skill, "team")).isNull();
|
||||
org.assertj.core.api.Assertions.assertThat(skill.getPendingAvatarId()).isNull();
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
var recovered = service.prepareScopeReview(11L, "publisher");
|
||||
org.assertj.core.api.Assertions.assertThat(recovered.candidateId()).isEqualTo(22L);
|
||||
org.assertj.core.api.Assertions.assertThat(recovered.avatarUrl()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void machineStorageFailureBecomesRetriableErrorThenPassesOnRecovery() {
|
||||
skill.setPendingAvatarId(21L);
|
||||
SkillAvatarCandidate candidate = new SkillAvatarCandidate(11L, "owner", "avatar/new.png", "abc",
|
||||
SkillVisibility.PUBLIC, true, false, "xfyun");
|
||||
ReflectionTestUtils.setField(candidate, "id", 21L);
|
||||
when(candidates.findById(21L)).thenReturn(Optional.of(candidate));
|
||||
when(storage.getObject("avatar/new.png"))
|
||||
.thenThrow(new IllegalStateException("temporary storage outage"))
|
||||
.thenReturn(new ByteArrayInputStream(new byte[] { 1 }));
|
||||
when(skills.findLockedById(11L)).thenReturn(Optional.of(skill));
|
||||
when(namespaces.findById(10L)).thenReturn(Optional.of(namespace));
|
||||
when(moderation.moderate(eq("xfyun"), any(byte[].class)))
|
||||
.thenReturn(AvatarModerationProvider.Verdict.PASS);
|
||||
|
||||
var failed = service.processMachine(21L, new AuditRequestContext(null, null));
|
||||
org.assertj.core.api.Assertions.assertThat(failed.machineStatus()).isEqualTo("ERROR");
|
||||
org.assertj.core.api.Assertions.assertThat(failed.avatarUrl()).isNull();
|
||||
org.assertj.core.api.Assertions.assertThat(skill.getPendingAvatarId()).isEqualTo(21L);
|
||||
assertThatThrownBy(() -> service.processMachine(21L, new AuditRequestContext(null, null)))
|
||||
.isInstanceOf(DomainConflictException.class);
|
||||
ReflectionTestUtils.setField(candidate, "machineNextRetryAt", Instant.now().minusSeconds(1));
|
||||
|
||||
var recovered = service.processMachine(21L, new AuditRequestContext(null, null));
|
||||
org.assertj.core.api.Assertions.assertThat(recovered.machineStatus()).isEqualTo("PASSED");
|
||||
org.assertj.core.api.Assertions.assertThat(recovered.avatarUrl()).isNotNull();
|
||||
org.assertj.core.api.Assertions.assertThat(skill.getPendingAvatarId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopeRecoveryReusesPendingErroredCandidateInsteadOfCreatingAnother() {
|
||||
skill.activateAvatar(21L, SkillVisibility.PRIVATE);
|
||||
skill.setPendingAvatarId(22L);
|
||||
SkillAvatarCandidate pending = new SkillAvatarCandidate(11L, "owner", "avatar/new.png", "abc",
|
||||
SkillVisibility.PUBLIC, true, false, "xfyun");
|
||||
pending.machineResult(SkillAvatarCandidate.MachineStatus.ERROR);
|
||||
ReflectionTestUtils.setField(pending, "id", 22L);
|
||||
when(skills.findLockedById(11L)).thenReturn(Optional.of(skill));
|
||||
when(namespaces.findById(10L)).thenReturn(Optional.of(namespace));
|
||||
when(candidates.findById(22L)).thenReturn(Optional.of(pending));
|
||||
|
||||
var result = service.prepareScopeReview(11L, "publisher");
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(result.candidateId()).isEqualTo(22L);
|
||||
org.assertj.core.api.Assertions.assertThat(result.machineStatus()).isEqualTo("ERROR");
|
||||
verifyNoInteractions(storage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void abandonedPendingMachineCandidateIsRecoveredWithoutCreatingAnother() {
|
||||
skill.activateAvatar(21L, SkillVisibility.PRIVATE);
|
||||
skill.setPendingAvatarId(22L);
|
||||
SkillAvatarCandidate pending = new SkillAvatarCandidate(11L, "owner", "avatar/new.png", "abc",
|
||||
SkillVisibility.PUBLIC, true, false, "xfyun");
|
||||
ReflectionTestUtils.setField(pending, "id", 22L);
|
||||
ReflectionTestUtils.setField(pending, "createdAt", Instant.now().minusSeconds(601));
|
||||
when(skills.findLockedById(11L)).thenReturn(Optional.of(skill));
|
||||
when(namespaces.findById(10L)).thenReturn(Optional.of(namespace));
|
||||
when(candidates.findById(22L)).thenReturn(Optional.of(pending));
|
||||
when(storage.getObject("avatar/new.png")).thenReturn(new ByteArrayInputStream(new byte[] { 1 }));
|
||||
when(moderation.moderate(eq("xfyun"), any(byte[].class)))
|
||||
.thenReturn(AvatarModerationProvider.Verdict.PASS);
|
||||
|
||||
var abandoned = service.prepareScopeReview(11L, "publisher");
|
||||
var recovered = service.processMachine(abandoned.candidateId(), new AuditRequestContext(null, null));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(recovered.machineStatus()).isEqualTo("PASSED");
|
||||
org.assertj.core.api.Assertions.assertThat(skill.getActiveAvatarId()).isEqualTo(22L);
|
||||
org.mockito.Mockito.verify(candidates, org.mockito.Mockito.never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lateMachineResultCannotOverwriteNewerAttempt() {
|
||||
SkillAvatarCandidate candidate = pendingCandidate();
|
||||
when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenAnswer(ignored -> {
|
||||
Instant afterLease = Instant.now().plusSeconds(601);
|
||||
org.assertj.core.api.Assertions.assertThat(candidate.claimMachineAttempt(
|
||||
"newer-attempt", afterLease, afterLease.plusSeconds(600))).isTrue();
|
||||
return AvatarModerationProvider.Verdict.PASS;
|
||||
});
|
||||
|
||||
var result = service.processMachine(21L, new AuditRequestContext(null, null));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(result.machineStatus()).isEqualTo("PROCESSING");
|
||||
org.assertj.core.api.Assertions.assertThat(skill.getActiveAvatarId()).isNull();
|
||||
org.assertj.core.api.Assertions.assertThat(skill.getPendingAvatarId()).isEqualTo(21L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredMachineLeaseIsReclaimedAfterCrash() {
|
||||
skill.activateAvatar(21L, SkillVisibility.PRIVATE);
|
||||
skill.setPendingAvatarId(22L);
|
||||
SkillAvatarCandidate pending = new SkillAvatarCandidate(11L, "owner", "avatar/new.png", "abc",
|
||||
SkillVisibility.PUBLIC, true, false, "xfyun");
|
||||
ReflectionTestUtils.setField(pending, "id", 22L);
|
||||
Instant old = Instant.now().minusSeconds(601);
|
||||
pending.claimMachineAttempt("crashed-attempt", old, old.plusSeconds(600));
|
||||
when(skills.findLockedById(11L)).thenReturn(Optional.of(skill));
|
||||
when(namespaces.findById(10L)).thenReturn(Optional.of(namespace));
|
||||
when(candidates.findById(22L)).thenReturn(Optional.of(pending));
|
||||
when(storage.getObject("avatar/new.png")).thenReturn(new ByteArrayInputStream(new byte[] { 1 }));
|
||||
when(moderation.moderate(eq("xfyun"), any(byte[].class)))
|
||||
.thenReturn(AvatarModerationProvider.Verdict.PASS);
|
||||
|
||||
var expired = service.prepareScopeReview(11L, "publisher");
|
||||
var recovered = service.processMachine(expired.candidateId(), new AuditRequestContext(null, null));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(expired.machineStatus()).isEqualTo("PROCESSING");
|
||||
org.assertj.core.api.Assertions.assertThat(recovered.machineStatus()).isEqualTo("PASSED");
|
||||
org.assertj.core.api.Assertions.assertThat(skill.getActiveAvatarId()).isEqualTo(22L);
|
||||
org.mockito.Mockito.verify(candidates, org.mockito.Mockito.never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processingMachineCandidateDoesNotEnterHumanReviewQueue() {
|
||||
SkillAvatarCandidate pending = new SkillAvatarCandidate(11L, "owner", "avatar/new.png", "abc",
|
||||
SkillVisibility.PUBLIC, true, true, "xfyun");
|
||||
pending.claimMachineAttempt("in-flight", Instant.now(), Instant.now().plusSeconds(600));
|
||||
when(candidates.findByStatusAndReviewStatusOrderByCreatedAtAsc(
|
||||
SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.ReviewStatus.PENDING))
|
||||
.thenReturn(List.of(pending));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(service.reviewQueue("reviewer",
|
||||
Map.of(10L, NamespaceRole.ADMIN), Set.of())).isEmpty();
|
||||
verifyNoInteractions(skills);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonOwnerCannotSubmitBeforeWritingToStorage() {
|
||||
stubSkillLookup();
|
||||
assertThatThrownBy(() -> service.submit("team", "foo", "stranger", new byte[] { 1 },
|
||||
new AuditRequestContext(null, null))).isInstanceOf(DomainForbiddenException.class);
|
||||
verifyNoInteractions(storage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleApprovalCannotReplaceNewerCandidate() {
|
||||
stubSkillLookup();
|
||||
when(namespaces.findById(10L)).thenReturn(Optional.of(namespace));
|
||||
assertThatThrownBy(() -> service.decide("team", "foo", 21L, true, "fine", "reviewer",
|
||||
Map.of(10L, NamespaceRole.ADMIN), Set.of(), new AuditRequestContext(null, null)))
|
||||
.isInstanceOf(DomainConflictException.class);
|
||||
verifyNoInteractions(candidates);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reviewRequiresMatchingNamespaceRole() {
|
||||
stubSkillLookup();
|
||||
when(namespaces.findById(10L)).thenReturn(Optional.of(namespace));
|
||||
assertThatThrownBy(() -> service.decide("team", "foo", 21L, true, "fine", "reviewer",
|
||||
Map.of(), Set.of("SKILL_ADMIN"), new AuditRequestContext(null, null)))
|
||||
.isInstanceOf(DomainForbiddenException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownMachineResultLeavesCandidatePrivate() {
|
||||
SkillAvatarCandidate candidate = pendingCandidate();
|
||||
when(moderation.moderate(org.mockito.ArgumentMatchers.eq("xfyun"), any(byte[].class)))
|
||||
.thenReturn(AvatarModerationProvider.Verdict.ERROR);
|
||||
|
||||
var result = service.processMachine(21L, new AuditRequestContext(null, null));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(result.machineStatus()).isEqualTo("ERROR");
|
||||
org.assertj.core.api.Assertions.assertThat(result.avatarUrl()).isNull();
|
||||
org.assertj.core.api.Assertions.assertThat(skill.getPendingAvatarId()).isEqualTo(21L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleMachineResultCannotActivateSupersededCandidate() {
|
||||
SkillAvatarCandidate candidate = pendingCandidate();
|
||||
when(moderation.moderate(org.mockito.ArgumentMatchers.eq("xfyun"), any(byte[].class)))
|
||||
.thenAnswer(ignored -> {
|
||||
candidate.supersede();
|
||||
skill.setPendingAvatarId(22L);
|
||||
return AvatarModerationProvider.Verdict.PASS;
|
||||
});
|
||||
|
||||
var result = service.processMachine(21L, new AuditRequestContext(null, null));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(result.avatarUrl()).isNull();
|
||||
org.assertj.core.api.Assertions.assertThat(skill.getPendingAvatarId()).isEqualTo(22L);
|
||||
org.assertj.core.api.Assertions.assertThat(candidate.getStatus())
|
||||
.isEqualTo(SkillAvatarCandidate.Status.SUPERSEDED);
|
||||
}
|
||||
|
||||
private SkillAvatarCandidate pendingCandidate() {
|
||||
skill.setPendingAvatarId(21L);
|
||||
SkillAvatarCandidate candidate = new SkillAvatarCandidate(11L, "owner", "avatar/one.png", "abc",
|
||||
SkillVisibility.PUBLIC, true, false, "xfyun");
|
||||
ReflectionTestUtils.setField(candidate, "id", 21L);
|
||||
when(candidates.findById(21L)).thenReturn(Optional.of(candidate));
|
||||
when(storage.getObject("avatar/one.png")).thenReturn(new ByteArrayInputStream(new byte[] { 1 }));
|
||||
when(skills.findLockedById(11L)).thenReturn(Optional.of(skill));
|
||||
when(namespaces.findById(10L)).thenReturn(Optional.of(namespace));
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private void stubSkillLookup() {
|
||||
when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace));
|
||||
when(skills.findByNamespaceIdAndSlug(10L, "foo")).thenReturn(List.of(skill));
|
||||
when(skills.findLockedById(11L)).thenReturn(Optional.of(skill));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SkillAvatarUrlsTest {
|
||||
@Test
|
||||
void hidesAvatarWhenCurrentVisibilityExceedsApprovedAudience() {
|
||||
assertThat(SkillAvatarUrls.current(1L, SkillVisibility.PRIVATE, SkillVisibility.NAMESPACE_ONLY,
|
||||
"team", "foo")).isNull();
|
||||
assertThat(SkillAvatarUrls.current(1L, SkillVisibility.NAMESPACE_ONLY, SkillVisibility.PUBLIC,
|
||||
"team", "foo")).isNull();
|
||||
assertThat(SkillAvatarUrls.current(1L, SkillVisibility.PUBLIC, SkillVisibility.PUBLIC,
|
||||
"global", "foo")).isEqualTo("/api/v1/skills/global/foo/avatar");
|
||||
assertThat(SkillAvatarUrls.current(null, SkillVisibility.PUBLIC, SkillVisibility.PUBLIC,
|
||||
"global", "foo")).isNull();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.iflytek.skillhub.task;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository;
|
||||
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||
import com.iflytek.skillhub.service.SkillAvatarAppService;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class SkillAvatarMachineRecoveryTaskTest {
|
||||
@Test
|
||||
void recoversAbandonedOrdinaryUploadAndContinuesAfterFailure() {
|
||||
SkillAvatarCandidateJpaRepository candidates = mock(SkillAvatarCandidateJpaRepository.class);
|
||||
SkillAvatarAppService avatars = mock(SkillAvatarAppService.class);
|
||||
SkillAvatarCandidate first = candidate(11L);
|
||||
SkillAvatarCandidate second = candidate(12L);
|
||||
when(candidates.findAbandonedMachineCandidates(eq(0L), eq(SkillAvatarCandidate.Status.PENDING),
|
||||
eq(SkillAvatarCandidate.MachineStatus.PENDING), eq(SkillAvatarCandidate.MachineStatus.PROCESSING),
|
||||
any(Instant.class), any(Instant.class), eq(PageRequest.of(0, 100))))
|
||||
.thenReturn(List.of(first, second));
|
||||
doThrow(new IllegalStateException("secret-bearing failure"))
|
||||
.when(avatars).processMachine(eq(11L), any(AuditRequestContext.class));
|
||||
|
||||
new SkillAvatarMachineRecoveryTask(candidates, avatars).recover();
|
||||
|
||||
verify(avatars).processMachine(eq(11L), any(AuditRequestContext.class));
|
||||
verify(avatars).processMachine(eq(12L), any(AuditRequestContext.class));
|
||||
}
|
||||
|
||||
private SkillAvatarCandidate candidate(Long id) {
|
||||
SkillAvatarCandidate candidate = new SkillAvatarCandidate(1L, "owner", "private.png", "sha",
|
||||
SkillVisibility.PUBLIC, true, false, "xfyun");
|
||||
ReflectionTestUtils.setField(candidate, "id", id);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package com.iflytek.skillhub.task;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.infra.jpa.SkillJpaRepository;
|
||||
import com.iflytek.skillhub.listener.SkillAvatarScopeReviewListener;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import java.time.Instant;
|
||||
|
||||
class SkillAvatarScopeReviewRecoveryTaskTest {
|
||||
@Test
|
||||
void scansOnlyUnreviewedExpandedScopesInBoundedIdOrder() {
|
||||
SkillJpaRepository skills = mock(SkillJpaRepository.class);
|
||||
SkillAvatarScopeReviewListener listener = mock(SkillAvatarScopeReviewListener.class);
|
||||
Skill skill = new Skill(10L, "foo", "owner", SkillVisibility.PUBLIC);
|
||||
ReflectionTestUtils.setField(skill, "id", 11L);
|
||||
when(skills.findScopeReviewRecoveryCandidates(eq(0L), eq(SkillStatus.ACTIVE), eq(SkillVisibility.PRIVATE),
|
||||
eq(SkillVisibility.NAMESPACE_ONLY), eq(SkillVisibility.PUBLIC), eq(SkillAvatarCandidate.Status.PENDING),
|
||||
eq(SkillAvatarCandidate.MachineStatus.PENDING), eq(SkillAvatarCandidate.MachineStatus.PROCESSING),
|
||||
eq(SkillAvatarCandidate.MachineStatus.ERROR), eq(3), eq(SkillAvatarCandidate.Status.REJECTED),
|
||||
any(Instant.class), any(Instant.class), eq(PageRequest.of(0, 100))))
|
||||
.thenReturn(List.of(skill));
|
||||
|
||||
new SkillAvatarScopeReviewRecoveryTask(skills, listener).recover();
|
||||
|
||||
verify(listener).reviewScope(11L, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedSkillDoesNotStarveLaterBatches() {
|
||||
SkillJpaRepository skills = mock(SkillJpaRepository.class);
|
||||
SkillAvatarScopeReviewListener listener = mock(SkillAvatarScopeReviewListener.class);
|
||||
Skill first = new Skill(10L, "first", "owner", SkillVisibility.PUBLIC);
|
||||
Skill second = new Skill(10L, "second", "owner", SkillVisibility.PUBLIC);
|
||||
ReflectionTestUtils.setField(first, "id", 11L);
|
||||
ReflectionTestUtils.setField(second, "id", 12L);
|
||||
when(skills.findScopeReviewRecoveryCandidates(eq(0L), eq(SkillStatus.ACTIVE), eq(SkillVisibility.PRIVATE),
|
||||
eq(SkillVisibility.NAMESPACE_ONLY), eq(SkillVisibility.PUBLIC), eq(SkillAvatarCandidate.Status.PENDING),
|
||||
eq(SkillAvatarCandidate.MachineStatus.PENDING), eq(SkillAvatarCandidate.MachineStatus.PROCESSING),
|
||||
eq(SkillAvatarCandidate.MachineStatus.ERROR), eq(3), eq(SkillAvatarCandidate.Status.REJECTED),
|
||||
any(Instant.class), any(Instant.class), eq(PageRequest.of(0, 100))))
|
||||
.thenReturn(List.of(first));
|
||||
when(skills.findScopeReviewRecoveryCandidates(eq(11L), eq(SkillStatus.ACTIVE), eq(SkillVisibility.PRIVATE),
|
||||
eq(SkillVisibility.NAMESPACE_ONLY), eq(SkillVisibility.PUBLIC), eq(SkillAvatarCandidate.Status.PENDING),
|
||||
eq(SkillAvatarCandidate.MachineStatus.PENDING), eq(SkillAvatarCandidate.MachineStatus.PROCESSING),
|
||||
eq(SkillAvatarCandidate.MachineStatus.ERROR), eq(3), eq(SkillAvatarCandidate.Status.REJECTED),
|
||||
any(Instant.class), any(Instant.class), eq(PageRequest.of(0, 100))))
|
||||
.thenReturn(List.of(second));
|
||||
doThrow(new IllegalStateException("storage unavailable")).when(listener).reviewScope(11L, null);
|
||||
SkillAvatarScopeReviewRecoveryTask task = new SkillAvatarScopeReviewRecoveryTask(skills, listener);
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(task::recover).isInstanceOf(IllegalStateException.class);
|
||||
task.recover();
|
||||
|
||||
verify(listener).reviewScope(12L, null);
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +55,9 @@ public class RouteSecurityPolicyRegistry {
|
|||
RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/web/skills/*/reviews/me"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/reviews/me"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/avatar-review/queue"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/avatar"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions/*"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/download"),
|
||||
|
|
@ -70,7 +72,9 @@ public class RouteSecurityPolicyRegistry {
|
|||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/file"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/labels"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/skills/avatar-review/queue"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/avatar"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions/*"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/download"),
|
||||
|
|
|
|||
|
|
@ -34,6 +34,20 @@ class RouteSecurityPolicyRegistryTest {
|
|||
registry.accessLevel("GET", "/api/v1/resolve/team/demo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentAvatarMayBePublicButCandidateAndReviewRoutesRequireAuthentication() {
|
||||
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL,
|
||||
registry.accessLevel("GET", "/api/v1/skills/global/demo/avatar"));
|
||||
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL,
|
||||
registry.accessLevel("GET", "/api/web/skills/global/demo/avatar"));
|
||||
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED,
|
||||
registry.accessLevel("GET", "/api/v1/skills/global/demo/avatar/candidates/1/image"));
|
||||
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED,
|
||||
registry.accessLevel("GET", "/api/v1/skills/avatar-review/queue"));
|
||||
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED,
|
||||
registry.accessLevel("GET", "/api/web/skills/avatar-review/queue"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reviewRoutesExposePublicListingButProtectCurrentUserMutations() {
|
||||
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import java.time.Instant;
|
|||
@Entity
|
||||
@Table(name = "skill_avatar_candidate")
|
||||
public class SkillAvatarCandidate {
|
||||
public enum MachineStatus { SKIPPED, PENDING, PASSED, BLOCKED, ERROR }
|
||||
public enum MachineStatus { SKIPPED, PENDING, PROCESSING, PASSED, BLOCKED, ERROR }
|
||||
public enum ReviewStatus { NOT_REQUIRED, PENDING, APPROVED, REJECTED, APPEAL_PENDING }
|
||||
public enum Status { PENDING, ACTIVE, REJECTED, SUPERSEDED, REMOVED }
|
||||
|
||||
|
|
@ -39,6 +39,18 @@ public class SkillAvatarCandidate {
|
|||
private boolean manualEnabled;
|
||||
@Column(name = "machine_provider", length = 32)
|
||||
private String machineProvider;
|
||||
@Column(name = "scope_review_version_id")
|
||||
private Long scopeReviewVersionId;
|
||||
@Column(name = "scope_review", nullable = false)
|
||||
private boolean scopeReview;
|
||||
@Column(name = "machine_attempt_id", length = 36)
|
||||
private String machineAttemptId;
|
||||
@Column(name = "machine_lease_until")
|
||||
private Instant machineLeaseUntil;
|
||||
@Column(name = "machine_next_retry_at")
|
||||
private Instant machineNextRetryAt;
|
||||
@Column(name = "machine_attempt_count", nullable = false)
|
||||
private int machineAttemptCount;
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "machine_status", nullable = false, length = 32)
|
||||
private MachineStatus machineStatus;
|
||||
|
|
@ -93,6 +105,11 @@ public class SkillAvatarCandidate {
|
|||
public boolean isMachineEnabled() { return machineEnabled; }
|
||||
public boolean isManualEnabled() { return manualEnabled; }
|
||||
public String getMachineProvider() { return machineProvider; }
|
||||
public Long getScopeReviewVersionId() { return scopeReviewVersionId; }
|
||||
public boolean isScopeReview() { return scopeReview; }
|
||||
public Instant getMachineLeaseUntil() { return machineLeaseUntil; }
|
||||
public Instant getMachineNextRetryAt() { return machineNextRetryAt; }
|
||||
public int getMachineAttemptCount() { return machineAttemptCount; }
|
||||
public MachineStatus getMachineStatus() { return machineStatus; }
|
||||
public ReviewStatus getReviewStatus() { return reviewStatus; }
|
||||
public Status getStatus() { return status; }
|
||||
|
|
@ -101,8 +118,46 @@ public class SkillAvatarCandidate {
|
|||
public Instant getCreatedAt() { return createdAt; }
|
||||
public Instant getDiscardAt() { return discardAt; }
|
||||
|
||||
public void markScopeReviewVersion(Long versionId) { scopeReviewVersionId = versionId; scopeReview = true; }
|
||||
|
||||
public boolean canRetryMachine(Instant now) {
|
||||
return status == Status.PENDING && machineEnabled
|
||||
&& (machineStatus == MachineStatus.ERROR
|
||||
&& (machineNextRetryAt == null || !machineNextRetryAt.isAfter(now))
|
||||
|| machineStatus == MachineStatus.PROCESSING
|
||||
&& machineLeaseUntil != null && !machineLeaseUntil.isAfter(now));
|
||||
}
|
||||
|
||||
public boolean claimMachineAttempt(String attemptId, Instant now, Instant leaseUntil) {
|
||||
if (status != Status.PENDING || !machineEnabled
|
||||
|| machineStatus != MachineStatus.PENDING && !canRetryMachine(now)) {
|
||||
return false;
|
||||
}
|
||||
machineStatus = MachineStatus.PROCESSING;
|
||||
machineAttemptId = attemptId;
|
||||
machineLeaseUntil = leaseUntil;
|
||||
machineNextRetryAt = null;
|
||||
machineAttemptCount++;
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean completeMachineAttempt(String attemptId, MachineStatus result) {
|
||||
if (status != Status.PENDING || machineStatus != MachineStatus.PROCESSING
|
||||
|| !attemptId.equals(machineAttemptId)
|
||||
|| result == MachineStatus.PENDING || result == MachineStatus.PROCESSING
|
||||
|| result == MachineStatus.SKIPPED) {
|
||||
return false;
|
||||
}
|
||||
machineStatus = result;
|
||||
machineAttemptId = null;
|
||||
machineLeaseUntil = null;
|
||||
machineNextRetryAt = result == MachineStatus.ERROR ? Instant.now().plusSeconds(3600) : null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void machineResult(MachineStatus result) {
|
||||
if (status != Status.PENDING || !machineEnabled || result == MachineStatus.SKIPPED || result == MachineStatus.PENDING) {
|
||||
if (status != Status.PENDING || !machineEnabled || result == MachineStatus.SKIPPED
|
||||
|| result == MachineStatus.PENDING || result == MachineStatus.PROCESSING) {
|
||||
throw new IllegalStateException("Avatar candidate cannot accept this machine result");
|
||||
}
|
||||
machineStatus = result;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,23 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.Instant;
|
||||
|
||||
class SkillAvatarCandidateTest {
|
||||
@Test
|
||||
void initialStatesCoverFourReviewPolicies() {
|
||||
for (boolean machine : new boolean[] { false, true }) {
|
||||
for (boolean manual : new boolean[] { false, true }) {
|
||||
SkillAvatarCandidate candidate = new SkillAvatarCandidate(10L, "owner", "avatars/one", "abc",
|
||||
SkillVisibility.PUBLIC, machine, manual, machine ? "xfyun" : null);
|
||||
assertThat(candidate.getMachineStatus()).isEqualTo(machine
|
||||
? SkillAvatarCandidate.MachineStatus.PENDING : SkillAvatarCandidate.MachineStatus.SKIPPED);
|
||||
assertThat(candidate.getReviewStatus()).isEqualTo(manual
|
||||
? SkillAvatarCandidate.ReviewStatus.PENDING : SkillAvatarCandidate.ReviewStatus.NOT_REQUIRED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void supersededCandidateCannotBeActivatedByLateMachineResult() {
|
||||
SkillAvatarCandidate candidate = new SkillAvatarCandidate(10L, "owner", "avatars/one", "abc",
|
||||
|
|
@ -31,4 +46,21 @@ class SkillAvatarCandidateTest {
|
|||
assertThat(candidate.getStatus()).isEqualTo(SkillAvatarCandidate.Status.ACTIVE);
|
||||
assertThat(candidate.getReviewedBy()).isEqualTo("reviewer");
|
||||
}
|
||||
|
||||
@Test
|
||||
void errorEnforcesOneHourCooldownBeforeManualOrAutomaticRetry() {
|
||||
SkillAvatarCandidate candidate = new SkillAvatarCandidate(10L, "owner", "avatars/one", "abc",
|
||||
SkillVisibility.PUBLIC, true, false, "xfyun");
|
||||
Instant now = Instant.now();
|
||||
assertThat(candidate.claimMachineAttempt("first", now, now.plusSeconds(600))).isTrue();
|
||||
assertThat(candidate.completeMachineAttempt("first", SkillAvatarCandidate.MachineStatus.ERROR)).isTrue();
|
||||
assertThat(candidate.getMachineAttemptCount()).isEqualTo(1);
|
||||
assertThat(candidate.canRetryMachine(now.plusSeconds(3599))).isFalse();
|
||||
assertThat(candidate.claimMachineAttempt("too-early", now.plusSeconds(3599), now.plusSeconds(4199)))
|
||||
.isFalse();
|
||||
Instant due = candidate.getMachineNextRetryAt();
|
||||
assertThat(candidate.canRetryMachine(due)).isTrue();
|
||||
assertThat(candidate.claimMachineAttempt("second", due, due.plusSeconds(600))).isTrue();
|
||||
assertThat(candidate.getMachineAttemptCount()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,38 @@
|
|||
package com.iflytek.skillhub.infra.jpa;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidateRepository;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface SkillAvatarCandidateJpaRepository extends JpaRepository<SkillAvatarCandidate, Long>, SkillAvatarCandidateRepository {
|
||||
List<SkillAvatarCandidate> findBySkillIdOrderByCreatedAtDesc(Long skillId);
|
||||
boolean existsBySkillIdAndAudienceAndScopeReviewVersionIdAndStatus(Long skillId,
|
||||
SkillVisibility audience, Long versionId,
|
||||
SkillAvatarCandidate.Status status);
|
||||
List<SkillAvatarCandidate> findByContentSha256AndMachineProviderAndMachineEnabledTrueAndManualEnabledAndMachineStatus(
|
||||
String contentSha256, String machineProvider, boolean manualEnabled,
|
||||
SkillAvatarCandidate.MachineStatus machineStatus);
|
||||
List<SkillAvatarCandidate> findByStatusAndReviewStatusOrderByCreatedAtAsc(
|
||||
SkillAvatarCandidate.Status status, SkillAvatarCandidate.ReviewStatus reviewStatus);
|
||||
List<SkillAvatarCandidate> findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(Instant before);
|
||||
|
||||
@Query("""
|
||||
SELECT c FROM SkillAvatarCandidate c, Skill s
|
||||
WHERE c.id > :afterId AND s.id = c.skillId AND s.pendingAvatarId = c.id
|
||||
AND c.scopeReview = false AND c.status = :pendingStatus
|
||||
AND ((c.machineStatus = :pendingMachine AND c.createdAt <= :staleBefore)
|
||||
OR (c.machineStatus = :processingMachine AND c.machineLeaseUntil <= :now))
|
||||
ORDER BY c.id ASC
|
||||
""")
|
||||
List<SkillAvatarCandidate> findAbandonedMachineCandidates(@Param("afterId") Long afterId,
|
||||
@Param("pendingStatus") SkillAvatarCandidate.Status pendingStatus,
|
||||
@Param("pendingMachine") SkillAvatarCandidate.MachineStatus pendingMachine,
|
||||
@Param("processingMachine") SkillAvatarCandidate.MachineStatus processingMachine,
|
||||
@Param("staleBefore") Instant staleBefore, @Param("now") Instant now, Pageable pageable);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package com.iflytek.skillhub.infra.jpa;
|
|||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
|
@ -15,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.time.Instant;
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
/**
|
||||
|
|
@ -26,6 +29,37 @@ public interface SkillJpaRepository extends JpaRepository<Skill, Long>, SkillRep
|
|||
@Query("SELECT s FROM Skill s WHERE s.id = :id")
|
||||
Optional<Skill> findLockedById(@Param("id") Long id);
|
||||
List<Skill> findByIdIn(List<Long> ids);
|
||||
@Query("""
|
||||
SELECT s FROM Skill s WHERE s.id > :afterId AND s.status = :activeStatus AND s.activeAvatarId IS NOT NULL
|
||||
AND ((s.activeAvatarAudience = :privateVisibility
|
||||
AND s.visibility IN (:teamVisibility, :publicVisibility))
|
||||
OR (s.activeAvatarAudience = :teamVisibility AND s.visibility = :publicVisibility))
|
||||
AND NOT EXISTS (SELECT c.id FROM SkillAvatarCandidate c WHERE c.id = s.pendingAvatarId
|
||||
AND c.audience = s.visibility AND c.status = :pendingStatus
|
||||
AND (c.machineStatus NOT IN (:pendingMachine, :processingMachine, :errorMachine)
|
||||
OR (c.machineStatus = :pendingMachine AND c.createdAt > :staleBefore)
|
||||
OR (c.machineStatus = :processingMachine AND c.machineLeaseUntil > :now)
|
||||
OR (c.machineStatus = :errorMachine AND (c.machineAttemptCount >= :maxAutoAttempts
|
||||
OR c.machineNextRetryAt > :now))))
|
||||
AND NOT EXISTS (SELECT c.id FROM SkillAvatarCandidate c WHERE c.skillId = s.id
|
||||
AND c.audience = s.visibility AND c.scopeReviewVersionId = s.latestVersionId
|
||||
AND c.status = :rejectedStatus)
|
||||
ORDER BY s.id ASC
|
||||
""")
|
||||
List<Skill> findScopeReviewRecoveryCandidates(@Param("afterId") Long afterId,
|
||||
@Param("activeStatus") SkillStatus activeStatus,
|
||||
@Param("privateVisibility") SkillVisibility privateVisibility,
|
||||
@Param("teamVisibility") SkillVisibility teamVisibility,
|
||||
@Param("publicVisibility") SkillVisibility publicVisibility,
|
||||
@Param("pendingStatus") SkillAvatarCandidate.Status pendingStatus,
|
||||
@Param("pendingMachine") SkillAvatarCandidate.MachineStatus pendingMachine,
|
||||
@Param("processingMachine") SkillAvatarCandidate.MachineStatus processingMachine,
|
||||
@Param("errorMachine") SkillAvatarCandidate.MachineStatus errorMachine,
|
||||
@Param("maxAutoAttempts") int maxAutoAttempts,
|
||||
@Param("rejectedStatus") SkillAvatarCandidate.Status rejectedStatus,
|
||||
@Param("staleBefore") Instant staleBefore,
|
||||
@Param("now") Instant now,
|
||||
Pageable pageable);
|
||||
List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
|
||||
List<Skill> findByNamespaceIdInAndSlugIn(List<Long> namespaceIds, List<String> slugs);
|
||||
Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId);
|
||||
|
|
|
|||
881
web/src/api/generated/schema.d.ts
vendored
881
web/src/api/generated/schema.d.ts
vendored
|
|
@ -490,6 +490,22 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/system-config/avatar-review": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["current_2"];
|
||||
put: operations["update"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/system-config/auth/local": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1356,6 +1372,166 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/skills/{namespace}/{slug}/avatar/takedown": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["takedown"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/skills/{namespace}/{slug}/avatar/takedown": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["takedown_1"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/skills/{namespace}/{slug}/avatar/candidates/{candidateId}/retry": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["retry"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/skills/{namespace}/{slug}/avatar/candidates/{candidateId}/retry": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["retry_1"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/skills/{namespace}/{slug}/avatar/candidates/{candidateId}/decision": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["decide"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/skills/{namespace}/{slug}/avatar/candidates/{candidateId}/decision": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["decide_1"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/skills/{namespace}/{slug}/avatar/candidates/{candidateId}/appeal": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["appeal"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/skills/{namespace}/{slug}/avatar/candidates/{candidateId}/appeal": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["appeal_1"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/skills/{namespace}/{slug}/avatar": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["current"];
|
||||
put?: never;
|
||||
post: operations["submit"];
|
||||
delete: operations["delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/skills/{namespace}/{slug}/avatar": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["current_1"];
|
||||
put?: never;
|
||||
post: operations["submit_1"];
|
||||
delete: operations["delete_1"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/skills/{namespace}/{slug}/archive": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1749,7 +1925,7 @@ export interface paths {
|
|||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["submit"];
|
||||
post: operations["submit_2"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
|
|
@ -1765,7 +1941,7 @@ export interface paths {
|
|||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["submit_1"];
|
||||
post: operations["submit_3"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
|
|
@ -3547,6 +3723,70 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/skills/{namespace}/{slug}/avatar/status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["status"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/skills/{namespace}/{slug}/avatar/status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["status_1"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/skills/{namespace}/{slug}/avatar/candidates/{candidateId}/image": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["candidate"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/skills/{namespace}/{slug}/avatar/candidates/{candidateId}/image": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["candidate_1"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/skills/{namespace}/{slug}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -3579,6 +3819,38 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/skills/avatar-review/queue": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["queue"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/skills/avatar-review/queue": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["queue_1"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/reviews/{id}/skill-detail": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -5619,6 +5891,29 @@ export interface components {
|
|||
/** Format: date-time */
|
||||
updatedAt?: string;
|
||||
};
|
||||
AvatarReviewSettingsUpdateRequest: {
|
||||
machineEnabled?: boolean;
|
||||
manualEnabled?: boolean;
|
||||
/** Format: int64 */
|
||||
version?: number;
|
||||
};
|
||||
ApiResponseAvatarReviewSettingsResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["AvatarReviewSettingsResponse"];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
AvatarReviewSettingsResponse: {
|
||||
machineEnabled?: boolean;
|
||||
manualEnabled?: boolean;
|
||||
/** Format: int64 */
|
||||
version?: number;
|
||||
/** Format: date-time */
|
||||
updatedAt?: string;
|
||||
};
|
||||
SystemAuthSettingsUpdateRequest: {
|
||||
passwordLoginEnabled: boolean;
|
||||
selfRegistrationEnabled: boolean;
|
||||
|
|
@ -5876,6 +6171,37 @@ export interface components {
|
|||
ConfirmPublishRequest: {
|
||||
version: string;
|
||||
};
|
||||
SkillAvatarReasonRequest: {
|
||||
reason: string;
|
||||
};
|
||||
ApiResponseSkillAvatarStatusResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["SkillAvatarStatusResponse"];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
SkillAvatarStatusResponse: {
|
||||
avatarUrl?: string;
|
||||
/** Format: int64 */
|
||||
candidateId?: number;
|
||||
candidateStatus?: string;
|
||||
machineStatus?: string;
|
||||
reviewStatus?: string;
|
||||
reviewReason?: string;
|
||||
candidateUrl?: string;
|
||||
namespace?: string;
|
||||
slug?: string;
|
||||
/** Format: date-time */
|
||||
machineNextRetryAt?: string;
|
||||
automaticRetryExhausted?: boolean;
|
||||
};
|
||||
SkillAvatarDecisionRequest: {
|
||||
approved?: boolean;
|
||||
reason: string;
|
||||
};
|
||||
ApiResponsePublishResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
|
|
@ -6691,6 +7017,7 @@ export interface components {
|
|||
resolutionMode?: string;
|
||||
complianceSnapshot?: components["schemas"]["ComplianceSnapshotResponse"];
|
||||
labels?: components["schemas"]["SkillLabelDto"][];
|
||||
avatarUrl?: string;
|
||||
};
|
||||
ApiResponseBoolean: {
|
||||
/** Format: int32 */
|
||||
|
|
@ -6984,6 +7311,16 @@ export interface components {
|
|||
resolutionMode?: string;
|
||||
entryForSuites?: components["schemas"]["SkillSuiteReferenceResponse"][];
|
||||
memberOfSuites?: components["schemas"]["PageResponseSkillSuiteReferenceResponse"];
|
||||
avatarUrl?: string;
|
||||
};
|
||||
ApiResponseListSkillAvatarStatusResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["SkillAvatarStatusResponse"][];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
ApiResponseReviewSkillDetailResponse: {
|
||||
/** Format: int32 */
|
||||
|
|
@ -9538,6 +9875,50 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
current_2: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseAvatarReviewSettingsResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AvatarReviewSettingsUpdateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseAvatarReviewSettingsResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getLocalSettings: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -10873,6 +11254,364 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
takedown: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillAvatarReasonRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseVoid"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
takedown_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillAvatarReasonRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseVoid"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
retry: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
candidateId: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
retry_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
candidateId: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
decide: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
candidateId: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillAvatarDecisionRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
decide_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
candidateId: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillAvatarDecisionRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
appeal: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
candidateId: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
appeal_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
candidateId: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
current: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
submit: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
/** Format: binary */
|
||||
file: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseVoid"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
current_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
submit_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
/** Format: binary */
|
||||
file: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseVoid"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
archiveSkill: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -11599,7 +12338,7 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
submit: {
|
||||
submit_2: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -11623,7 +12362,7 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
submit_1: {
|
||||
submit_3: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -14619,6 +15358,100 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
status: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
status_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
candidate: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
candidateId: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
candidate_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
slug: string;
|
||||
candidateId: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getSkillDetail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -14715,6 +15548,46 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
queue: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseListSkillAvatarStatusResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
queue_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseListSkillAvatarStatusResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getReviewSkillDetail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ export interface SkillAvatarStatus {
|
|||
avatarUrl: string | null
|
||||
candidateId: number | null
|
||||
candidateStatus: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'SUPERSEDED' | 'REMOVED' | null
|
||||
machineStatus: 'SKIPPED' | 'PENDING' | 'PASSED' | 'BLOCKED' | 'ERROR' | null
|
||||
machineStatus: 'SKIPPED' | 'PENDING' | 'PROCESSING' | 'PASSED' | 'BLOCKED' | 'ERROR' | null
|
||||
machineNextRetryAt: string | null
|
||||
automaticRetryExhausted: boolean
|
||||
reviewStatus: 'NOT_REQUIRED' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'APPEAL_PENDING' | null
|
||||
reviewReason: string | null
|
||||
candidateUrl: string | null
|
||||
|
|
@ -363,7 +365,7 @@ export type SkillDetail = Omit<
|
|||
| 'entryForSuites'
|
||||
| 'memberOfSuites'
|
||||
>,
|
||||
'labels' | 'headlineVersion' | 'publishedVersion' | 'ownerPreviewVersion' | 'entryForSuites' | 'memberOfSuites'
|
||||
'avatarUrl' | 'labels' | 'headlineVersion' | 'publishedVersion' | 'ownerPreviewVersion' | 'entryForSuites' | 'memberOfSuites'
|
||||
> & {
|
||||
avatarUrl?: string | null
|
||||
labels?: LabelItem[]
|
||||
|
|
|
|||
34
web/src/features/skill/avatar-review-settings.test.tsx
Normal file
34
web/src/features/skill/avatar-review-settings.test.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const api = vi.hoisted(() => ({ getAvatarReview: vi.fn(), updateAvatarReview: vi.fn() }))
|
||||
vi.mock('@/api/client', () => ({ systemConfigApi: api }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
import { AvatarReviewSettingsPanel } from './avatar-review-settings'
|
||||
|
||||
describe('AvatarReviewSettingsPanel', () => {
|
||||
afterEach(() => { cleanup(); api.getAvatarReview.mockReset(); api.updateAvatarReview.mockReset() })
|
||||
|
||||
it('submits both switches with the observed version', async () => {
|
||||
api.getAvatarReview.mockResolvedValue({ machineEnabled: false, manualEnabled: true, version: 3 })
|
||||
api.updateAvatarReview.mockResolvedValue({ machineEnabled: true, manualEnabled: false, version: 4 })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><AvatarReviewSettingsPanel /></QueryClientProvider>)
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'skillAvatar.machineEnabled' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'skillAvatar.manualEnabled' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.save' }))
|
||||
await waitFor(() => expect(api.updateAvatarReview.mock.calls[0]?.[0]).toEqual({
|
||||
machineEnabled: true, manualEnabled: false, version: 3,
|
||||
}))
|
||||
})
|
||||
|
||||
it('warns before saving when both review switches are off', async () => {
|
||||
api.getAvatarReview.mockResolvedValue({ machineEnabled: false, manualEnabled: true, version: 3 })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><AvatarReviewSettingsPanel /></QueryClientProvider>)
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'skillAvatar.manualEnabled' }))
|
||||
expect(screen.getByRole('alert').textContent).toBe('skillAvatar.noReviewRisk')
|
||||
})
|
||||
})
|
||||
|
|
@ -26,6 +26,7 @@ export function AvatarReviewSettingsPanel() {
|
|||
{settings.isError && <p role="alert">{t('skillAvatar.failed')}</p>}
|
||||
{settings.data && <>
|
||||
{(['machineEnabled', 'manualEnabled'] as const).map((field) => <label key={field} className="flex items-center justify-between gap-4 rounded-lg border p-4"><span>{t(`skillAvatar.${field}`)}</span><input type="checkbox" checked={draft[field]} onChange={(event) => { setDraft({ ...draft, [field]: event.target.checked }); setSaved(false) }} /></label>)}
|
||||
{!draft.machineEnabled && !draft.manualEnabled && <p role="alert" className="text-sm text-destructive">{t('skillAvatar.noReviewRisk')}</p>}
|
||||
<Button disabled={!dirty || update.isPending} onClick={() => update.mutate({ ...draft, version: settings.data!.version })}>{t('skillAvatar.save')}</Button>
|
||||
</>}
|
||||
{saved && <p role="status" className="text-sm">{t('skillAvatar.saved')}</p>}
|
||||
|
|
|
|||
39
web/src/features/skill/skill-avatar-manager.test.tsx
Normal file
39
web/src/features/skill/skill-avatar-manager.test.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const api = vi.hoisted(() => ({ status: vi.fn() }))
|
||||
vi.mock('@/api/client', () => ({ skillAvatarApi: api, buildApiUrl: (url: string) => url }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
import { SkillAvatarManager } from './skill-avatar-manager'
|
||||
|
||||
describe('SkillAvatarManager appeal', () => {
|
||||
afterEach(() => { cleanup(); api.status.mockReset() })
|
||||
|
||||
it.each([
|
||||
['PENDING', 'BLOCKED', 'NOT_REQUIRED', true],
|
||||
['PENDING', 'BLOCKED', 'APPEAL_PENDING', false],
|
||||
['PENDING', 'PROCESSING', 'NOT_REQUIRED', false],
|
||||
['REJECTED', 'BLOCKED', 'REJECTED', false],
|
||||
['PENDING', 'ERROR', 'NOT_REQUIRED', false],
|
||||
] as const)('shows appeal only for machine-only blocked candidate: %s/%s/%s', async (candidateStatus, machineStatus, reviewStatus, expected) => {
|
||||
api.status.mockResolvedValue({ avatarUrl: null, candidateId: 7, candidateStatus, machineStatus, reviewStatus, candidateUrl: null, reviewReason: null })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" /></QueryClientProvider>)
|
||||
expect((await screen.findByRole('status')).textContent).toContain(`skillAvatar.states.${candidateStatus}`)
|
||||
expect(screen.queryByRole('button', { name: 'skillAvatar.appeal' }) !== null).toBe(expected)
|
||||
})
|
||||
|
||||
it('disables paid retry during cooldown and explains when automatic retries stop', async () => {
|
||||
api.status.mockResolvedValue({ avatarUrl: null, candidateId: 7, candidateStatus: 'PENDING',
|
||||
machineStatus: 'ERROR', reviewStatus: 'NOT_REQUIRED', candidateUrl: null, reviewReason: null,
|
||||
machineNextRetryAt: new Date(Date.now() + 3600000).toISOString(), automaticRetryExhausted: true })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" /></QueryClientProvider>)
|
||||
|
||||
expect(await screen.findByText('skillAvatar.retryAfter')).toBeTruthy()
|
||||
expect(screen.getByText('skillAvatar.automaticRetryStopped')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'skillAvatar.retry' }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -54,12 +54,15 @@ export function SkillAvatarManager({ namespace, slug }: { namespace: string; slu
|
|||
} finally { setBusy(false) }
|
||||
}
|
||||
const candidate = status.data
|
||||
const retryCoolingDown = !!candidate?.machineNextRetryAt && Date.parse(candidate.machineNextRetryAt) > Date.now()
|
||||
return (
|
||||
<Card className="space-y-3 p-4">
|
||||
<h2 className="font-semibold">{t('skillAvatar.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('skillAvatar.ownerHelp')}</p>
|
||||
{candidate?.candidateStatus && <p role="status" className="text-sm">{t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)} · {t(`skillAvatar.machine.${candidate.machineStatus ?? 'SKIPPED'}`)}</p>}
|
||||
{candidate?.reviewReason && <p className="text-sm">{candidate.reviewReason}</p>}
|
||||
{candidate?.machineStatus === 'ERROR' && retryCoolingDown && <p className="text-sm text-muted-foreground">{t('skillAvatar.retryAfter', { time: new Date(candidate.machineNextRetryAt!).toLocaleString() })}</p>}
|
||||
{candidate?.machineStatus === 'ERROR' && candidate.automaticRetryExhausted && <p className="text-sm text-muted-foreground">{t('skillAvatar.automaticRetryStopped')}</p>}
|
||||
{candidate?.candidateUrl && <img src={buildApiUrl(candidate.candidateUrl)} alt={t('skillAvatar.candidatePreview')} className="h-24 w-24 rounded-lg object-cover" />}
|
||||
<Input type="file" accept="image/png,image/jpeg" aria-label={t('skillAvatar.choose')} onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
|
|
@ -77,8 +80,10 @@ export function SkillAvatarManager({ namespace, slug }: { namespace: string; slu
|
|||
</div>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(candidate?.avatarUrl || candidate?.candidateStatus === 'PENDING') && <Button variant="outline" disabled={mutation.isPending} onClick={() => void mutation.mutateAsync({ kind: 'remove' })}>{t('skillAvatar.remove')}</Button>}
|
||||
{candidate?.candidateStatus === 'REJECTED' && candidate.candidateId && <Button variant="outline" disabled={mutation.isPending} onClick={() => void mutation.mutateAsync({ kind: 'appeal', id: candidate.candidateId! })}>{t('skillAvatar.appeal')}</Button>}
|
||||
{candidate?.machineStatus === 'ERROR' && candidate.candidateId && <Button variant="outline" disabled={mutation.isPending} onClick={() => void mutation.mutateAsync({ kind: 'retry', id: candidate.candidateId! })}>{t('skillAvatar.retry')}</Button>}
|
||||
{candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED'
|
||||
&& candidate.reviewStatus === 'NOT_REQUIRED' && candidate.candidateId
|
||||
&& <Button variant="outline" disabled={mutation.isPending} onClick={() => void mutation.mutateAsync({ kind: 'appeal', id: candidate.candidateId! })}>{t('skillAvatar.appeal')}</Button>}
|
||||
{candidate?.machineStatus === 'ERROR' && candidate.candidateId && <Button variant="outline" disabled={mutation.isPending || retryCoolingDown} onClick={() => void mutation.mutateAsync({ kind: 'retry', id: candidate.candidateId! })}>{t('skillAvatar.retry')}</Button>}
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
</Card>
|
||||
|
|
|
|||
22
web/src/features/skill/skill-avatar.test.tsx
Normal file
22
web/src/features/skill/skill-avatar.test.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/api/client', () => ({ buildApiUrl: (url: string) => `/proxy${url}` }))
|
||||
import { SkillAvatar } from './skill-avatar'
|
||||
|
||||
describe('SkillAvatar', () => {
|
||||
afterEach(cleanup)
|
||||
it('shows the skill initial when no image is approved', () => {
|
||||
render(<SkillAvatar name="Weather" />)
|
||||
expect(screen.getByText('W')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to the initial if an approved image is unavailable', () => {
|
||||
const { container } = render(<SkillAvatar name="Weather" url="/avatar" />)
|
||||
const image = container.querySelector('img')!
|
||||
expect(image.getAttribute('src')).toBe('/proxy/avatar')
|
||||
fireEvent.error(image)
|
||||
expect(screen.getByText('W')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
@ -4,6 +4,8 @@ import { describe, expect, it, vi } from 'vitest'
|
|||
import * as mod from './skill-card'
|
||||
import { SkillCard } from './skill-card'
|
||||
|
||||
vi.mock('@/api/client', () => ({ buildApiUrl: (url: string) => url }))
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: false,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex min-w-0 items-center gap-3 space-y-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<SkillAvatar name={skill.displayName} url={skill.avatarUrl} />
|
||||
<h3 className="font-semibold text-base group-hover:text-primary transition-colors duration-150" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{skill.displayName}
|
||||
|
|
|
|||
|
|
@ -2581,6 +2581,8 @@
|
|||
"remove": "Remove current avatar",
|
||||
"appeal": "Request review",
|
||||
"retry": "Retry machine review",
|
||||
"retryAfter": "Machine review failed. You can retry after {{time}}.",
|
||||
"automaticRetryStopped": "Automatic retries have stopped. Retry manually after the cooldown or submit a new avatar.",
|
||||
"failed": "Action failed. Please try again.",
|
||||
"invalid": "Choose a PNG or JPEG image under 2 MB.",
|
||||
"reviewTitle": "Avatar review",
|
||||
|
|
@ -2593,6 +2595,7 @@
|
|||
"settingsHelp": "The machine provider is set at deployment; these switches control machine and manual review.",
|
||||
"machineEnabled": "Enable machine review",
|
||||
"manualEnabled": "Enable manual review",
|
||||
"noReviewRisk": "Both reviews are off. New avatars will appear immediately. Confirm the risk before saving.",
|
||||
"save": "Save settings",
|
||||
"saved": "Saved",
|
||||
"states": {
|
||||
|
|
@ -2605,6 +2608,7 @@
|
|||
"machine": {
|
||||
"SKIPPED": "Machine review off",
|
||||
"PENDING": "Machine review pending",
|
||||
"PROCESSING": "Machine review in progress",
|
||||
"PASSED": "Machine review passed",
|
||||
"BLOCKED": "Machine review blocked",
|
||||
"ERROR": "Machine review failed"
|
||||
|
|
|
|||
|
|
@ -2583,6 +2583,8 @@
|
|||
"remove": "Удалить аватар",
|
||||
"appeal": "Запросить пересмотр",
|
||||
"retry": "Повторить автоматическую проверку",
|
||||
"retryAfter": "Автоматическая проверка не удалась. Повторить можно после {{time}}.",
|
||||
"automaticRetryStopped": "Автоматические повторы остановлены. После ожидания повторите вручную или загрузите новый аватар.",
|
||||
"failed": "Ошибка. Повторите попытку.",
|
||||
"invalid": "Выберите PNG или JPEG размером до 2 МБ.",
|
||||
"reviewTitle": "Проверка аватаров",
|
||||
|
|
@ -2595,6 +2597,7 @@
|
|||
"settingsHelp": "Поставщик автоматической проверки задаётся при развертывании; здесь включаются виды проверки.",
|
||||
"machineEnabled": "Автоматическая проверка",
|
||||
"manualEnabled": "Ручная проверка",
|
||||
"noReviewRisk": "Обе проверки отключены. Новые аватары будут опубликованы сразу. Подтвердите риск перед сохранением.",
|
||||
"save": "Сохранить",
|
||||
"saved": "Сохранено",
|
||||
"states": {
|
||||
|
|
@ -2607,6 +2610,7 @@
|
|||
"machine": {
|
||||
"SKIPPED": "Проверка выключена",
|
||||
"PENDING": "Проверяется",
|
||||
"PROCESSING": "Проверяется",
|
||||
"PASSED": "Проверка пройдена",
|
||||
"BLOCKED": "Заблокирован",
|
||||
"ERROR": "Ошибка проверки"
|
||||
|
|
|
|||
|
|
@ -2580,6 +2580,8 @@
|
|||
"remove": "删除当前头像",
|
||||
"appeal": "申请复核",
|
||||
"retry": "重试机器审核",
|
||||
"retryAfter": "机器审核暂时失败,可在 {{time}} 后手动重试。",
|
||||
"automaticRetryStopped": "自动重试已停止;冷却结束后可手动重试或提交新头像。",
|
||||
"failed": "操作失败,请稍后重试",
|
||||
"invalid": "请选择不超过 2 MB 的 PNG 或 JPEG 图片",
|
||||
"reviewTitle": "头像审核",
|
||||
|
|
@ -2592,6 +2594,7 @@
|
|||
"settingsHelp": "机器审核服务在部署时指定;这里只控制是否启用机器与人工审核。",
|
||||
"machineEnabled": "启用机器审核",
|
||||
"manualEnabled": "启用人工审核",
|
||||
"noReviewRisk": "机器和人工审核均已关闭,新头像将直接展示,请确认风险后保存。",
|
||||
"save": "保存设置",
|
||||
"saved": "已保存",
|
||||
"states": {
|
||||
|
|
@ -2604,6 +2607,7 @@
|
|||
"machine": {
|
||||
"SKIPPED": "未启用机器审核",
|
||||
"PENDING": "机器审核中",
|
||||
"PROCESSING": "机器审核中",
|
||||
"PASSED": "机器审核通过",
|
||||
"BLOCKED": "机器审核阻止",
|
||||
"ERROR": "机器审核失败"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ const api = vi.hoisted(() => ({
|
|||
createRoleGrant: vi.fn(),
|
||||
updateRoleGrant: vi.fn(),
|
||||
disableRoleGrant: vi.fn(),
|
||||
getAvatarReview: vi.fn(),
|
||||
updateAvatarReview: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
|
|
@ -35,6 +37,7 @@ describe('SystemConfigPage', () => {
|
|||
api.getLocalAuth.mockResolvedValue({ passwordLoginEnabled: true, selfRegistrationEnabled: true, version: 3 })
|
||||
api.listRoles.mockResolvedValue([{ code: 'SUPER_ADMIN', name: 'Super Admin' }, { code: 'USER', name: 'User' }])
|
||||
api.listRoleGrants.mockResolvedValue(ruleList)
|
||||
api.getAvatarReview.mockResolvedValue({ machineEnabled: false, manualEnabled: true, version: 0 })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><SystemConfigPage /></QueryClientProvider>)
|
||||
return client
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ vi.mock('@/features/namespace/namespace-header', () => ({
|
|||
NamespaceHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/skill/skill-avatar-review', () => ({ SkillAvatarReviewQueue: () => null }))
|
||||
|
||||
import { NamespaceReviewsPage } from './namespace-reviews'
|
||||
|
||||
describe('NamespaceReviewsPage', () => {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ vi.mock('./profile-review-table', () => ({
|
|||
ProfileReviewTable: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/skill/skill-avatar-review', () => ({ SkillAvatarReviewQueue: () => null }))
|
||||
|
||||
import { ReviewsPage } from './reviews'
|
||||
|
||||
describe('ReviewsPage', () => {
|
||||
|
|
|
|||
|
|
@ -266,7 +266,7 @@ export function ReviewsPage() {
|
|||
<div className="space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t('reviews.title')} subtitle={t('reviews.subtitle')} />
|
||||
|
||||
{isSkillAdmin && <SkillAvatarReviewQueue />}
|
||||
{isSkillAdmin && <SkillAvatarReviewQueue namespace="global" />}
|
||||
|
||||
{showTypeTabs ? (
|
||||
<Tabs value={activeType} onValueChange={handleTypeChange}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue