From 57477e2614b2999f2ebb0f68f23e1626599fc106 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:32:13 +0800 Subject: [PATCH 01/47] feat(avatar): normalize uploaded crop on server Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../service/SkillAvatarImageProcessor.java | 79 +++++++++++++++++++ .../SkillAvatarImageProcessorTest.java | 44 +++++++++++ 2 files changed, 123 insertions(+) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarImageProcessor.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarImageProcessorTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarImageProcessor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarImageProcessor.java new file mode 100644 index 00000000..076f80c2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarImageProcessor.java @@ -0,0 +1,79 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import org.springframework.stereotype.Component; + +/** Decodes a final crop and writes fresh pixels, without client-supplied metadata. */ +@Component +public class SkillAvatarImageProcessor { + public static final int MAX_BYTES = 2 * 1024 * 1024; + public static final int MAX_SIDE = 1024; + + public byte[] normalize(byte[] input) { + if (input == null || input.length == 0 || input.length > MAX_BYTES) { + throw invalidImage(); + } + boolean png = input.length >= 8 && (input[0] & 0xff) == 0x89 + && input[1] == 'P' && input[2] == 'N' && input[3] == 'G' + && (input[4] & 0xff) == 13 && input[5] == 10 && (input[6] & 0xff) == 26 && input[7] == 10; + boolean jpeg = input.length >= 3 && (input[0] & 0xff) == 0xff + && (input[1] & 0xff) == 0xd8 && (input[2] & 0xff) == 0xff; + if (!png && !jpeg) { + throw invalidImage(); + } + try (ImageInputStream stream = ImageIO.createImageInputStream(new ByteArrayInputStream(input))) { + if (stream == null) { + throw invalidImage(); + } + var readers = ImageIO.getImageReaders(stream); + if (!readers.hasNext()) { + throw invalidImage(); + } + ImageReader reader = readers.next(); + try { + if (!((png && "png".equalsIgnoreCase(reader.getFormatName())) + || (jpeg && "jpeg".equalsIgnoreCase(reader.getFormatName())))) { + throw invalidImage(); + } + reader.setInput(stream, true, true); + int width = reader.getWidth(0); + int height = reader.getHeight(0); + if (width < 1 || height < 1 || width != height || width > MAX_SIDE) { + throw invalidImage(); + } + BufferedImage decoded = reader.read(0); + if (decoded == null) { + throw invalidImage(); + } + BufferedImage clean = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = clean.createGraphics(); + try { + graphics.drawImage(decoded, 0, 0, null); + } finally { + graphics.dispose(); + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + if (!ImageIO.write(clean, "png", output) || output.size() > MAX_BYTES) { + throw invalidImage(); + } + return output.toByteArray(); + } finally { + reader.dispose(); + } + } catch (IOException | IllegalArgumentException failure) { + throw invalidImage(); + } + } + + private DomainBadRequestException invalidImage() { + return new DomainBadRequestException("error.skill.avatar.invalidImage"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarImageProcessorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarImageProcessorTest.java new file mode 100644 index 00000000..3dd19c16 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarImageProcessorTest.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Test; + +class SkillAvatarImageProcessorTest { + private final SkillAvatarImageProcessor processor = new SkillAvatarImageProcessor(); + + @Test + void acceptsSquareJpegAndReencodesAsPng() throws Exception { + byte[] jpeg = image("jpeg", 48, 48); + byte[] result = processor.normalize(jpeg); + assertThat(result).startsWith((byte) 0x89, (byte) 'P', (byte) 'N', (byte) 'G'); + BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(result)); + assertThat(decoded.getWidth()).isEqualTo(48); + assertThat(decoded.getHeight()).isEqualTo(48); + } + + @Test + void rejectsOversizedMalformedAndNonSquareInput() throws Exception { + assertThatThrownBy(() -> processor.normalize(new byte[SkillAvatarImageProcessor.MAX_BYTES + 1])) + .isInstanceOf(DomainBadRequestException.class); + assertThatThrownBy(() -> processor.normalize(new byte[] {(byte) 0x89, 'P', 'N', 'G'})) + .isInstanceOf(DomainBadRequestException.class); + assertThatThrownBy(() -> processor.normalize(image("png", 48, 32))) + .isInstanceOf(DomainBadRequestException.class); + assertThatThrownBy(() -> processor.normalize(image("png", 1025, 1025))) + .isInstanceOf(DomainBadRequestException.class); + } + + private byte[] image(String format, int width, int height) throws Exception { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, format, out); + return out.toByteArray(); + } +} From 6d42bd6708f99c95f2207ae7c7be26ba3c78abac Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:34:39 +0800 Subject: [PATCH 02/47] feat(avatar): persist candidate lifecycle Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../db/migration/V69__skill_avatars.sql | 27 +++ .../iflytek/skillhub/domain/skill/Skill.java | 14 ++ .../domain/skill/SkillAvatarCandidate.java | 157 ++++++++++++++++++ .../skill/SkillAvatarCandidateTest.java | 34 ++++ .../SkillAvatarCandidateJpaRepository.java | 13 ++ 5 files changed, 245 insertions(+) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidate.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateTest.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java diff --git a/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql b/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql new file mode 100644 index 00000000..ffc5fabf --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql @@ -0,0 +1,27 @@ +ALTER TABLE skill ADD COLUMN active_avatar_id BIGINT; +ALTER TABLE skill ADD COLUMN pending_avatar_id BIGINT; + +CREATE TABLE skill_avatar_candidate ( + id BIGSERIAL PRIMARY KEY, + skill_id BIGINT NOT NULL REFERENCES skill(id), + submitted_by VARCHAR(128) NOT NULL, + object_key VARCHAR(512), + content_sha256 VARCHAR(64) NOT NULL, + audience VARCHAR(32) NOT NULL, + machine_enabled BOOLEAN NOT NULL, + manual_enabled BOOLEAN NOT NULL, + machine_provider VARCHAR(32), + machine_status VARCHAR(32) NOT NULL, + review_status VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + review_reason TEXT, + reviewed_by VARCHAR(128), + reviewed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + discard_at TIMESTAMP +); + +CREATE INDEX idx_avatar_candidate_skill ON skill_avatar_candidate(skill_id, created_at DESC); +CREATE INDEX idx_avatar_candidate_review ON skill_avatar_candidate(status, review_status, created_at); +CREATE INDEX idx_avatar_candidate_discard ON skill_avatar_candidate(discard_at) WHERE object_key IS NOT NULL; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java index cc18cba0..1188347f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java @@ -42,6 +42,12 @@ public class Skill { @Column(name = "latest_version_id") private Long latestVersionId; + @Column(name = "active_avatar_id") + private Long activeAvatarId; + + @Column(name = "pending_avatar_id") + private Long pendingAvatarId; + @Column(name = "download_count", nullable = false) private Long downloadCount = 0L; @@ -141,6 +147,10 @@ public class Skill { return latestVersionId; } + public Long getActiveAvatarId() { return activeAvatarId; } + + public Long getPendingAvatarId() { return pendingAvatarId; } + public Long getDownloadCount() { return downloadCount; } @@ -214,6 +224,10 @@ public class Skill { this.latestVersionId = latestVersionId; } + public void setActiveAvatarId(Long activeAvatarId) { this.activeAvatarId = activeAvatarId; } + + public void setPendingAvatarId(Long pendingAvatarId) { this.pendingAvatarId = pendingAvatarId; } + public void setCreatedBy(String createdBy) { this.createdBy = createdBy; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidate.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidate.java new file mode 100644 index 00000000..db1c3205 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidate.java @@ -0,0 +1,157 @@ +package com.iflytek.skillhub.domain.skill; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "skill_avatar_candidate") +public class SkillAvatarCandidate { + public enum MachineStatus { SKIPPED, PENDING, PASSED, BLOCKED, ERROR } + public enum ReviewStatus { NOT_REQUIRED, PENDING, APPROVED, REJECTED, APPEAL_PENDING } + public enum Status { PENDING, ACTIVE, REJECTED, SUPERSEDED, REMOVED } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "skill_id", nullable = false) + private Long skillId; + @Column(name = "submitted_by", nullable = false, length = 128) + private String submittedBy; + @Column(name = "object_key", length = 512) + private String objectKey; + @Column(name = "content_sha256", nullable = false, length = 64) + private String contentSha256; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private SkillVisibility audience; + @Column(name = "machine_enabled", nullable = false) + private boolean machineEnabled; + @Column(name = "manual_enabled", nullable = false) + private boolean manualEnabled; + @Column(name = "machine_provider", length = 32) + private String machineProvider; + @Enumerated(EnumType.STRING) + @Column(name = "machine_status", nullable = false, length = 32) + private MachineStatus machineStatus; + @Enumerated(EnumType.STRING) + @Column(name = "review_status", nullable = false, length = 32) + private ReviewStatus reviewStatus; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private Status status; + @Column(name = "review_reason", columnDefinition = "TEXT") + private String reviewReason; + @Column(name = "reviewed_by", length = 128) + private String reviewedBy; + @Column(name = "reviewed_at") + private Instant reviewedAt; + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + @Column(name = "discard_at") + private Instant discardAt; + + protected SkillAvatarCandidate() {} + + public SkillAvatarCandidate(Long skillId, String submittedBy, String objectKey, String contentSha256, + SkillVisibility audience, boolean machineEnabled, boolean manualEnabled, + String machineProvider) { + this.skillId = skillId; + this.submittedBy = submittedBy; + this.objectKey = objectKey; + this.contentSha256 = contentSha256; + this.audience = audience; + this.machineEnabled = machineEnabled; + this.manualEnabled = manualEnabled; + this.machineProvider = machineProvider; + this.machineStatus = machineEnabled ? MachineStatus.PENDING : MachineStatus.SKIPPED; + this.reviewStatus = manualEnabled ? ReviewStatus.PENDING : ReviewStatus.NOT_REQUIRED; + this.status = Status.PENDING; + } + + @PrePersist + void prePersist() { createdAt = Instant.now(); updatedAt = createdAt; } + @PreUpdate + void preUpdate() { updatedAt = Instant.now(); } + + public Long getId() { return id; } + public Long getSkillId() { return skillId; } + public String getSubmittedBy() { return submittedBy; } + public String getObjectKey() { return objectKey; } + public String getContentSha256() { return contentSha256; } + public SkillVisibility getAudience() { return audience; } + public boolean isMachineEnabled() { return machineEnabled; } + public boolean isManualEnabled() { return manualEnabled; } + public String getMachineProvider() { return machineProvider; } + public MachineStatus getMachineStatus() { return machineStatus; } + public ReviewStatus getReviewStatus() { return reviewStatus; } + public Status getStatus() { return status; } + public String getReviewReason() { return reviewReason; } + public String getReviewedBy() { return reviewedBy; } + public Instant getCreatedAt() { return createdAt; } + public Instant getDiscardAt() { return discardAt; } + + public void machineResult(MachineStatus result) { + if (status != Status.PENDING || !machineEnabled || result == MachineStatus.SKIPPED || result == MachineStatus.PENDING) { + throw new IllegalStateException("Avatar candidate cannot accept this machine result"); + } + machineStatus = result; + } + + public void appeal() { + if (status != Status.PENDING || machineStatus != MachineStatus.BLOCKED || manualEnabled) { + throw new IllegalStateException("Avatar candidate cannot be appealed"); + } + reviewStatus = ReviewStatus.APPEAL_PENDING; + } + + public void approve(String actor, String reason) { + if (status != Status.PENDING || !(reviewStatus == ReviewStatus.PENDING || reviewStatus == ReviewStatus.APPEAL_PENDING)) { + throw new IllegalStateException("Avatar candidate is not reviewable"); + } + reviewedBy = actor; + reviewReason = reason; + reviewedAt = Instant.now(); + reviewStatus = ReviewStatus.APPROVED; + } + + public void reject(String actor, String reason) { + approve(actor, reason); + reviewStatus = ReviewStatus.REJECTED; + status = Status.REJECTED; + discardAt = Instant.now().plusSeconds(30L * 24 * 60 * 60); + } + + public void activate() { + if (status != Status.PENDING) { + throw new IllegalStateException("Avatar candidate is not pending"); + } + status = Status.ACTIVE; + } + + public void supersede() { + if (status == Status.PENDING || status == Status.ACTIVE) { + status = Status.SUPERSEDED; + discardAt = Instant.now().plusSeconds(30L * 24 * 60 * 60); + } + } + + public void remove() { + if (status == Status.PENDING || status == Status.ACTIVE) { + status = Status.REMOVED; + discardAt = Instant.now().plusSeconds(30L * 24 * 60 * 60); + } + } + + public void clearObjectKey() { objectKey = null; } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateTest.java new file mode 100644 index 00000000..7f52e2fc --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateTest.java @@ -0,0 +1,34 @@ +package com.iflytek.skillhub.domain.skill; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class SkillAvatarCandidateTest { + @Test + void supersededCandidateCannotBeActivatedByLateMachineResult() { + SkillAvatarCandidate candidate = new SkillAvatarCandidate(10L, "owner", "avatars/one", "abc", + SkillVisibility.PUBLIC, true, false, "xfyun"); + candidate.supersede(); + + assertThat(candidate.getStatus()).isEqualTo(SkillAvatarCandidate.Status.SUPERSEDED); + assertThat(candidate.getDiscardAt()).isNotNull(); + assertThatThrownBy(() -> candidate.machineResult(SkillAvatarCandidate.MachineStatus.PASSED)) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(candidate::activate).isInstanceOf(IllegalStateException.class); + } + + @Test + void blockedMachineOnlyCandidateCanRequestManualAppeal() { + SkillAvatarCandidate candidate = new SkillAvatarCandidate(10L, "owner", "avatars/one", "abc", + SkillVisibility.PUBLIC, true, false, "aliyun"); + candidate.machineResult(SkillAvatarCandidate.MachineStatus.BLOCKED); + candidate.appeal(); + candidate.approve("reviewer", "false positive"); + candidate.activate(); + + assertThat(candidate.getStatus()).isEqualTo(SkillAvatarCandidate.Status.ACTIVE); + assertThat(candidate.getReviewedBy()).isEqualTo("reviewer"); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java new file mode 100644 index 00000000..1b188c44 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import java.time.Instant; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SkillAvatarCandidateJpaRepository extends JpaRepository { + List findBySkillIdOrderByCreatedAtDesc(Long skillId); + List findByStatusAndReviewStatusOrderByCreatedAtAsc( + SkillAvatarCandidate.Status status, SkillAvatarCandidate.ReviewStatus reviewStatus); + List findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(Instant before); +} From b1d5391102ba9e8446604f61e2977aa05caa7aa8 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:34:25 +0800 Subject: [PATCH 03/47] feat(namespace): add single-use invitation persistence Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../migration/V70__namespace_invitation.sql | 9 + .../domain/namespace/NamespaceInvitation.java | 64 +++++++ .../NamespaceInvitationRepository.java | 10 ++ .../namespace/NamespaceInvitationService.java | 163 ++++++++++++++++++ .../domain/namespace/NamespaceRepository.java | 1 + .../NamespaceInvitationServiceTest.java | 68 ++++++++ .../jpa/NamespaceInvitationJpaRepository.java | 14 ++ .../infra/jpa/NamespaceJpaRepository.java | 8 + 8 files changed, 337 insertions(+) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java diff --git a/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql b/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql new file mode 100644 index 00000000..3b6ac7a4 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql @@ -0,0 +1,9 @@ +CREATE TABLE namespace_invitation ( + namespace_id BIGINT PRIMARY KEY REFERENCES namespace(id) ON DELETE CASCADE, + token_hash CHAR(64) NOT NULL UNIQUE, + issued_by VARCHAR(128) NOT NULL, + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_by VARCHAR(128), + consumed_at TIMESTAMPTZ +); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java new file mode 100644 index 00000000..eb97b7c1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java @@ -0,0 +1,64 @@ +package com.iflytek.skillhub.domain.namespace; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +@Entity +@Table(name = "namespace_invitation") +public class NamespaceInvitation { + @Id + @Column(name = "namespace_id") + private Long namespaceId; + + @Column(name = "token_hash", nullable = false, length = 64) + private String tokenHash; + + @Column(name = "issued_by", nullable = false, length = 128) + private String issuedBy; + + @Column(name = "issued_at", nullable = false) + private Instant issuedAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "consumed_by", length = 128) + private String consumedBy; + + @Column(name = "consumed_at") + private Instant consumedAt; + + protected NamespaceInvitation() {} + + public NamespaceInvitation(Long namespaceId, String tokenHash, String issuedBy, Instant issuedAt, Instant expiresAt) { + this.namespaceId = namespaceId; + this.tokenHash = tokenHash; + this.issuedBy = issuedBy; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + } + + public Long getNamespaceId() { return namespaceId; } + public String getTokenHash() { return tokenHash; } + public String getIssuedBy() { return issuedBy; } + public Instant getIssuedAt() { return issuedAt; } + public Instant getExpiresAt() { return expiresAt; } + public String getConsumedBy() { return consumedBy; } + public Instant getConsumedAt() { return consumedAt; } + public void replace(String hash, String issuer, Instant now) { + this.tokenHash = hash; + this.issuedBy = issuer; + this.issuedAt = now; + this.expiresAt = now.plusSeconds(86400); + this.consumedBy = null; + this.consumedAt = null; + } + public void consume(String userId, Instant now) { + this.consumedBy = userId; + this.consumedAt = now; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java new file mode 100644 index 00000000..cf54afb3 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.Optional; + +public interface NamespaceInvitationRepository { + Optional findByNamespaceId(Long namespaceId); + Optional findByTokenHash(String tokenHash); + NamespaceInvitation save(NamespaceInvitation invitation); + void deleteByNamespaceId(Long namespaceId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java new file mode 100644 index 00000000..877b94eb --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java @@ -0,0 +1,163 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.HexFormat; + +@Service +public class NamespaceInvitationService { + private static final SecureRandom RANDOM = new SecureRandom(); + private final NamespaceRepository namespaces; + private final NamespaceInvitationRepository invitations; + private final NamespaceMemberRepository members; + private final UserAccountRepository users; + private final AuditLogService audit; + private final Clock clock; + + public NamespaceInvitationService(NamespaceRepository namespaces, NamespaceInvitationRepository invitations, + NamespaceMemberRepository members, UserAccountRepository users, + AuditLogService audit, Clock clock) { + this.namespaces = namespaces; + this.invitations = invitations; + this.members = members; + this.users = users; + this.audit = audit; + this.clock = clock; + } + + public record Created(String token, Instant expiresAt) {} + public record Preview(String namespaceSlug, String namespaceName, boolean alreadyMember) {} + + @Transactional + public Created create(String slug, String actorId) { + Namespace namespace = lock(slug); + requireManager(namespace, actorId); + requireActiveTeam(namespace); + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + Instant now = clock.instant(); + NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElse(null); + boolean replaced = invitation != null; + if (invitation == null) { + invitation = new NamespaceInvitation(namespace.getId(), hash(token), actorId, now, now.plusSeconds(86400)); + } else { + invitation.replace(hash(token), actorId, now); + } + invitations.save(invitation); + record(actorId, replaced ? "NAMESPACE_INVITE_REPLACED" : "NAMESPACE_INVITE_CREATED", namespace.getId()); + return new Created(token, invitation.getExpiresAt()); + } + + @Transactional + public void revoke(String slug, String actorId) { + Namespace namespace = lock(slug); + requireManager(namespace, actorId); + requireActiveTeam(namespace); + if (invitations.findByNamespaceId(namespace.getId()).isEmpty()) { + throw invalid(); + } + invitations.deleteByNamespaceId(namespace.getId()); + record(actorId, "NAMESPACE_INVITE_REVOKED", namespace.getId()); + } + + @Transactional(readOnly = true) + public boolean hasActiveInvitation(String slug, String actorId) { + Namespace namespace = namespaces.findBySlug(slug) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.invite.invalid")); + requireManager(namespace, actorId); + return invitations.findByNamespaceId(namespace.getId()) + .filter(invite -> invite.getConsumedAt() == null && invite.getExpiresAt().isAfter(clock.instant())) + .isPresent(); + } + + @Transactional(readOnly = true) + public Preview preview(String token, String userId) { + NamespaceInvitation invitation = current(token); + Namespace namespace = namespaces.findById(invitation.getNamespaceId()).orElseThrow(this::invalid); + boolean member = members.findByNamespaceIdAndUserId(namespace.getId(), userId).isPresent(); + if (!member) { + requireActiveUser(userId); + requireValid(namespace, invitation); + } + return new Preview(namespace.getSlug(), namespace.getDisplayName(), member); + } + + @Transactional + public Preview accept(String token, String userId) { + NamespaceInvitation located = current(token); + Namespace namespace = namespaces.findLockedById(located.getNamespaceId()).orElseThrow(this::invalid); + NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElseThrow(this::invalid); + if (!MessageDigest.isEqual(invitation.getTokenHash().getBytes(StandardCharsets.US_ASCII), + hash(token).getBytes(StandardCharsets.US_ASCII))) { + throw invalid(); + } + if (members.findByNamespaceIdAndUserId(namespace.getId(), userId).isPresent()) { + return new Preview(namespace.getSlug(), namespace.getDisplayName(), true); + } + requireActiveUser(userId); + requireValid(namespace, invitation); + members.save(new NamespaceMember(namespace.getId(), userId, NamespaceRole.MEMBER)); + invitation.consume(userId, clock.instant()); + invitations.save(invitation); + record(userId, "NAMESPACE_INVITE_ACCEPTED", namespace.getId()); + return new Preview(namespace.getSlug(), namespace.getDisplayName(), false); + } + + private Namespace lock(String slug) { + Namespace found = namespaces.findBySlug(slug).orElseThrow(this::invalid); + return namespaces.findLockedById(found.getId()).orElseThrow(this::invalid); + } + + private NamespaceInvitation current(String token) { + if (token == null || !token.matches("[A-Za-z0-9_-]{43}")) throw invalid(); + return invitations.findByTokenHash(hash(token)).orElseThrow(this::invalid); + } + + private void requireManager(Namespace namespace, String userId) { + NamespaceRole role = members.findByNamespaceIdAndUserId(namespace.getId(), userId) + .map(NamespaceMember::getRole).orElse(null); + if (role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) throw invalid(); + requireActiveUser(userId); + } + + private void requireActiveUser(String userId) { + if (users.findById(userId).filter(user -> user.getStatus() == UserStatus.ACTIVE).isEmpty()) throw invalid(); + } + + private void requireActiveTeam(Namespace namespace) { + if (namespace.getStatus() != NamespaceStatus.ACTIVE || namespace.getType() != NamespaceType.TEAM) throw invalid(); + } + + private void requireValid(Namespace namespace, NamespaceInvitation invitation) { + requireActiveTeam(namespace); + if (invitation.getConsumedAt() != null || !invitation.getExpiresAt().isAfter(clock.instant())) throw invalid(); + } + + private DomainNotFoundException invalid() { return new DomainNotFoundException("error.namespace.invite.invalid"); } + + private String hash(String token) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(token.getBytes(StandardCharsets.US_ASCII))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + private void record(String actorId, String action, Long namespaceId) { + audit.record(actorId, action, "NAMESPACE", namespaceId, null, null, null, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index cdedbfec..d5bbedbc 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -11,6 +11,7 @@ import java.util.Optional; */ public interface NamespaceRepository { Optional findById(Long id); + Optional findLockedById(Long id); List findAll(); List findByIdIn(List ids); List findBySlugIn(List slugs); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java new file mode 100644 index 00000000..a2d78607 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java @@ -0,0 +1,68 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class NamespaceInvitationServiceTest { + private final NamespaceRepository namespaces = mock(NamespaceRepository.class); + private final NamespaceInvitationRepository invitations = mock(NamespaceInvitationRepository.class); + private final NamespaceMemberRepository members = mock(NamespaceMemberRepository.class); + private final UserAccountRepository users = mock(UserAccountRepository.class); + private final AuditLogService audit = mock(AuditLogService.class); + private final Clock clock = Clock.fixed(Instant.parse("2026-10-12T00:00:00Z"), ZoneOffset.UTC); + private final NamespaceInvitationService service = new NamespaceInvitationService(namespaces, invitations, members, users, audit, clock); + + @BeforeEach + void setup() { + Namespace namespace = new Namespace("team", "Team", "owner"); + // Entity ID is database-generated; mock the locked lookup with the same instance. + try { + var id = Namespace.class.getDeclaredField("id"); + id.setAccessible(true); + id.set(namespace, 7L); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(namespaces.findLockedById(7L)).thenReturn(Optional.of(namespace)); + when(namespaces.findById(7L)).thenReturn(Optional.of(namespace)); + when(members.findByNamespaceIdAndUserId(7L, "owner")) + .thenReturn(Optional.of(new NamespaceMember(7L, "owner", NamespaceRole.OWNER))); + UserAccount owner = mock(UserAccount.class); + when(owner.getStatus()).thenReturn(com.iflytek.skillhub.domain.user.UserStatus.ACTIVE); + when(users.findById("owner")).thenReturn(Optional.of(owner)); + when(users.findById("guest")).thenReturn(Optional.of(owner)); + } + + @Test + void createsSingleUseTokenWithoutPersistingRawValue() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + assertThat(created.token()).matches("[A-Za-z0-9_-]{43}"); + assertThat(saved.getValue().getTokenHash()).matches("[a-f0-9]{64}").doesNotContain(created.token()); + assertThat(created.expiresAt()).isEqualTo(Instant.parse("2026-10-13T00:00:00Z")); + } + + @Test + void revokedTokenCannotBePreviewed() { + assertThatThrownBy(() -> service.preview("A".repeat(43), "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(invitations).findByTokenHash(any()); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java new file mode 100644 index 00000000..074fa035 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.namespace.NamespaceInvitation; +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface NamespaceInvitationJpaRepository extends JpaRepository, NamespaceInvitationRepository { + Optional findByTokenHash(String tokenHash); + void deleteByNamespaceId(Long namespaceId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index ed9a6724..0f909e58 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -6,6 +6,10 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import jakarta.persistence.LockModeType; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -17,6 +21,10 @@ import java.util.Optional; @Repository public interface NamespaceJpaRepository extends JpaRepository, NamespaceRepository { + @Override + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select n from Namespace n where n.id = :id") + Optional findLockedById(@Param("id") Long id); List findByIdIn(List ids); List findBySlugIn(List slugs); Page findByIdIn(List ids, Pageable pageable); From f7e96bb8a490a60c4fb41e2d94e724db05e66caf Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:49:48 +0800 Subject: [PATCH 04/47] feat(avatar): add owner and reviewer workflows Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- server/skillhub-app/pom.xml | 11 + .../admin/AvatarReviewSettingsController.java | 43 ++ .../portal/SkillAvatarController.java | 148 +++++++ .../controller/portal/SkillController.java | 6 +- .../dto/AvatarReviewSettingsResponse.java | 6 + .../AvatarReviewSettingsUpdateRequest.java | 6 + .../dto/SkillAvatarDecisionRequest.java | 5 + .../dto/SkillAvatarStatusResponse.java | 5 + .../skillhub/dto/SkillDetailResponse.java | 3 +- .../skillhub/dto/SkillSummaryResponse.java | 18 +- .../repository/JpaMySkillQueryRepository.java | 4 +- .../AliyunAvatarModerationProvider.java | 108 +++++ .../service/AvatarModerationGateway.java | 37 ++ .../service/AvatarModerationProvider.java | 10 + .../service/AvatarReviewSettingsService.java | 64 +++ .../service/ReviewSkillDetailAppService.java | 3 +- .../service/SkillAvatarAppService.java | 387 ++++++++++++++++++ .../skillhub/service/SkillAvatarUrls.java | 36 ++ .../service/SkillSearchAppService.java | 3 +- .../XfyunAvatarModerationProvider.java | 114 ++++++ .../skillhub/task/SkillAvatarCleanupTask.java | 29 ++ .../db/migration/V69__skill_avatars.sql | 6 +- .../src/main/resources/messages.properties | 8 + .../src/main/resources/messages_ru.properties | 8 + .../src/main/resources/messages_zh.properties | 8 + .../ReviewPortalControllerTest.java | 3 +- .../controller/SkillControllerTest.java | 4 +- .../AliyunAvatarModerationProviderTest.java | 34 ++ .../XfyunAvatarModerationProviderTest.java | 23 ++ .../iflytek/skillhub/domain/skill/Skill.java | 16 +- .../skill/SkillAvatarCandidateRepository.java | 7 + .../skill/service/SkillHardDeleteService.java | 9 + .../skill/service/SkillQueryService.java | 8 +- .../service/SkillHardDeleteServiceTest.java | 3 + .../SkillAvatarCandidateJpaRepository.java | 6 +- .../infra/jpa/SkillJpaRepository.java | 5 + 36 files changed, 1180 insertions(+), 14 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AvatarReviewSettingsController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsUpdateRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarDecisionRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationGateway.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationProvider.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarReviewSettingsService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUrls.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/XfyunAvatarModerationProvider.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarCleanupTask.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/XfyunAvatarModerationProviderTest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateRepository.java diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index 876d9ddc..0dc91ad7 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -18,6 +18,17 @@ + + + com.aliyun + green20220302 + 3.3.3 + + + com.aliyun.oss + aliyun-sdk-oss + 3.16.3 + org.springframework.boot spring-boot-starter-web diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AvatarReviewSettingsController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AvatarReviewSettingsController.java new file mode 100644 index 00000000..939bfae5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AvatarReviewSettingsController.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse; +import com.iflytek.skillhub.dto.AvatarReviewSettingsUpdateRequest; +import com.iflytek.skillhub.service.AvatarReviewSettingsService; +import com.iflytek.skillhub.service.AuditRequestContext; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/system-config/avatar-review") +@PreAuthorize("hasRole('SUPER_ADMIN')") +public class AvatarReviewSettingsController extends BaseApiController { + private final AvatarReviewSettingsService service; + + public AvatarReviewSettingsController(AvatarReviewSettingsService service, ApiResponseFactory responses) { + super(responses); + this.service = service; + } + + @GetMapping + public ApiResponse current() { + return ok("response.success.read", service.current()); + } + + @PutMapping + public ApiResponse update(@Valid @RequestBody AvatarReviewSettingsUpdateRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest httpRequest) { + return ok("response.success.updated", service.update(request, principal.userId(), + AuditRequestContext.from(httpRequest))); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java new file mode 100644 index 00000000..f589f846 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java @@ -0,0 +1,148 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.SkillAvatarDecisionRequest; +import com.iflytek.skillhub.dto.SkillAvatarStatusResponse; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.SkillAvatarAppService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping({"/api/v1/skills", "/api/web/skills"}) +public class SkillAvatarController extends BaseApiController { + private final SkillAvatarAppService service; + + public SkillAvatarController(SkillAvatarAppService service, ApiResponseFactory responses) { + super(responses); + this.service = service; + } + + @GetMapping("/{namespace}/{slug}/avatar") + public ResponseEntity current(@PathVariable String namespace, @PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles) { + InputStream image = service.readActive(namespace, slug, userId(principal), roles, platformRoles(principal)); + return png(image); + } + + @PostMapping(value = "/{namespace}/{slug}/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse submit(@PathVariable String namespace, @PathVariable String slug, + @RequestParam("file") MultipartFile file, @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) throws IOException { + if (file.getSize() < 1 || file.getSize() > com.iflytek.skillhub.service.SkillAvatarImageProcessor.MAX_BYTES) { + throw new DomainBadRequestException("error.skill.avatar.invalidImage"); + } + AuditRequestContext context = AuditRequestContext.from(httpRequest); + SkillAvatarStatusResponse created = service.submit(namespace, slug, userId(principal), file.getBytes(), context); + if ("PENDING".equals(created.machineStatus()) && "PENDING".equals(created.candidateStatus())) { + return ok("response.success.created", service.processMachine(created.candidateId(), context)); + } + return ok("response.success.created", created); + } + + @GetMapping("/{namespace}/{slug}/avatar/status") + public ApiResponse status(@PathVariable String namespace, @PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal) { + return ok("response.success.read", service.ownerStatus(namespace, slug, userId(principal))); + } + + @GetMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/image") + public ResponseEntity candidate(@PathVariable String namespace, @PathVariable String slug, + @PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles) { + return png(service.readCandidate(namespace, slug, candidateId, userId(principal), roles, platformRoles(principal))); + } + + @PostMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/appeal") + public ApiResponse appeal(@PathVariable String namespace, @PathVariable String slug, + @PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) { + return ok("response.success.updated", service.appeal(namespace, slug, candidateId, + userId(principal), AuditRequestContext.from(request))); + } + + @PostMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/retry") + public ApiResponse retry(@PathVariable String namespace, @PathVariable String slug, + @PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) { + service.requireCurrentOwnerCandidate(namespace, slug, candidateId, userId(principal)); + return ok("response.success.updated", service.processMachine(candidateId, AuditRequestContext.from(request))); + } + + @GetMapping("/avatar-review/queue") + public ApiResponse> queue( + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles) { + return ok("response.success.read", service.reviewQueue(userId(principal), roles, platformRoles(principal))); + } + + @PostMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/decision") + public ApiResponse decide(@PathVariable String namespace, @PathVariable String slug, + @PathVariable long candidateId, @Valid @RequestBody SkillAvatarDecisionRequest decision, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + HttpServletRequest request) { + return ok("response.success.updated", service.decide(namespace, slug, candidateId, + decision.approved(), decision.reason(), userId(principal), roles, platformRoles(principal), + AuditRequestContext.from(request))); + } + + @DeleteMapping("/{namespace}/{slug}/avatar") + public ApiResponse delete(@PathVariable String namespace, @PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + HttpServletRequest request) { + service.remove(namespace, slug, userId(principal), false, null, roles, platformRoles(principal), + AuditRequestContext.from(request)); + return ok("response.success.updated", null); + } + + @PostMapping("/{namespace}/{slug}/avatar/takedown") + public ApiResponse takedown(@PathVariable String namespace, @PathVariable String slug, + @RequestBody String reason, @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + HttpServletRequest request) { + service.remove(namespace, slug, userId(principal), true, reason, roles, platformRoles(principal), + AuditRequestContext.from(request)); + return ok("response.success.updated", null); + } + + private ResponseEntity png(InputStream input) { + return ResponseEntity.ok().contentType(MediaType.IMAGE_PNG).cacheControl(CacheControl.noStore()) + .body(new InputStreamResource(input)); + } + + private String userId(PlatformPrincipal principal) { + return principal == null ? null : principal.userId(); + } + + private Set platformRoles(PlatformPrincipal principal) { + return principal == null || principal.platformRoles() == null ? Set.of() : principal.platformRoles(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 213f78d9..1062db56 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -25,6 +25,8 @@ import com.iflytek.skillhub.dto.SkillVersionResponse; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.SkillLabelAppService; +import com.iflytek.skillhub.service.SkillAvatarUrls; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.service.ComplianceSnapshotProjectionService; import com.iflytek.skillhub.service.SkillSuiteAppService; import org.springframework.core.io.InputStreamResource; @@ -127,7 +129,9 @@ public class SkillController extends BaseApiController { memberOfSuites.items().stream() .filter(SkillSuiteReferenceResponse::currentSkillEntry) .toList(), - memberOfSuites + memberOfSuites, + SkillAvatarUrls.current(detail.activeAvatarId(), detail.activeAvatarAudience(), + SkillVisibility.valueOf(detail.visibility()), namespace, detail.slug()) ); return ok("response.success.read", response); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsResponse.java new file mode 100644 index 00000000..80c46b8f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsResponse.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +public record AvatarReviewSettingsResponse(boolean machineEnabled, boolean manualEnabled, + long version, Instant updatedAt) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsUpdateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsUpdateRequest.java new file mode 100644 index 00000000..7ef4eb24 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsUpdateRequest.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Min; + +public record AvatarReviewSettingsUpdateRequest(boolean machineEnabled, boolean manualEnabled, + @Min(0) long version) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarDecisionRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarDecisionRequest.java new file mode 100644 index 00000000..4f2d4c02 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarDecisionRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SkillAvatarDecisionRequest(boolean approved, @NotBlank String reason) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java new file mode 100644 index 00000000..b0b84714 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +public record SkillAvatarStatusResponse(String avatarUrl, Long candidateId, String candidateStatus, + String machineStatus, String reviewStatus, String reviewReason, + String candidateUrl) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index a3825624..9f0665ba 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -30,5 +30,6 @@ public record SkillDetailResponse( String ownerPreviewReviewComment, String resolutionMode, List entryForSuites, - PageResponse memberOfSuites + PageResponse memberOfSuites, + String avatarUrl ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index fdabac73..1773bec4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -32,9 +32,23 @@ public record SkillSummaryResponse( * that do not opt in. */ @JsonInclude(JsonInclude.Include.NON_NULL) - List labels + List labels, + String avatarUrl ) { + public SkillSummaryResponse( + Long id, String slug, String displayName, String summary, String visibility, String status, + Long downloadCount, Integer starCount, BigDecimal ratingAvg, Integer ratingCount, + String namespace, Instant updatedAt, String ownerId, String ownerDisplayName, + boolean canSubmitPromotion, SkillLifecycleVersionResponse headlineVersion, + SkillLifecycleVersionResponse publishedVersion, SkillLifecycleVersionResponse ownerPreviewVersion, + String resolutionMode, ComplianceSnapshotResponse complianceSnapshot, List labels) { + this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg, + ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion, + headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, + labels, null); + } + /** * Summary without label projection. */ @@ -93,6 +107,6 @@ public record SkillSummaryResponse( return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion, headlineVersion, - publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels); + publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels, avatarUrl); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java index e7a3acd7..7ad9e013 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.service.SkillAvatarUrls; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -145,7 +146,8 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository { toLifecycleVersion(ownerPreviewVersion), projection.resolutionMode().name(), null, - null + null, + SkillAvatarUrls.current(skill, namespace != null ? namespace.getSlug() : "") ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java new file mode 100644 index 00000000..3b6dffc4 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java @@ -0,0 +1,108 @@ +package com.iflytek.skillhub.service; + +import com.aliyun.green20220302.Client; +import com.aliyun.green20220302.models.DescribeUploadTokenResponse; +import com.aliyun.green20220302.models.DescribeUploadTokenResponseBody.DescribeUploadTokenResponseBodyData; +import com.aliyun.green20220302.models.ImageModerationRequest; +import com.aliyun.green20220302.models.ImageModerationResponse; +import com.aliyun.green20220302.models.ImageModerationResponseBody; +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.model.PutObjectRequest; +import com.aliyun.teaopenapi.models.Config; +import com.aliyun.teautil.models.RuntimeOptions; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayInputStream; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** Uses Alibaba Cloud's short-lived private upload token; never exposes a public candidate URL. */ +@Component +public class AliyunAvatarModerationProvider implements AvatarModerationProvider { + private static final String ENDPOINT = "green-cip.cn-shanghai.aliyuncs.com"; + private final String accessKeyId; + private final String accessKeySecret; + private final ObjectMapper mapper; + + public AliyunAvatarModerationProvider( + @Value("${skillhub.avatar.moderation.aliyun.access-key-id:}") String accessKeyId, + @Value("${skillhub.avatar.moderation.aliyun.access-key-secret:}") String accessKeySecret, + ObjectMapper mapper) { + this.accessKeyId = accessKeyId; + this.accessKeySecret = accessKeySecret; + this.mapper = mapper; + } + + @Override public String code() { return "aliyun"; } + + @Override public boolean configured() { return !accessKeyId.isBlank() && !accessKeySecret.isBlank(); } + + @Override public Verdict moderate(byte[] normalizedPng) { + if (!configured()) { + return Verdict.ERROR; + } + OSS oss = null; + try { + Config config = new Config(); + config.setAccessKeyId(accessKeyId); + config.setAccessKeySecret(accessKeySecret); + config.setEndpoint(ENDPOINT); + Client client = new Client(config); + DescribeUploadTokenResponse tokenResponse = client.describeUploadToken(); + if (tokenResponse == null || tokenResponse.getBody() == null + || !Objects.equals(tokenResponse.getBody().getCode(), 200)) { + return Verdict.ERROR; + } + DescribeUploadTokenResponseBodyData token = tokenResponse.getBody().getData(); + if (token == null || token.getBucketName() == null || token.getFileNamePrefix() == null + || token.getOssInternetEndPoint() == null || token.getSecurityToken() == null) { + return Verdict.ERROR; + } + oss = new OSSClientBuilder().build(token.getOssInternetEndPoint(), token.getAccessKeyId(), + token.getAccessKeySecret(), token.getSecurityToken()); + String objectName = token.getFileNamePrefix() + UUID.randomUUID() + ".png"; + oss.putObject(new PutObjectRequest(token.getBucketName(), objectName, + new ByteArrayInputStream(normalizedPng))); + + ImageModerationRequest request = new ImageModerationRequest(); + request.setService("profilePhotoCheck"); + request.setServiceParameters(mapper.writeValueAsString(Map.of( + "ossBucketName", token.getBucketName(), + "ossObjectName", objectName, + "dataId", UUID.randomUUID().toString()))); + ImageModerationResponse response = client.imageModerationWithOptions(request, new RuntimeOptions()); + return classify(response); + } catch (Exception failure) { + // SDK exception text may include credential or temporary upload details. + return Verdict.ERROR; + } finally { + if (oss != null) { + oss.shutdown(); + } + } + } + + Verdict classify(ImageModerationResponse response) { + if (response == null || !Objects.equals(response.getStatusCode(), 200)) { + return Verdict.ERROR; + } + ImageModerationResponseBody body = response.getBody(); + if (body == null || !Objects.equals(body.getCode(), 200) || body.getData() == null + || body.getData().getResult() == null || body.getData().getResult().isEmpty()) { + return Verdict.ERROR; + } + if (body.getData().getRiskLevel() == null) { + return Verdict.ERROR; + } + // Official guidance treats medium as requiring human review. BLOCK keeps the image + // private and lets the owner request the platform's exceptional manual review. + return switch (body.getData().getRiskLevel()) { + case "none", "low" -> Verdict.PASS; + case "medium", "high" -> Verdict.BLOCK; + default -> Verdict.ERROR; + }; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationGateway.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationGateway.java new file mode 100644 index 00000000..13528cad --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationGateway.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class AvatarModerationGateway { + private final String selectedProvider; + private final List providers; + + public AvatarModerationGateway(@Value("${skillhub.avatar.moderation.provider:}") String selectedProvider, + List providers) { + this.selectedProvider = selectedProvider; + this.providers = providers; + } + + public String requireConfiguredProvider() { + return providers.stream() + .filter(provider -> provider.code().equals(selectedProvider) && provider.configured()) + .findFirst() + .map(AvatarModerationProvider::code) + .orElseThrow(() -> new DomainBadRequestException("error.skill.avatar.providerUnavailable")); + } + + public AvatarModerationProvider.Verdict moderate(String providerCode, byte[] image) { + if (!providerCode.equals(selectedProvider)) { + return AvatarModerationProvider.Verdict.ERROR; + } + return providers.stream() + .filter(provider -> provider.code().equals(providerCode) && provider.configured()) + .findFirst() + .map(provider -> provider.moderate(image)) + .orElse(AvatarModerationProvider.Verdict.ERROR); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationProvider.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationProvider.java new file mode 100644 index 00000000..c694c4b7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationProvider.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.service; + +/** A deployment-selected image reviewer; implementations must never log credentials or raw URLs. */ +public interface AvatarModerationProvider { + enum Verdict { PASS, BLOCK, ERROR } + + String code(); + boolean configured(); + Verdict moderate(byte[] normalizedPng); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarReviewSettingsService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarReviewSettingsService.java new file mode 100644 index 00000000..86e7bba5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarReviewSettingsService.java @@ -0,0 +1,64 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.settings.SystemSetting; +import com.iflytek.skillhub.auth.settings.SystemSettingRepository; +import com.iflytek.skillhub.domain.audit.AuditDetail; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse; +import com.iflytek.skillhub.dto.AvatarReviewSettingsUpdateRequest; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AvatarReviewSettingsService { + public static final String SETTING_KEY = "skill.avatar-review"; + + private final SystemSettingRepository settings; + private final AvatarModerationGateway gateway; + private final AuditLogService audit; + private final RequestIdAccessor requestIds; + + public AvatarReviewSettingsService(SystemSettingRepository settings, AvatarModerationGateway gateway, + AuditLogService audit, RequestIdAccessor requestIds) { + this.settings = settings; + this.gateway = gateway; + this.audit = audit; + this.requestIds = requestIds; + } + + public AvatarReviewSettingsResponse current() { + SystemSetting setting = load(); + return new AvatarReviewSettingsResponse( + Boolean.TRUE.equals(setting.getValue().get("machineEnabled")), + !Boolean.FALSE.equals(setting.getValue().get("manualEnabled")), + setting.getVersion(), setting.getUpdatedAt()); + } + + @Transactional + public AvatarReviewSettingsResponse update(AvatarReviewSettingsUpdateRequest request, String actor, + AuditRequestContext context) { + if (request.machineEnabled()) { + gateway.requireConfiguredProvider(); + } + SystemSetting setting = load(); + if (setting.getVersion() != request.version()) { + throw new DomainConflictException("error.system.settings.version.conflict"); + } + Map previous = setting.getValue(); + setting.update(Map.of("machineEnabled", request.machineEnabled(), + "manualEnabled", request.manualEnabled()), actor); + settings.saveAndFlush(setting); + audit.record(actor, "SKILL_AVATAR_SETTINGS_UPDATE", "SYSTEM_SETTING", setting.getId(), + requestIds.current(), context.clientIp(), context.userAgent(), + AuditDetail.builder().put("previous", previous).put("current", setting.getValue()).build()); + return current(); + } + + private SystemSetting load() { + return settings.findBySettingKey(SETTING_KEY) + .orElseThrow(() -> new IllegalStateException("Missing required skill.avatar-review setting")); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java index 49a629ab..769e1989 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java @@ -87,7 +87,8 @@ public class ReviewSkillDetailAppService { null, "REVIEW_TASK", List.of(), - new PageResponse<>(List.of(), 0, 0, 20) + new PageResponse<>(List.of(), 0, 0, 20), + SkillAvatarUrls.current(snapshot.skill(), context.namespace().getSlug()) ); List versions = snapshot.versions().stream() diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java new file mode 100644 index 00000000..6b63331c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java @@ -0,0 +1,387 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.audit.AuditDetail; +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.DomainBadRequestException; +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.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +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.VisibilityChecker; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse; +import com.iflytek.skillhub.dto.SkillAvatarStatusResponse; +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.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Service +public class SkillAvatarAppService { + private final SkillJpaRepository skills; + private final NamespaceRepository namespaces; + private final SkillAvatarCandidateJpaRepository candidates; + private final ObjectStorageService storage; + private final SkillAvatarImageProcessor imageProcessor; + private final AvatarReviewSettingsService settings; + private final AvatarModerationGateway moderation; + private final VisibilityChecker visibility; + private final SkillQueryService skillQueries; + private final AuditLogService audit; + private final RequestIdAccessor requestIds; + private final EntityManager entityManager; + + 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) { + this.skills = skills; + this.namespaces = namespaces; + this.candidates = candidates; + this.storage = storage; + this.imageProcessor = imageProcessor; + this.settings = settings; + this.moderation = moderation; + this.visibility = visibility; + this.skillQueries = skillQueries; + this.audit = audit; + this.requestIds = requestIds; + this.entityManager = entityManager; + } + + @Transactional + public SkillAvatarStatusResponse submit(String namespaceSlug, String slug, String actor, byte[] image, + AuditRequestContext context) { + Skill skill = lockedSkill(namespaceSlug, slug); + requireOwner(skill, actor); + byte[] normalized = imageProcessor.normalize(image); + AvatarReviewSettingsResponse policy = settings.current(); + boolean privateOnly = skill.getVisibility() == SkillVisibility.PRIVATE; + boolean machineEnabled = !privateOnly && policy.machineEnabled(); + boolean manualEnabled = !privateOnly && policy.manualEnabled(); + String provider = machineEnabled ? moderation.requireConfiguredProvider() : null; + String sha256 = sha256(normalized); + String key = "skill-avatars/" + skill.getId() + "/" + UUID.randomUUID() + ".png"; + storage.putObject(key, new ByteArrayInputStream(normalized), normalized.length, "image/png"); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCompletion(int result) { + if (result != STATUS_COMMITTED) { + storage.deleteObject(key); + } + } + }); + + if (skill.getPendingAvatarId() != null) { + candidates.findById(skill.getPendingAvatarId()).ifPresent(SkillAvatarCandidate::supersede); + } + SkillAvatarCandidate candidate = new SkillAvatarCandidate(skill.getId(), actor, key, sha256, + skill.getVisibility(), machineEnabled, manualEnabled, provider); + if (machineEnabled) { + // Only the machine verdict is reusable. Every new audience has its own human decision. + boolean reusedPass = !candidates.findByContentSha256AndMachineProviderAndMachineEnabledTrueAndManualEnabledAndMachineStatus( + sha256, provider, manualEnabled, SkillAvatarCandidate.MachineStatus.PASSED).stream() + .findFirst().isEmpty(); + boolean reusedBlock = !candidates.findByContentSha256AndMachineProviderAndMachineEnabledTrueAndManualEnabledAndMachineStatus( + sha256, provider, manualEnabled, SkillAvatarCandidate.MachineStatus.BLOCKED).stream() + .findFirst().isEmpty(); + if (reusedPass) { + candidate.machineResult(SkillAvatarCandidate.MachineStatus.PASSED); + } else if (reusedBlock) { + candidate.machineResult(SkillAvatarCandidate.MachineStatus.BLOCKED); + } + } + candidate = candidates.saveAndFlush(candidate); + skill.setPendingAvatarId(candidate.getId()); + if (!machineEnabled && !manualEnabled + || candidate.getMachineStatus() == SkillAvatarCandidate.MachineStatus.PASSED && !manualEnabled) { + activate(skill, candidate); + } + log(actor, "SKILL_AVATAR_SUBMIT", 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 + 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; + }); + if (verdict == AvatarModerationProvider.Verdict.PASS && !candidate.isManualEnabled()) { + activate(skill, candidate); + } + log(candidate.getSubmittedBy(), "SKILL_AVATAR_MACHINE_RESULT", candidate.getId(), context, + AuditDetail.of("verdict", verdict.name())); + return status(skill, candidate, namespaceSlug(skill)); + } + + @Transactional(readOnly = true) + public SkillAvatarStatusResponse ownerStatus(String namespaceSlug, String slug, String actor) { + Skill skill = findSkill(namespaceSlug, slug); + requireOwner(skill, actor); + return status(skill, candidates.findBySkillIdOrderByCreatedAtDesc(skill.getId()).stream().findFirst().orElse(null), + namespaceSlug); + } + + @Transactional(readOnly = true) + public InputStream readActive(String namespaceSlug, String slug, String actor, + Map namespaceRoles, Set platformRoles) { + skillQueries.getSkillDetail(namespaceSlug, slug, actor, namespaceRoles == null ? Map.of() : namespaceRoles); + Skill skill = findSkill(namespaceSlug, slug); + if (!visibility.canAccess(skill, actor, namespaceRoles, platformRoles) + || SkillAvatarUrls.current(skill, namespaceSlug) == null) { + throw notFound(); + } + SkillAvatarCandidate candidate = findCandidate(skill.getActiveAvatarId()); + if (candidate.getStatus() != SkillAvatarCandidate.Status.ACTIVE || candidate.getObjectKey() == null) { + throw notFound(); + } + return storage.getObject(candidate.getObjectKey()); + } + + @Transactional(readOnly = true) + public InputStream readCandidate(String namespaceSlug, String slug, long candidateId, String actor, + Map namespaceRoles, Set platformRoles) { + Skill skill = findSkill(namespaceSlug, slug); + SkillAvatarCandidate candidate = findCandidate(candidateId); + if (!skill.getId().equals(candidate.getSkillId()) || candidate.getObjectKey() == null + || !skill.getOwnerId().equals(actor) && !canReview(skill, actor, namespaceRoles, platformRoles)) { + throw notFound(); + } + return storage.getObject(candidate.getObjectKey()); + } + + @Transactional + public SkillAvatarStatusResponse appeal(String namespaceSlug, String slug, long candidateId, String actor, + AuditRequestContext context) { + Skill skill = lockedSkill(namespaceSlug, slug); + requireOwner(skill, actor); + SkillAvatarCandidate candidate = currentPending(skill, candidateId); + candidate.appeal(); + log(actor, "SKILL_AVATAR_APPEAL", candidateId, context, AuditDetail.of("skillId", skill.getId())); + return status(skill, candidate, namespaceSlug); + } + + @Transactional(readOnly = true) + public void requireCurrentOwnerCandidate(String namespaceSlug, String slug, long candidateId, String actor) { + Skill skill = findSkill(namespaceSlug, slug); + requireOwner(skill, actor); + if (!Objects.equals(skill.getPendingAvatarId(), candidateId)) { + throw new DomainConflictException("error.skill.avatar.notPending"); + } + } + + @Transactional + public SkillAvatarStatusResponse decide(String namespaceSlug, String slug, long candidateId, boolean approved, + String reason, String actor, Map namespaceRoles, + Set platformRoles, AuditRequestContext context) { + Skill skill = lockedSkill(namespaceSlug, slug); + requireReviewer(skill, actor, namespaceRoles, platformRoles); + SkillAvatarCandidate candidate = currentPending(skill, candidateId); + if (candidate.isMachineEnabled() && candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PASSED + && candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.BLOCKED) { + throw new DomainConflictException("error.skill.avatar.machineIncomplete"); + } + if (reason == null || reason.isBlank()) { + throw new DomainBadRequestException("error.skill.avatar.reasonRequired"); + } + if (approved) { + candidate.approve(actor, reason.trim()); + activate(skill, candidate); + } else { + candidate.reject(actor, reason.trim()); + skill.setPendingAvatarId(null); + } + log(actor, approved ? "SKILL_AVATAR_APPROVE" : "SKILL_AVATAR_REJECT", candidateId, context, + AuditDetail.builder().put("skillId", skill.getId()).put("reason", reason.trim()).build()); + return status(skill, candidate, namespaceSlug); + } + + @Transactional + public void remove(String namespaceSlug, String slug, String actor, boolean byAdmin, String reason, + Map namespaceRoles, Set platformRoles, + AuditRequestContext context) { + Skill skill = lockedSkill(namespaceSlug, slug); + if (byAdmin) { + requireReviewer(skill, actor, namespaceRoles, platformRoles); + if (reason == null || reason.isBlank()) { + throw new DomainBadRequestException("error.skill.avatar.reasonRequired"); + } + } else { + requireOwner(skill, actor); + } + if (skill.getActiveAvatarId() == null && skill.getPendingAvatarId() == null) { + throw notFound(); + } + if (skill.getActiveAvatarId() != null) { + candidates.findById(skill.getActiveAvatarId()).ifPresent(SkillAvatarCandidate::remove); + } + if (skill.getPendingAvatarId() != null) { + candidates.findById(skill.getPendingAvatarId()).ifPresent(SkillAvatarCandidate::remove); + } + skill.clearAvatar(); + skill.setPendingAvatarId(null); + log(actor, byAdmin ? "SKILL_AVATAR_TAKEDOWN" : "SKILL_AVATAR_DELETE", skill.getId(), context, + AuditDetail.of("reason", reason == null ? "" : reason.trim())); + } + + @Transactional(readOnly = true) + public List reviewQueue(String actor, Map namespaceRoles, + Set platformRoles) { + return java.util.stream.Stream.concat( + candidates.findByStatusAndReviewStatusOrderByCreatedAtAsc( + SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.ReviewStatus.PENDING).stream(), + candidates.findByStatusAndReviewStatusOrderByCreatedAtAsc( + SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.ReviewStatus.APPEAL_PENDING).stream()) + .filter(candidate -> candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.ERROR) + .filter(candidate -> candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PENDING) + .filter(candidate -> ((SkillRepository) skills).findById(candidate.getSkillId()) + .filter(skill -> canReview(skill, actor, namespaceRoles, platformRoles)).isPresent()) + .map(candidate -> { + Skill skill = ((SkillRepository) skills).findById(candidate.getSkillId()).orElseThrow(() -> notFound()); + return status(skill, candidate, namespaceSlug(skill)); + }).toList(); + } + + private SkillAvatarCandidate currentPending(Skill skill, long candidateId) { + if (!Objects.equals(skill.getPendingAvatarId(), candidateId)) { + throw new DomainConflictException("error.skill.avatar.notPending"); + } + SkillAvatarCandidate candidate = findCandidate(candidateId); + if (candidate.getStatus() != SkillAvatarCandidate.Status.PENDING) { + throw new DomainConflictException("error.skill.avatar.notPending"); + } + return candidate; + } + + private void activate(Skill skill, SkillAvatarCandidate candidate) { + if (skill.getActiveAvatarId() != null) { + candidates.findById(skill.getActiveAvatarId()).ifPresent(SkillAvatarCandidate::supersede); + } + candidate.activate(); + skill.activateAvatar(candidate.getId(), candidate.getAudience()); + skill.setPendingAvatarId(null); + } + + private Skill findSkill(String namespaceSlug, String slug) { + Namespace namespace = namespaces.findBySlug(namespaceSlug).orElseThrow(() -> notFound()); + return skills.findByNamespaceIdAndSlug(namespace.getId(), slug).stream() + .filter(skill -> skill.getStatus() == SkillStatus.ACTIVE).findFirst() + .orElseThrow(() -> notFound()); + } + + private Skill lockedSkill(String namespaceSlug, String slug) { + Skill skill = findSkill(namespaceSlug, slug); + return skills.findLockedById(skill.getId()).orElseThrow(() -> notFound()); + } + + private SkillAvatarCandidate findCandidate(Long candidateId) { + return candidates.findById(candidateId).orElseThrow(() -> notFound()); + } + + private String namespaceSlug(Skill skill) { + return namespaces.findById(skill.getNamespaceId()).orElseThrow(() -> notFound()).getSlug(); + } + + private void requireOwner(Skill skill, String actor) { + if (actor == null || !skill.getOwnerId().equals(actor)) { + throw new DomainForbiddenException("error.skill.avatar.ownerOnly"); + } + } + + private void requireReviewer(Skill skill, String actor, Map roles, + Set platformRoles) { + if (actor == null || !canReview(skill, actor, roles, platformRoles)) { + throw new DomainForbiddenException("error.skill.avatar.reviewerOnly"); + } + } + + private boolean canReview(Skill skill, String actor, Map roles, + Set platformRoles) { + if (actor == null) { + return false; + } + Namespace namespace = namespaces.findById(skill.getNamespaceId()).orElseThrow(() -> notFound()); + if (namespace.getType() == NamespaceType.GLOBAL) { + return platformRoles != null && (platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN")); + } + NamespaceRole role = roles == null ? null : roles.get(skill.getNamespaceId()); + return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; + } + + private SkillAvatarStatusResponse status(Skill skill, SkillAvatarCandidate candidate, String namespaceSlug) { + return new SkillAvatarStatusResponse(SkillAvatarUrls.current(skill, namespaceSlug), + candidate == null ? null : candidate.getId(), + candidate == null ? null : candidate.getStatus().name(), + candidate == null ? null : candidate.getMachineStatus().name(), + candidate == null ? null : candidate.getReviewStatus().name(), + candidate == null ? null : candidate.getReviewReason(), + candidate == null || candidate.getObjectKey() == null ? null + : SkillAvatarUrls.candidate(namespaceSlug, skill, candidate.getId())); + } + + private String sha256(byte[] image) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(image)); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } + + private void log(String actor, String action, Long targetId, AuditRequestContext context, String detail) { + audit.record(actor, action, "SKILL_AVATAR", targetId, requestIds.current(), + context.clientIp(), context.userAgent(), detail); + } + + private DomainNotFoundException notFound() { + return new DomainNotFoundException("error.skill.avatar.notFound"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUrls.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUrls.java new file mode 100644 index 00000000..1f980765 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUrls.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +public final class SkillAvatarUrls { + private SkillAvatarUrls() {} + + public static String current(Skill skill, String namespaceSlug) { + return current(skill.getActiveAvatarId(), skill.getActiveAvatarAudience(), skill.getVisibility(), + namespaceSlug, skill.getSlug()); + } + + public static String current(Long activeAvatarId, SkillVisibility approved, SkillVisibility current, + String namespaceSlug, String skillSlug) { + if (activeAvatarId == null || approved == null) { + return null; + } + if (current == SkillVisibility.PUBLIC && approved != SkillVisibility.PUBLIC + || current == SkillVisibility.NAMESPACE_ONLY && approved == SkillVisibility.PRIVATE) { + return null; + } + return "/api/v1/skills/" + segment(namespaceSlug) + "/" + segment(skillSlug) + "/avatar"; + } + + public static String candidate(String namespaceSlug, Skill skill, long candidateId) { + return "/api/v1/skills/" + segment(namespaceSlug) + "/" + segment(skill.getSlug()) + + "/avatar/candidates/" + candidateId + "/image"; + } + + private static String segment(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 458a30ee..8af16c49 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -274,7 +274,8 @@ public class SkillSearchAppService { headlineVersion != null ? complianceSnapshotProjectionService.fromParsedMetadataJson(headlineVersion.parsedMetadataJson()) : null, - null + null, + SkillAvatarUrls.current(skill, namespaceSlug) ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/XfyunAvatarModerationProvider.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/XfyunAvatarModerationProvider.java new file mode 100644 index 00000000..9b85cbdc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/XfyunAvatarModerationProvider.java @@ -0,0 +1,114 @@ +package com.iflytek.skillhub.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** Xfyun image compliance API v2. Its documented query contains a secret: never log URI or exceptions. */ +@Component +public class XfyunAvatarModerationProvider implements AvatarModerationProvider { + private static final String ENDPOINT = "https://audit.iflyaisol.com/audit/v2/image"; + private static final DateTimeFormatter UTC_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ"); + + private final String appId; + private final String accessKeyId; + private final String accessKeySecret; + private final ObjectMapper mapper; + private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build(); + + public XfyunAvatarModerationProvider( + @Value("${skillhub.avatar.moderation.xfyun.app-id:}") String appId, + @Value("${skillhub.avatar.moderation.xfyun.access-key-id:}") String accessKeyId, + @Value("${skillhub.avatar.moderation.xfyun.access-key-secret:}") String accessKeySecret, + ObjectMapper mapper) { + this.appId = appId; + this.accessKeyId = accessKeyId; + this.accessKeySecret = accessKeySecret; + this.mapper = mapper; + } + + @Override public String code() { return "xfyun"; } + + @Override public boolean configured() { + return !appId.isBlank() && !accessKeyId.isBlank() && !accessKeySecret.isBlank(); + } + + @Override public Verdict moderate(byte[] normalizedPng) { + if (!configured()) { + return Verdict.ERROR; + } + try { + TreeMap params = new TreeMap<>(Map.of( + "accessKeyId", accessKeyId, + "accessKeySecret", accessKeySecret, + "appId", appId, + "modeType", "base64", + "utc", OffsetDateTime.now(ZoneOffset.UTC).format(UTC_FORMAT), + "uuid", UUID.randomUUID().toString())); + String base = encode(params); + params.put("signature", sign(base)); + String payload = mapper.writeValueAsString(Map.of("content", Base64.getEncoder().encodeToString(normalizedPng))); + HttpRequest request = HttpRequest.newBuilder(URI.create(ENDPOINT + "?" + encode(params))) + .timeout(Duration.ofSeconds(8)) + .header("Content-Type", "application/json;charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(payload)).build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 200 ? parse(response.body()) : Verdict.ERROR; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return Verdict.ERROR; + } catch (IOException | IllegalArgumentException | InvalidKeyException | NoSuchAlgorithmException failure) { + // The request URI includes accessKeySecret. Do not log or propagate exception text. + return Verdict.ERROR; + } + } + + Verdict parse(String response) throws IOException { + JsonNode body = mapper.readTree(response); + if (!"000000".equals(body.path("code").asText())) { + return Verdict.ERROR; + } + return switch (body.path("data").path("result").path("suggest").asText()) { + case "pass" -> Verdict.PASS; + case "block" -> Verdict.BLOCK; + default -> Verdict.ERROR; + }; + } + + private String sign(String base) throws NoSuchAlgorithmException, InvalidKeyException { + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(accessKeySecret.getBytes(StandardCharsets.UTF_8), "HmacSHA1")); + return Base64.getEncoder().encodeToString(mac.doFinal(base.getBytes(StandardCharsets.UTF_8))); + } + + private String encode(Map params) { + StringBuilder query = new StringBuilder(); + params.forEach((key, value) -> { + if (value != null && !value.isEmpty()) { + if (!query.isEmpty()) query.append('&'); + query.append(key).append('=').append(URLEncoder.encode(value, StandardCharsets.UTF_8)); + } + }); + return query.toString(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarCleanupTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarCleanupTask.java new file mode 100644 index 00000000..9a4c6ef9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarCleanupTask.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.task; + +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.time.Instant; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class SkillAvatarCleanupTask { + private final SkillAvatarCandidateJpaRepository candidates; + private final ObjectStorageService storage; + + public SkillAvatarCleanupTask(SkillAvatarCandidateJpaRepository candidates, ObjectStorageService storage) { + this.candidates = candidates; + this.storage = storage; + } + + @Scheduled(cron = "0 35 2 * * ?") + @Transactional + public void removeExpiredImages() { + for (SkillAvatarCandidate candidate : candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(Instant.now())) { + storage.deleteObject(candidate.getObjectKey()); + candidate.clearObjectKey(); + } + } +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql b/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql index ffc5fabf..18e990de 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql @@ -1,9 +1,10 @@ ALTER TABLE skill ADD COLUMN active_avatar_id BIGINT; +ALTER TABLE skill ADD COLUMN active_avatar_audience VARCHAR(32); ALTER TABLE skill ADD COLUMN pending_avatar_id BIGINT; CREATE TABLE skill_avatar_candidate ( id BIGSERIAL PRIMARY KEY, - skill_id BIGINT NOT NULL REFERENCES skill(id), + skill_id BIGINT NOT NULL REFERENCES skill(id) ON DELETE CASCADE, submitted_by VARCHAR(128) NOT NULL, object_key VARCHAR(512), content_sha256 VARCHAR(64) NOT NULL, @@ -25,3 +26,6 @@ CREATE TABLE skill_avatar_candidate ( CREATE INDEX idx_avatar_candidate_skill ON skill_avatar_candidate(skill_id, created_at DESC); CREATE INDEX idx_avatar_candidate_review ON skill_avatar_candidate(status, review_status, created_at); CREATE INDEX idx_avatar_candidate_discard ON skill_avatar_candidate(discard_at) WHERE object_key IS NOT NULL; + +INSERT INTO system_setting(setting_key, value_json) +VALUES ('skill.avatar-review', '{"machineEnabled":false,"manualEnabled":true}'::jsonb); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 24bf9324..febcfcd0 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -129,6 +129,14 @@ review_task.not_found_for_version=No pending review submission found for version error.skill.publish.summary.tooLong=Skill description must not exceed {0} characters error.skill.notFound=Skill not found: {0} error.skill.access.denied=Access denied to skill: {0} +error.skill.avatar.invalidImage=Upload a square PNG or JPEG image up to 1024 pixels and 2 MB. +error.skill.avatar.providerUnavailable=The configured image moderation provider is unavailable. +error.skill.avatar.notPending=This avatar request is no longer pending. +error.skill.avatar.machineIncomplete=Wait for image moderation before reviewing this avatar. +error.skill.avatar.reasonRequired=Enter a reason for this decision. +error.skill.avatar.ownerOnly=Only the skill owner can change its avatar. +error.skill.avatar.reviewerOnly=You cannot review this skill avatar. +error.skill.avatar.notFound=Skill avatar not found. error.skill.status.notActive=Skill is not active error.skill.lifecycle.noPermission=Only the skill owner or namespace admin can manage this skill error.skill.version.exists=Version already exists: {0} diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 566d443e..1c5aaea0 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -126,6 +126,14 @@ review_task.not_found_for_version=Не найдена ожидающая зая error.skill.publish.summary.tooLong=Описание скилла не должно превышать {0} символов error.skill.notFound=Скилл не найден: {0} error.skill.access.denied=Нет доступа к скиллу: {0} +error.skill.avatar.invalidImage=Загрузите квадратное изображение PNG или JPEG до 2 МБ и 1024 пикселей. +error.skill.avatar.providerUnavailable=Сервис автоматической проверки изображений недоступен. +error.skill.avatar.notPending=Эта заявка на аватар больше не ожидает рассмотрения. +error.skill.avatar.machineIncomplete=Дождитесь автоматической проверки изображения. +error.skill.avatar.reasonRequired=Укажите причину решения. +error.skill.avatar.ownerOnly=Изменять аватар может только владелец скилла. +error.skill.avatar.reviewerOnly=У вас нет прав на проверку этого аватара. +error.skill.avatar.notFound=Аватар скилла не найден. error.skill.status.notActive=Скилл не активен error.skill.lifecycle.noPermission=Управлять этим скиллом может только владелец скилла или администратор пространства имён error.skill.version.exists=Версия уже существует: {0} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 90bad54b..8d72536c 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -129,6 +129,14 @@ review_task.not_found_for_version=未找到该版本对应的待审核记录:{ error.skill.publish.summary.tooLong=技能描述长度不能超过 {0} 个字符 error.skill.notFound=未找到技能:{0} error.skill.access.denied=没有权限访问技能:{0} +error.skill.avatar.invalidImage=请上传不超过 2 MB、边长不超过 1024 像素的正方形 PNG 或 JPEG 图片。 +error.skill.avatar.providerUnavailable=部署的图片机器审核服务不可用。 +error.skill.avatar.notPending=这条头像申请已失效,不能继续处理。 +error.skill.avatar.machineIncomplete=请等待图片机器审核完成后再处理。 +error.skill.avatar.reasonRequired=请填写处理原因。 +error.skill.avatar.ownerOnly=只有技能所有者可以维护头像。 +error.skill.avatar.reviewerOnly=你没有审核此技能头像的权限。 +error.skill.avatar.notFound=未找到技能头像。 error.skill.status.notActive=技能未处于 ACTIVE 状态 error.skill.lifecycle.noPermission=只有技能所有者或命名空间管理员可以管理该技能 error.skill.version.exists=版本已存在:{0} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java index 3e35e561..a942b481 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java @@ -202,7 +202,8 @@ class ReviewPortalControllerTest { null, "REVIEW_TASK", List.of(), - new com.iflytek.skillhub.dto.PageResponse<>(List.of(), 0, 0, 20) + new com.iflytek.skillhub.dto.PageResponse<>(List.of(), 0, 0, 20), + null ), List.of(new SkillVersionResponse(100L, "1.2.0", "PENDING_REVIEW", null, 1, 10L, null, true, null)), List.of(new SkillFileResponse(1L, "README.md", 123L, "text/markdown", "sha")), diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 60a6a64f..cb9eebf7 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -186,7 +186,9 @@ class SkillControllerTest { null, new com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService.VersionProjection(11L, "1.1.0", "PENDING_REVIEW"), null, - "OWNER_PREVIEW" + "OWNER_PREVIEW", + null, + null )); SkillSuiteReferenceResponse suiteReference = new SkillSuiteReferenceResponse( 9L, "team", "demo-suite", "Demo Suite", "2.0.0", 3, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java new file mode 100644 index 00000000..0a7b3a20 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java @@ -0,0 +1,34 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.aliyun.green20220302.models.ImageModerationResponse; +import com.aliyun.green20220302.models.ImageModerationResponseBody; +import com.aliyun.green20220302.models.ImageModerationResponseBody.ImageModerationResponseBodyData; +import com.aliyun.green20220302.models.ImageModerationResponseBody.ImageModerationResponseBodyDataResult; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AliyunAvatarModerationProviderTest { + private final AliyunAvatarModerationProvider provider = + new AliyunAvatarModerationProvider("key", "secret", new ObjectMapper()); + + @Test + void classifiesDocumentedRiskLevelsAndFailsClosedOnUnknown() { + assertThat(provider.classify(response("none"))).isEqualTo(AvatarModerationProvider.Verdict.PASS); + assertThat(provider.classify(response("low"))).isEqualTo(AvatarModerationProvider.Verdict.PASS); + assertThat(provider.classify(response("medium"))).isEqualTo(AvatarModerationProvider.Verdict.BLOCK); + assertThat(provider.classify(response("high"))).isEqualTo(AvatarModerationProvider.Verdict.BLOCK); + assertThat(provider.classify(response(null))).isEqualTo(AvatarModerationProvider.Verdict.ERROR); + assertThat(provider.classify(response("unknown"))).isEqualTo(AvatarModerationProvider.Verdict.ERROR); + } + + private ImageModerationResponse response(String level) { + ImageModerationResponseBodyData data = new ImageModerationResponseBodyData() + .setRiskLevel(level) + .setResult(List.of(new ImageModerationResponseBodyDataResult().setLabel("nonLabel"))); + return new ImageModerationResponse().setStatusCode(200) + .setBody(new ImageModerationResponseBody().setCode(200).setData(data)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/XfyunAvatarModerationProviderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/XfyunAvatarModerationProviderTest.java new file mode 100644 index 00000000..56b2813b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/XfyunAvatarModerationProviderTest.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +class XfyunAvatarModerationProviderTest { + private final XfyunAvatarModerationProvider provider = + new XfyunAvatarModerationProvider("app", "key", "secret", new ObjectMapper()); + + @Test + void acceptsOnlyDocumentedPassAndBlockVerdicts() throws Exception { + assertThat(provider.parse("{\"code\":\"000000\",\"data\":{\"result\":{\"suggest\":\"pass\"}}}")) + .isEqualTo(AvatarModerationProvider.Verdict.PASS); + assertThat(provider.parse("{\"code\":\"000000\",\"data\":{\"result\":{\"suggest\":\"block\"}}}")) + .isEqualTo(AvatarModerationProvider.Verdict.BLOCK); + assertThat(provider.parse("{\"code\":\"000000\",\"data\":{\"result\":{\"suggest\":\"review\"}}}")) + .isEqualTo(AvatarModerationProvider.Verdict.ERROR); + assertThat(provider.parse("{\"code\":\"100001\"}")) + .isEqualTo(AvatarModerationProvider.Verdict.ERROR); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java index 1188347f..7367345f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java @@ -45,6 +45,10 @@ public class Skill { @Column(name = "active_avatar_id") private Long activeAvatarId; + @Enumerated(EnumType.STRING) + @Column(name = "active_avatar_audience", length = 32) + private SkillVisibility activeAvatarAudience; + @Column(name = "pending_avatar_id") private Long pendingAvatarId; @@ -149,6 +153,8 @@ public class Skill { public Long getActiveAvatarId() { return activeAvatarId; } + public SkillVisibility getActiveAvatarAudience() { return activeAvatarAudience; } + public Long getPendingAvatarId() { return pendingAvatarId; } public Long getDownloadCount() { @@ -224,7 +230,15 @@ public class Skill { this.latestVersionId = latestVersionId; } - public void setActiveAvatarId(Long activeAvatarId) { this.activeAvatarId = activeAvatarId; } + public void activateAvatar(Long activeAvatarId, SkillVisibility audience) { + this.activeAvatarId = activeAvatarId; + this.activeAvatarAudience = audience; + } + + public void clearAvatar() { + this.activeAvatarId = null; + this.activeAvatarAudience = null; + } public void setPendingAvatarId(Long pendingAvatarId) { this.pendingAvatarId = pendingAvatarId; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateRepository.java new file mode 100644 index 00000000..4085d7e5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateRepository.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.skill; + +import java.util.List; + +public interface SkillAvatarCandidateRepository { + List findBySkillIdOrderByCreatedAtDesc(Long skillId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java index 6308f753..616385c8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java @@ -9,6 +9,8 @@ import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.security.SecurityScanService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidateRepository; import com.iflytek.skillhub.domain.skill.SkillFile; import com.iflytek.skillhub.domain.skill.SkillFileRepository; import com.iflytek.skillhub.domain.skill.SkillRepository; @@ -49,6 +51,7 @@ public class SkillHardDeleteService { private final SkillRatingRepository skillRatingRepository; private final SkillReportRepository skillReportRepository; private final SkillVersionStatsRepository skillVersionStatsRepository; + private final SkillAvatarCandidateRepository avatarCandidates; private final ObjectStorageService objectStorageService; private final SkillStorageDeletionCompensationService compensationService; private final SecurityScanService securityScanService; @@ -65,6 +68,7 @@ public class SkillHardDeleteService { SkillRatingRepository skillRatingRepository, SkillReportRepository skillReportRepository, SkillVersionStatsRepository skillVersionStatsRepository, + SkillAvatarCandidateRepository avatarCandidates, ObjectStorageService objectStorageService, SkillStorageDeletionCompensationService compensationService, SecurityScanService securityScanService, @@ -80,6 +84,7 @@ public class SkillHardDeleteService { this.skillRatingRepository = skillRatingRepository; this.skillReportRepository = skillReportRepository; this.skillVersionStatsRepository = skillVersionStatsRepository; + this.avatarCandidates = avatarCandidates; this.objectStorageService = objectStorageService; this.compensationService = compensationService; this.securityScanService = securityScanService; @@ -117,6 +122,10 @@ public class SkillHardDeleteService { .forEach(storageKeys::add); storageKeys.add(buildBundleStorageKey(skill.getId(), version.getId())); } + avatarCandidates.findBySkillIdOrderByCreatedAtDesc(skill.getId()).stream() + .map(SkillAvatarCandidate::getObjectKey) + .filter(key -> key != null && !key.isBlank()) + .forEach(storageKeys::add); deleteStorageAfterCommit(skill, namespaceSlug, storageKeys); skill.setLatestVersionId(null); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index ddea24d0..3f57af1e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -116,7 +116,9 @@ public class SkillQueryService { SkillLifecycleProjectionService.VersionProjection publishedVersion, SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion, String ownerPreviewReviewComment, - String resolutionMode + String resolutionMode, + Long activeAvatarId, + SkillVisibility activeAvatarAudience ) {} public record SkillVersionDetailDTO( @@ -249,7 +251,9 @@ public class SkillQueryService { publishedVersion, ownerPreviewVersion, ownerPreviewReviewComment, - projection.resolutionMode().name() + projection.resolutionMode().name(), + skill.getActiveAvatarId(), + skill.getActiveAvatarAudience() ); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java index c500570e..56241774 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java @@ -64,6 +64,8 @@ class SkillHardDeleteServiceTest { @Mock private SkillVersionStatsRepository skillVersionStatsRepository; @Mock + private com.iflytek.skillhub.domain.skill.SkillAvatarCandidateRepository avatarCandidates; + @Mock private ObjectStorageService objectStorageService; @Mock private SkillStorageDeletionCompensationService compensationService; @@ -94,6 +96,7 @@ class SkillHardDeleteServiceTest { skillRatingRepository, skillReportRepository, skillVersionStatsRepository, + avatarCandidates, objectStorageService, compensationService, securityScanService, diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java index 1b188c44..cfc6971f 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java @@ -1,12 +1,16 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidateRepository; import java.time.Instant; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -public interface SkillAvatarCandidateJpaRepository extends JpaRepository { +public interface SkillAvatarCandidateJpaRepository extends JpaRepository, SkillAvatarCandidateRepository { List findBySkillIdOrderByCreatedAtDesc(Long skillId); + List findByContentSha256AndMachineProviderAndMachineEnabledTrueAndManualEnabledAndMachineStatus( + String contentSha256, String machineProvider, boolean manualEnabled, + SkillAvatarCandidate.MachineStatus machineStatus); List findByStatusAndReviewStatusOrderByCreatedAtAsc( SkillAvatarCandidate.Status status, SkillAvatarCandidate.ReviewStatus reviewStatus); List findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(Instant before); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java index c9e623e2..a7cb8e40 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.skill.SkillStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -14,12 +15,16 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; +import jakarta.persistence.LockModeType; /** * Base Spring Data JPA repository for persisted skill aggregates and common skill queries. */ @Repository public interface SkillJpaRepository extends JpaRepository, SkillRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM Skill s WHERE s.id = :id") + Optional findLockedById(@Param("id") Long id); List findByIdIn(List ids); List findByNamespaceIdAndSlug(Long namespaceId, String slug); List findByNamespaceIdInAndSlugIn(List namespaceIds, List slugs); From 9c997f7ecaa7fa6aff9d2f8d4c5d6413f99e954b Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:55:47 +0800 Subject: [PATCH 05/47] feat(namespace): add one-time invitation links Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/skillhub/en/guide/namespace.md | 9 ++ docs/skillhub/guide/namespace.md | 9 ++ .../add-namespace-invite-links/.openspec.yaml | 2 + .../add-namespace-invite-links/design.md | 19 +++ .../add-namespace-invite-links/proposal.md | 30 +++++ .../specs/namespace-invitations/spec.md | 96 ++++++++++++++++ .../add-namespace-invite-links/tasks.md | 17 +++ .../portal/NamespaceInvitationController.java | 88 ++++++++++++++ .../dto/NamespaceInvitationCreateRequest.java | 3 + .../dto/NamespaceInvitationTokenRequest.java | 5 + .../service/AdminNamespaceAppService.java | 37 ++++-- .../NamespacePortalCommandAppService.java | 24 ++-- .../migration/V70__namespace_invitation.sql | 2 +- .../src/main/resources/messages.properties | 2 + .../src/main/resources/messages_ru.properties | 2 + .../src/main/resources/messages_zh.properties | 2 + .../NamespaceInvitationControllerTest.java | 52 +++++++++ .../NamespaceInvitationPostgresTest.java | 108 ++++++++++++++++++ .../service/AdminNamespaceAppServiceTest.java | 11 ++ .../NamespacePortalCommandAppServiceTest.java | 18 +-- .../auth/merge/AccountMergeService.java | 6 + .../auth/merge/AccountMergeServiceTest.java | 4 + .../GlobalNamespaceMembershipService.java | 14 ++- .../namespace/NamespaceGovernanceService.java | 18 ++- .../NamespaceInvitationRepository.java | 1 + .../namespace/NamespaceInvitationService.java | 32 +++++- .../namespace/NamespaceMemberService.java | 25 +++- .../domain/namespace/NamespaceRepository.java | 1 + .../domain/namespace/NamespaceService.java | 10 +- .../GlobalNamespaceMembershipServiceTest.java | 8 +- .../NamespaceGovernanceServiceTest.java | 17 ++- .../NamespaceInvitationServiceTest.java | 71 ++++++++++++ .../namespace/NamespaceMemberServiceTest.java | 5 + .../namespace/NamespaceServiceTest.java | 9 +- .../jpa/NamespaceInvitationJpaRepository.java | 4 + .../infra/jpa/NamespaceJpaRepository.java | 3 + web/src/api/client.ts | 31 +++++ web/src/app/router.tsx | 11 ++ .../namespace/invitation-controls.tsx | 66 +++++++++++ web/src/i18n/locales/en.json | 25 ++++ web/src/i18n/locales/ru.json | 25 ++++ web/src/i18n/locales/zh.json | 25 ++++ web/src/pages/dashboard/namespace-members.tsx | 2 + web/src/pages/join.tsx | 74 ++++++++++++ .../shared/lib/namespace-invitation.test.ts | 31 +++++ web/src/shared/lib/namespace-invitation.ts | 28 +++++ 46 files changed, 1028 insertions(+), 54 deletions(-) create mode 100644 openspec/changes/add-namespace-invite-links/.openspec.yaml create mode 100644 openspec/changes/add-namespace-invite-links/design.md create mode 100644 openspec/changes/add-namespace-invite-links/proposal.md create mode 100644 openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md create mode 100644 openspec/changes/add-namespace-invite-links/tasks.md create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationCreateRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationTokenRequest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java create mode 100644 web/src/features/namespace/invitation-controls.tsx create mode 100644 web/src/pages/join.tsx create mode 100644 web/src/shared/lib/namespace-invitation.test.ts create mode 100644 web/src/shared/lib/namespace-invitation.ts diff --git a/docs/skillhub/en/guide/namespace.md b/docs/skillhub/en/guide/namespace.md index b693cc6e..8c2f391a 100644 --- a/docs/skillhub/en/guide/namespace.md +++ b/docs/skillhub/en/guide/namespace.md @@ -73,6 +73,15 @@ Discover a security issue in the namespace and temporarily freeze all publish op 5. Select a role (Owner / Admin / Member) 6. Confirm to add +**Invite with a link**: + +1. An Owner or Admin opens the Members page of an ACTIVE team space and selects "Invite member". +2. Share the copied link securely. It expires after 24 hours and can be redeemed once by one person who is not already a member. +3. The recipient must first pass the usual SkillHub login and account access rules. The page shows the space, current account, and MEMBER permissions. Joining happens only after explicit confirmation. +4. "Revoke invitation" invalidates the link immediately. Creating a new link asks for confirmation before replacing a still usable one; existing members remain members. + +Anyone who obtains the link can use it after normal account admission. Do not post it in a public channel. The login return URL does not contain the invitation token; the current browser tab stores it temporarily until joining or terminal invalidation. + **Manage Permissions**: 1. Find the target user in the member list diff --git a/docs/skillhub/guide/namespace.md b/docs/skillhub/guide/namespace.md index 8af0d2ec..e26d7890 100644 --- a/docs/skillhub/guide/namespace.md +++ b/docs/skillhub/guide/namespace.md @@ -73,6 +73,15 @@ 5. 选择角色(Owner / Admin / Member) 6. 确认添加 +**通过链接邀请成员**: + +1. Owner 或 Admin 在处于 ACTIVE 状态的团队空间「成员」页点击「邀请成员」。 +2. 复制链接并安全地发给要加入的人。链接 24 小时内有效,只能由一名尚未加入的用户使用一次。 +3. 对方先按现有登录及账号准入规则进入 SkillHub。打开链接后会看到目标空间、当前账号和 MEMBER 权限;点击「确认加入」才会成为成员。 +4. 如需提前失效,点击「作废邀请」。重新生成链接时,页面会要求确认旧链接立即失效;已加入的成员不受影响。 + +邀请链接是持有者可用的加入凭证,不限定邮箱或账号。不要在公开频道分享。登录回跳不会携带邀请 Token;同一浏览器标签页会暂存链接直到确认、已加入或确认失效。 + **管理权限**: 1. 在成员列表中找到目标用户 diff --git a/openspec/changes/add-namespace-invite-links/.openspec.yaml b/openspec/changes/add-namespace-invite-links/.openspec.yaml new file mode 100644 index 00000000..b9ebfc72 --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/add-namespace-invite-links/design.md b/openspec/changes/add-namespace-invite-links/design.md new file mode 100644 index 00000000..f67f0a5c --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/design.md @@ -0,0 +1,19 @@ +## Context + +现有 Namespace 成员只能由管理员直接添加。`NamespaceMemberService`、平台管理服务、生命周期服务和账号合并均会修改同一 Namespace 的状态或成员,但目前没有共同的 Namespace 行锁。前端已有 Session/CSRF、登录 returnTo 与 BASE_PATH 逻辑;当前 returnTo helper 会保留 fragment,所以 `/join` 必须先移除 Token 再触发登录跳转。 + +## Decisions + +1. 每个 Namespace 只保存一条当前邀请:Token SHA-256、签发人和时间、过期时间、消费人和时间。原文只在生成响应中返回一次。作废删除当前邀请记录或等价地使其不可匹配,写审计;不删除已加入成员。 +2. 涉及用户的写操作先按 userId 升序锁参与者用户行,再按 Namespace ID 升序锁目标 Namespace 行,最后读取或写入邀请与成员;账号合并沿用先锁用户的现有顺序,以免与成员或审计外键检查形成锁环。邀请创建、替换、作废锁操作者后锁 Namespace;接受锁接收者后锁 Namespace。纯 Namespace 生命周期与删除操作锁 Namespace。批量操作预先收集 ID、去重并排序。接受在锁内重检,成员创建、消费和审计原子提交。 +3. 前端在 `/join` 入口同步读取 fragment,放入当前标签页 sessionStorage,保留 path/query 清除 fragment,再应用原登录逻辑。登录 `returnTo=/join` 不传 Token。分享链接复用 `resolvePublicRegistryUrl` 构造并兼容 BASE_PATH。 +4. Preview/Accept 复用 Issue 的判定顺序:先验证 Token 为当前邀请,再查已有成员;已有成员返回 ALREADY_MEMBER。非成员必须面对 ACTIVE TEAM、未消费且未过期的邀请。失效统一 404,不区分随机、旧、作废、过期或已用 Token。Accept 锁前只按 Token 哈希查询标量 Namespace ID;取得 Namespace 锁后重新加载当前邀请并校验,不能沿用锁前已经进入 ORM 一级缓存的邀请实体,否则并发兑换可能都看到未消费状态。 +5. 只有可用浏览器 Session 能创建、预览、接受、作废;现有账号准入不能由邀请绕过。浏览器请求使用既有 CSRF。创建/预览/接受/作废与缓存或日志配置均不得泄露原文和哈希。 +6. 管理页面保留原直接添加成员入口,新增邀请按钮和单独作废按钮。仍可兑换的旧链接替换前先确认;剪贴板失败时展示本次生成的同一链接。 + +## Risks and verification + +- 账号合并已先锁 UserAccount;邀请创建/替换/作废/接受、直接成员变更、平台成员管理及账号合并要按相同的用户后 Namespace 顺序加锁,避免与成员或审计外键检查形成环。 +- 并发兑换、直接添加与兑换、替换/作废与兑换、冻结/归档/删除与兑换均需事务级验证。 +- 根路径及子路径部署都要验证分享 URL、登录回跳和 fragment 清理。 +- API Token、跨团队管理员、平台角色和禁用账号的越权路径需明确拒绝。 diff --git a/openspec/changes/add-namespace-invite-links/proposal.md b/openspec/changes/add-namespace-invite-links/proposal.md new file mode 100644 index 00000000..5d8db8be --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/proposal.md @@ -0,0 +1,30 @@ +## Why + +Namespace 管理员目前只能逐个选择现有用户添加成员。短期、单次邀请链接能让成员自行加入,同时让管理员控制链接失效和成员权限边界。 + +## What Changes + +- ACTIVE TEAM 的 OWNER/ADMIN 可以生成一条 24 小时内有效、仅一名非成员可兑换的邀请链接;替换未使用链接前须确认。 +- 管理员可以单独作废当前邀请,不需要生成新链接;不影响已经加入的成员。 +- 持链接者先按现有注册、登录和账号准入策略获得可用账号,再看到空间、当前账号和 MEMBER 权限,明确确认后加入。 +- Token 不存明文、不进 URL path/query 或登录回跳参数;创建、预览、兑换、作废均受权限及事务约束。 +- 邀请兑换与成员及 Namespace 生命周期操作并发时保持一致,不出现重复成员或部分消费。 + +## Capabilities + +### New Capabilities + +- `namespace-invitations`: 邀请创建、替换、作废、预览、确认加入、安全与并发规则。 + +### Modified Capabilities + +无。 + +## Impact + +- Namespace 领域、成员管理、账号合并事务、数据库迁移、审计、Session/CSRF 接口和 OpenAPI 类型。 +- Namespace 管理页面、邀请页、登录回跳与子路径部署、多语言文案及浏览器验证。 + +## 相对 Issue #911 的调整 + +原 Issue 把独立撤销入口列为范围外;本方案增加“作废邀请”按钮和相应后端动作。邀请不绕过现有账号准入策略。 diff --git a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md new file mode 100644 index 00000000..5d8b25da --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md @@ -0,0 +1,96 @@ +## Purpose + +让 Namespace 管理员使用可控的短期单次链接邀请新成员。 + +## ADDED Requirements + +### Requirement: REQ-INV-01 管理员 SHALL 创建、替换及作废邀请 + +有权管理员 SHALL 可创建、替换及作废邀请。 + +只有 ACTIVE TEAM 的 OWNER/ADMIN 可以创建邀请;平台角色不能绕过成员角色限制。每个 Namespace 仅有一条当前邀请,24 小时内仅一名非成员可兑换一次。替换仍可用的邀请前须确认;管理员可以单独作废当前邀请,且已加入成员不受影响。 + +#### Scenario: 替换未使用链接 +- **WHEN** 管理员再次点击邀请且当前链接仍可兑换 +- **THEN** 页面先说明旧链接将失效并要求确认 +- **AND** 仅确认后生成新链接,旧链接立即失效 + +#### Scenario: 单独作废 +- **WHEN** 管理员点击作废并确认 +- **THEN** 当前链接立即失效,不会创建新链接或移除成员 + +### Requirement: REQ-INV-02 邀请 SHALL 服从现有账号准入 + +邀请 SHALL 服从现有账号准入规则。 + +邀请只授予目标 Namespace 的 MEMBER 权限,不使用户绕过现有注册、登录、外部身份准入和账号审批。接受者必须先拥有可用登录会话,看到目标 Namespace、当前账号和 MEMBER 可访问及发布资源的说明,再明确确认加入。页面加载不得自动兑换。 + +#### Scenario: 新用户未通过准入 +- **WHEN** 持链接者无法按既有策略获得可用账号 +- **THEN** 邀请不能使其进入目标 Namespace + +#### Scenario: 已登录非成员 +- **WHEN** 非成员打开有效链接 +- **THEN** 页面展示 Namespace、账号和权限,只有点击确认才新增 MEMBER + +### Requirement: REQ-INV-03 Token SHALL 避免意外泄露 + +Token SHALL 避免出现在日志、持久化明文和登录 URL 中。 + +Token 使用安全随机数,数据库仅保存哈希;原文只在生成响应中出现一次。分享 URL 使用 `/join#token`,页面立即移入当前标签页 sessionStorage 并清除 fragment;登录回跳只包含应用内 `/join`,不带 Token。创建、预览、兑换、作废响应禁止缓存;日志和审计不得包含原文或哈希。四类操作仅允许浏览器 Session,变更请求遵守 CSRF。 +Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码;数据库仅保存 SHA-256 哈希。 + +#### Scenario: 经过登录回跳 +- **WHEN** 未登录者打开邀请后登录 +- **THEN** Token 不出现在登录回跳 URL,回到原标签页后才从 sessionStorage 恢复邀请 + +#### Scenario: 子路径部署 +- **WHEN** 应用部署在 `/skillhub/` +- **THEN** 分享链接指向公开基地址下 `/skillhub/join#token`,应用内 returnTo 仍为 `/join` + +### Requirement: REQ-INV-04 服务端 SHALL 在接受时重新判断状态 + +服务端 SHALL 在接受邀请时重新判断全部状态。 + +预览不消费邀请。接受时重新检查 Token 是否仍为当前、用户是否已有成员关系、Namespace 是否 ACTIVE TEAM、邀请是否未消费且未过期。Token 匹配当前邀请且用户已是成员时返回 ALREADY_MEMBER,不再检查过期、消费或 Namespace 冻结归档状态,也不消费。未知、过期、替换、作废或已用 Token 对非成员返回统一失效响应。冻结或归档时非成员不能加入也不消费,恢复后仅在原有效期内可兑换。 +统一失效响应使用 HTTP 404、envelope `code=404` 与 `data=null`;前端只按状态处理,不解析本地化文案。 + +#### Scenario: 预览后链接被替换 +- **WHEN** 用户看到预览后管理员替换或作废了链接 +- **THEN** 接受请求失败且不新增成员 + +#### Scenario: 已有成员打开当前链接 +- **WHEN** Token 仍是当前邀请且当前用户已经是成员 +- **THEN** 返回 ALREADY_MEMBER,直接进入 Namespace,不消耗邀请 + +#### Scenario: 被移除者重用已消费链接 +- **WHEN** 曾兑换的成员后来被移除 +- **THEN** 原链接仍保持已消费,不能再次加入 + +### Requirement: REQ-INV-05 并发操作 SHALL 保持成员和邀请原子一致 + +并发操作 SHALL 保持成员和邀请原子一致。 + +邀请创建、替换、作废和接受与成员添加/删除/转交、Namespace 冻结/归档/恢复/删除、账号合并在相同 Namespace 上须串行化。涉及用户和 Namespace 的写操作先按用户 ID 升序、再按 Namespace ID 升序加锁;创建/替换/作废锁操作者,接受锁接收者;纯 Namespace 生命周期与删除只锁 Namespace。接受时成员创建、邀请消费和审计同一事务完成;并发兑换最多一名非成员成功,不能产生“已消费但无成员”。批量涉及多个 Namespace 的操作按去重 ID 升序锁定。 + +#### Scenario: 两人同时兑换 +- **WHEN** 两个非成员同时确认同一链接 +- **THEN** 最多一人新增 MEMBER,另一人收到统一失效结果 + +#### Scenario: 管理员直接添加与邀请并发 +- **WHEN** 管理员直接添加同一用户和该用户兑换邀请同时发生 +- **THEN** 不出现未处理的唯一约束异常、重复成员或错误消费 + +### Requirement: REQ-INV-06 前端 SHALL 完成链接分享和状态闭环 + +前端 SHALL 完成链接分享、登录回跳和邀请状态闭环。 + +管理页提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。 + +#### Scenario: 剪贴板写入失败 +- **WHEN** 创建成功但浏览器拒绝写剪贴板 +- **THEN** 页面显示同一链接供手动复制,不再次生成邀请 + +#### Scenario: 网络错误 +- **WHEN** 预览或接受遇到可重试网络错误 +- **THEN** 页面保留 Token,用户可重试而无需新邀请 diff --git a/openspec/changes/add-namespace-invite-links/tasks.md b/openspec/changes/add-namespace-invite-links/tasks.md new file mode 100644 index 00000000..ebefc1b5 --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/tasks.md @@ -0,0 +1,17 @@ +## 1. 持久化与事务 + +- [ ] 1.1 增加当前邀请表、Token 哈希、时效和审计迁移。 +- [ ] 1.2 实现 Namespace 根锁,并让成员、生命周期、删除和账号合并路径遵循统一锁顺序。 +- [ ] 1.3 实现创建/替换/作废/预览/接受,接受时原子新增成员、消费邀请与写审计。 + +## 2. API 与页面 + +- [ ] 2.1 新增仅 Session 可调用且受 CSRF 保护的邀请接口,重新生成 OpenAPI 类型。 +- [ ] 2.2 在成员管理入口添加邀请和作废动作,替换前确认,复制失败展示原链接。 +- [ ] 2.3 实现 `/join` 的 fragment 捕获、同标签页登录回跳、确认加入和失效状态;覆盖 BASE_PATH 与三语文案。 + +## 3. 验证与交付 + +- [ ] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。 +- [ ] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。 +- [ ] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java new file mode 100644 index 00000000..aa07b4af --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java @@ -0,0 +1,88 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.MessageResponse; +import com.iflytek.skillhub.dto.NamespaceInvitationTokenRequest; +import com.iflytek.skillhub.dto.NamespaceInvitationCreateRequest; +import com.iflytek.skillhub.exception.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping({"/api/v1", "/api/web"}) +public class NamespaceInvitationController extends BaseApiController { + private final NamespaceInvitationService invitations; + + public NamespaceInvitationController(NamespaceInvitationService invitations, ApiResponseFactory responseFactory) { + super(responseFactory); + this.invitations = invitations; + } + + @GetMapping("/namespaces/{slug}/invitation") + public ApiResponse status(@PathVariable String slug, @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + return ok("response.success.read", new InvitationStatus(invitations.hasActiveInvitation(slug, principal.userId()))); + } + + @PostMapping("/namespaces/{slug}/invitation") + public ApiResponse create(@PathVariable String slug, + @RequestBody(required = false) NamespaceInvitationCreateRequest body, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + return ok("response.success.created", invitations.create(slug, principal.userId(), body != null && body.replace())); + } + + @DeleteMapping("/namespaces/{slug}/invitation") + public ApiResponse revoke(@PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + invitations.revoke(slug, principal.userId()); + return ok("response.success.deleted", new MessageResponse("Invitation revoked")); + } + + @PostMapping("/invitations/preview") + public ApiResponse preview(@Valid @RequestBody NamespaceInvitationTokenRequest body, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + return ok("response.success.read", invitations.preview(body.token(), principal.userId())); + } + + @PostMapping("/invitations/accept") + public ApiResponse accept(@Valid @RequestBody NamespaceInvitationTokenRequest body, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + return ok("response.success.updated", invitations.accept(body.token(), principal.userId())); + } + + private void requireSession(PlatformPrincipal principal, HttpServletRequest request) { + if (principal == null || "api_token".equals(principal.oauthProvider()) || request.getSession(false) == null) { + throw new UnauthorizedException("error.auth.required"); + } + } + + private void noStore(HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store, private"); + response.setHeader("Pragma", "no-cache"); + } + + public record InvitationStatus(boolean active) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationCreateRequest.java new file mode 100644 index 00000000..ed247002 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationCreateRequest.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.dto; + +public record NamespaceInvitationCreateRequest(boolean replace) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationTokenRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationTokenRequest.java new file mode 100644 index 00000000..3f87ff6f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationTokenRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record NamespaceInvitationTokenRequest(@NotBlank String token) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java index e67cd87f..1286096d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; @@ -48,6 +49,7 @@ public class AdminNamespaceAppService { private final NamespaceGovernanceService namespaceGovernanceService; private final NamespaceMemberService namespaceMemberService; private final NamespaceMemberRepository namespaceMemberRepository; + private final NamespaceRepository namespaceRepository; private final NamespaceMemberCandidateService namespaceMemberCandidateService; private final NamespaceAccessPolicy namespaceAccessPolicy; private final UserAccountRepository userAccountRepository; @@ -57,6 +59,7 @@ public class AdminNamespaceAppService { NamespaceGovernanceService namespaceGovernanceService, NamespaceMemberService namespaceMemberService, NamespaceMemberRepository namespaceMemberRepository, + NamespaceRepository namespaceRepository, NamespaceMemberCandidateService namespaceMemberCandidateService, NamespaceAccessPolicy namespaceAccessPolicy, UserAccountRepository userAccountRepository) { @@ -65,6 +68,7 @@ public class AdminNamespaceAppService { this.namespaceGovernanceService = namespaceGovernanceService; this.namespaceMemberService = namespaceMemberService; this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceRepository = namespaceRepository; this.namespaceMemberCandidateService = namespaceMemberCandidateService; this.namespaceAccessPolicy = namespaceAccessPolicy; this.userAccountRepository = userAccountRepository; @@ -139,7 +143,7 @@ public class AdminNamespaceAppService { @Transactional public MemberResponse addMember(String slug, MemberRequest request, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Namespace namespace = lockedMutableTeamNamespace(slug, List.of(request.userId())); if (request.role() == NamespaceRole.OWNER) { throw new DomainBadRequestException("error.namespace.member.owner.assignDirect"); } @@ -152,7 +156,7 @@ public class AdminNamespaceAppService { @Transactional public BatchMemberResponse batchAddMembers(String slug, BatchMemberRequest request, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Namespace namespace = lockedMutableTeamNamespace(slug, request.members().stream().map(MemberRequest::userId).toList()); List results = new ArrayList<>(); int successCount = 0; int failureCount = 0; @@ -178,7 +182,7 @@ public class AdminNamespaceAppService { @Transactional public MemberResponse updateMemberRole(String slug, String userId, UpdateMemberRoleRequest request, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Namespace namespace = lockedMutableTeamNamespace(slug, List.of(userId)); if (request.role() == NamespaceRole.OWNER) { throw new DomainBadRequestException("error.namespace.member.owner.setDirect"); } @@ -194,7 +198,7 @@ public class AdminNamespaceAppService { @Transactional public MessageResponse removeMember(String slug, String userId, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Namespace namespace = lockedMutableTeamNamespace(slug, List.of(userId)); NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), userId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.member.notFound")); if (member.getRole() == NamespaceRole.OWNER) { @@ -206,7 +210,12 @@ public class AdminNamespaceAppService { @Transactional public MessageResponse transferOwnership(String slug, String newOwnerId, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Long namespaceId = namespaceRepository.findIdBySlug(slug) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + String oldOwnerId = namespaceMemberRepository.findByNamespaceIdAndRoleIn(namespaceId, List.of(NamespaceRole.OWNER)) + .stream().findFirst().map(NamespaceMember::getUserId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.current.notFound")); + Namespace namespace = lockedMutableTeamNamespace(slug, List.of(oldOwnerId, newOwnerId)); NamespaceMember currentOwner = namespaceMemberRepository.findByNamespaceIdAndRoleIn(namespace.getId(), List.of(NamespaceRole.OWNER)) .stream() .findFirst() @@ -295,15 +304,19 @@ public class AdminNamespaceAppService { .collect(Collectors.toMap(NamespaceMember::getNamespaceId, NamespaceMember::getRole)); } - private Namespace requireMutableTeamNamespace(String slug) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - if (namespaceAccessPolicy.isImmutable(namespace)) { - throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug()); + private Namespace lockedMutableTeamNamespace(String slug, List userIds) { + Long namespaceId = namespaceRepository.findIdBySlug(slug) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + userIds.stream().distinct().sorted().forEach(userAccountRepository::findLockedById); + Namespace locked = namespaceRepository.findLockedById(namespaceId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + if (namespaceAccessPolicy.isImmutable(locked)) { + throw new DomainBadRequestException("error.namespace.system.immutable", slug); } - if (!namespaceAccessPolicy.canManageMembers(namespace)) { - throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + if (!namespaceAccessPolicy.canManageMembers(locked)) { + throw new DomainBadRequestException("error.namespace.readonly", slug); } - return namespace; + return locked; } private NamespaceStatus parseStatus(String status) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java index 4bf8af91..b5ef6468 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java @@ -84,8 +84,7 @@ public class NamespacePortalCommandAppService { @Transactional public MessageResponse deleteNamespace(String slug, String userId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - namespaceService.deleteNamespace(namespace.getId(), userId); + namespaceService.deleteNamespace(namespaceId(slug), userId); return new MessageResponse("Namespace deleted successfully"); } @@ -147,9 +146,8 @@ public class NamespacePortalCommandAppService { @Transactional public MemberResponse addMember(String slug, String memberUserId, com.iflytek.skillhub.domain.namespace.NamespaceRole role, String operatorUserId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); NamespaceMember member = namespaceMemberService.addMember( - namespace.getId(), + namespaceId(slug), memberUserId, role, operatorUserId @@ -161,8 +159,7 @@ public class NamespacePortalCommandAppService { // Intentionally not @Transactional: each addMember runs in its own transaction // so partial success is possible (some members added even if others fail). public BatchMemberResponse batchAddMembers(String slug, List members, String operatorUserId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - Long namespaceId = namespace.getId(); + Long namespaceId = namespaceId(slug); List results = new ArrayList<>(); int successCount = 0; @@ -195,8 +192,7 @@ public class NamespacePortalCommandAppService { @Transactional public MessageResponse removeMember(String slug, String memberUserId, String operatorUserId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - namespaceMemberService.removeMember(namespace.getId(), memberUserId, operatorUserId); + namespaceMemberService.removeMember(namespaceId(slug), memberUserId, operatorUserId); return new MessageResponse("Member removed successfully"); } @@ -205,9 +201,8 @@ public class NamespacePortalCommandAppService { String userId, UpdateMemberRoleRequest request, String operatorUserId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); NamespaceMember member = namespaceMemberService.updateMemberRole( - namespace.getId(), + namespaceId(slug), userId, request.role(), operatorUserId @@ -217,8 +212,7 @@ public class NamespacePortalCommandAppService { @Transactional public MessageResponse transferOwnership(String slug, String newOwnerId, String currentOwnerId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - namespaceMemberService.transferOwnership(namespace.getId(), currentOwnerId, newOwnerId); + namespaceMemberService.transferOwnership(namespaceId(slug), currentOwnerId, newOwnerId); return new MessageResponse("Ownership transferred successfully"); } @@ -226,4 +220,10 @@ public class NamespacePortalCommandAppService { return principal.platformRoles().contains("SKILL_ADMIN") || principal.platformRoles().contains("SUPER_ADMIN"); } + + private Long namespaceId(String slug) { + return namespaceRepository.findIdBySlug(slug) + .orElseThrow(() -> new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException( + "error.namespace.slug.notFound", slug)); + } } diff --git a/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql b/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql index 3b6ac7a4..ed42c0be 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql @@ -1,6 +1,6 @@ CREATE TABLE namespace_invitation ( namespace_id BIGINT PRIMARY KEY REFERENCES namespace(id) ON DELETE CASCADE, - token_hash CHAR(64) NOT NULL UNIQUE, + token_hash VARCHAR(64) NOT NULL UNIQUE, issued_by VARCHAR(128) NOT NULL, issued_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL, diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 24bf9324..b7555086 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -88,6 +88,8 @@ label.translation.locale.conflict=Duplicate label translation locale error.namespace.slug.exists=Namespace slug ''{0}'' already exists error.namespace.id.notFound=Namespace not found: {0} error.namespace.slug.notFound=Namespace not found: {0} +error.namespace.invite.invalid=Invitation not found or expired +error.namespace.invite.replaceConfirmationRequired=Confirm replacement of the current invitation error.namespace.membership.required=Namespace membership required error.namespace.global.members.platformAdmin.required=Only platform user administrators can list global namespace members error.namespace.admin.required=Namespace owner or admin role required diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 566d443e..496c2c31 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -85,6 +85,8 @@ label.translation.locale.conflict=Дублирующаяся локаль пер error.namespace.slug.exists=Slug пространства имён ''{0}'' уже существует error.namespace.id.notFound=Пространство имён не найдено: {0} error.namespace.slug.notFound=Пространство имён не найдено: {0} +error.namespace.invite.invalid=Приглашение не найдено или недействительно +error.namespace.invite.replaceConfirmationRequired=Подтвердите замену текущего приглашения error.namespace.membership.required=Требуется членство в пространстве имён error.namespace.global.members.platformAdmin.required=Только администраторы пользователей платформы могут просматривать участников глобального пространства имён error.namespace.admin.required=Требуется роль владельца или администратора пространства имён diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 90bad54b..046a459b 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -88,6 +88,8 @@ label.translation.locale.conflict=标签翻译 locale 重复 error.namespace.slug.exists=命名空间 slug ''{0}'' 已存在 error.namespace.id.notFound=未找到命名空间:{0} error.namespace.slug.notFound=未找到命名空间:{0} +error.namespace.invite.invalid=邀请不存在或已失效 +error.namespace.invite.replaceConfirmationRequired=请先确认替换当前邀请 error.namespace.membership.required=需要先加入该命名空间 error.namespace.global.members.platformAdmin.required=只有平台用户管理员可以查看 global 命名空间成员 error.namespace.admin.required=需要命名空间管理员或所有者权限 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java new file mode 100644 index 00000000..8ff1e84a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java @@ -0,0 +1,52 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.portal.NamespaceInvitationController; +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.NamespaceInvitationCreateRequest; +import com.iflytek.skillhub.exception.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +class NamespaceInvitationControllerTest { + private final NamespaceInvitationService invitations = mock(NamespaceInvitationService.class); + private final ApiResponseFactory responses = mock(ApiResponseFactory.class); + private final NamespaceInvitationController controller = new NamespaceInvitationController(invitations, responses); + private final HttpServletRequest request = mock(HttpServletRequest.class); + private final HttpServletResponse response = mock(HttpServletResponse.class); + + @Test + void refusesApiTokenEvenWhenItCarriesAnAdminPlatformRole() { + when(request.getSession(false)).thenReturn(mock(HttpSession.class)); + PlatformPrincipal token = new PlatformPrincipal("admin", "Admin", null, null, "api_token", Set.of("SUPER_ADMIN")); + assertThatThrownBy(() -> controller.create("team", new NamespaceInvitationCreateRequest(true), token, request, response)) + .isInstanceOf(UnauthorizedException.class); + verifyNoInteractions(invitations); + } + + @Test + void refusesAuthenticationWithoutAnExistingBrowserSession() { + PlatformPrincipal principal = new PlatformPrincipal("owner", "Owner", null, null, "local", Set.of()); + assertThatThrownBy(() -> controller.preview(new com.iflytek.skillhub.dto.NamespaceInvitationTokenRequest("secret"), + principal, request, response)).isInstanceOf(UnauthorizedException.class); + verifyNoInteractions(invitations); + } + + @Test + void sendsNoStoreHeadersBeforeReturningAnInvitation() { + when(request.getSession(false)).thenReturn(mock(HttpSession.class)); + PlatformPrincipal principal = new PlatformPrincipal("owner", "Owner", null, null, "local", Set.of()); + controller.create("team", new NamespaceInvitationCreateRequest(false), principal, request, response); + verify(response).setHeader("Cache-Control", "no-store, private"); + verify(response).setHeader("Pragma", "no-cache"); + verify(invitations).create("team", "owner", false); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java new file mode 100644 index 00000000..6fdbc8d4 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java @@ -0,0 +1,108 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.infra.jpa.NamespaceInvitationJpaRepository; +import com.iflytek.skillhub.infra.jpa.NamespaceJpaRepository; +import com.iflytek.skillhub.infra.jpa.NamespaceMemberJpaRepository; +import com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository; +import java.time.Clock; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Import({NamespaceInvitationService.class, AuditLogService.class, NamespaceInvitationPostgresTest.ClockConfig.class}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class NamespaceInvitationPostgresTest { + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void postgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + + @TestConfiguration + static class ClockConfig { + @Bean Clock clock() { return Clock.systemUTC(); } + } + + @Autowired private NamespaceInvitationService invitations; + @Autowired private NamespaceInvitationJpaRepository invitationRows; + @Autowired private NamespaceJpaRepository namespaces; + @Autowired private NamespaceMemberJpaRepository members; + @Autowired private UserAccountJpaRepository users; + @Autowired private PlatformTransactionManager transactionManager; + + @Test + void twoConcurrentAcceptsCreateOnlyOneMemberAndOneConsumption() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String first = "inv-a-" + suffix; + String second = "inv-b-" + suffix; + Long namespaceId = new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(first, first, null, null)); + users.saveAndFlush(new UserAccount(second, second, null, null)); + Namespace namespace = namespaces.saveAndFlush(new Namespace("inv-" + suffix, "Invitation test", owner)); + members.saveAndFlush(new NamespaceMember(namespace.getId(), owner, NamespaceRole.OWNER)); + return namespace.getId(); + }); + String token = invitations.create("inv-" + suffix, owner).token(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var one = pool.submit(() -> attempt(start, token, first)); + var two = pool.submit(() -> attempt(start, token, second)); + start.countDown(); + assertThat(one.get(20, TimeUnit.SECONDS) + two.get(20, TimeUnit.SECONDS)).isEqualTo(1); + } + assertThat(members.findByNamespaceId(namespaceId, org.springframework.data.domain.Pageable.unpaged()) + .getContent().stream().filter(member -> member.getRole() == NamespaceRole.MEMBER)).hasSize(1); + assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedBy()).isIn(first, second); + } + + private int attempt(CountDownLatch start, String token, String userId) throws InterruptedException { + start.await(); + try { + invitations.accept(token, userId); + return 1; + } catch (DomainNotFoundException expected) { + return 0; + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java index 149f885d..16012dba 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; @@ -40,6 +41,7 @@ class AdminNamespaceAppServiceTest { private final NamespaceGovernanceService namespaceGovernanceService = mock(NamespaceGovernanceService.class); private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class); private final NamespaceMemberRepository namespaceMemberRepository = mock(NamespaceMemberRepository.class); + private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); private final NamespaceMemberCandidateService namespaceMemberCandidateService = mock(NamespaceMemberCandidateService.class); private final NamespaceAccessPolicy namespaceAccessPolicy = mock(NamespaceAccessPolicy.class); private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); @@ -49,6 +51,7 @@ class AdminNamespaceAppServiceTest { namespaceGovernanceService, namespaceMemberService, namespaceMemberRepository, + namespaceRepository, namespaceMemberCandidateService, namespaceAccessPolicy, userAccountRepository @@ -84,6 +87,8 @@ class AdminNamespaceAppServiceTest { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); NamespaceMember saved = member(10L, 1L, "user-2", NamespaceRole.ADMIN); when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")).thenReturn(Optional.empty()); @@ -100,6 +105,8 @@ class AdminNamespaceAppServiceTest { void updateMemberRoleRejectsDirectOwnerAssignment() { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); @@ -118,6 +125,8 @@ class AdminNamespaceAppServiceTest { NamespaceMember currentOwner = member(10L, 1L, "owner-1", NamespaceRole.OWNER); NamespaceMember newOwner = member(11L, 1L, "admin-1", NamespaceRole.ADMIN); when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndRoleIn(1L, List.of(NamespaceRole.OWNER))) @@ -138,6 +147,8 @@ class AdminNamespaceAppServiceTest { void globalNamespaceCannotBeMutatedFromAdminMemberApi() { Namespace global = namespace(1L, "global", NamespaceStatus.ACTIVE, NamespaceType.GLOBAL); when(namespaceService.getNamespaceBySlug("global")).thenReturn(global); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(global)); + when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L)); when(namespaceAccessPolicy.isImmutable(global)).thenReturn(true); assertThatThrownBy(() -> service.addMember( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java index 1f4ad1ac..b069b1b0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java @@ -80,7 +80,7 @@ class NamespacePortalCommandAppServiceTest { @Test void deleteNamespace_delegatesToDomainService() { Namespace namespace = namespace(7L, "team-alpha"); - when(namespaceService.getNamespaceBySlug("team-alpha")).thenReturn(namespace); + when(namespaceRepository.findIdBySlug("team-alpha")).thenReturn(Optional.of(7L)); MessageResponse response = service.deleteNamespace("team-alpha", "owner-1"); @@ -102,7 +102,7 @@ class NamespacePortalCommandAppServiceTest { ReflectionTestUtils.setField(member, "id", 10L); UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberService.addMember(1L, "user-2", NamespaceRole.ADMIN, "owner-1")) .thenReturn(member); when(userAccountRepository.findById("user-2")) @@ -122,7 +122,7 @@ class NamespacePortalCommandAppServiceTest { NamespaceMember member = new NamespaceMember(1L, "ghost", NamespaceRole.MEMBER); ReflectionTestUtils.setField(member, "id", 20L); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberService.addMember(1L, "ghost", NamespaceRole.MEMBER, "owner-1")) .thenReturn(member); when(userAccountRepository.findById("ghost")) @@ -142,7 +142,7 @@ class NamespacePortalCommandAppServiceTest { ReflectionTestUtils.setField(member, "id", 10L); UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberService.updateMemberRole(1L, "user-2", NamespaceRole.OWNER, "owner-1")) .thenReturn(member); when(userAccountRepository.findById("user-2")) @@ -166,7 +166,7 @@ class NamespacePortalCommandAppServiceTest { NamespaceMember member = new NamespaceMember(1L, "ghost", NamespaceRole.MEMBER); ReflectionTestUtils.setField(member, "id", 20L); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberService.updateMemberRole(1L, "ghost", NamespaceRole.ADMIN, "owner-1")) .thenReturn(member); when(userAccountRepository.findById("ghost")) @@ -186,7 +186,7 @@ class NamespacePortalCommandAppServiceTest { @Test void transferOwnership_success() { Namespace ns = namespace(1L, "team-a"); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); MessageResponse result = service.transferOwnership("team-a", "new-owner", "current-owner"); @@ -197,7 +197,7 @@ class NamespacePortalCommandAppServiceTest { @Test void transferOwnership_nonOwnerFails() { Namespace ns = namespace(1L, "team-a"); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); doThrow(new DomainBadRequestException("error.namespace.owner.current.invalid")) .when(namespaceMemberService).transferOwnership(1L, "non-owner", "new-owner"); @@ -208,7 +208,7 @@ class NamespacePortalCommandAppServiceTest { @Test void transferOwnership_targetNotFoundFails() { Namespace ns = namespace(1L, "team-a"); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); doThrow(new DomainBadRequestException("error.namespace.owner.new.notFound")) .when(namespaceMemberService).transferOwnership(1L, "owner-1", "non-existent"); @@ -220,7 +220,7 @@ class NamespacePortalCommandAppServiceTest { void transferOwnership_frozenNamespaceFails() { Namespace ns = namespace(1L, "team-a"); ns.setStatus(NamespaceStatus.FROZEN); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); doThrow(new DomainBadRequestException("error.namespace.readonly", "team-a")) .when(namespaceMemberService).transferOwnership(1L, "owner-1", "new-owner"); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java index b5de4dbe..ecfd3794 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java @@ -12,6 +12,7 @@ import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -49,6 +50,7 @@ public class AccountMergeService { private final UserRoleBindingRepository userRoleBindingRepository; private final ApiTokenRepository apiTokenRepository; private final NamespaceMemberRepository namespaceMemberRepository; + private final NamespaceRepository namespaceRepository; private final Clock clock; public AccountMergeService(AccountMergeRequestRepository mergeRequestRepository, @@ -58,6 +60,7 @@ public class AccountMergeService { UserRoleBindingRepository userRoleBindingRepository, ApiTokenRepository apiTokenRepository, NamespaceMemberRepository namespaceMemberRepository, + NamespaceRepository namespaceRepository, Clock clock) { this.mergeRequestRepository = mergeRequestRepository; this.userAccountRepository = userAccountRepository; @@ -66,6 +69,7 @@ public class AccountMergeService { this.userRoleBindingRepository = userRoleBindingRepository; this.apiTokenRepository = apiTokenRepository; this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceRepository = namespaceRepository; this.clock = clock; } @@ -287,6 +291,8 @@ public class AccountMergeService { private void migrateNamespaceMemberships(String primaryUserId, String secondaryUserId) { List secondaryMemberships = namespaceMemberRepository.findByUserId(secondaryUserId); + secondaryMemberships.stream().map(NamespaceMember::getNamespaceId).distinct().sorted() + .forEach(namespaceRepository::findLockedById); for (NamespaceMember secondaryMembership : secondaryMemberships) { Optional existingPrimaryMembership = namespaceMemberRepository .findByNamespaceIdAndUserId(secondaryMembership.getNamespaceId(), primaryUserId); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java index 08a6d480..49d678b5 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java @@ -20,6 +20,7 @@ import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -52,6 +53,8 @@ class AccountMergeServiceTest { private ApiTokenRepository apiTokenRepository; @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private NamespaceRepository namespaceRepository; private AccountMergeService service; private Clock clock; @@ -66,6 +69,7 @@ class AccountMergeServiceTest { userRoleBindingRepository, apiTokenRepository, namespaceMemberRepository, + namespaceRepository, clock ); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java index 1ef7d247..5b251a91 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.domain.namespace; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,21 +14,26 @@ public class GlobalNamespaceMembershipService { private final NamespaceRepository namespaceRepository; private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; public GlobalNamespaceMembershipService(NamespaceRepository namespaceRepository, - NamespaceMemberRepository namespaceMemberRepository) { + NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; } @Transactional public void ensureMember(String userId) { - Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE_SLUG) + userAccountRepository.findLockedById(userId); + Long namespaceId = namespaceRepository.findIdBySlug(GLOBAL_NAMESPACE_SLUG) .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); + namespaceRepository.findLockedById(namespaceId); - namespaceMemberRepository.findByNamespaceIdAndUserId(globalNamespace.getId(), userId) + namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) .orElseGet(() -> namespaceMemberRepository.save( - new NamespaceMember(globalNamespace.getId(), userId, NamespaceRole.MEMBER) + new NamespaceMember(namespaceId, userId, NamespaceRole.MEMBER) )); } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java index 984fe4fc..4e0072fd 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.audit.AuditDetail; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,15 +19,18 @@ public class NamespaceGovernanceService { private final NamespaceMemberRepository namespaceMemberRepository; private final NamespaceAccessPolicy namespaceAccessPolicy; private final AuditLogService auditLogService; + private final UserAccountRepository userAccountRepository; public NamespaceGovernanceService(NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, NamespaceAccessPolicy namespaceAccessPolicy, - AuditLogService auditLogService) { + AuditLogService auditLogService, + UserAccountRepository userAccountRepository) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.namespaceAccessPolicy = namespaceAccessPolicy; this.auditLogService = auditLogService; + this.userAccountRepository = userAccountRepository; } @Transactional @@ -36,6 +40,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); NamespaceRole role = requireRole(namespace.getId(), actorUserId); if (namespace.getStatus() != NamespaceStatus.ACTIVE) { @@ -57,6 +62,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); if (namespace.getStatus() != NamespaceStatus.ACTIVE) { throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); @@ -73,6 +79,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); NamespaceRole role = requireRole(namespace.getId(), actorUserId); if (namespace.getStatus() != NamespaceStatus.FROZEN) { @@ -93,6 +100,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); if (namespace.getStatus() != NamespaceStatus.FROZEN) { throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); @@ -110,6 +118,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); NamespaceRole role = requireRole(namespace.getId(), actorUserId); if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { @@ -131,6 +140,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); @@ -147,6 +157,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); NamespaceRole role = requireRole(namespace.getId(), actorUserId); if (namespace.getStatus() != NamespaceStatus.ARCHIVED) { @@ -167,6 +178,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); if (namespace.getStatus() != NamespaceStatus.ARCHIVED) { throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); @@ -178,7 +190,9 @@ public class NamespaceGovernanceService { } private Namespace loadNamespaceBySlug(String slug) { - Namespace namespace = namespaceRepository.findBySlug(slug) + Long namespaceId = namespaceRepository.findIdBySlug(slug) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + Namespace namespace = namespaceRepository.findLockedById(namespaceId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); if (namespaceAccessPolicy.isImmutable(namespace)) { throw new DomainBadRequestException("error.namespace.system.immutable", slug); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java index cf54afb3..36e0f99b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java @@ -5,6 +5,7 @@ import java.util.Optional; public interface NamespaceInvitationRepository { Optional findByNamespaceId(Long namespaceId); Optional findByTokenHash(String tokenHash); + Optional findNamespaceIdByTokenHash(String tokenHash); NamespaceInvitation save(NamespaceInvitation invitation); void deleteByNamespaceId(Long namespaceId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java index 877b94eb..0706f121 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.namespace; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; import org.springframework.stereotype.Service; @@ -42,6 +43,12 @@ public class NamespaceInvitationService { @Transactional public Created create(String slug, String actorId) { + return create(slug, actorId, false); + } + + @Transactional + public Created create(String slug, String actorId, boolean replace) { + requireActiveLockedUser(actorId); Namespace namespace = lock(slug); requireManager(namespace, actorId); requireActiveTeam(namespace); @@ -51,6 +58,10 @@ public class NamespaceInvitationService { Instant now = clock.instant(); NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElse(null); boolean replaced = invitation != null; + if (invitation != null && invitation.getConsumedAt() == null + && invitation.getExpiresAt().isAfter(now) && !replace) { + throw new DomainConflictException("error.namespace.invite.replaceConfirmationRequired"); + } if (invitation == null) { invitation = new NamespaceInvitation(namespace.getId(), hash(token), actorId, now, now.plusSeconds(86400)); } else { @@ -63,6 +74,7 @@ public class NamespaceInvitationService { @Transactional public void revoke(String slug, String actorId) { + requireActiveLockedUser(actorId); Namespace namespace = lock(slug); requireManager(namespace, actorId); requireActiveTeam(namespace); @@ -97,8 +109,11 @@ public class NamespaceInvitationService { @Transactional public Preview accept(String token, String userId) { - NamespaceInvitation located = current(token); - Namespace namespace = namespaces.findLockedById(located.getNamespaceId()).orElseThrow(this::invalid); + // Membership writers and account merge lock users before the Namespace root. + // The FK check on namespace_member would otherwise invert that order. + requireActiveLockedUser(userId); + Long namespaceId = locateNamespaceId(token); + Namespace namespace = namespaces.findLockedById(namespaceId).orElseThrow(this::invalid); NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElseThrow(this::invalid); if (!MessageDigest.isEqual(invitation.getTokenHash().getBytes(StandardCharsets.US_ASCII), hash(token).getBytes(StandardCharsets.US_ASCII))) { @@ -117,8 +132,8 @@ public class NamespaceInvitationService { } private Namespace lock(String slug) { - Namespace found = namespaces.findBySlug(slug).orElseThrow(this::invalid); - return namespaces.findLockedById(found.getId()).orElseThrow(this::invalid); + Long namespaceId = namespaces.findIdBySlug(slug).orElseThrow(this::invalid); + return namespaces.findLockedById(namespaceId).orElseThrow(this::invalid); } private NamespaceInvitation current(String token) { @@ -126,6 +141,11 @@ public class NamespaceInvitationService { return invitations.findByTokenHash(hash(token)).orElseThrow(this::invalid); } + private Long locateNamespaceId(String token) { + if (token == null || !token.matches("[A-Za-z0-9_-]{43}")) throw invalid(); + return invitations.findNamespaceIdByTokenHash(hash(token)).orElseThrow(this::invalid); + } + private void requireManager(Namespace namespace, String userId) { NamespaceRole role = members.findByNamespaceIdAndUserId(namespace.getId(), userId) .map(NamespaceMember::getRole).orElse(null); @@ -137,6 +157,10 @@ public class NamespaceInvitationService { if (users.findById(userId).filter(user -> user.getStatus() == UserStatus.ACTIVE).isEmpty()) throw invalid(); } + private void requireActiveLockedUser(String userId) { + if (users.findLockedById(userId).filter(user -> user.getStatus() == UserStatus.ACTIVE).isEmpty()) throw invalid(); + } + private void requireActiveTeam(Namespace namespace) { if (namespace.getStatus() != NamespaceStatus.ACTIVE || namespace.getType() != NamespaceType.TEAM) throw invalid(); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java index d8e8bc74..040f048b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.domain.namespace; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -18,17 +19,24 @@ public class NamespaceMemberService { private final NamespaceMemberRepository namespaceMemberRepository; private final NamespaceService namespaceService; private final NamespaceAccessPolicy namespaceAccessPolicy; + private final NamespaceRepository namespaceRepository; + private final UserAccountRepository userAccountRepository; public NamespaceMemberService(NamespaceMemberRepository namespaceMemberRepository, NamespaceService namespaceService, - NamespaceAccessPolicy namespaceAccessPolicy) { + NamespaceAccessPolicy namespaceAccessPolicy, + NamespaceRepository namespaceRepository, + UserAccountRepository userAccountRepository) { this.namespaceMemberRepository = namespaceMemberRepository; this.namespaceService = namespaceService; this.namespaceAccessPolicy = namespaceAccessPolicy; + this.namespaceRepository = namespaceRepository; + this.userAccountRepository = userAccountRepository; } @Transactional public NamespaceMember addMember(Long namespaceId, String userId, NamespaceRole role, String operatorUserId) { + lockUserThenNamespace(userId, namespaceId); assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); @@ -46,6 +54,7 @@ public class NamespaceMemberService { @Transactional public void removeMember(Long namespaceId, String userId, String operatorUserId) { + lockUserThenNamespace(userId, namespaceId); assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); @@ -61,6 +70,7 @@ public class NamespaceMemberService { @Transactional public NamespaceMember updateMemberRole(Long namespaceId, String userId, NamespaceRole newRole, String operatorUserId) { + lockUserThenNamespace(userId, namespaceId); assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); @@ -77,6 +87,14 @@ public class NamespaceMemberService { @Transactional public void transferOwnership(Long namespaceId, String currentOwnerId, String newOwnerId) { + if (currentOwnerId.compareTo(newOwnerId) <= 0) { + userAccountRepository.findLockedById(currentOwnerId); + userAccountRepository.findLockedById(newOwnerId); + } else { + userAccountRepository.findLockedById(newOwnerId); + userAccountRepository.findLockedById(currentOwnerId); + } + namespaceRepository.findLockedById(namespaceId); Namespace namespace = namespaceService.getNamespace(namespaceId); if (!namespaceAccessPolicy.canTransferOwnership(namespace)) { throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); @@ -117,4 +135,9 @@ public class NamespaceMemberService { throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); } } + + private void lockUserThenNamespace(String userId, Long namespaceId) { + userAccountRepository.findLockedById(userId); + namespaceRepository.findLockedById(namespaceId); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index d5bbedbc..b235d2f5 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -17,6 +17,7 @@ public interface NamespaceRepository { List findBySlugIn(List slugs); Page findByIdIn(List ids, Pageable pageable); Optional findBySlug(String slug); + Optional findIdBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); Namespace save(Namespace namespace); void delete(Namespace namespace); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java index e54379e0..764f30f3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java @@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,19 +23,22 @@ public class NamespaceService { private final SkillRepository skillRepository; private final ReviewTaskRepository reviewTaskRepository; private final PromotionRequestRepository promotionRequestRepository; + private final UserAccountRepository userAccountRepository; public NamespaceService(NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, NamespaceAccessPolicy namespaceAccessPolicy, SkillRepository skillRepository, ReviewTaskRepository reviewTaskRepository, - PromotionRequestRepository promotionRequestRepository) { + PromotionRequestRepository promotionRequestRepository, + UserAccountRepository userAccountRepository) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.namespaceAccessPolicy = namespaceAccessPolicy; this.skillRepository = skillRepository; this.reviewTaskRepository = reviewTaskRepository; this.promotionRequestRepository = promotionRequestRepository; + this.userAccountRepository = userAccountRepository; } /** @@ -43,6 +47,7 @@ public class NamespaceService { */ @Transactional public Namespace createNamespace(String slug, String displayName, String description, String creatorUserId) { + userAccountRepository.findLockedById(creatorUserId); SlugValidator.validate(slug); if (namespaceRepository.findBySlug(slug).isPresent()) { @@ -119,7 +124,8 @@ public class NamespaceService { */ @Transactional public void deleteNamespace(Long namespaceId, String operatorUserId) { - Namespace namespace = getNamespace(namespaceId); + Namespace namespace = namespaceRepository.findLockedById(namespaceId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); assertNotImmutable(namespace); NamespaceRole role = requireRole(namespaceId, operatorUserId); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java index c4e211bb..95ae6ad1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java @@ -24,12 +24,14 @@ class GlobalNamespaceMembershipServiceTest { @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository; private GlobalNamespaceMembershipService service; @BeforeEach void setUp() { - service = new GlobalNamespaceMembershipService(namespaceRepository, namespaceMemberRepository); + service = new GlobalNamespaceMembershipService(namespaceRepository, namespaceMemberRepository, userAccountRepository); } @Test @@ -37,7 +39,7 @@ class GlobalNamespaceMembershipServiceTest { Namespace global = new Namespace("global", "Global", "system"); setNamespaceId(global, 1L); - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.empty()); service.ensureMember("usr_1"); @@ -55,7 +57,7 @@ class GlobalNamespaceMembershipServiceTest { setNamespaceId(global, 1L); NamespaceMember existing = new NamespaceMember(1L, "usr_1", NamespaceRole.ADMIN); - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.of(existing)); service.ensureMember("usr_1"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java index 65290270..f164cc2e 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java @@ -3,7 +3,9 @@ package com.iflytek.skillhub.domain.namespace; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -30,14 +32,23 @@ class NamespaceGovernanceServiceTest { @Mock private AuditLogService auditLogService; + @Mock + private UserAccountRepository userAccountRepository; @InjectMocks private NamespaceGovernanceService governanceService; + @BeforeEach + void lockResolvedNamespace() { + org.mockito.Mockito.lenient().when(namespaceRepository.findLockedById(org.mockito.ArgumentMatchers.anyLong())) + .thenAnswer(call -> namespaceRepository.findBySlug("team-a")); + } + @Test void freezeNamespace_allowsAdminOnActiveTeamNamespace() { Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) .thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN))); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); @@ -54,6 +65,7 @@ class NamespaceGovernanceServiceTest { void archiveNamespace_rejectsAdminAndAllowsOnlyOwner() { Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) .thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN))); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); @@ -67,6 +79,7 @@ class NamespaceGovernanceServiceTest { void restoreNamespace_movesArchivedNamespaceBackToActive() { Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ARCHIVED); when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner-1")) .thenReturn(Optional.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); @@ -81,7 +94,8 @@ class NamespaceGovernanceServiceTest { @Test void freezeNamespace_rejectsGlobalNamespace() { Namespace namespace = namespace(1L, "global", NamespaceType.GLOBAL, NamespaceStatus.ACTIVE); - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L)); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); assertThrows(DomainBadRequestException.class, @@ -92,6 +106,7 @@ class NamespaceGovernanceServiceTest { void unfreezeNamespace_rejectsIllegalTransition() { Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner-1")) .thenReturn(Optional.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java index a2d78607..9e4895e2 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java @@ -39,6 +39,7 @@ class NamespaceInvitationServiceTest { throw new AssertionError(e); } when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(namespaces.findIdBySlug("team")).thenReturn(Optional.of(7L)); when(namespaces.findLockedById(7L)).thenReturn(Optional.of(namespace)); when(namespaces.findById(7L)).thenReturn(Optional.of(namespace)); when(members.findByNamespaceIdAndUserId(7L, "owner")) @@ -47,6 +48,8 @@ class NamespaceInvitationServiceTest { when(owner.getStatus()).thenReturn(com.iflytek.skillhub.domain.user.UserStatus.ACTIVE); when(users.findById("owner")).thenReturn(Optional.of(owner)); when(users.findById("guest")).thenReturn(Optional.of(owner)); + lenient().when(users.findLockedById("owner")).thenReturn(Optional.of(owner)); + lenient().when(users.findLockedById("guest")).thenReturn(Optional.of(owner)); } @Test @@ -65,4 +68,72 @@ class NamespaceInvitationServiceTest { .isInstanceOf(DomainNotFoundException.class); verify(invitations).findByTokenHash(any()); } + + @Test + void acceptCreatesMemberAndConsumesExactlyTheCurrentInvitation() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation invitation = saved.getValue(); + when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation)); + when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); + + assertThat(service.preview(created.token(), "guest").alreadyMember()).isFalse(); + assertThat(service.accept(created.token(), "guest").alreadyMember()).isFalse(); + + verify(members).save(argThat(member -> member.getRole() == NamespaceRole.MEMBER + && member.getNamespaceId().equals(7L) && member.getUserId().equals("guest"))); + assertThat(invitation.getConsumedBy()).isEqualTo("guest"); + assertThat(invitation.getConsumedAt()).isEqualTo(clock.instant()); + Optional guest = users.findById("guest"); + when(users.findLockedById("another")).thenReturn(guest); + when(users.findById("another")).thenReturn(guest); + assertThatThrownBy(() -> service.accept(created.token(), "another")) + .isInstanceOf(DomainNotFoundException.class); + } + + @Test + void existingMemberCanOpenCurrentConsumedInvitationWithoutReconsuming() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation invitation = saved.getValue(); + invitation.consume("someone", clock.instant()); + when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation)); + when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); + when(members.findByNamespaceIdAndUserId(7L, "guest")) + .thenReturn(Optional.of(new NamespaceMember(7L, "guest", NamespaceRole.MEMBER))); + + assertThat(service.preview(created.token(), "guest").alreadyMember()).isTrue(); + assertThat(service.accept(created.token(), "guest").alreadyMember()).isTrue(); + verify(members, never()).save(any()); + } + + @Test + void replacingCurrentInvitationMakesOldTokenInvalidAfterPreview() { + NamespaceInvitationService.Created first = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation invitation = saved.getValue(); + when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation)); + when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); + service.preview(first.token(), "guest"); + + service.create("team", "owner", true); + assertThatThrownBy(() -> service.accept(first.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(members, never()).save(any()); + } + + @Test + void revokeDeletesCurrentInvitationWithoutRemovingMembers() { + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(new NamespaceInvitation( + 7L, "a".repeat(64), "owner", clock.instant(), clock.instant().plusSeconds(86400)))); + service.revoke("team", "owner"); + verify(invitations).deleteByNamespaceId(7L); + verify(members, never()).deleteByNamespaceId(any()); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java index 84d823c1..5d897a3d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.namespace; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -22,6 +23,10 @@ class NamespaceMemberServiceTest { private NamespaceService namespaceService; @Mock private NamespaceAccessPolicy namespaceAccessPolicy; + @Mock + private NamespaceRepository namespaceRepository; + @Mock + private UserAccountRepository userAccountRepository; @InjectMocks private NamespaceMemberService namespaceMemberService; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java index 12c555f3..1631fa6c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java @@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -38,6 +39,8 @@ class NamespaceServiceTest { @Mock private PromotionRequestRepository promotionRequestRepository; + @Mock + private UserAccountRepository userAccountRepository; @InjectMocks private NamespaceService namespaceService; @@ -214,7 +217,7 @@ class NamespaceServiceTest { Long namespaceId = 1L; String operatorUserId = "owner-1"; Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); - when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findLockedById(namespaceId)).thenReturn(Optional.of(namespace)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) @@ -234,7 +237,7 @@ class NamespaceServiceTest { Long namespaceId = 1L; String operatorUserId = "admin-1"; Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); - when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findLockedById(namespaceId)).thenReturn(Optional.of(namespace)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.ADMIN)).thenReturn(false); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) @@ -252,7 +255,7 @@ class NamespaceServiceTest { Long namespaceId = 1L; String operatorUserId = "owner-1"; Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); - when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findLockedById(namespaceId)).thenReturn(Optional.of(namespace)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java index 074fa035..b305ef6e 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java @@ -3,6 +3,8 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.namespace.NamespaceInvitation; import com.iflytek.skillhub.domain.namespace.NamespaceInvitationRepository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -10,5 +12,7 @@ import java.util.Optional; @Repository public interface NamespaceInvitationJpaRepository extends JpaRepository, NamespaceInvitationRepository { Optional findByTokenHash(String tokenHash); + @Query("select invitation.namespaceId from NamespaceInvitation invitation where invitation.tokenHash = :tokenHash") + Optional findNamespaceIdByTokenHash(@Param("tokenHash") String tokenHash); void deleteByNamespaceId(Long namespaceId); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index 0f909e58..fd112bf3 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -29,5 +29,8 @@ public interface NamespaceJpaRepository List findBySlugIn(List slugs); Page findByIdIn(List ids, Pageable pageable); Optional findBySlug(String slug); + @Override + @Query("select n.id from Namespace n where n.slug = :slug") + Optional findIdBySlug(@Param("slug") String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 09f2fd9d..5b33c645 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -747,6 +747,37 @@ export const labelApi = { } export const namespaceApi = { + async invitationStatus(slug: string): Promise<{ active: boolean }> { + return fetchJson<{ active: boolean }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`) + }, + + async createInvitation(slug: string, replace: boolean): Promise<{ token: string; expiresAt: string }> { + return fetchJson<{ token: string; expiresAt: string }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`, { + method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ replace }), + }) + }, + + async revokeInvitation(slug: string): Promise { + await fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`, { + method: 'DELETE', headers: await ensureCsrfHeaders(), + }) + }, + + async previewInvitation(token: string): Promise<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }> { + return fetchJson<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }>(`${WEB_API_PREFIX}/invitations/preview`, { + method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ token }), + }) + }, + + async acceptInvitation(token: string): Promise<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }> { + return fetchJson<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }>(`${WEB_API_PREFIX}/invitations/accept`, { + method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ token }), + }) + }, + async create(request: CreateNamespaceRequest): Promise { const namespace = await fetchJson('/api/v1/namespaces', { method: 'POST', diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 54573332..6a706d03 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -7,6 +7,7 @@ import { RouteError } from '@/shared/components/route-error' import { createRedirectAuthenticated, createRequireAuth, isSafeAuthReturnTo } from '@/shared/lib/auth-route' import { clearDynamicImportReloadGuard, recoverFromDynamicImportError } from '@/shared/lib/dynamic-import-recovery' import { normalizeSearchQuery } from '@/shared/lib/search-query' +import { captureInvitationFragment } from '@/shared/lib/namespace-invitation' /** * Central route registry for the SkillHub web app. @@ -16,6 +17,7 @@ import { normalizeSearchQuery } from '@/shared/lib/search-query' */ // Capture original URL before TanStack Router rewrites it const ORIGINAL_URL_SEARCH = typeof window !== 'undefined' ? window.location.search : '' +captureInvitationFragment() // Export for use in cli-auth page export { ORIGINAL_URL_SEARCH } @@ -95,6 +97,7 @@ const SuitesPage = createLazyRouteComponent(() => import('@/pages/suites'), 'Sui const SuiteDetailPage = createLazyRouteComponent(() => import('@/pages/suite-detail'), 'SuiteDetailPage') const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage') const NamespacePage = createLazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage') +const JoinPage = createLazyRouteComponent(() => import('@/pages/join'), 'JoinPage') const SkillDetailPage = createLazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage') const SkillVersionComparePage = createLazyRouteComponent(() => import('@/pages/skill-version-compare'), 'SkillVersionComparePage') const dashboardRouteOptions = { silentFallback: true } satisfies LazyRouteOptions @@ -323,6 +326,13 @@ const namespaceRoute = createRoute({ component: NamespacePage, }) +const joinRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'join', + beforeLoad: requireAuth, + component: JoinPage, +}) + const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/space/$namespace/$slug', @@ -678,6 +688,7 @@ const routeTree = rootRoute.addChildren([ suiteDetailRoute, termsRoute, namespaceRoute, + joinRoute, skillDetailRoute, skillVersionCompareRoute, dashboardRoute, diff --git a/web/src/features/namespace/invitation-controls.tsx b/web/src/features/namespace/invitation-controls.tsx new file mode 100644 index 00000000..b0e4fdc5 --- /dev/null +++ b/web/src/features/namespace/invitation-controls.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { getAppBaseUrl, namespaceApi } from '@/api/client' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { BASE_PATH } from '@/shared/lib/base-path' +import { invitationUrl } from '@/shared/lib/namespace-invitation' +import { resolvePublicRegistryUrl } from '@/shared/lib/registry-url' +import { toast } from '@/shared/lib/toast' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' + +export function InvitationControls({ slug }: { slug: string }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [confirm, setConfirm] = useState<'replace' | 'revoke' | null>(null) + const [link, setLink] = useState(null) + const status = useQuery({ queryKey: ['namespace-invitation-status', slug], queryFn: () => namespaceApi.invitationStatus(slug) }) + const create = useMutation({ + mutationFn: (replace: boolean) => namespaceApi.createInvitation(slug, replace), + onSuccess: async ({ token }) => { + await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }) + const url = invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), token) + setLink(url) + try { + await navigator.clipboard.writeText(url) + toast.success(t('invitation.copied')) + } catch { + toast.info(t('invitation.manualCopy')) + } + }, + }) + const revoke = useMutation({ + mutationFn: () => namespaceApi.revokeInvitation(slug), + onSuccess: async () => { + setLink(null) + await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }) + toast.success(t('invitation.revoked')) + }, + }) + const handleCreate = () => { + if (status.data?.active) setConfirm('replace') + else create.mutate(false) + } + return ( +
+
+ + {status.data?.active ? : null} +
+ {link ? ( +
+ + event.currentTarget.select()} /> +

{t('invitation.linkHint')}

+
+ ) : null} + {(create.error || revoke.error || status.error) ?

{t('invitation.error')}

: null} + { if (!open) setConfirm(null) }} + title={t(confirm === 'revoke' ? 'invitation.revokeTitle' : 'invitation.replaceTitle')} + description={t(confirm === 'revoke' ? 'invitation.revokeDescription' : 'invitation.replaceDescription')} + confirmText={t('dialog.confirm')} + onConfirm={async () => { if (confirm === 'revoke') await revoke.mutateAsync(); else await create.mutateAsync(true) }} /> +
+ ) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a863e68d..378617d4 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1388,6 +1388,31 @@ "searchRebuildSuccessDescription": "The system is rebuilding the full search index using the current rules.", "searchRebuildErrorTitle": "Search index rebuild failed" }, + "invitation": { + "title": "Join a team space", + "create": "Invite member", + "revoke": "Revoke invitation", + "replaceTitle": "Replace the current invitation?", + "replaceDescription": "The old link will stop working immediately. Existing members are unaffected.", + "revokeTitle": "Revoke the current invitation?", + "revokeDescription": "The link will stop working immediately. Existing members are unaffected.", + "link": "Invitation link", + "linkHint": "Valid for 24 hours and one new member. Share it securely.", + "copied": "Invitation link copied", + "manualCopy": "Automatic copy failed. Copy the link below manually.", + "revoked": "Invitation revoked", + "error": "Invitation action failed. Try again.", + "invalid": "This invitation is invalid or expired. Ask a space admin for a new link.", + "loading": "Checking invitation…", + "networkError": "Connection failed. Try again.", + "retry": "Retry", + "namespace": "Space: {{name}}", + "account": "Current account: {{name}}", + "permissions": "Confirm to join as MEMBER. You can access space resources and publish skills.", + "alreadyMember": "You are already a member of this space.", + "enter": "Enter space", + "confirm": "Confirm and join" + }, "members": { "title": "Member Management", "addMember": "Add Member", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 5f7d8503..1c118256 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1453,6 +1453,31 @@ "searchRebuildSuccessDescription": "Система перестраивает полный индекс поиска по текущим правилам.", "searchRebuildErrorTitle": "Не удалось перестроить индекс поиска" }, + "invitation": { + "title": "Вступить в командное пространство", + "create": "Пригласить участника", + "revoke": "Отозвать приглашение", + "replaceTitle": "Заменить текущее приглашение?", + "replaceDescription": "Старая ссылка сразу перестанет работать. Участники не изменятся.", + "revokeTitle": "Отозвать текущее приглашение?", + "revokeDescription": "Ссылка сразу перестанет работать. Участники не изменятся.", + "link": "Ссылка приглашения", + "linkHint": "Действует 24 часа и только для одного нового участника. Передавайте безопасно.", + "copied": "Ссылка скопирована", + "manualCopy": "Не удалось скопировать автоматически. Скопируйте ссылку ниже.", + "revoked": "Приглашение отозвано", + "error": "Не удалось выполнить действие. Попробуйте ещё раз.", + "invalid": "Приглашение недействительно или истекло. Запросите новую ссылку у администратора.", + "loading": "Проверка приглашения…", + "networkError": "Ошибка соединения. Попробуйте ещё раз.", + "retry": "Повторить", + "namespace": "Пространство: {{name}}", + "account": "Текущая учётная запись: {{name}}", + "permissions": "Подтвердите вступление с ролью MEMBER. Вы сможете просматривать ресурсы и публиковать навыки.", + "alreadyMember": "Вы уже участник этого пространства.", + "enter": "Открыть пространство", + "confirm": "Подтвердить вступление" + }, "members": { "title": "Управление участниками", "addMember": "Добавить участника", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index ba4d66fa..be47e8b5 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1388,6 +1388,31 @@ "searchRebuildSuccessDescription": "系统正在按当前规则重建全部搜索索引。", "searchRebuildErrorTitle": "搜索索引重建失败" }, + "invitation": { + "title": "加入团队空间", + "create": "邀请成员", + "revoke": "作废邀请", + "replaceTitle": "替换现有邀请?", + "replaceDescription": "旧链接将立即失效,已加入的成员不受影响。", + "revokeTitle": "作废当前邀请?", + "revokeDescription": "链接将立即失效,已加入的成员不受影响。", + "link": "邀请链接", + "linkHint": "24 小时内有效,仅能由一名新成员使用一次。请安全分享。", + "copied": "邀请链接已复制", + "manualCopy": "无法自动复制,请手动复制下方链接", + "revoked": "邀请已作废", + "error": "邀请操作失败,请重试", + "invalid": "邀请无效或已失效,请联系空间管理员获取新链接。", + "loading": "正在核对邀请…", + "networkError": "连接失败,请重试。", + "retry": "重试", + "namespace": "目标空间:{{name}}", + "account": "当前账号:{{name}}", + "permissions": "确认后,你将以 MEMBER 身份加入,可访问空间资源并发布技能。", + "alreadyMember": "你已是该空间成员。", + "enter": "进入空间", + "confirm": "确认加入" + }, "members": { "title": "成员管理", "addMember": "添加成员", diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx index 97afd0bf..4b660726 100644 --- a/web/src/pages/dashboard/namespace-members.tsx +++ b/web/src/pages/dashboard/namespace-members.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import { formatLocalDateTime } from '@/shared/lib/date-time' import { AddNamespaceMemberDialog } from '@/features/namespace/add-namespace-member-dialog' import { BatchImportMembersDialog } from '@/features/namespace/batch-import-members-dialog' +import { InvitationControls } from '@/features/namespace/invitation-controls' import { NamespaceHeader } from '@/features/namespace/namespace-header' import { TransferOwnershipDialog } from '@/features/namespace/transfer-ownership-dialog' import { ConfirmDialog } from '@/shared/components/confirm-dialog' @@ -165,6 +166,7 @@ export function NamespaceMembersPage() { ) : null}
+ {canManageMembers ? : null} {canTransferOwnership ? ( diff --git a/web/src/pages/join.tsx b/web/src/pages/join.tsx new file mode 100644 index 00000000..e43b135f --- /dev/null +++ b/web/src/pages/join.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { namespaceApi } from '@/api/client' +import { useAuth } from '@/features/auth/use-auth' +import { ApiError } from '@/shared/lib/api-error' +import { captureInvitationFragment, clearInvitationToken, storedInvitationToken } from '@/shared/lib/namespace-invitation' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' + +export function JoinPage() { + const { t } = useTranslation() + const navigate = useNavigate() + const queryClient = useQueryClient() + const { user } = useAuth() + const [token] = useState(() => { + captureInvitationFragment() + return storedInvitationToken() + }) + const preview = useQuery({ + queryKey: ['namespace-invitation', token], + queryFn: () => namespaceApi.previewInvitation(token!), + enabled: !!token, + retry: false, + }) + const accept = useMutation({ + mutationFn: () => namespaceApi.acceptInvitation(token!), + onSuccess: async (result) => { + clearInvitationToken() + await queryClient.invalidateQueries({ queryKey: ['namespaces'] }) + await navigate({ to: '/space/$namespace', params: { namespace: result.namespaceSlug } }) + }, + }) + const invalid = !token || (preview.error instanceof ApiError && preview.error.status === 404) + || (accept.error instanceof ApiError && accept.error.status === 404) + useEffect(() => { if (invalid) clearInvitationToken() }, [invalid]) + + return ( +
+ +

{t('invitation.title')}

+ {invalid ?

{t('invitation.invalid')}

: null} + {!invalid && preview.isPending ?

{t('invitation.loading')}

: null} + {!invalid && preview.error ? ( +
+

{t('invitation.networkError')}

+ +
+ ) : null} + {!invalid && preview.data ? ( +
+

{t('invitation.namespace', { name: preview.data.namespaceName })}

+

{t('invitation.account', { name: user?.displayName ?? user?.userId ?? '' })}

+ {preview.data.alreadyMember ? ( + <> +

{t('invitation.alreadyMember')}

+ + + ) : ( + <> +

{t('invitation.permissions')}

+ + {accept.error && !(accept.error instanceof ApiError && accept.error.status === 404) ? ( +

{t('invitation.networkError')}

+ ) : null} + + )} +
+ ) : null} +
+
+ ) +} diff --git a/web/src/shared/lib/namespace-invitation.test.ts b/web/src/shared/lib/namespace-invitation.test.ts new file mode 100644 index 00000000..0b389281 --- /dev/null +++ b/web/src/shared/lib/namespace-invitation.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from 'vitest' +import { captureInvitationFragment, clearInvitationToken, invitationUrl, storedInvitationToken } from './namespace-invitation' + +const token = 'A'.repeat(43) + +describe('namespace invitation URL handoff', () => { + beforeEach(() => { + clearInvitationToken() + window.history.replaceState(null, '', '/') + }) + + it('moves a token from the fragment to tab-scoped storage before login routing', () => { + window.history.replaceState(null, '', `/join?from=mail#${token}`) + captureInvitationFragment() + expect(storedInvitationToken()).toBe(token) + expect(window.location.href).toContain('/join?from=mail') + expect(window.location.hash).toBe('') + }) + + it('rejects malformed fragments and still removes them from the URL', () => { + window.history.replaceState(null, '', '/join#bad-token') + captureInvitationFragment() + expect(storedInvitationToken()).toBeNull() + expect(window.location.hash).toBe('') + }) + + it('uses a resolved public base path in the shared link', () => { + expect(invitationUrl('https://example.com/skillhub', token)).toBe(`https://example.com/skillhub/join#${token}`) + }) +}) diff --git a/web/src/shared/lib/namespace-invitation.ts b/web/src/shared/lib/namespace-invitation.ts new file mode 100644 index 00000000..9a1b9140 --- /dev/null +++ b/web/src/shared/lib/namespace-invitation.ts @@ -0,0 +1,28 @@ +import { toRouterPath } from './base-path' + +const STORAGE_KEY = 'skillhub.namespace.invitation' + +// Run before route guards inspect the location: a login redirect must never copy the fragment. +export function captureInvitationFragment() { + if (typeof window === 'undefined' || !window.location.hash) return + if (toRouterPath(window.location.pathname) !== '/join') return + const token = window.location.hash.slice(1) + try { + if (/^[A-Za-z0-9_-]{43}$/.test(token)) window.sessionStorage.setItem(STORAGE_KEY, token) + } finally { + window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}`) + } +} + +export function storedInvitationToken(): string | null { + if (typeof window === 'undefined') return null + return window.sessionStorage.getItem(STORAGE_KEY) +} + +export function clearInvitationToken() { + if (typeof window !== 'undefined') window.sessionStorage.removeItem(STORAGE_KEY) +} + +export function invitationUrl(publicBase: string, token: string): string { + return `${publicBase}/join#${token}` +} From 0452af07935bb3a20866a23a8b1d04d016a25e02 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:56:29 +0800 Subject: [PATCH 06/47] docs(namespace): clarify invitation lock order Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- openspec/changes/add-namespace-invite-links/design.md | 2 +- .../specs/namespace-invitations/spec.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/add-namespace-invite-links/design.md b/openspec/changes/add-namespace-invite-links/design.md index f67f0a5c..fd3c405d 100644 --- a/openspec/changes/add-namespace-invite-links/design.md +++ b/openspec/changes/add-namespace-invite-links/design.md @@ -5,7 +5,7 @@ ## Decisions 1. 每个 Namespace 只保存一条当前邀请:Token SHA-256、签发人和时间、过期时间、消费人和时间。原文只在生成响应中返回一次。作废删除当前邀请记录或等价地使其不可匹配,写审计;不删除已加入成员。 -2. 涉及用户的写操作先按 userId 升序锁参与者用户行,再按 Namespace ID 升序锁目标 Namespace 行,最后读取或写入邀请与成员;账号合并沿用先锁用户的现有顺序,以免与成员或审计外键检查形成锁环。邀请创建、替换、作废锁操作者后锁 Namespace;接受锁接收者后锁 Namespace。纯 Namespace 生命周期与删除操作锁 Namespace。批量操作预先收集 ID、去重并排序。接受在锁内重检,成员创建、消费和审计原子提交。 +2. 涉及用户的写操作先按 userId 升序锁参与者用户行,再按 Namespace ID 升序锁目标 Namespace 行,最后读取或写入邀请与成员;账号合并沿用先锁用户的现有顺序,以免与成员或审计外键检查形成锁环。邀请创建、替换、作废以及带操作者审计的生命周期操作先锁操作者,再锁 Namespace;接受先锁接收者,再锁 Namespace。当前不写用户审计的 Namespace 删除只锁 Namespace。批量操作预先收集 ID、去重并排序。接受在锁内重检,成员创建、消费和审计原子提交。 3. 前端在 `/join` 入口同步读取 fragment,放入当前标签页 sessionStorage,保留 path/query 清除 fragment,再应用原登录逻辑。登录 `returnTo=/join` 不传 Token。分享链接复用 `resolvePublicRegistryUrl` 构造并兼容 BASE_PATH。 4. Preview/Accept 复用 Issue 的判定顺序:先验证 Token 为当前邀请,再查已有成员;已有成员返回 ALREADY_MEMBER。非成员必须面对 ACTIVE TEAM、未消费且未过期的邀请。失效统一 404,不区分随机、旧、作废、过期或已用 Token。Accept 锁前只按 Token 哈希查询标量 Namespace ID;取得 Namespace 锁后重新加载当前邀请并校验,不能沿用锁前已经进入 ORM 一级缓存的邀请实体,否则并发兑换可能都看到未消费状态。 5. 只有可用浏览器 Session 能创建、预览、接受、作废;现有账号准入不能由邀请绕过。浏览器请求使用既有 CSRF。创建/预览/接受/作废与缓存或日志配置均不得泄露原文和哈希。 diff --git a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md index 5d8b25da..34b2244f 100644 --- a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md +++ b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md @@ -71,7 +71,7 @@ Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码; 并发操作 SHALL 保持成员和邀请原子一致。 -邀请创建、替换、作废和接受与成员添加/删除/转交、Namespace 冻结/归档/恢复/删除、账号合并在相同 Namespace 上须串行化。涉及用户和 Namespace 的写操作先按用户 ID 升序、再按 Namespace ID 升序加锁;创建/替换/作废锁操作者,接受锁接收者;纯 Namespace 生命周期与删除只锁 Namespace。接受时成员创建、邀请消费和审计同一事务完成;并发兑换最多一名非成员成功,不能产生“已消费但无成员”。批量涉及多个 Namespace 的操作按去重 ID 升序锁定。 +邀请创建、替换、作废和接受与成员添加/删除/转交、Namespace 冻结/归档/恢复/删除、账号合并在相同 Namespace 上须串行化。涉及用户和 Namespace 的写操作先按用户 ID 升序、再按 Namespace ID 升序加锁;创建/替换/作废及带操作者审计的生命周期操作锁操作者,接受锁接收者;当前不写用户审计的 Namespace 删除只锁 Namespace。接受时成员创建、邀请消费和审计同一事务完成;并发兑换最多一名非成员成功,不能产生“已消费但无成员”。批量涉及多个 Namespace 的操作按去重 ID 升序锁定。 #### Scenario: 两人同时兑换 - **WHEN** 两个非成员同时确认同一链接 From 27687713d2adf4c724a935419015dbdc68c77317 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:06:34 +0800 Subject: [PATCH 07/47] fix(namespace): complete invitation review follow-up Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../specs/namespace-invitations/spec.md | 4 +- .../NamespaceInvitationPostgresTest.java | 40 ++ .../namespace/NamespaceInvitationService.java | 10 +- .../NamespaceInvitationServiceTest.java | 18 +- web/src/api/client.ts | 4 +- web/src/api/generated/schema.d.ts | 387 +++++++++++++++++- .../namespace/invitation-controls.tsx | 6 +- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/ru.json | 1 + web/src/i18n/locales/zh.json | 1 + web/src/pages/dashboard/my-namespaces.tsx | 11 + web/src/pages/join.tsx | 5 +- 12 files changed, 469 insertions(+), 19 deletions(-) diff --git a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md index 34b2244f..c793af05 100644 --- a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md +++ b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md @@ -8,7 +8,7 @@ 有权管理员 SHALL 可创建、替换及作废邀请。 -只有 ACTIVE TEAM 的 OWNER/ADMIN 可以创建邀请;平台角色不能绕过成员角色限制。每个 Namespace 仅有一条当前邀请,24 小时内仅一名非成员可兑换一次。替换仍可用的邀请前须确认;管理员可以单独作废当前邀请,且已加入成员不受影响。 +只有 ACTIVE TEAM 的 OWNER/ADMIN 可以创建邀请;平台角色不能绕过成员角色限制。每个 Namespace 仅有一条当前邀请,24 小时内仅一名非成员可兑换一次。替换仍可用的邀请前须确认,创建响应仅在旧链接当时仍可兑换时标记 `replaced=true`;已消费或过期后的新建不标记替换。管理员可以单独作废当前邀请,且已加入成员不受影响。 #### Scenario: 替换未使用链接 - **WHEN** 管理员再次点击邀请且当前链接仍可兑换 @@ -85,7 +85,7 @@ Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码; 前端 SHALL 完成链接分享、登录回跳和邀请状态闭环。 -管理页提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。 +空间卡片和成员管理页提供邀请入口,管理页提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token,随后进入空间时替换浏览器历史中的邀请页。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。 #### Scenario: 剪贴板写入失败 - **WHEN** 创建成功但浏览器拒绝写剪贴板 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java index 6fdbc8d4..a64c8cdc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java @@ -96,6 +96,46 @@ class NamespaceInvitationPostgresTest { assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedBy()).isIn(first, second); } + @Test + void revokeAndReplaceSerializeWithAcceptance() throws Exception { + for (boolean replace : new boolean[] {false, true}) { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(guest, guest, null, null)); + Namespace namespace = namespaces.saveAndFlush(new Namespace(slug, "Invitation race", owner)); + members.saveAndFlush(new NamespaceMember(namespace.getId(), owner, NamespaceRole.OWNER)); + return namespace.getId(); + }); + String oldToken = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> attempt(start, oldToken, guest)); + var change = pool.submit(() -> { + start.await(); + if (replace) invitations.create(slug, owner, true); + else invitations.revoke(slug, owner); + return true; + }); + start.countDown(); + int accepted = accept.get(20, TimeUnit.SECONDS); + assertThat(change.get(20, TimeUnit.SECONDS)).isTrue(); + assertThat(members.findByNamespaceIdAndUserId(namespaceId, guest).isPresent()) + .isEqualTo(accepted == 1); + if (replace) { + assertThat(invitationRows.findByNamespaceId(namespaceId)).isPresent(); + assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedAt()).isNull(); + } else { + assertThat(invitationRows.findByNamespaceId(namespaceId)).isEmpty(); + } + assertThat(attempt(new CountDownLatch(0), oldToken, guest)).isZero(); + } + } + } + private int attempt(CountDownLatch start, String token, String userId) throws InterruptedException { start.await(); try { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java index 0706f121..ae8bfc95 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java @@ -38,7 +38,7 @@ public class NamespaceInvitationService { this.clock = clock; } - public record Created(String token, Instant expiresAt) {} + public record Created(String token, Instant expiresAt, boolean replaced) {} public record Preview(String namespaceSlug, String namespaceName, boolean alreadyMember) {} @Transactional @@ -57,9 +57,9 @@ public class NamespaceInvitationService { String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); Instant now = clock.instant(); NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElse(null); - boolean replaced = invitation != null; - if (invitation != null && invitation.getConsumedAt() == null - && invitation.getExpiresAt().isAfter(now) && !replace) { + boolean replaced = invitation != null && invitation.getConsumedAt() == null + && invitation.getExpiresAt().isAfter(now); + if (replaced && !replace) { throw new DomainConflictException("error.namespace.invite.replaceConfirmationRequired"); } if (invitation == null) { @@ -69,7 +69,7 @@ public class NamespaceInvitationService { } invitations.save(invitation); record(actorId, replaced ? "NAMESPACE_INVITE_REPLACED" : "NAMESPACE_INVITE_CREATED", namespace.getId()); - return new Created(token, invitation.getExpiresAt()); + return new Created(token, invitation.getExpiresAt(), replaced); } @Transactional diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java index 9e4895e2..1c4d1ad3 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java @@ -60,6 +60,7 @@ class NamespaceInvitationServiceTest { assertThat(created.token()).matches("[A-Za-z0-9_-]{43}"); assertThat(saved.getValue().getTokenHash()).matches("[a-f0-9]{64}").doesNotContain(created.token()); assertThat(created.expiresAt()).isEqualTo(Instant.parse("2026-10-13T00:00:00Z")); + assertThat(created.replaced()).isFalse(); } @Test @@ -122,7 +123,7 @@ class NamespaceInvitationServiceTest { when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); service.preview(first.token(), "guest"); - service.create("team", "owner", true); + assertThat(service.create("team", "owner", true).replaced()).isTrue(); assertThatThrownBy(() -> service.accept(first.token(), "guest")) .isInstanceOf(DomainNotFoundException.class); verify(members, never()).save(any()); @@ -136,4 +137,19 @@ class NamespaceInvitationServiceTest { verify(invitations).deleteByNamespaceId(7L); verify(members, never()).deleteByNamespaceId(any()); } + + @Test + void renewingConsumedInvitationDoesNotClaimToReplaceUsableLink() { + NamespaceInvitationService.Created first = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation old = saved.getValue(); + old.consume("guest", clock.instant()); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(old)); + + NamespaceInvitationService.Created renewed = service.create("team", "owner", false); + + assertThat(renewed.replaced()).isFalse(); + assertThat(renewed.token()).isNotEqualTo(first.token()); + } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5b33c645..b35dd8a6 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -751,8 +751,8 @@ export const namespaceApi = { return fetchJson<{ active: boolean }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`) }, - async createInvitation(slug: string, replace: boolean): Promise<{ token: string; expiresAt: string }> { - return fetchJson<{ token: string; expiresAt: string }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`, { + async createInvitation(slug: string, replace: boolean): Promise<{ token: string; expiresAt: string; replaced: boolean }> { + return fetchJson<{ token: string; expiresAt: string; replaced: boolean }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`, { method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ replace }), }) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 8884f9ca..979714c1 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1932,6 +1932,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/namespaces/{slug}/invitation": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["status"]; + put?: never; + post: operations["create"]; + delete: operations["revoke"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/invitation": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["status_1"]; + put?: never; + post: operations["create_1"]; + delete: operations["revoke_1"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/namespaces/{slug}/freeze": { parameters: { query?: never; @@ -2028,6 +2060,70 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/invitations/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["preview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/invitations/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["preview_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/invitations/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["accept"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/invitations/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["accept_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/governance/notifications/{id}/read": { parameters: { query?: never; @@ -2069,7 +2165,7 @@ export interface paths { }; get: operations["list_4"]; put?: never; - post: operations["create"]; + post: operations["create_2"]; delete?: never; options?: never; head?: never; @@ -5245,7 +5341,7 @@ export interface paths { get?: never; put?: never; post?: never; - delete: operations["revoke"]; + delete: operations["revoke_2"]; options?: never; head?: never; patch?: never; @@ -6070,9 +6166,44 @@ export interface components { success?: boolean; error?: string; }; + NamespaceInvitationCreateRequest: { + replace?: boolean; + }; + ApiResponseCreated: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["Created"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + Created: { + token?: string; + /** Format: date-time */ + expiresAt?: string; + replaced?: boolean; + }; NamespaceLifecycleRequest: { reason?: string; }; + NamespaceInvitationTokenRequest: { + token: string; + }; + ApiResponsePreview: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["Preview"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + Preview: { + namespaceSlug?: string; + namespaceName?: string; + alreadyMember?: boolean; + }; ApiResponseGovernanceNotificationResponse: { /** Format: int32 */ code?: number; @@ -7265,6 +7396,18 @@ export interface components { email?: string; status?: string; }; + ApiResponseInvitationStatus: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["InvitationStatus"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + InvitationStatus: { + active?: boolean; + }; ApiResponsePageResponseNamespaceResponse: { /** Format: int32 */ code?: number; @@ -11939,6 +12082,146 @@ export interface operations { }; }; }; + status: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseInvitationStatus"]; + }; + }; + }; + }; + create: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["NamespaceInvitationCreateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCreated"]; + }; + }; + }; + }; + revoke: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + status_1: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseInvitationStatus"]; + }; + }; + }; + }; + create_1: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["NamespaceInvitationCreateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCreated"]; + }; + }; + }; + }; + revoke_1: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; freezeNamespace: { parameters: { query?: never; @@ -12135,6 +12418,102 @@ export interface operations { }; }; }; + preview: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceInvitationTokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePreview"]; + }; + }; + }; + }; + preview_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceInvitationTokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePreview"]; + }; + }; + }; + }; + accept: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceInvitationTokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePreview"]; + }; + }; + }; + }; + accept_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceInvitationTokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePreview"]; + }; + }; + }; + }; markNotificationRead: { parameters: { query?: never; @@ -12202,7 +12581,7 @@ export interface operations { }; }; }; - create: { + create_2: { parameters: { query?: never; header?: never; @@ -17093,7 +17472,7 @@ export interface operations { }; }; }; - revoke: { + revoke_2: { parameters: { query?: never; header?: never; diff --git a/web/src/features/namespace/invitation-controls.tsx b/web/src/features/namespace/invitation-controls.tsx index b0e4fdc5..058fa75a 100644 --- a/web/src/features/namespace/invitation-controls.tsx +++ b/web/src/features/namespace/invitation-controls.tsx @@ -18,13 +18,13 @@ export function InvitationControls({ slug }: { slug: string }) { const status = useQuery({ queryKey: ['namespace-invitation-status', slug], queryFn: () => namespaceApi.invitationStatus(slug) }) const create = useMutation({ mutationFn: (replace: boolean) => namespaceApi.createInvitation(slug, replace), - onSuccess: async ({ token }) => { + onSuccess: async ({ token, replaced }) => { await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }) const url = invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), token) setLink(url) try { await navigator.clipboard.writeText(url) - toast.success(t('invitation.copied')) + toast.success(t(replaced ? 'invitation.replacedCopied' : 'invitation.copied')) } catch { toast.info(t('invitation.manualCopy')) } @@ -43,7 +43,7 @@ export function InvitationControls({ slug }: { slug: string }) { else create.mutate(false) } return ( -
+
{status.data?.active ? : null} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 378617d4..7669e0d3 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1399,6 +1399,7 @@ "link": "Invitation link", "linkHint": "Valid for 24 hours and one new member. Share it securely.", "copied": "Invitation link copied", + "replacedCopied": "Old invitation replaced; new link copied", "manualCopy": "Automatic copy failed. Copy the link below manually.", "revoked": "Invitation revoked", "error": "Invitation action failed. Try again.", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 1c118256..1515824b 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1464,6 +1464,7 @@ "link": "Ссылка приглашения", "linkHint": "Действует 24 часа и только для одного нового участника. Передавайте безопасно.", "copied": "Ссылка скопирована", + "replacedCopied": "Старое приглашение заменено, новая ссылка скопирована", "manualCopy": "Не удалось скопировать автоматически. Скопируйте ссылку ниже.", "revoked": "Приглашение отозвано", "error": "Не удалось выполнить действие. Попробуйте ещё раз.", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index be47e8b5..2efc716e 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1399,6 +1399,7 @@ "link": "邀请链接", "linkHint": "24 小时内有效,仅能由一名新成员使用一次。请安全分享。", "copied": "邀请链接已复制", + "replacedCopied": "旧邀请已替换,新链接已复制", "manualCopy": "无法自动复制,请手动复制下方链接", "revoked": "邀请已作废", "error": "邀请操作失败,请重试", diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index b50f6717..afa08d22 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -178,6 +178,11 @@ export function MyNamespacesPage() { navigate({ to: `/dashboard/namespaces/${encodeURIComponent(slug)}/members` }) } + const handleInviteClick = (slug: string, e: React.MouseEvent) => { + e.stopPropagation() + navigate({ to: '/dashboard/namespaces/$slug/members', params: { slug }, hash: 'invite' }) + } + const handleReviewsClick = (slug: string, e: React.MouseEvent) => { e.stopPropagation() navigate({ to: `/dashboard/namespaces/${encodeURIComponent(slug)}/reviews` }) @@ -306,6 +311,12 @@ export function MyNamespacesPage() { {t('myNamespaces.manageMembers')} )} + {namespace.type === 'TEAM' && namespace.status === 'ACTIVE' + && (namespace.currentUserRole === 'OWNER' || namespace.currentUserRole === 'ADMIN') && ( + + )} + ) : ( <> From 7d36d420565248a007d0aeb1fc3d79ef62ed020f Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:15:49 +0800 Subject: [PATCH 08/47] test(namespace): cover invitation mutation races Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../specs/namespace-invitations/spec.md | 2 +- .../add-namespace-invite-links/tasks.md | 2 +- .../NamespaceInvitationPostgresTest.java | 177 +++++++++++++++++- .../namespace/invitation-controls.tsx | 15 +- web/src/pages/dashboard/my-namespaces.tsx | 10 +- 5 files changed, 188 insertions(+), 18 deletions(-) diff --git a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md index c793af05..5d370443 100644 --- a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md +++ b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md @@ -85,7 +85,7 @@ Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码; 前端 SHALL 完成链接分享、登录回跳和邀请状态闭环。 -空间卡片和成员管理页提供邀请入口,管理页提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token,随后进入空间时替换浏览器历史中的邀请页。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。 +空间卡片提供单击创建并复制邀请的入口,有仍可兑换的旧链接时先确认替换;成员管理页也提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token,随后进入空间时替换浏览器历史中的邀请页。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。 #### Scenario: 剪贴板写入失败 - **WHEN** 创建成功但浏览器拒绝写剪贴板 diff --git a/openspec/changes/add-namespace-invite-links/tasks.md b/openspec/changes/add-namespace-invite-links/tasks.md index ebefc1b5..1e2f9f3d 100644 --- a/openspec/changes/add-namespace-invite-links/tasks.md +++ b/openspec/changes/add-namespace-invite-links/tasks.md @@ -13,5 +13,5 @@ ## 3. 验证与交付 - [ ] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。 -- [ ] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。 +- [x] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。 - [ ] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java index a64c8cdc..b677e1bc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java @@ -1,17 +1,30 @@ package com.iflytek.skillhub.repository; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.auth.merge.AccountMergeRequest; +import com.iflytek.skillhub.auth.merge.AccountMergeRequestRepository; +import com.iflytek.skillhub.auth.merge.AccountMergeService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; +import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserStatus; import com.iflytek.skillhub.infra.jpa.NamespaceInvitationJpaRepository; import com.iflytek.skillhub.infra.jpa.NamespaceJpaRepository; import com.iflytek.skillhub.infra.jpa.NamespaceMemberJpaRepository; import com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository; import java.time.Clock; +import java.time.Instant; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; @@ -21,6 +34,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; @@ -40,7 +54,10 @@ import static org.assertj.core.api.Assertions.assertThat; @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ActiveProfiles("test") @Testcontainers -@Import({NamespaceInvitationService.class, AuditLogService.class, NamespaceInvitationPostgresTest.ClockConfig.class}) +@Import({NamespaceInvitationService.class, NamespaceMemberService.class, NamespaceService.class, + AccountMergeService.class, + NamespaceAccessPolicy.class, NamespaceGovernanceService.class, AuditLogService.class, + NamespaceInvitationPostgresTest.ClockConfig.class}) @Transactional(propagation = Propagation.NOT_SUPPORTED) class NamespaceInvitationPostgresTest { @Container @@ -63,11 +80,19 @@ class NamespaceInvitationPostgresTest { } @Autowired private NamespaceInvitationService invitations; + @Autowired private NamespaceMemberService memberService; + @Autowired private NamespaceGovernanceService governance; + @Autowired private NamespaceService namespaceService; + @Autowired private AccountMergeService accountMerge; + @Autowired private AccountMergeRequestRepository mergeRequests; @Autowired private NamespaceInvitationJpaRepository invitationRows; @Autowired private NamespaceJpaRepository namespaces; @Autowired private NamespaceMemberJpaRepository members; @Autowired private UserAccountJpaRepository users; @Autowired private PlatformTransactionManager transactionManager; + @MockBean private SkillRepository skills; + @MockBean private ReviewTaskRepository reviewTasks; + @MockBean private PromotionRequestRepository promotions; @Test void twoConcurrentAcceptsCreateOnlyOneMemberAndOneConsumption() throws Exception { @@ -136,6 +161,156 @@ class NamespaceInvitationPostgresTest { } } + @Test + void directAddAndAcceptProduceOneMembershipWithoutConsumingUnusedLink() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = createTeam(slug, owner, guest); + String token = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + boolean acceptedAsExistingMember; + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> { + start.await(); + return invitations.accept(token, guest).alreadyMember(); + }); + var direct = pool.submit(() -> { + start.await(); + try { + memberService.addMember(namespaceId, guest, NamespaceRole.MEMBER, owner); + return true; + } catch (DomainBadRequestException alreadyMember) { + assertThat(alreadyMember).hasMessageContaining("error.namespace.member.alreadyExists"); + return false; + } + }); + start.countDown(); + acceptedAsExistingMember = accept.get(20, TimeUnit.SECONDS); + boolean directAdded = direct.get(20, TimeUnit.SECONDS); + assertThat(acceptedAsExistingMember).isEqualTo(directAdded); + } + assertThat(members.findByNamespaceId(namespaceId, org.springframework.data.domain.Pageable.unpaged()) + .getContent().stream().filter(member -> member.getUserId().equals(guest))).hasSize(1); + assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedAt() != null) + .isEqualTo(!acceptedAsExistingMember); + } + + @Test + void freezeAndArchiveSerializeWithAcceptance() throws Exception { + for (boolean archive : new boolean[] {false, true}) { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = createTeam(slug, owner, guest); + String token = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> attempt(start, token, guest)); + var lifecycle = pool.submit(() -> { + start.await(); + if (archive) governance.archiveNamespace(slug, owner, "test", null, null, null); + else governance.freezeNamespace(slug, owner, "test", null, null, null); + return true; + }); + start.countDown(); + int accepted = accept.get(20, TimeUnit.SECONDS); + assertThat(lifecycle.get(20, TimeUnit.SECONDS)).isTrue(); + assertThat(members.findByNamespaceIdAndUserId(namespaceId, guest).isPresent()) + .isEqualTo(accepted == 1); + assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedAt() != null) + .isEqualTo(accepted == 1); + } + } + } + + @Test + void deletingNamespaceAndAcceptingInvitationLeaveNoOrphan() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = createTeam(slug, owner, guest); + String token = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> attempt(start, token, guest)); + var deletion = pool.submit(() -> { + start.await(); + namespaceService.deleteNamespace(namespaceId, owner); + return true; + }); + start.countDown(); + accept.get(20, TimeUnit.SECONDS); + assertThat(deletion.get(20, TimeUnit.SECONDS)).isTrue(); + } + assertThat(namespaces.findBySlug(slug)).isEmpty(); + assertThat(invitationRows.findByNamespaceId(namespaceId)).isEmpty(); + assertThat(members.findByNamespaceIdAndUserId(namespaceId, guest)).isEmpty(); + } + + @Test + void overlappingNamespaceAccountMergeAndAcceptKeepOnePrimaryMembership() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String primary = "inv-a-" + suffix; + String secondary = "inv-z-" + suffix; + String inviteSlug = "inv-b-" + suffix; + Long[] ids = new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(primary, primary, null, null)); + users.saveAndFlush(new UserAccount(secondary, secondary, null, null)); + Namespace shared = namespaces.saveAndFlush(new Namespace("inv-a-" + suffix, "Shared", owner)); + Namespace invited = namespaces.saveAndFlush(new Namespace(inviteSlug, "Invited", owner)); + for (Namespace ns : new Namespace[] {shared, invited}) { + members.saveAndFlush(new NamespaceMember(ns.getId(), owner, NamespaceRole.OWNER)); + members.saveAndFlush(new NamespaceMember(ns.getId(), secondary, NamespaceRole.MEMBER)); + } + members.saveAndFlush(new NamespaceMember(shared.getId(), primary, NamespaceRole.MEMBER)); + AccountMergeRequest request = new AccountMergeRequest(primary, secondary, null, + Instant.now().plusSeconds(3600)); + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + return new Long[] {shared.getId(), invited.getId(), mergeRequests.saveAndFlush(request).getId()}; + }); + String token = invitations.create(inviteSlug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + boolean alreadyMember; + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> { + start.await(); + return invitations.accept(token, primary).alreadyMember(); + }); + var merge = pool.submit(() -> { + start.await(); + accountMerge.confirm(primary, ids[2]); + return true; + }); + start.countDown(); + alreadyMember = accept.get(20, TimeUnit.SECONDS); + assertThat(merge.get(20, TimeUnit.SECONDS)).isTrue(); + } + assertThat(users.findAllById(java.util.List.of(secondary)).iterator().next().getStatus()).isEqualTo(UserStatus.MERGED); + assertThat(members.findByNamespaceIdAndUserId(ids[0], secondary)).isEmpty(); + assertThat(members.findByNamespaceIdAndUserId(ids[1], secondary)).isEmpty(); + assertThat(members.findByNamespaceIdAndUserId(ids[1], primary)).isPresent(); + assertThat(members.findByNamespaceId(ids[1], org.springframework.data.domain.Pageable.unpaged()) + .getContent().stream().filter(member -> member.getUserId().equals(primary))).hasSize(1); + assertThat(invitationRows.findByNamespaceId(ids[1]).orElseThrow().getConsumedAt() != null) + .isEqualTo(!alreadyMember); + } + + private Long createTeam(String slug, String owner, String guest) { + return new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(guest, guest, null, null)); + Namespace namespace = namespaces.saveAndFlush(new Namespace(slug, "Invitation race", owner)); + members.saveAndFlush(new NamespaceMember(namespace.getId(), owner, NamespaceRole.OWNER)); + return namespace.getId(); + }); + } + private int attempt(CountDownLatch start, String token, String userId) throws InterruptedException { start.await(); try { diff --git a/web/src/features/namespace/invitation-controls.tsx b/web/src/features/namespace/invitation-controls.tsx index 058fa75a..fc014371 100644 --- a/web/src/features/namespace/invitation-controls.tsx +++ b/web/src/features/namespace/invitation-controls.tsx @@ -10,12 +10,12 @@ import { toast } from '@/shared/lib/toast' import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' -export function InvitationControls({ slug }: { slug: string }) { +export function InvitationControls({ slug, compact = false }: { slug: string; compact?: boolean }) { const { t } = useTranslation() const queryClient = useQueryClient() const [confirm, setConfirm] = useState<'replace' | 'revoke' | null>(null) const [link, setLink] = useState(null) - const status = useQuery({ queryKey: ['namespace-invitation-status', slug], queryFn: () => namespaceApi.invitationStatus(slug) }) + const status = useQuery({ queryKey: ['namespace-invitation-status', slug], queryFn: () => namespaceApi.invitationStatus(slug), enabled: !compact }) const create = useMutation({ mutationFn: (replace: boolean) => namespaceApi.createInvitation(slug, replace), onSuccess: async ({ token, replaced }) => { @@ -38,14 +38,15 @@ export function InvitationControls({ slug }: { slug: string }) { toast.success(t('invitation.revoked')) }, }) - const handleCreate = () => { - if (status.data?.active) setConfirm('replace') - else create.mutate(false) + const handleCreate = async () => { + const current = compact ? (await status.refetch()).data : status.data + if (current?.active) setConfirm('replace') + else if (current) create.mutate(false) } return ( -
+
event.stopPropagation() : undefined}>
- + {status.data?.active ? : null}
{link ? ( diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index afa08d22..48f60f7d 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -10,6 +10,7 @@ import { Pagination } from '@/shared/components/pagination' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { CreateNamespaceDialog } from '@/features/namespace/create-namespace-dialog' +import { InvitationControls } from '@/features/namespace/invitation-controls' import { useArchiveNamespace, useDeleteNamespace, @@ -178,11 +179,6 @@ export function MyNamespacesPage() { navigate({ to: `/dashboard/namespaces/${encodeURIComponent(slug)}/members` }) } - const handleInviteClick = (slug: string, e: React.MouseEvent) => { - e.stopPropagation() - navigate({ to: '/dashboard/namespaces/$slug/members', params: { slug }, hash: 'invite' }) - } - const handleReviewsClick = (slug: string, e: React.MouseEvent) => { e.stopPropagation() navigate({ to: `/dashboard/namespaces/${encodeURIComponent(slug)}/reviews` }) @@ -313,9 +309,7 @@ export function MyNamespacesPage() { )} {namespace.type === 'TEAM' && namespace.status === 'ACTIVE' && (namespace.currentUserRole === 'OWNER' || namespace.currentUserRole === 'ADMIN') && ( - + )}
{link ? (
- - event.currentTarget.select()} /> + + event.currentTarget.select()} />

{t('invitation.linkHint')}

) : null} From ff69136ddbbde61afea55309158fe1170df0d091 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:28:59 +0800 Subject: [PATCH 10/47] test(namespace): cover invitation expiry and role boundaries Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../NamespaceInvitationServiceTest.java | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java index 1c4d1ad3..bfa1a670 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java @@ -63,6 +63,48 @@ class NamespaceInvitationServiceTest { assertThat(created.replaced()).isFalse(); } + @Test + void namespaceAdminCanCreateInvitation() { + Optional active = users.findById("owner"); + when(members.findByNamespaceIdAndUserId(7L, "admin")) + .thenReturn(Optional.of(new NamespaceMember(7L, "admin", NamespaceRole.ADMIN))); + when(users.findLockedById("admin")).thenReturn(active); + when(users.findById("admin")).thenReturn(active); + + assertThat(service.create("team", "admin").token()).matches("[A-Za-z0-9_-]{43}"); + verify(invitations).save(any()); + } + + @Test + void platformAdminWithoutNamespaceMembershipCannotCreateInvitation() { + Optional active = users.findById("owner"); + when(users.findLockedById("platform-admin")).thenReturn(active); + + assertThatThrownBy(() -> service.create("team", "platform-admin")) + .isInstanceOf(DomainNotFoundException.class); + verify(invitations, never()).save(any()); + } + + @Test + void ownerCanCreateInvitationForPersonalNamespaceRepresentedAsTeam() { + Namespace personal = new Namespace("personal", "Personal", "owner"); + try { + var id = Namespace.class.getDeclaredField("id"); + id.setAccessible(true); + id.set(personal, 8L); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + assertThat(personal.getType()).isEqualTo(NamespaceType.TEAM); + when(namespaces.findIdBySlug("personal")).thenReturn(Optional.of(8L)); + when(namespaces.findLockedById(8L)).thenReturn(Optional.of(personal)); + when(members.findByNamespaceIdAndUserId(8L, "owner")) + .thenReturn(Optional.of(new NamespaceMember(8L, "owner", NamespaceRole.OWNER))); + + assertThat(service.create("personal", "owner").token()).matches("[A-Za-z0-9_-]{43}"); + verify(invitations).save(any()); + } + @Test void revokedTokenCannotBePreviewed() { assertThatThrownBy(() -> service.preview("A".repeat(43), "guest")) @@ -152,4 +194,59 @@ class NamespaceInvitationServiceTest { assertThat(renewed.replaced()).isFalse(); assertThat(renewed.token()).isNotEqualTo(first.token()); } + + @Test + void nonMemberCannotPreviewOrAcceptAtExactExpiryInstant() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + NamespaceInvitation expired = invitationExpiringNow(); + when(invitations.findByTokenHash(expired.getTokenHash())).thenReturn(Optional.of(expired)); + when(invitations.findNamespaceIdByTokenHash(expired.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(expired)); + + assertThat(service.hasActiveInvitation("team", "owner")).isFalse(); + assertThatThrownBy(() -> service.preview(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + assertThatThrownBy(() -> service.accept(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(members, never()).save(any()); + assertThat(expired.getConsumedAt()).isNull(); + } + + @Test + void existingMemberCanPreviewAndAcceptCurrentExpiredInvitationWhileNamespaceFrozen() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + NamespaceInvitation expired = invitationExpiringNow(); + when(invitations.findByTokenHash(expired.getTokenHash())).thenReturn(Optional.of(expired)); + when(invitations.findNamespaceIdByTokenHash(expired.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(expired)); + when(members.findByNamespaceIdAndUserId(7L, "guest")) + .thenReturn(Optional.of(new NamespaceMember(7L, "guest", NamespaceRole.MEMBER))); + namespaces.findById(7L).orElseThrow().setStatus(NamespaceStatus.FROZEN); + clearInvocations(invitations, members, audit); + + assertThat(service.preview(created.token(), "guest").alreadyMember()).isTrue(); + assertThat(service.accept(created.token(), "guest").alreadyMember()).isTrue(); + verify(members, never()).save(any()); + verify(invitations, never()).save(any()); + assertThat(expired.getConsumedAt()).isNull(); + } + + @Test + void renewingAtExactExpiryDoesNotClaimToReplaceUsableLink() { + NamespaceInvitationService.Created first = service.create("team", "owner"); + NamespaceInvitation expired = invitationExpiringNow(); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(expired)); + + NamespaceInvitationService.Created renewed = service.create("team", "owner", false); + + assertThat(renewed.replaced()).isFalse(); + assertThat(renewed.token()).isNotEqualTo(first.token()); + } + + private NamespaceInvitation invitationExpiringNow() { + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + return new NamespaceInvitation(7L, saved.getValue().getTokenHash(), "owner", + clock.instant().minusSeconds(86400), clock.instant()); + } } From f12019cf1448b13b57bfa94913b9d6ea59a07001 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:32:12 +0800 Subject: [PATCH 11/47] fix(invitation): keep login return target on join route Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../NamespaceInvitationServiceTest.java | 25 +++++++++++++++++++ web/src/app/router.test.ts | 20 +++++++++++++++ web/src/app/router.tsx | 3 ++- web/src/shared/lib/auth-route.ts | 4 +-- .../shared/lib/namespace-invitation.test.ts | 3 ++- 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java index bfa1a670..550c2e26 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -136,6 +137,30 @@ class NamespaceInvitationServiceTest { .isInstanceOf(DomainNotFoundException.class); } + @Test + void nonActiveUserCannotPreviewOrAcceptAndDoesNotConsumeInvitation() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation invitation = saved.getValue(); + when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation)); + when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); + UserAccount pending = mock(UserAccount.class); + when(pending.getStatus()).thenReturn(UserStatus.PENDING); + when(users.findById("guest")).thenReturn(Optional.of(pending)); + when(users.findLockedById("guest")).thenReturn(Optional.of(pending)); + clearInvocations(invitations, members, audit); + + assertThatThrownBy(() -> service.preview(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + assertThatThrownBy(() -> service.accept(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(members, never()).save(any()); + verify(invitations, never()).save(any()); + assertThat(invitation.getConsumedAt()).isNull(); + } + @Test void existingMemberCanOpenCurrentConsumedInvitationWithoutReconsuming() { NamespaceInvitationService.Created created = service.create("team", "owner"); diff --git a/web/src/app/router.test.ts b/web/src/app/router.test.ts index f8a95847..50c879cb 100644 --- a/web/src/app/router.test.ts +++ b/web/src/app/router.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { isRedirect } from '@tanstack/react-router' // The router module captures window.location.search at module load time. // We test the exported ORIGINAL_URL_SEARCH constant and the buildReturnTo @@ -51,4 +52,23 @@ describe('router', () => { const childPaths = children.map((route) => route.fullPath ?? route.path) expect(childPaths).toContain('/device') }) + + it('returns unauthenticated invitation visitors to /join even with a query and deployment base path', async () => { + const children = (router.routeTree.children ?? []) as Array<{ + fullPath?: string + options?: { beforeLoad?: (context: { location: { pathname: string; searchStr: string; hash: string } }) => Promise } + }> + const join = children.find((route) => route.fullPath === '/join') + expect(join?.options?.beforeLoad).toBeDefined() + + await expect(join!.options!.beforeLoad!({ + location: { pathname: '/skillhub/join', searchStr: '?x=1', hash: '#invitation-token' }, + })).rejects.toSatisfy((error: unknown) => { + expect(isRedirect(error)).toBe(true) + if (!isRedirect(error)) return false + expect(error.options.to).toBe('/login') + expect(error.options.search).toEqual({ returnTo: '/join' }) + return true + }) + }) }) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 6a706d03..55e332ea 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -236,6 +236,7 @@ const rootRoute = createRootRoute({ }) const requireAuth = createRequireAuth(getCurrentUser) +const requireJoinAuth = createRequireAuth(getCurrentUser, '/join') const redirectAuthenticated = createRedirectAuthenticated(getCurrentUser) const landingRoute = createRoute({ @@ -329,7 +330,7 @@ const namespaceRoute = createRoute({ const joinRoute = createRoute({ getParentRoute: () => rootRoute, path: 'join', - beforeLoad: requireAuth, + beforeLoad: requireJoinAuth, component: JoinPage, }) diff --git a/web/src/shared/lib/auth-route.ts b/web/src/shared/lib/auth-route.ts index ada9d746..6988445f 100644 --- a/web/src/shared/lib/auth-route.ts +++ b/web/src/shared/lib/auth-route.ts @@ -53,13 +53,13 @@ export function createRedirectAuthenticated(getCurrentUser: () => Promise Promise) { +export function createRequireAuth(getCurrentUser: () => Promise, returnTo?: string) { return async function requireAuth({ location }: { location: RouteLocationLike }) { const user = await getCurrentUser() if (!user) { throw redirect({ to: '/login', - search: { returnTo: buildReturnTo(location) }, + search: { returnTo: returnTo ?? buildReturnTo(location) }, }) } return { user } diff --git a/web/src/shared/lib/namespace-invitation.test.ts b/web/src/shared/lib/namespace-invitation.test.ts index 0b389281..09aa5b05 100644 --- a/web/src/shared/lib/namespace-invitation.test.ts +++ b/web/src/shared/lib/namespace-invitation.test.ts @@ -14,7 +14,8 @@ describe('namespace invitation URL handoff', () => { window.history.replaceState(null, '', `/join?from=mail#${token}`) captureInvitationFragment() expect(storedInvitationToken()).toBe(token) - expect(window.location.href).toContain('/join?from=mail') + expect(window.location.pathname).toBe('/join') + expect(window.location.search).toBe('?from=mail') expect(window.location.hash).toBe('') }) From 6299733dd8e94182c1c616b36f63662d2777b44e Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:36:35 +0800 Subject: [PATCH 12/47] test(invitation): cover invitation permission boundaries Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../NamespaceInvitationControllerTest.java | 13 +++++++++ .../NamespaceInvitationServiceTest.java | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java index 8ff1e84a..f3727e92 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java @@ -32,6 +32,19 @@ class NamespaceInvitationControllerTest { verifyNoInteractions(invitations); } + @Test + void refusesApiTokenForInvitationStatusAndRevoke() { + when(request.getSession(false)).thenReturn(mock(HttpSession.class)); + PlatformPrincipal token = new PlatformPrincipal("admin", "Admin", null, null, "api_token", Set.of("SUPER_ADMIN")); + + assertThatThrownBy(() -> controller.status("team", token, request, response)) + .isInstanceOf(UnauthorizedException.class); + verifyNoInteractions(invitations); + assertThatThrownBy(() -> controller.revoke("team", token, request, response)) + .isInstanceOf(UnauthorizedException.class); + verifyNoInteractions(invitations); + } + @Test void refusesAuthenticationWithoutAnExistingBrowserSession() { PlatformPrincipal principal = new PlatformPrincipal("owner", "Owner", null, null, "local", Set.of()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java index 550c2e26..1e98a4c5 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java @@ -76,6 +76,35 @@ class NamespaceInvitationServiceTest { verify(invitations).save(any()); } + @Test + void namespaceMemberCannotCreateInvitation() { + Optional active = users.findById("owner"); + when(members.findByNamespaceIdAndUserId(7L, "guest")) + .thenReturn(Optional.of(new NamespaceMember(7L, "guest", NamespaceRole.MEMBER))); + when(users.findLockedById("guest")).thenReturn(active); + + assertThatThrownBy(() -> service.create("team", "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(invitations, never()).save(any()); + } + + @Test + void ownerCannotCreateInvitationForGlobalFrozenOrArchivedNamespace() { + Namespace namespace = namespaces.findById(7L).orElseThrow(); + namespace.setType(NamespaceType.GLOBAL); + assertThatThrownBy(() -> service.create("team", "owner")) + .isInstanceOf(DomainNotFoundException.class); + + namespace.setType(NamespaceType.TEAM); + for (NamespaceStatus status : new NamespaceStatus[] {NamespaceStatus.FROZEN, NamespaceStatus.ARCHIVED}) { + namespace.setStatus(status); + assertThatThrownBy(() -> service.create("team", "owner")) + .as("Namespace status %s", status) + .isInstanceOf(DomainNotFoundException.class); + } + verify(invitations, never()).save(any()); + } + @Test void platformAdminWithoutNamespaceMembershipCannotCreateInvitation() { Optional active = users.findById("owner"); From 44a1623620e1aeef41eeafa7e9de58c540f7c7d2 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:48:18 +0800 Subject: [PATCH 13/47] fix(invitation): align API with issue contract Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../add-namespace-invite-links/design.md | 1 + .../specs/namespace-invitations/spec.md | 16 ++++ .../add-namespace-invite-links/tasks.md | 14 +-- .../portal/NamespaceInvitationController.java | 12 +-- .../src/main/resources/messages.properties | 2 +- .../src/main/resources/messages_ru.properties | 2 +- .../src/main/resources/messages_zh.properties | 2 +- .../NamespaceInvitationControllerTest.java | 95 +++++++++++++++++++ .../NamespaceInvitationPostgresTest.java | 4 +- .../namespace/NamespaceInvitationService.java | 19 ++-- .../NamespaceInvitationServiceTest.java | 26 +++-- web/src/api/client.test.ts | 31 ++++++ web/src/api/client.ts | 14 +-- web/src/api/generated/schema.d.ts | 38 ++++++-- web/src/i18n/locales/en.json | 2 +- web/src/i18n/locales/ru.json | 2 +- web/src/i18n/locales/zh.json | 2 +- web/src/pages/join.tsx | 6 +- 18 files changed, 233 insertions(+), 55 deletions(-) diff --git a/openspec/changes/add-namespace-invite-links/design.md b/openspec/changes/add-namespace-invite-links/design.md index fd3c405d..624597ce 100644 --- a/openspec/changes/add-namespace-invite-links/design.md +++ b/openspec/changes/add-namespace-invite-links/design.md @@ -10,6 +10,7 @@ 4. Preview/Accept 复用 Issue 的判定顺序:先验证 Token 为当前邀请,再查已有成员;已有成员返回 ALREADY_MEMBER。非成员必须面对 ACTIVE TEAM、未消费且未过期的邀请。失效统一 404,不区分随机、旧、作废、过期或已用 Token。Accept 锁前只按 Token 哈希查询标量 Namespace ID;取得 Namespace 锁后重新加载当前邀请并校验,不能沿用锁前已经进入 ORM 一级缓存的邀请实体,否则并发兑换可能都看到未消费状态。 5. 只有可用浏览器 Session 能创建、预览、接受、作废;现有账号准入不能由邀请绕过。浏览器请求使用既有 CSRF。创建/预览/接受/作废与缓存或日志配置均不得泄露原文和哈希。 6. 管理页面保留原直接添加成员入口,新增邀请按钮和单独作废按钮。仍可兑换的旧链接替换前先确认;剪贴板失败时展示本次生成的同一链接。 +7. 正式路径沿用 Issue #911 的复数合同:`POST /api/v1/namespaces/{slug}/invitations` 创建或替换,`POST /api/v1/namespace-invitations/preview|accept` 预览及确认。状态查询和作废使用同一 `/namespaces/{slug}/invitations` 路径的 GET/DELETE;保留现有 `/api/web` 别名。Preview 返回 `namespaceSlug`、`namespaceDisplayName`、`namespaceAvatarUrl`、`outcome`、`expiresAt`;Accept 仅返回 `namespaceSlug`、`outcome`。无效邀请的本地化键为 `error.namespace.invitation.invalid`。 ## Risks and verification diff --git a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md index 5d370443..f1f2769c 100644 --- a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md +++ b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md @@ -94,3 +94,19 @@ Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码; #### Scenario: 网络错误 - **WHEN** 预览或接受遇到可重试网络错误 - **THEN** 页面保留 Token,用户可重试而无需新邀请 + +### Requirement: REQ-INV-07 API SHALL 使用 Issue #911 的路径与响应合同 + +API SHALL 使用 Issue #911 的路径与响应合同。 + +创建或替换使用 `POST /api/v1/namespaces/{slug}/invitations`,首次创建允许无请求体;有仍可兑换的当前邀请时须按已批准的确认流程传入 `replace=true`。状态查询和单独作废分别使用同一路径的 GET 与 DELETE。预览和接受分别使用 `POST /api/v1/namespace-invitations/preview` 与 `POST /api/v1/namespace-invitations/accept`,请求体均为 `{ "token": "..." }`;可保留 `/api/web` 别名。 + +创建数据仅包含 `token`、`expiresAt`、`replaced`。预览数据包含 `namespaceSlug`、`namespaceDisplayName`、`namespaceAvatarUrl`、`outcome`(`CONFIRM_REQUIRED` 或 `ALREADY_MEMBER`)和 `expiresAt`。接受数据仅包含 `namespaceSlug` 和 `outcome`(`JOINED` 或 `ALREADY_MEMBER`)。无效邀请使用 `error.namespace.invitation.invalid` 本地化键,中文为“邀请链接无效或已失效”,响应为 HTTP 404、envelope `code=404`、`data=null`。 + +#### Scenario: 首次创建和预览 +- **WHEN** OWNER/ADMIN 首次创建邀请且不传请求体,非成员预览链接 +- **THEN** 创建返回三字段,预览返回五字段且 `outcome=CONFIRM_REQUIRED` + +#### Scenario: 接受与已有成员 +- **WHEN** 非成员确认加入,或已有成员访问当前邀请 +- **THEN** 接受分别返回 `JOINED` 或 `ALREADY_MEMBER`,且仅包含 `namespaceSlug` 和 `outcome` diff --git a/openspec/changes/add-namespace-invite-links/tasks.md b/openspec/changes/add-namespace-invite-links/tasks.md index 1e2f9f3d..0b8b0f17 100644 --- a/openspec/changes/add-namespace-invite-links/tasks.md +++ b/openspec/changes/add-namespace-invite-links/tasks.md @@ -1,17 +1,17 @@ ## 1. 持久化与事务 -- [ ] 1.1 增加当前邀请表、Token 哈希、时效和审计迁移。 -- [ ] 1.2 实现 Namespace 根锁,并让成员、生命周期、删除和账号合并路径遵循统一锁顺序。 -- [ ] 1.3 实现创建/替换/作废/预览/接受,接受时原子新增成员、消费邀请与写审计。 +- [x] 1.1 增加当前邀请表、Token 哈希、时效和审计迁移。 +- [x] 1.2 实现 Namespace 根锁,并让成员、生命周期、删除和账号合并路径遵循统一锁顺序。 +- [x] 1.3 实现创建/替换/作废/预览/接受,接受时原子新增成员、消费邀请与写审计。 ## 2. API 与页面 -- [ ] 2.1 新增仅 Session 可调用且受 CSRF 保护的邀请接口,重新生成 OpenAPI 类型。 -- [ ] 2.2 在成员管理入口添加邀请和作废动作,替换前确认,复制失败展示原链接。 -- [ ] 2.3 实现 `/join` 的 fragment 捕获、同标签页登录回跳、确认加入和失效状态;覆盖 BASE_PATH 与三语文案。 +- [x] 2.1 新增仅 Session 可调用且受 CSRF 保护的邀请接口,重新生成 OpenAPI 类型。 +- [x] 2.2 在成员管理入口添加邀请和作废动作,替换前确认,复制失败展示原链接。 +- [x] 2.3 实现 `/join` 的 fragment 捕获、同标签页登录回跳、确认加入和失效状态;覆盖 BASE_PATH 与三语文案。 ## 3. 验证与交付 -- [ ] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。 +- [x] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。 - [x] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。 - [ ] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java index aa07b4af..02879c68 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java @@ -31,7 +31,7 @@ public class NamespaceInvitationController extends BaseApiController { this.invitations = invitations; } - @GetMapping("/namespaces/{slug}/invitation") + @GetMapping("/namespaces/{slug}/invitations") public ApiResponse status(@PathVariable String slug, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { noStore(response); @@ -39,7 +39,7 @@ public class NamespaceInvitationController extends BaseApiController { return ok("response.success.read", new InvitationStatus(invitations.hasActiveInvitation(slug, principal.userId()))); } - @PostMapping("/namespaces/{slug}/invitation") + @PostMapping("/namespaces/{slug}/invitations") public ApiResponse create(@PathVariable String slug, @RequestBody(required = false) NamespaceInvitationCreateRequest body, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { @@ -48,7 +48,7 @@ public class NamespaceInvitationController extends BaseApiController { return ok("response.success.created", invitations.create(slug, principal.userId(), body != null && body.replace())); } - @DeleteMapping("/namespaces/{slug}/invitation") + @DeleteMapping("/namespaces/{slug}/invitations") public ApiResponse revoke(@PathVariable String slug, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { noStore(response); @@ -57,7 +57,7 @@ public class NamespaceInvitationController extends BaseApiController { return ok("response.success.deleted", new MessageResponse("Invitation revoked")); } - @PostMapping("/invitations/preview") + @PostMapping("/namespace-invitations/preview") public ApiResponse preview(@Valid @RequestBody NamespaceInvitationTokenRequest body, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { noStore(response); @@ -65,8 +65,8 @@ public class NamespaceInvitationController extends BaseApiController { return ok("response.success.read", invitations.preview(body.token(), principal.userId())); } - @PostMapping("/invitations/accept") - public ApiResponse accept(@Valid @RequestBody NamespaceInvitationTokenRequest body, + @PostMapping("/namespace-invitations/accept") + public ApiResponse accept(@Valid @RequestBody NamespaceInvitationTokenRequest body, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { noStore(response); requireSession(principal, request); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index b7555086..1635516d 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -88,7 +88,7 @@ label.translation.locale.conflict=Duplicate label translation locale error.namespace.slug.exists=Namespace slug ''{0}'' already exists error.namespace.id.notFound=Namespace not found: {0} error.namespace.slug.notFound=Namespace not found: {0} -error.namespace.invite.invalid=Invitation not found or expired +error.namespace.invitation.invalid=Invitation link is invalid or expired error.namespace.invite.replaceConfirmationRequired=Confirm replacement of the current invitation error.namespace.membership.required=Namespace membership required error.namespace.global.members.platformAdmin.required=Only platform user administrators can list global namespace members diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 496c2c31..dbaefe0c 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -85,7 +85,7 @@ label.translation.locale.conflict=Дублирующаяся локаль пер error.namespace.slug.exists=Slug пространства имён ''{0}'' уже существует error.namespace.id.notFound=Пространство имён не найдено: {0} error.namespace.slug.notFound=Пространство имён не найдено: {0} -error.namespace.invite.invalid=Приглашение не найдено или недействительно +error.namespace.invitation.invalid=Ссылка-приглашение недействительна или срок её действия истёк error.namespace.invite.replaceConfirmationRequired=Подтвердите замену текущего приглашения error.namespace.membership.required=Требуется членство в пространстве имён error.namespace.global.members.platformAdmin.required=Только администраторы пользователей платформы могут просматривать участников глобального пространства имён diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 046a459b..1a38634b 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -88,7 +88,7 @@ label.translation.locale.conflict=标签翻译 locale 重复 error.namespace.slug.exists=命名空间 slug ''{0}'' 已存在 error.namespace.id.notFound=未找到命名空间:{0} error.namespace.slug.notFound=未找到命名空间:{0} -error.namespace.invite.invalid=邀请不存在或已失效 +error.namespace.invitation.invalid=邀请链接无效或已失效 error.namespace.invite.replaceConfirmationRequired=请先确认替换当前邀请 error.namespace.membership.required=需要先加入该命名空间 error.namespace.global.members.platformAdmin.required=只有平台用户管理员可以查看 global 命名空间成员 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java index f3727e92..8da40754 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java @@ -1,18 +1,38 @@ package com.iflytek.skillhub.controller; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.portal.NamespaceInvitationController; import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.NamespaceInvitationCreateRequest; +import com.iflytek.skillhub.dto.NamespaceInvitationTokenRequest; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.exception.UnauthorizedException; +import com.iflytek.skillhub.exception.GlobalExceptionHandler; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.security.SensitiveLogSanitizer; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import org.junit.jupiter.api.Test; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Locale; import java.util.Set; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; @@ -62,4 +82,79 @@ class NamespaceInvitationControllerTest { verify(response).setHeader("Pragma", "no-cache"); verify(invitations).create("team", "owner", false); } + + @Test + void issueRoutesUsePluralPathsForBothApiPrefixes() throws NoSuchMethodException { + assertThat(NamespaceInvitationController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/v1", "/api/web"); + assertThat(NamespaceInvitationController.class.getMethod("create", String.class, + NamespaceInvitationCreateRequest.class, PlatformPrincipal.class, HttpServletRequest.class, + HttpServletResponse.class).getAnnotation(PostMapping.class).value()) + .containsExactly("/namespaces/{slug}/invitations"); + assertThat(NamespaceInvitationController.class.getMethod("status", String.class, PlatformPrincipal.class, + HttpServletRequest.class, HttpServletResponse.class).getAnnotation(GetMapping.class).value()) + .containsExactly("/namespaces/{slug}/invitations"); + assertThat(NamespaceInvitationController.class.getMethod("revoke", String.class, PlatformPrincipal.class, + HttpServletRequest.class, HttpServletResponse.class).getAnnotation(DeleteMapping.class).value()) + .containsExactly("/namespaces/{slug}/invitations"); + assertThat(NamespaceInvitationController.class.getMethod("preview", NamespaceInvitationTokenRequest.class, + PlatformPrincipal.class, HttpServletRequest.class, HttpServletResponse.class) + .getAnnotation(PostMapping.class).value()).containsExactly("/namespace-invitations/preview"); + assertThat(NamespaceInvitationController.class.getMethod("accept", NamespaceInvitationTokenRequest.class, + PlatformPrincipal.class, HttpServletRequest.class, HttpServletResponse.class) + .getAnnotation(PostMapping.class).value()).containsExactly("/namespace-invitations/accept"); + } + + @Test + void firstCreateNeedsNoBodyAndSuccessDtosMatchIssueContract() { + when(request.getSession(false)).thenReturn(mock(HttpSession.class)); + PlatformPrincipal owner = new PlatformPrincipal("owner", "Owner", null, null, "local", Set.of()); + Instant expiry = Instant.parse("2026-10-13T00:00:00Z"); + NamespaceInvitationService.Created created = new NamespaceInvitationService.Created("raw-token", expiry, false); + when(invitations.create("team", "owner", false)).thenReturn(created); + when(responses.ok(anyString(), any())).thenAnswer(call -> new ApiResponse<>(0, "ok", call.getArgument(1), expiry, null)); + + assertThat(controller.create("team", null, owner, request, response).data()).isEqualTo(created); + verify(invitations).create("team", "owner", false); + + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + assertThat(mapper.valueToTree(created).fieldNames()).toIterable() + .containsExactlyInAnyOrder("token", "expiresAt", "replaced"); + for (NamespaceInvitationService.PreviewOutcome outcome : NamespaceInvitationService.PreviewOutcome.values()) { + NamespaceInvitationService.Preview preview = new NamespaceInvitationService.Preview( + "team", "Team", null, outcome, expiry); + assertThat(mapper.valueToTree(preview).fieldNames()).toIterable() + .containsExactlyInAnyOrder("namespaceSlug", "namespaceDisplayName", "namespaceAvatarUrl", "outcome", "expiresAt"); + assertThat(mapper.valueToTree(preview).get("outcome").asText()).isEqualTo(outcome.name()); + } + for (NamespaceInvitationService.AcceptOutcome outcome : NamespaceInvitationService.AcceptOutcome.values()) { + NamespaceInvitationService.Accepted accepted = new NamespaceInvitationService.Accepted("team", outcome); + assertThat(mapper.valueToTree(accepted).fieldNames()).toIterable() + .containsExactlyInAnyOrder("namespaceSlug", "outcome"); + assertThat(mapper.valueToTree(accepted).get("outcome").asText()).isEqualTo(outcome.name()); + } + } + + @Test + void invalidInvitationUsesIssueErrorKeyAndLocalized404Envelope() { + ResourceBundleMessageSource messages = new ResourceBundleMessageSource(); + messages.setBasename("messages"); + messages.setDefaultEncoding("UTF-8"); + ApiResponseFactory factory = new ApiResponseFactory(messages, Clock.fixed( + Instant.parse("2026-10-12T00:00:00Z"), ZoneOffset.UTC), new RequestIdAccessor()); + GlobalExceptionHandler handler = new GlobalExceptionHandler(factory, + mock(SensitiveLogSanitizer.class), mock(SkillHubMetrics.class), new RequestIdAccessor()); + LocaleContextHolder.setLocale(Locale.SIMPLIFIED_CHINESE); + try { + var result = handler.handleLocalizedDomainException( + new DomainNotFoundException("error.namespace.invitation.invalid"), request); + assertThat(result.getStatusCode().value()).isEqualTo(404); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().code()).isEqualTo(404); + assertThat(result.getBody().msg()).isEqualTo("邀请链接无效或已失效"); + assertThat(result.getBody().data()).isNull(); + } finally { + LocaleContextHolder.resetLocaleContext(); + } + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java index b677e1bc..74d5a554 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java @@ -174,7 +174,7 @@ class NamespaceInvitationPostgresTest { try (var pool = Executors.newFixedThreadPool(2)) { var accept = pool.submit(() -> { start.await(); - return invitations.accept(token, guest).alreadyMember(); + return invitations.accept(token, guest).outcome() == NamespaceInvitationService.AcceptOutcome.ALREADY_MEMBER; }); var direct = pool.submit(() -> { start.await(); @@ -280,7 +280,7 @@ class NamespaceInvitationPostgresTest { try (var pool = Executors.newFixedThreadPool(2)) { var accept = pool.submit(() -> { start.await(); - return invitations.accept(token, primary).alreadyMember(); + return invitations.accept(token, primary).outcome() == NamespaceInvitationService.AcceptOutcome.ALREADY_MEMBER; }); var merge = pool.submit(() -> { start.await(); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java index ae8bfc95..fa50f5cb 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java @@ -39,7 +39,11 @@ public class NamespaceInvitationService { } public record Created(String token, Instant expiresAt, boolean replaced) {} - public record Preview(String namespaceSlug, String namespaceName, boolean alreadyMember) {} + public enum PreviewOutcome { CONFIRM_REQUIRED, ALREADY_MEMBER } + public enum AcceptOutcome { JOINED, ALREADY_MEMBER } + public record Preview(String namespaceSlug, String namespaceDisplayName, String namespaceAvatarUrl, + PreviewOutcome outcome, Instant expiresAt) {} + public record Accepted(String namespaceSlug, AcceptOutcome outcome) {} @Transactional public Created create(String slug, String actorId) { @@ -88,7 +92,7 @@ public class NamespaceInvitationService { @Transactional(readOnly = true) public boolean hasActiveInvitation(String slug, String actorId) { Namespace namespace = namespaces.findBySlug(slug) - .orElseThrow(() -> new DomainNotFoundException("error.namespace.invite.invalid")); + .orElseThrow(() -> new DomainNotFoundException("error.namespace.invitation.invalid")); requireManager(namespace, actorId); return invitations.findByNamespaceId(namespace.getId()) .filter(invite -> invite.getConsumedAt() == null && invite.getExpiresAt().isAfter(clock.instant())) @@ -104,11 +108,12 @@ public class NamespaceInvitationService { requireActiveUser(userId); requireValid(namespace, invitation); } - return new Preview(namespace.getSlug(), namespace.getDisplayName(), member); + return new Preview(namespace.getSlug(), namespace.getDisplayName(), namespace.getAvatarUrl(), + member ? PreviewOutcome.ALREADY_MEMBER : PreviewOutcome.CONFIRM_REQUIRED, invitation.getExpiresAt()); } @Transactional - public Preview accept(String token, String userId) { + public Accepted accept(String token, String userId) { // Membership writers and account merge lock users before the Namespace root. // The FK check on namespace_member would otherwise invert that order. requireActiveLockedUser(userId); @@ -120,7 +125,7 @@ public class NamespaceInvitationService { throw invalid(); } if (members.findByNamespaceIdAndUserId(namespace.getId(), userId).isPresent()) { - return new Preview(namespace.getSlug(), namespace.getDisplayName(), true); + return new Accepted(namespace.getSlug(), AcceptOutcome.ALREADY_MEMBER); } requireActiveUser(userId); requireValid(namespace, invitation); @@ -128,7 +133,7 @@ public class NamespaceInvitationService { invitation.consume(userId, clock.instant()); invitations.save(invitation); record(userId, "NAMESPACE_INVITE_ACCEPTED", namespace.getId()); - return new Preview(namespace.getSlug(), namespace.getDisplayName(), false); + return new Accepted(namespace.getSlug(), AcceptOutcome.JOINED); } private Namespace lock(String slug) { @@ -170,7 +175,7 @@ public class NamespaceInvitationService { if (invitation.getConsumedAt() != null || !invitation.getExpiresAt().isAfter(clock.instant())) throw invalid(); } - private DomainNotFoundException invalid() { return new DomainNotFoundException("error.namespace.invite.invalid"); } + private DomainNotFoundException invalid() { return new DomainNotFoundException("error.namespace.invitation.invalid"); } private String hash(String token) { try { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java index 1e98a4c5..940c67d6 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java @@ -138,7 +138,8 @@ class NamespaceInvitationServiceTest { @Test void revokedTokenCannotBePreviewed() { assertThatThrownBy(() -> service.preview("A".repeat(43), "guest")) - .isInstanceOf(DomainNotFoundException.class); + .isInstanceOf(DomainNotFoundException.class) + .hasMessage("error.namespace.invitation.invalid"); verify(invitations).findByTokenHash(any()); } @@ -151,9 +152,16 @@ class NamespaceInvitationServiceTest { when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation)); when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L)); when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); + namespaces.findById(7L).orElseThrow().setAvatarUrl("https://example.com/team.png"); - assertThat(service.preview(created.token(), "guest").alreadyMember()).isFalse(); - assertThat(service.accept(created.token(), "guest").alreadyMember()).isFalse(); + NamespaceInvitationService.Preview preview = service.preview(created.token(), "guest"); + assertThat(preview.namespaceSlug()).isEqualTo("team"); + assertThat(preview.namespaceDisplayName()).isEqualTo("Team"); + assertThat(preview.namespaceAvatarUrl()).isEqualTo("https://example.com/team.png"); + assertThat(preview.outcome()).isEqualTo(NamespaceInvitationService.PreviewOutcome.CONFIRM_REQUIRED); + assertThat(preview.expiresAt()).isEqualTo(created.expiresAt()); + assertThat(service.accept(created.token(), "guest")) + .isEqualTo(new NamespaceInvitationService.Accepted("team", NamespaceInvitationService.AcceptOutcome.JOINED)); verify(members).save(argThat(member -> member.getRole() == NamespaceRole.MEMBER && member.getNamespaceId().equals(7L) && member.getUserId().equals("guest"))); @@ -203,8 +211,10 @@ class NamespaceInvitationServiceTest { when(members.findByNamespaceIdAndUserId(7L, "guest")) .thenReturn(Optional.of(new NamespaceMember(7L, "guest", NamespaceRole.MEMBER))); - assertThat(service.preview(created.token(), "guest").alreadyMember()).isTrue(); - assertThat(service.accept(created.token(), "guest").alreadyMember()).isTrue(); + assertThat(service.preview(created.token(), "guest").outcome()) + .isEqualTo(NamespaceInvitationService.PreviewOutcome.ALREADY_MEMBER); + assertThat(service.accept(created.token(), "guest").outcome()) + .isEqualTo(NamespaceInvitationService.AcceptOutcome.ALREADY_MEMBER); verify(members, never()).save(any()); } @@ -278,8 +288,10 @@ class NamespaceInvitationServiceTest { namespaces.findById(7L).orElseThrow().setStatus(NamespaceStatus.FROZEN); clearInvocations(invitations, members, audit); - assertThat(service.preview(created.token(), "guest").alreadyMember()).isTrue(); - assertThat(service.accept(created.token(), "guest").alreadyMember()).isTrue(); + assertThat(service.preview(created.token(), "guest").outcome()) + .isEqualTo(NamespaceInvitationService.PreviewOutcome.ALREADY_MEMBER); + assertThat(service.accept(created.token(), "guest").outcome()) + .isEqualTo(NamespaceInvitationService.AcceptOutcome.ALREADY_MEMBER); verify(members, never()).save(any()); verify(invitations, never()).save(any()); assertThat(expired.getConsumedAt()).isNull(); diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index da659024..d1d25158 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -172,6 +172,37 @@ describe('namespaceApi.delete', () => { }) }) +describe('namespace invitation API', () => { + it('uses the Issue #911 plural paths for status, create, revoke, preview, and accept', async () => { + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { cookie: 'XSRF-TOKEN=test-token' }, + }) + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ code: 0, msg: 'ok', data: {}, timestamp: '', requestId: '' }), + }) + vi.stubGlobal('fetch', fetchMock) + + await namespaceApi.invitationStatus('@team') + await namespaceApi.createInvitation('@team', false) + await namespaceApi.revokeInvitation('@team') + await namespaceApi.previewInvitation('raw-token') + await namespaceApi.acceptInvitation('raw-token') + + expect(fetchMock.mock.calls.map(([url, init]) => [url, init.method ?? 'GET'])).toEqual([ + ['/api/web/namespaces/team/invitations', 'GET'], + ['/api/web/namespaces/team/invitations', 'POST'], + ['/api/web/namespaces/team/invitations', 'DELETE'], + ['/api/web/namespace-invitations/preview', 'POST'], + ['/api/web/namespace-invitations/accept', 'POST'], + ]) + expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ replace: false }) + expect(JSON.parse(fetchMock.mock.calls[3][1].body)).toEqual({ token: 'raw-token' }) + }) +}) + describe('getDirectAuthRuntimeConfig', () => { it('returns disabled when no runtime config is present', () => { const config = getDirectAuthRuntimeConfig() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index b35dd8a6..c1968dda 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -748,31 +748,31 @@ export const labelApi = { export const namespaceApi = { async invitationStatus(slug: string): Promise<{ active: boolean }> { - return fetchJson<{ active: boolean }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`) + return fetchJson<{ active: boolean }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitations`) }, async createInvitation(slug: string, replace: boolean): Promise<{ token: string; expiresAt: string; replaced: boolean }> { - return fetchJson<{ token: string; expiresAt: string; replaced: boolean }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`, { + return fetchJson<{ token: string; expiresAt: string; replaced: boolean }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitations`, { method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ replace }), }) }, async revokeInvitation(slug: string): Promise { - await fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`, { + await fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitations`, { method: 'DELETE', headers: await ensureCsrfHeaders(), }) }, - async previewInvitation(token: string): Promise<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }> { - return fetchJson<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }>(`${WEB_API_PREFIX}/invitations/preview`, { + async previewInvitation(token: string): Promise<{ namespaceSlug: string; namespaceDisplayName: string; namespaceAvatarUrl: string | null; outcome: 'CONFIRM_REQUIRED' | 'ALREADY_MEMBER'; expiresAt: string }> { + return fetchJson<{ namespaceSlug: string; namespaceDisplayName: string; namespaceAvatarUrl: string | null; outcome: 'CONFIRM_REQUIRED' | 'ALREADY_MEMBER'; expiresAt: string }>(`${WEB_API_PREFIX}/namespace-invitations/preview`, { method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ token }), }) }, - async acceptInvitation(token: string): Promise<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }> { - return fetchJson<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }>(`${WEB_API_PREFIX}/invitations/accept`, { + async acceptInvitation(token: string): Promise<{ namespaceSlug: string; outcome: 'JOINED' | 'ALREADY_MEMBER' }> { + return fetchJson<{ namespaceSlug: string; outcome: 'JOINED' | 'ALREADY_MEMBER' }>(`${WEB_API_PREFIX}/namespace-invitations/accept`, { method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ token }), }) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 979714c1..8423d97c 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1932,7 +1932,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/web/namespaces/{slug}/invitation": { + "/api/web/namespaces/{slug}/invitations": { parameters: { query?: never; header?: never; @@ -1948,7 +1948,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/namespaces/{slug}/invitation": { + "/api/v1/namespaces/{slug}/invitations": { parameters: { query?: never; header?: never; @@ -2060,7 +2060,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/invitations/preview": { + "/api/web/namespace-invitations/preview": { parameters: { query?: never; header?: never; @@ -2076,7 +2076,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/web/invitations/preview": { + "/api/v1/namespace-invitations/preview": { parameters: { query?: never; header?: never; @@ -2092,7 +2092,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/web/invitations/accept": { + "/api/web/namespace-invitations/accept": { parameters: { query?: never; header?: never; @@ -2108,7 +2108,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/invitations/accept": { + "/api/v1/namespace-invitations/accept": { parameters: { query?: never; header?: never; @@ -6201,8 +6201,26 @@ export interface components { }; Preview: { namespaceSlug?: string; - namespaceName?: string; - alreadyMember?: boolean; + namespaceDisplayName?: string; + namespaceAvatarUrl?: string; + /** @enum {string} */ + outcome?: "CONFIRM_REQUIRED" | "ALREADY_MEMBER"; + /** Format: date-time */ + expiresAt?: string; + }; + Accepted: { + namespaceSlug?: string; + /** @enum {string} */ + outcome?: "JOINED" | "ALREADY_MEMBER"; + }; + ApiResponseAccepted: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["Accepted"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; }; ApiResponseGovernanceNotificationResponse: { /** Format: int32 */ @@ -12485,7 +12503,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePreview"]; + "*/*": components["schemas"]["ApiResponseAccepted"]; }; }; }; @@ -12509,7 +12527,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePreview"]; + "*/*": components["schemas"]["ApiResponseAccepted"]; }; }; }; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 7669e0d3..2a8ce3d7 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1403,7 +1403,7 @@ "manualCopy": "Automatic copy failed. Copy the link below manually.", "revoked": "Invitation revoked", "error": "Invitation action failed. Try again.", - "invalid": "This invitation is invalid or expired. Ask a space admin for a new link.", + "invalid": "This invitation link is invalid or expired. Ask a space admin for a new link.", "loading": "Checking invitation…", "networkError": "Connection failed. Try again.", "retry": "Retry", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 1515824b..fbfe0e39 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1468,7 +1468,7 @@ "manualCopy": "Не удалось скопировать автоматически. Скопируйте ссылку ниже.", "revoked": "Приглашение отозвано", "error": "Не удалось выполнить действие. Попробуйте ещё раз.", - "invalid": "Приглашение недействительно или истекло. Запросите новую ссылку у администратора.", + "invalid": "Ссылка-приглашение недействительна или истекла. Запросите новую ссылку у администратора.", "loading": "Проверка приглашения…", "networkError": "Ошибка соединения. Попробуйте ещё раз.", "retry": "Повторить", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 2efc716e..320a9e5a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1403,7 +1403,7 @@ "manualCopy": "无法自动复制,请手动复制下方链接", "revoked": "邀请已作废", "error": "邀请操作失败,请重试", - "invalid": "邀请无效或已失效,请联系空间管理员获取新链接。", + "invalid": "邀请链接无效或已失效,请联系空间管理员获取新链接。", "loading": "正在核对邀请…", "networkError": "连接失败,请重试。", "retry": "重试", diff --git a/web/src/pages/join.tsx b/web/src/pages/join.tsx index a8ca210a..74b672b1 100644 --- a/web/src/pages/join.tsx +++ b/web/src/pages/join.tsx @@ -35,7 +35,7 @@ export function JoinPage() { const invalid = !token || (preview.error instanceof ApiError && preview.error.status === 404) || (accept.error instanceof ApiError && accept.error.status === 404) useEffect(() => { if (invalid) clearInvitationToken() }, [invalid]) - useEffect(() => { if (preview.data?.alreadyMember) clearInvitationToken() }, [preview.data?.alreadyMember]) + useEffect(() => { if (preview.data?.outcome === 'ALREADY_MEMBER') clearInvitationToken() }, [preview.data?.outcome]) return (
@@ -51,9 +51,9 @@ export function JoinPage() { ) : null} {!invalid && preview.data ? (
-

{t('invitation.namespace', { name: preview.data.namespaceName })}

+

{t('invitation.namespace', { name: preview.data.namespaceDisplayName })}

{t('invitation.account', { name: user?.displayName ?? user?.userId ?? '' })}

- {preview.data.alreadyMember ? ( + {preview.data.outcome === 'ALREADY_MEMBER' ? ( <>

{t('invitation.alreadyMember')}

From ee17c1eb437594a81914272cea2abff8345e476a Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:52:04 +0800 Subject: [PATCH 14/47] fix(invitation): enter namespace after member preview Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../specs/namespace-invitations/spec.md | 2 +- web/src/pages/join.test.tsx | 84 +++++++++++++++++++ web/src/pages/join.tsx | 19 +++-- 3 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 web/src/pages/join.test.tsx diff --git a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md index f1f2769c..9f7277f5 100644 --- a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md +++ b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md @@ -85,7 +85,7 @@ Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码; 前端 SHALL 完成链接分享、登录回跳和邀请状态闭环。 -空间卡片提供单击创建并复制邀请的入口,有仍可兑换的旧链接时先确认替换;成员管理页也提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token,随后进入空间时替换浏览器历史中的邀请页。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。 +空间卡片提供单击创建并复制邀请的入口,有仍可兑换的旧链接时先确认替换;成员管理页也提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token 并自动使用 replace 导航进入空间,避免重复导航。加载状态使用可访问的 status 提示,终态失效使用 alert 提示。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。 #### Scenario: 剪贴板写入失败 - **WHEN** 创建成功但浏览器拒绝写剪贴板 diff --git a/web/src/pages/join.test.tsx b/web/src/pages/join.test.tsx new file mode 100644 index 00000000..c2452441 --- /dev/null +++ b/web/src/pages/join.test.tsx @@ -0,0 +1,84 @@ +/** @vitest-environment jsdom */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), + preview: vi.fn(), + accept: vi.fn(), +})) + +vi.mock('@tanstack/react-router', () => ({ useNavigate: () => mocks.navigate })) +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) +vi.mock('@/api/client', () => ({ + namespaceApi: { previewInvitation: mocks.preview, acceptInvitation: mocks.accept }, +})) +vi.mock('@/features/auth/use-auth', () => ({ + useAuth: () => ({ user: { userId: 'guest', displayName: 'Guest' } }), +})) +vi.mock('@/shared/lib/api-error', () => ({ + ApiError: class ApiError extends Error { + constructor(message: string, public status: number) { super(message) } + }, +})) + +import { ApiError } from '@/shared/lib/api-error' +import { JoinPage } from './join' + +const token = 'A'.repeat(43) + +function renderJoin() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return { client, ...render() } +} + +describe('JoinPage', () => { + beforeEach(() => { + window.sessionStorage.clear() + window.history.replaceState(null, '', '/') + mocks.navigate.mockReset() + mocks.preview.mockReset() + mocks.accept.mockReset() + }) + + afterEach(cleanup) + + it('clears the token and navigates once with replace when preview finds an existing member', async () => { + window.history.replaceState(null, '', `/join#${token}`) + mocks.preview.mockResolvedValue({ + namespaceSlug: 'team', namespaceDisplayName: 'Team', namespaceAvatarUrl: null, + outcome: 'ALREADY_MEMBER', expiresAt: '2026-10-13T00:00:00Z', + }) + + const view = renderJoin() + + await waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith({ + to: '/space/$namespace', params: { namespace: 'team' }, replace: true, + })) + expect(window.sessionStorage.length).toBe(0) + expect(window.location.hash).toBe('') + view.rerender() + expect(mocks.navigate).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('button', { name: 'invitation.enter' })).toBeNull() + }) + + it('announces loading while preview is pending', () => { + window.history.replaceState(null, '', `/join#${token}`) + mocks.preview.mockImplementation(() => new Promise(() => {})) + + renderJoin() + + expect(screen.getByRole('status').textContent).toBe('invitation.loading') + }) + + it('announces a terminal invalid link', async () => { + window.history.replaceState(null, '', `/join#${token}`) + mocks.preview.mockRejectedValue(new ApiError('invalid', 404)) + + renderJoin() + + expect((await screen.findByRole('alert')).textContent).toBe('invitation.invalid') + expect(window.sessionStorage.length).toBe(0) + }) +}) diff --git a/web/src/pages/join.tsx b/web/src/pages/join.tsx index 74b672b1..60dc8d6a 100644 --- a/web/src/pages/join.tsx +++ b/web/src/pages/join.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' @@ -14,6 +14,7 @@ export function JoinPage() { const navigate = useNavigate() const queryClient = useQueryClient() const { user } = useAuth() + const enteredNamespace = useRef(false) const [token] = useState(() => { captureInvitationFragment() return storedInvitationToken() @@ -35,14 +36,19 @@ export function JoinPage() { const invalid = !token || (preview.error instanceof ApiError && preview.error.status === 404) || (accept.error instanceof ApiError && accept.error.status === 404) useEffect(() => { if (invalid) clearInvitationToken() }, [invalid]) - useEffect(() => { if (preview.data?.outcome === 'ALREADY_MEMBER') clearInvitationToken() }, [preview.data?.outcome]) + useEffect(() => { + if (preview.data?.outcome !== 'ALREADY_MEMBER' || enteredNamespace.current) return + enteredNamespace.current = true + clearInvitationToken() + void navigate({ to: '/space/$namespace', params: { namespace: preview.data.namespaceSlug }, replace: true }) + }, [navigate, preview.data]) return (

{t('invitation.title')}

- {invalid ?

{t('invitation.invalid')}

: null} - {!invalid && preview.isPending ?

{t('invitation.loading')}

: null} + {invalid ?

{t('invitation.invalid')}

: null} + {!invalid && preview.isPending ?

{t('invitation.loading')}

: null} {!invalid && preview.error ? (

{t('invitation.networkError')}

@@ -54,10 +60,7 @@ export function JoinPage() {

{t('invitation.namespace', { name: preview.data.namespaceDisplayName })}

{t('invitation.account', { name: user?.displayName ?? user?.userId ?? '' })}

{preview.data.outcome === 'ALREADY_MEMBER' ? ( - <> -

{t('invitation.alreadyMember')}

- - +

{t('invitation.alreadyMember')}

) : ( <>

{t('invitation.permissions')}

From e65795d313a7dc7693c7b9365c6007bd7bcccd5a Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:14:26 +0800 Subject: [PATCH 15/47] test(namespace): update command lookup fixtures Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../controller/NamespaceBatchMemberControllerTest.java | 7 +++++++ .../skillhub/controller/NamespacePortalControllerTest.java | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java index 8ac33168..8a8145af 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java @@ -17,6 +17,7 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; import com.iflytek.skillhub.service.NamespacePortalCommandAppService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -31,6 +32,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.RequestPostProcessor; import java.util.List; +import java.util.Optional; import java.util.Set; import static org.mockito.ArgumentMatchers.any; @@ -80,6 +82,11 @@ class NamespaceBatchMemberControllerTest { @MockBean private UserAccountRepository userAccountRepository; + @BeforeEach + void setUpNamespaceLookup() { + given(namespaceRepository.findIdBySlug("team-a")).willReturn(Optional.of(1L)); + } + @Test void batchAddMembers_partialSuccess_returnsResultsPerRow() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index d7dc9142..ac60aad1 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -31,6 +32,7 @@ import org.springframework.test.web.servlet.request.RequestPostProcessor; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import static org.mockito.ArgumentMatchers.any; @@ -78,6 +80,11 @@ class NamespacePortalControllerTest { @MockBean private UserAccountRepository userAccountRepository; + @BeforeEach + void setUpNamespaceLookup() { + given(namespaceRepository.findIdBySlug("team-a")).willReturn(Optional.of(1L)); + } + @Test void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); From b67ea7ddb8abd5eddf72b123bf8230ea8c181b3d Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:42:11 +0800 Subject: [PATCH 16/47] feat(avatar): add review and owner interfaces Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/26-skill-avatars.md | 31 +++++ .../.openspec.yaml | 2 + .../design.md | 22 ++++ .../proposal.md | 32 +++++ .../specs/skill-avatars/spec.md | 117 ++++++++++++++++++ .../add-skill-avatars-and-moderation/tasks.md | 23 ++++ .../portal/SkillAvatarController.java | 5 +- .../dto/SkillAvatarReasonRequest.java | 5 + .../dto/SkillAvatarStatusResponse.java | 2 +- .../AliyunAvatarModerationProvider.java | 12 +- .../service/SkillAvatarAppService.java | 3 +- .../AliyunAvatarModerationProviderTest.java | 9 ++ web/src/api/client.ts | 58 +++++++++ web/src/api/types.ts | 21 ++++ .../features/skill/avatar-review-settings.tsx | 35 ++++++ .../features/skill/skill-avatar-manager.tsx | 86 +++++++++++++ .../features/skill/skill-avatar-review.tsx | 41 ++++++ .../features/skill/skill-avatar-takedown.tsx | 24 ++++ web/src/features/skill/skill-avatar.tsx | 12 ++ web/src/features/skill/skill-card.tsx | 4 +- web/src/i18n/locales/en.json | 43 +++++++ web/src/i18n/locales/ru.json | 43 +++++++ web/src/i18n/locales/zh.json | 43 +++++++ web/src/pages/admin/system-config.tsx | 2 + web/src/pages/dashboard/namespace-reviews.tsx | 2 + web/src/pages/dashboard/reviews.tsx | 3 + web/src/pages/skill-detail.tsx | 12 +- web/src/shared/hooks/use-namespace-queries.ts | 3 +- 28 files changed, 685 insertions(+), 10 deletions(-) create mode 100644 docs/26-skill-avatars.md create mode 100644 openspec/changes/add-skill-avatars-and-moderation/.openspec.yaml create mode 100644 openspec/changes/add-skill-avatars-and-moderation/design.md create mode 100644 openspec/changes/add-skill-avatars-and-moderation/proposal.md create mode 100644 openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md create mode 100644 openspec/changes/add-skill-avatars-and-moderation/tasks.md create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarReasonRequest.java create mode 100644 web/src/features/skill/avatar-review-settings.tsx create mode 100644 web/src/features/skill/skill-avatar-manager.tsx create mode 100644 web/src/features/skill/skill-avatar-review.tsx create mode 100644 web/src/features/skill/skill-avatar-takedown.tsx create mode 100644 web/src/features/skill/skill-avatar.tsx diff --git a/docs/26-skill-avatars.md b/docs/26-skill-avatars.md new file mode 100644 index 00000000..958287f5 --- /dev/null +++ b/docs/26-skill-avatars.md @@ -0,0 +1,31 @@ +# 技能头像与审核 + +技能所有者可在技能详情页上传 PNG/JPEG,网页提供方形裁剪和缩放。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。 + +## 审核策略 + +超级管理员在“系统配置”中分别开关机器审核和人工审核。默认关闭机器审核、开启人工审核。两个开关都关闭时直接发布;只开启机器时,通过即发布、阻止或未知结果保持私有;只开启人工时进入审核队列;两者都开启时先机器后人工。机器阻止后所有者可申请人工复核;机器服务失败时可重试,不会自动放行。相同图片且机器供应商与审核策略未变时可复用机器结果;扩大可见范围时必须重新人工审核。`PRIVATE` 技能头像由所有者直接使用。 + +团队范围由团队管理员审核,全局范围由平台管理员审核。头像只能由技能所有者提交、替换或删除;管理员可审核和撤下,不可代传。新申请使旧待审申请失效;旧审核决定不能覆盖新头像。被拒绝、替换、删除的候选图片私有保留 30 天后清理,文字审核记录继续保留。头像撤下后显示首字母兜底。 + +## 部署配置 + +在部署密钥配置中指定唯一供应商及其凭证,然后才可从后台开启机器审核。Spring 属性如下;将值映射到部署 Secret/环境变量时请遵循当前部署平台的配置方式,不要写入仓库或后台数据库: + +| 供应商 | 属性 | +| --- | --- | +| 通用 | `skillhub.avatar.moderation.provider`:`xfyun` 或 `aliyun` | +| 讯飞 | `skillhub.avatar.moderation.xfyun.app-id`、`skillhub.avatar.moderation.xfyun.access-key-id`、`skillhub.avatar.moderation.xfyun.access-key-secret` | +| 阿里云 | `skillhub.avatar.moderation.aliyun.access-key-id`、`skillhub.avatar.moderation.aliyun.access-key-secret` | + +后台只显示两个启停开关,不回显供应商或密钥。阿里云适配器使用上海地域的 `profilePhotoCheck`,通过短期上传令牌把私有候选送到官方 OSS 临时对象,再调用同步审核;不会公开候选 URL。官方返回 `RiskLevel=none/low` 通过,`medium/high` 保持私有并允许人工复核。把 `medium` 归为阻止是本产品策略。响应非 200、空结果或未知值均不放行。机器接口可能按量计费,开启前由部署方确认供应商账号与费用。 + +讯飞官方图片审核接口要求把 `accessKeySecret` 放在请求 URL 参数中。应用不会记录完整 URL 或异常原文,但反向代理、出口代理、链路追踪及第三方日志仍可能记录该 URL。部署方使用讯飞适配器前,必须确认相关网络组件不采集请求 URL,限制凭证权限并建立轮换机制。若无法满足,先不要开启该适配器。 + +## 与原 Issue 的差异 + +原 Issue 提议自动图片审核;本期产品决策改为“上传、裁剪、机器审核可选、人工审核可选”的四种策略。默认使用人工审核,机器审核仅在部署好单一供应商且管理员开启后运行。个人头像不在本期范围。 + +## 验收要点 + +分别验证四种开关组合、候选与当前头像隔离、拥有者和审核员权限、审核通过/驳回/复核、相同图片复用机器结果、未知机器结果不放行、连续替换后旧审核无效、删除后首字母兜底、30 天清理以及团队/全局可见范围变化。外部供应商真实计费调用需要部署方测试账号与授权;单元测试使用模拟响应。 diff --git a/openspec/changes/add-skill-avatars-and-moderation/.openspec.yaml b/openspec/changes/add-skill-avatars-and-moderation/.openspec.yaml new file mode 100644 index 00000000..b9ebfc72 --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/add-skill-avatars-and-moderation/design.md b/openspec/changes/add-skill-avatars-and-moderation/design.md new file mode 100644 index 00000000..9e668b08 --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/design.md @@ -0,0 +1,22 @@ +## Context + +当前 Skill 无头像字段。详情、搜索和“我的技能”由不同查询路径投影,Web 卡片与详情也没有统一头像组件。`ObjectStorageService` 支持写入、读取和删除,但本地实现不产生预签名 URL。现有 ReviewTask 只处理 SkillVersion/SuiteVersion;头像不能伪装成版本审核。V68 已提供 versioned `system_setting`。 + +## Decisions + +1. 为 Skill 保存当前已激活头像引用;另建头像候选及决定记录。候选包含所属 Skill、提交者、私有对象键、内容哈希、目标可见范围、提交时审核策略快照、机器结果、人工决定、状态和时间。所有状态转移按候选 ID 和当前候选 ID 条件更新,防止迟到结果覆盖。 +2. 单独实现头像审核服务和查询队列,不扩展现有 SkillVersion 审核分支。头像上传 API 显式校验 `skill.ownerId == actor`;审核、复核和撤下分别校验团队管理员或平台管理员。系统管理员若不是所有者也不能代传。 +3. 图片通过受控 GET 接口提供。私人和待审对象必须鉴权,当前公开头像可提供稳定、无时效的访问 URL;服务端不得将对象存储临时签名地址持久化。公开范围变更之前须重新判断头像是否已获目标范围许可,避免私人图片借 Skill 可见性变化直接泄露。 +4. 上传只接受最终裁剪图,服务端独立解码、校验、重新编码并去掉元数据。上限和格式以现有上传约定及测试验证后在实现中固定,避免前端校验成为唯一防线。 +5. `system_setting` 新增独立头像审核键,仅保存机器和人工开关。部署配置只允许一个供应商及其 Secret;后台不展示供应商或 Secret。适配器在服务端主动调用,供应商异常保留候选和重试,不自动通过。供应商实现与 API 参数在接入时以官方文档验证。 +6. 开关在提交候选时快照。PRIVATE 且仅所有者可见直接激活;其他候选按照快照执行。机器通过且人工关闭可激活;机器阻止时允许例外申诉;人工开启时有权审核员可改判并留理由。 +7. 替换待审候选时旧候选变为 SUPERSEDED。删除和管理员撤下立即取消当前头像并恢复兜底。失效图片保留私有 30 天,再由有界清理任务删除;审计记录不随图片删除。 +8. `SkillSummaryResponse` 的所有映射路径、详情 DTO 与生成类型包含当前头像 URL;Web 使用一个头像/兜底组件,所有者操作和审核队列分别展示其可执行动作。 + +## Risks and verification + +- 私人候选和已发布头像的读取权限用非所有者、匿名和跨团队请求验证。 +- 上传、更换、审核和撤下并发时验证当前候选不被旧结果激活。 +- V68 后迁移在已有数据上通过;旧 Skill 无头像时无需回填。 +- 服务端图片解码处理需设置资源上限;机器供应商凭证不得进入日志、数据库或前端。 +- 原 Issue 的“机器不得直接拒绝”与本轮方案不同。机器单开模式的质量和误判处理需在运行验证中单列,不以外部 API 返回成功代替有效审核验证。 diff --git a/openspec/changes/add-skill-avatars-and-moderation/proposal.md b/openspec/changes/add-skill-avatars-and-moderation/proposal.md new file mode 100644 index 00000000..d8cb2f5c --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/proposal.md @@ -0,0 +1,32 @@ +## Why + +Skill 目前没有稳定的图像标识,卡片和详情难以快速区分。上传图片后还需要保证文件安全、访问权限和可配置的内容审核,不让待审图片提前公开。 + +## What Changes + +- 为个人、团队和全局 Skill 增加由 Skill 所有者维护的可选头像;所有展示位置使用一致的首字兜底。 +- 上传最终裁剪图片,经服务端规范化后存为私有候选;头像属于 Skill,不随版本更新重置。 +- 按实际可见范围处理审核:仅本人可见的 PRIVATE 头像可直接使用;团队可见或公开头像按部署方的机器、人工审核开关处理;全局头像由平台管理员审核。 +- 部署时选择唯一机器审核供应商并配置凭证,提供讯飞和阿里云适配;后台仅管理审核开关。机器失败不放行,机器误判可申请人工复核。 +- 仅 Skill 所有者可提交、更换或主动删除;管理员可以审核、复核和撤下违规头像。旧候选失效后不能被迟到的审核结果激活。 +- 被驳回、被替换或主动删除的图片私有保留 30 天后清理,文字审计记录保留。 + +## Capabilities + +### New Capabilities + +- `skill-avatars`: Skill 头像、权限、候选生命周期、审核策略、访问和清理。 + +### Modified Capabilities + +无。 + +## Impact + +- Skill 领域、数据库迁移、对象存储、公开摘要和详情 API、OpenAPI 生成类型。 +- 系统配置、机器审核供应商配置、团队及平台审核入口、审计和清理任务。 +- Skill 卡片、详情、所有者操作和审核页面;后端、前端与浏览器验证。 + +## 相对 Issue #910 的调整 + +原 Issue 建议机器审核首版仅作风险分流。本方案允许部署方单独开启机器审核并据此自动发布或阻止发布,同时提供误判申诉。实现和验收必须明确这一差异、供应商故障时不放行,以及机器判定质量的回归验证。 diff --git a/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md new file mode 100644 index 00000000..a47375e0 --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md @@ -0,0 +1,117 @@ +## Purpose + +为 Skill 提供安全、一致、可治理的自定义头像。 + +## ADDED Requirements + +### Requirement: REQ-AV-01 Skill SHALL 有可选头像和统一兜底 + +Skill SHALL 支持可选头像和统一兜底。 + +头像属于 Skill 容器而非 SkillVersion。摘要与详情只返回当前已激活的稳定头像访问地址;无头像时所有 Skill 展示位置使用展示名称首字符的大写兜底。新版本发布不得重置头像。 + +#### Scenario: 旧 Skill 没有头像 +- **WHEN** 客户端读取无头像的 Skill +- **THEN** 不需数据回填即可展示一致的首字兜底 + +#### Scenario: 发布新版本 +- **WHEN** 已有头像的 Skill 发布新版本 +- **THEN** 原头像保持不变 + +### Requirement: REQ-AV-02 仅 Skill 所有者 SHALL 维护头像 + +头像维护接口 SHALL 仅允许 Skill 所有者调用。 + +只有当前 Skill 所有者可提交、更换或主动删除头像。团队及平台管理员的审核权不授予代传权;有权管理员可以撤下违规头像并记录原因。所有者停用时本期不新增所有权交接或代传机制。 + +#### Scenario: 管理员不是所有者 +- **WHEN** 团队或平台管理员尝试上传、替换或主动删除别人的 Skill 头像 +- **THEN** 服务端拒绝,不依赖前端隐藏按钮 + +#### Scenario: 所有者停用 +- **WHEN** 头像已发布而所有者无法操作 +- **THEN** 有权管理员仍可撤下该头像并恢复兜底,但不能代传新头像 + +### Requirement: REQ-AV-03 服务端 SHALL 验证并私存最终裁剪图片 + +服务端 SHALL 验证并私存最终裁剪图片。 + +Web 端提供正方形裁剪且只上传最终裁剪图。服务端独立校验文件签名、尺寸、字节与像素限制,解码、重新编码并去除元数据。待审候选只在私有存储中,且只能由提交者和有权审核员读取;不得持久化临时预签名 URL 作为头像地址。 + +#### Scenario: 伪造图片或畸形文件 +- **WHEN** 上传的文件签名、解码结果或大小不符合限制 +- **THEN** 服务端拒绝创建候选,不公开文件 + +#### Scenario: 候选待审 +- **WHEN** 新候选尚未通过所需审核 +- **THEN** 公众继续看到原头像或兜底,无法读取候选图片 + +### Requirement: REQ-AV-04 审核 SHALL 依据实际可见范围 + +头像审核 SHALL 依据实际可见范围执行。 + +PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使用。NAMESPACE_ONLY 或 PUBLIC 的团队 Skill 使用团队范围策略和团队管理员人工审核;全局 Skill 使用全局范围策略和平台管理员人工审核。个人 Namespace 当前按 TEAM 处理,不能仅凭名称免审。可见范围扩大时,未经过目标范围审核的头像不得直接对新受众公开。 + +#### Scenario: 个人私有 Skill 扩大可见范围 +- **WHEN** 原来仅本人可见的 Skill 变为团队可见或公开 +- **THEN** 原头像进入目标范围审核,审核完成前新受众只能看到兜底或先前已获目标范围批准的头像 + +#### Scenario: 团队 Skill 提升至全局 +- **WHEN** 团队 Skill 派生全局 Skill +- **THEN** 团队头像不会自动成为全局头像;全局候选按全局策略审核 + +### Requirement: REQ-AV-05 部署方 SHALL 控制两项审核开关 + +部署方 SHALL 可独立控制机器和人工审核开关。 + +机器与人工审核开关保存于独立的系统设置键,后台仅显示和修改这两个开关;唯一机器供应商及凭证由部署配置确定,凭证不得写入系统设置或回显。机器开关开启而供应商未正确配置时应拒绝设置。 + +#### Scenario: 四种策略 +- **WHEN** 机器/人工为关/开、开/开、开/关、关/关 +- **THEN** 分别执行人工通过、机器后人工、机器通过自动发布且判违规不发布、直接发布 +- **AND** 关闭全部审核时后台显示风险提示 + +### Requirement: REQ-AV-06 机器审核 SHALL 失败关闭并支持复核 + +机器审核 SHALL 在失败时保持候选私有,并支持例外复核。 + +服务端主动调用部署时确定的讯飞或阿里云适配器,首版不接受外部结果回调。机器调用失败、超时或结果不明不能视为通过;候选保持私有并可重试。机器判违规且人工审核开启时审核员可记理由改判;仅机器开启时提交者可申请例外人工复核,不把全部申请放入人工队列。 +同一最终图片在供应商和审核策略未变时可以复用机器结果;新目标范围的人工决定不能复用。图片、供应商或策略改变时必须重新检测。 + +#### Scenario: 供应商超时 +- **WHEN** 机器审核超时 +- **THEN** 候选不公开,系统保留可重试状态 + +#### Scenario: 机器误判 +- **WHEN** 提交者对机器阻止的候选申请复核 +- **THEN** 相应范围的管理员可以批准或驳回,记录决定人和理由 + +### Requirement: REQ-AV-07 候选状态 SHALL 防止旧结果激活 + +候选状态 SHALL 防止已失效申请被迟到结果激活。 + +提交候选时固定当时的审核策略。一个 Skill 的新申请替换待审旧申请,旧申请保留为已被替换状态;迟到的机器结果或审核动作不得激活它。管理员本人也是所有者时可以自审,但提交和决定都须留痕。 + +#### Scenario: 旧机器结果晚到 +- **WHEN** 新候选已经替换旧候选,旧候选的机器结果才返回 +- **THEN** 旧候选保持失效,当前头像不受其影响 + +### Requirement: REQ-AV-08 删除与撤下 SHALL 恢复兜底并保留审计 + +删除与撤下 SHALL 立即恢复兜底并保留审计。 + +所有者主动删除当前头像无需审核;管理员违规撤下时须记录操作者和原因。两种动作均立即恢复兜底。被驳回、被替换或主动删除的图片私有保留 30 天后清理,文字申请与审核记录继续保留;确认违法的图片可提前清理。 + +#### Scenario: 管理员撤下 +- **WHEN** 有权管理员撤下违规的已发布头像 +- **THEN** 对外立即显示兜底,审计保留原因与操作者 + +### Requirement: REQ-AV-09 前后端 SHALL 提供可理解的操作闭环 + +前后端 SHALL 提供可理解的头像提交与审核闭环。 + +所有者可以在 Skill 详情查看当前头像、提交候选、状态和驳回原因;审核员可以读取其管辖范围候选并处理,机器单开时可以处理例外申诉。所有展示 Skill 身份的位置使用同一头像与兜底组件。 + +#### Scenario: 申请等待审核 +- **WHEN** 所有者上传候选后需人工决定 +- **THEN** 页面展示待审状态且已发布头像维持不变 diff --git a/openspec/changes/add-skill-avatars-and-moderation/tasks.md b/openspec/changes/add-skill-avatars-and-moderation/tasks.md new file mode 100644 index 00000000..4c14a4af --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/tasks.md @@ -0,0 +1,23 @@ +## 1. 领域与存储 + +- [ ] 1.1 增加 Skill 头像指针、候选/审计持久化和向后兼容迁移。 +- [ ] 1.2 实现图片解码、大小及像素上限、规范化、私有写入、受控读取与 30 天清理。 +- [ ] 1.3 实现所有者提交/替换/删除、管理员审核/复核/撤下和旧候选失效保护。 + +## 2. 审核配置与供应商 + +- [ ] 2.1 在 `system_setting` 增加独立头像审核开关,验证四种策略及未配置供应商时拒绝开启。 +- [ ] 2.2 依据官方接口实现讯飞和阿里云适配器,部署只选择一家,凭证仅使用部署 Secret。 +- [ ] 2.3 处理超时、未知结果、重试、人工改判和例外申诉。 + +## 3. API 与 Web + +- [ ] 3.1 扩展 Skill 摘要/详情和头像操作/审核接口,重新生成 OpenAPI 类型。 +- [ ] 3.2 实现统一头像兜底、所有者裁剪/提交/状态及审核员队列。 +- [ ] 3.3 更新中文、英文、俄文文案和操作说明。 + +## 4. 验证与交付 + +- [ ] 4.1 覆盖权限、文件安全、状态并发、策略矩阵、可见范围变化和清理边界。 +- [ ] 4.2 运行相关后端、前端、API 类型检查、容器化本地预览及浏览器操作。 +- [ ] 4.3 独立 Review、Draft PR、CI 与验收报告。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java index f589f846..df2efd81 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.SkillAvatarDecisionRequest; +import com.iflytek.skillhub.dto.SkillAvatarReasonRequest; import com.iflytek.skillhub.dto.SkillAvatarStatusResponse; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.SkillAvatarAppService; @@ -125,10 +126,10 @@ public class SkillAvatarController extends BaseApiController { @PostMapping("/{namespace}/{slug}/avatar/takedown") public ApiResponse takedown(@PathVariable String namespace, @PathVariable String slug, - @RequestBody String reason, @AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody SkillAvatarReasonRequest input, @AuthenticationPrincipal PlatformPrincipal principal, @RequestAttribute(value = "userNsRoles", required = false) Map roles, HttpServletRequest request) { - service.remove(namespace, slug, userId(principal), true, reason, roles, platformRoles(principal), + service.remove(namespace, slug, userId(principal), true, input.reason(), roles, platformRoles(principal), AuditRequestContext.from(request)); return ok("response.success.updated", null); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarReasonRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarReasonRequest.java new file mode 100644 index 00000000..423781e9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarReasonRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SkillAvatarReasonRequest(@NotBlank String reason) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java index b0b84714..d0439e72 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java @@ -2,4 +2,4 @@ package com.iflytek.skillhub.dto; public record SkillAvatarStatusResponse(String avatarUrl, Long candidateId, String candidateStatus, String machineStatus, String reviewStatus, String reviewReason, - String candidateUrl) {} + String candidateUrl, String namespace, String slug) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java index 3b6dffc4..8bf4c53d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java @@ -8,6 +8,7 @@ import com.aliyun.green20220302.models.ImageModerationResponse; import com.aliyun.green20220302.models.ImageModerationResponseBody; import com.aliyun.oss.OSS; import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.ClientBuilderConfiguration; import com.aliyun.oss.model.PutObjectRequest; import com.aliyun.teaopenapi.models.Config; import com.aliyun.teautil.models.RuntimeOptions; @@ -51,7 +52,9 @@ public class AliyunAvatarModerationProvider implements AvatarModerationProvider config.setAccessKeySecret(accessKeySecret); config.setEndpoint(ENDPOINT); Client client = new Client(config); - DescribeUploadTokenResponse tokenResponse = client.describeUploadToken(); + RuntimeOptions runtime = new RuntimeOptions().setConnectTimeout(3000).setReadTimeout(8000) + .setAutoretry(false); + DescribeUploadTokenResponse tokenResponse = client.describeUploadTokenWithOptions(runtime); if (tokenResponse == null || tokenResponse.getBody() == null || !Objects.equals(tokenResponse.getBody().getCode(), 200)) { return Verdict.ERROR; @@ -61,8 +64,11 @@ public class AliyunAvatarModerationProvider implements AvatarModerationProvider || token.getOssInternetEndPoint() == null || token.getSecurityToken() == null) { return Verdict.ERROR; } + ClientBuilderConfiguration ossConfig = new ClientBuilderConfiguration(); + ossConfig.setConnectionTimeout(3000); + ossConfig.setSocketTimeout(8000); oss = new OSSClientBuilder().build(token.getOssInternetEndPoint(), token.getAccessKeyId(), - token.getAccessKeySecret(), token.getSecurityToken()); + token.getAccessKeySecret(), token.getSecurityToken(), ossConfig); String objectName = token.getFileNamePrefix() + UUID.randomUUID() + ".png"; oss.putObject(new PutObjectRequest(token.getBucketName(), objectName, new ByteArrayInputStream(normalizedPng))); @@ -73,7 +79,7 @@ public class AliyunAvatarModerationProvider implements AvatarModerationProvider "ossBucketName", token.getBucketName(), "ossObjectName", objectName, "dataId", UUID.randomUUID().toString()))); - ImageModerationResponse response = client.imageModerationWithOptions(request, new RuntimeOptions()); + ImageModerationResponse response = client.imageModerationWithOptions(request, runtime); return classify(response); } catch (Exception failure) { // SDK exception text may include credential or temporary upload details. diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java index 6b63331c..5183be87 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java @@ -365,7 +365,8 @@ public class SkillAvatarAppService { candidate == null ? null : candidate.getReviewStatus().name(), candidate == null ? null : candidate.getReviewReason(), candidate == null || candidate.getObjectKey() == null ? null - : SkillAvatarUrls.candidate(namespaceSlug, skill, candidate.getId())); + : SkillAvatarUrls.candidate(namespaceSlug, skill, candidate.getId()), + namespaceSlug, skill.getSlug()); } private String sha256(byte[] image) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java index 0a7b3a20..a7a6019a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java @@ -22,6 +22,15 @@ class AliyunAvatarModerationProviderTest { assertThat(provider.classify(response("high"))).isEqualTo(AvatarModerationProvider.Verdict.BLOCK); assertThat(provider.classify(response(null))).isEqualTo(AvatarModerationProvider.Verdict.ERROR); assertThat(provider.classify(response("unknown"))).isEqualTo(AvatarModerationProvider.Verdict.ERROR); + assertThat(provider.classify(response("none").setStatusCode(500))) + .isEqualTo(AvatarModerationProvider.Verdict.ERROR); + assertThat(provider.classify(response("none").setBody( + new ImageModerationResponseBody().setCode(500)))) + .isEqualTo(AvatarModerationProvider.Verdict.ERROR); + assertThat(provider.classify(response("none").setBody( + new ImageModerationResponseBody().setCode(200).setData( + new ImageModerationResponseBodyData().setRiskLevel("none").setResult(List.of()))))) + .isEqualTo(AvatarModerationProvider.Verdict.ERROR); } private ImageModerationResponse response(String level) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 09f2fd9d..2d3d07fa 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -36,6 +36,8 @@ import type { AuthMethod, LocalAuthCapabilities, SystemAuthSettings, + AvatarReviewSettings, + SkillAvatarStatus, ExternalRoleGrantRule, PlatformRole, OAuthProvider, @@ -459,6 +461,17 @@ export const authApi = { } export const systemConfigApi = { + getAvatarReview(): Promise { + return fetchJson('/api/v1/admin/system-config/avatar-review') + }, + + updateAvatarReview(request: Pick): Promise { + return fetchJson('/api/v1/admin/system-config/avatar-review', { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(request), + }) + }, getLocalAuth(): Promise { return fetchJson('/api/v1/admin/system-config/auth/local') }, @@ -503,6 +516,51 @@ export const systemConfigApi = { }, } +function avatarPath(namespace: string, slug: string): string { + return `/api/v1/skills/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/avatar` +} + +export const skillAvatarApi = { + status(namespace: string, slug: string): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/status`) + }, + upload(namespace: string, slug: string, file: Blob): Promise { + const body = new FormData() + body.append('file', file, 'avatar.png') + return fetchJson(avatarPath(namespace, slug), { + method: 'POST', headers: getCsrfHeaders(), body, + }) + }, + delete(namespace: string, slug: string): Promise { + return fetchJson(avatarPath(namespace, slug), { method: 'DELETE', headers: getCsrfHeaders() }) + }, + appeal(namespace: string, slug: string, candidateId: number): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/candidates/${candidateId}/appeal`, { + method: 'POST', headers: getCsrfHeaders(), + }) + }, + retry(namespace: string, slug: string, candidateId: number): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/candidates/${candidateId}/retry`, { + method: 'POST', headers: getCsrfHeaders(), + }) + }, + queue(): Promise { + return fetchJson('/api/v1/skills/avatar-review/queue') + }, + decide(namespace: string, slug: string, candidateId: number, approved: boolean, reason: string): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/candidates/${candidateId}/decision`, { + method: 'POST', headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ approved, reason }), + }) + }, + takedown(namespace: string, slug: string, reason: string): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/takedown`, { + method: 'POST', headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ reason }), + }) + }, +} + export const accountApi = { async initiateMerge(request: MergeInitiateRequest): Promise { return fetchJson('/api/v1/account/merge/initiate', { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 1c999825..9120b4e7 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -36,6 +36,25 @@ export interface SystemAuthSettings { updatedAt: string } +export interface AvatarReviewSettings { + machineEnabled: boolean + manualEnabled: boolean + version: number + updatedAt: string +} + +export interface SkillAvatarStatus { + avatarUrl: string | null + candidateId: number | null + candidateStatus: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'SUPERSEDED' | 'REMOVED' | null + machineStatus: 'SKIPPED' | 'PENDING' | 'PASSED' | 'BLOCKED' | 'ERROR' | null + reviewStatus: 'NOT_REQUIRED' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'APPEAL_PENDING' | null + reviewReason: string | null + candidateUrl: string | null + namespace: string + slug: string +} + export interface ExternalRoleGrantRule { id: number providerCode: string @@ -252,6 +271,7 @@ export interface SkillSummary { ownerPreviewVersion?: SkillLifecycleVersion resolutionMode?: string complianceSnapshot?: ComplianceSnapshot + avatarUrl?: string | null } export type LabelItem = Omit & { @@ -345,6 +365,7 @@ export type SkillDetail = Omit< >, 'labels' | 'headlineVersion' | 'publishedVersion' | 'ownerPreviewVersion' | 'entryForSuites' | 'memberOfSuites' > & { + avatarUrl?: string | null labels?: LabelItem[] headlineVersion?: SkillLifecycleVersion publishedVersion?: SkillLifecycleVersion diff --git a/web/src/features/skill/avatar-review-settings.tsx b/web/src/features/skill/avatar-review-settings.tsx new file mode 100644 index 00000000..615c9c9a --- /dev/null +++ b/web/src/features/skill/avatar-review-settings.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { systemConfigApi } from '@/api/client' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' + +export function AvatarReviewSettingsPanel() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const settings = useQuery({ queryKey: ['system-config', 'avatar-review'], queryFn: systemConfigApi.getAvatarReview }) + const [draft, setDraft] = useState({ machineEnabled: false, manualEnabled: true }) + const [error, setError] = useState('') + const [saved, setSaved] = useState(false) + useEffect(() => { if (settings.data) setDraft({ machineEnabled: settings.data.machineEnabled, manualEnabled: settings.data.manualEnabled }) }, [settings.data]) + const update = useMutation({ + mutationFn: systemConfigApi.updateAvatarReview, + onSuccess: async () => { setError(''); setSaved(true); await queryClient.invalidateQueries({ queryKey: ['system-config', 'avatar-review'] }) }, + onError: (cause) => { setSaved(false); setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')) }, + }) + const dirty = !!settings.data && (settings.data.machineEnabled !== draft.machineEnabled || settings.data.manualEnabled !== draft.manualEnabled) + return + {t('skillAvatar.settingsTitle')}

{t('skillAvatar.settingsHelp')}

+ + {settings.isLoading &&

{t('skillAvatar.loading')}

} + {settings.isError &&

{t('skillAvatar.failed')}

} + {settings.data && <> + {(['machineEnabled', 'manualEnabled'] as const).map((field) => )} + + } + {saved &&

{t('skillAvatar.saved')}

} + {error &&

{error}

} +
+
+} diff --git a/web/src/features/skill/skill-avatar-manager.tsx b/web/src/features/skill/skill-avatar-manager.tsx new file mode 100644 index 00000000..ba54ef50 --- /dev/null +++ b/web/src/features/skill/skill-avatar-manager.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { buildApiUrl, skillAvatarApi } from '@/api/client' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' + +function cropSquare(image: HTMLImageElement, zoom: number): Promise { + const canvas = document.createElement('canvas') + canvas.width = 512 + canvas.height = 512 + const context = canvas.getContext('2d') + if (!context) return Promise.reject(new Error('Canvas unavailable')) + const side = Math.min(image.naturalWidth, image.naturalHeight) / zoom + context.drawImage(image, (image.naturalWidth - side) / 2, (image.naturalHeight - side) / 2, side, side, 0, 0, 512, 512) + return new Promise((resolve, reject) => canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error('Image conversion failed')), 'image/png')) +} + +export function SkillAvatarManager({ namespace, slug }: { namespace: string; slug: string }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const key = ['skill-avatar', namespace, slug] + const status = useQuery({ queryKey: key, queryFn: () => skillAvatarApi.status(namespace, slug) }) + const [sourceUrl, setSourceUrl] = useState(null) + const [zoom, setZoom] = useState(1) + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + useEffect(() => () => { if (sourceUrl) URL.revokeObjectURL(sourceUrl) }, [sourceUrl]) + const mutation = useMutation({ + mutationFn: async (action: { kind: 'upload'; blob: Blob } | { kind: 'remove' | 'appeal' | 'retry'; id?: number }) => { + if (action.kind === 'upload') return skillAvatarApi.upload(namespace, slug, action.blob) + if (action.kind === 'remove') return skillAvatarApi.delete(namespace, slug) + if (action.kind === 'appeal') return skillAvatarApi.appeal(namespace, slug, action.id!) + return skillAvatarApi.retry(namespace, slug, action.id!) + }, + onSuccess: async () => { + setError('') + setSourceUrl(null) + await Promise.all([queryClient.invalidateQueries({ queryKey: key }), queryClient.invalidateQueries({ queryKey: ['skill'] }), queryClient.invalidateQueries({ queryKey: ['skills'] })]) + }, + onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')), + }) + async function submit() { + if (!sourceUrl) return + setBusy(true) + try { + const image = new Image() + image.src = sourceUrl + await image.decode() + await mutation.mutateAsync({ kind: 'upload', blob: await cropSquare(image, zoom) }) + } catch (cause) { + setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')) + } finally { setBusy(false) } + } + const candidate = status.data + return ( + +

{t('skillAvatar.title')}

+

{t('skillAvatar.ownerHelp')}

+ {candidate?.candidateStatus &&

{t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)} · {t(`skillAvatar.machine.${candidate.machineStatus ?? 'SKIPPED'}`)}

} + {candidate?.reviewReason &&

{candidate.reviewReason}

} + {candidate?.candidateUrl && {t('skillAvatar.candidatePreview')}} + { + const file = event.target.files?.[0] + if (!file) return + if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 2 * 1024 * 1024) { setError(t('skillAvatar.invalid')); return } + if (sourceUrl) URL.revokeObjectURL(sourceUrl) + setSourceUrl(URL.createObjectURL(file)) + setZoom(1) + setError('') + }} /> + {sourceUrl &&
+
{t('skillAvatar.cropPreview')}
+ + +
} +
+ {(candidate?.avatarUrl || candidate?.candidateStatus === 'PENDING') && } + {candidate?.candidateStatus === 'REJECTED' && candidate.candidateId && } + {candidate?.machineStatus === 'ERROR' && candidate.candidateId && } +
+ {error &&

{error}

} +
+ ) +} diff --git a/web/src/features/skill/skill-avatar-review.tsx b/web/src/features/skill/skill-avatar-review.tsx new file mode 100644 index 00000000..27ffc401 --- /dev/null +++ b/web/src/features/skill/skill-avatar-review.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { buildApiUrl, skillAvatarApi } from '@/api/client' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' + +export function SkillAvatarReviewQueue({ namespace }: { namespace?: string }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [reasons, setReasons] = useState>({}) + const [error, setError] = useState('') + const queue = useQuery({ queryKey: ['skill-avatar-review'], queryFn: skillAvatarApi.queue }) + const decide = useMutation({ + mutationFn: (input: { namespace: string; slug: string; candidateId: number; approved: boolean; reason: string }) => + skillAvatarApi.decide(input.namespace, input.slug, input.candidateId, input.approved, input.reason), + onSuccess: async () => { setError(''); await queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] }) }, + onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')), + }) + const items = queue.data?.filter((item) => !namespace || item.namespace === namespace) ?? [] + return +

{t('skillAvatar.reviewTitle')}

+ {queue.isLoading &&

{t('skillAvatar.loading')}

} + {queue.isError &&

{t('skillAvatar.failed')}

} + {!queue.isLoading && !queue.isError && items.length === 0 &&

{t('skillAvatar.emptyQueue')}

} + {items.map((item) => item.candidateId &&
+ {item.candidateUrl && {t('skillAvatar.candidatePreview')}} +
+

@{item.namespace}/{item.slug}

+

{t(`skillAvatar.machine.${item.machineStatus ?? 'SKIPPED'}`)}

+ setReasons({ ...reasons, [item.candidateId!]: event.target.value })} placeholder={t('skillAvatar.reason')} aria-label={t('skillAvatar.reason')} /> +
+ + +
+
+
)} + {error &&

{error}

} +
+} diff --git a/web/src/features/skill/skill-avatar-takedown.tsx b/web/src/features/skill/skill-avatar-takedown.tsx new file mode 100644 index 00000000..f5ff821b --- /dev/null +++ b/web/src/features/skill/skill-avatar-takedown.tsx @@ -0,0 +1,24 @@ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { skillAvatarApi } from '@/api/client' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' + +export function SkillAvatarTakedown({ namespace, slug }: { namespace: string; slug: string }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [reason, setReason] = useState('') + const [error, setError] = useState('') + const mutation = useMutation({ + mutationFn: () => skillAvatarApi.takedown(namespace, slug, reason), + onSuccess: async () => { setError(''); setReason(''); await Promise.all([queryClient.invalidateQueries({ queryKey: ['skill'] }), queryClient.invalidateQueries({ queryKey: ['skills'] })]) }, + onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')), + }) + return
+

{t('skillAvatar.takedownHelp')}

+ setReason(event.target.value)} placeholder={t('skillAvatar.reason')} aria-label={t('skillAvatar.reason')} /> + + {error &&

{error}

} +
+} diff --git a/web/src/features/skill/skill-avatar.tsx b/web/src/features/skill/skill-avatar.tsx new file mode 100644 index 00000000..09b1d540 --- /dev/null +++ b/web/src/features/skill/skill-avatar.tsx @@ -0,0 +1,12 @@ +import { useState } from 'react' +import { buildApiUrl } from '@/api/client' + +export function SkillAvatar({ name, url, size = 'h-10 w-10' }: { name: string; url?: string | null; size?: string }) { + const [failedUrl, setFailedUrl] = useState(null) + const imageUrl = url && failedUrl !== url ? buildApiUrl(url) : null + return ( + + ) +} diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index 7af306ab..27098660 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -4,6 +4,7 @@ import { useAuth } from '@/features/auth/use-auth' import { useStarredIdSet } from '@/features/social/use-star' import { Card } from '@/shared/ui/card' import { NamespaceBadge } from '@/shared/components/namespace-badge' +import { SkillAvatar } from './skill-avatar' import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle' import { formatCompactCount } from '@/shared/lib/number-format' import { formatRelativeTime } from '@/shared/lib/format-relative-time' @@ -51,7 +52,8 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard >
-
+
+

{skill.displayName}

diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a863e68d..2daf9d12 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1598,6 +1598,8 @@ "save": "Save review", "cancel": "Cancel", "delete": "Delete review", + "takedown": "Take down avatar", + "takedownHelp": "Enter a reason to remove the current avatar. The skill will show its initial.", "saved": "Review saved", "deleted": "Review deleted; your star rating was kept", "textRequired": "Enter a review before saving", @@ -2566,5 +2568,46 @@ "VERSION_UNAVAILABLE": "Version unavailable", "VISIBILITY_INCOMPATIBLE": "Visibility incompatible" } + }, + "skillAvatar": { + "title": "Skill avatar", + "ownerHelp": "Upload and crop a square image. The current avatar stays visible until the new one is approved.", + "status": "Status", + "candidatePreview": "Avatar candidate", + "choose": "Choose avatar image", + "cropPreview": "Crop preview", + "zoom": "Zoom", + "submit": "Submit avatar", + "remove": "Remove current avatar", + "appeal": "Request review", + "retry": "Retry machine review", + "failed": "Action failed. Please try again.", + "invalid": "Choose a PNG or JPEG image under 2 MB.", + "reviewTitle": "Avatar review", + "loading": "Loading", + "emptyQueue": "No avatars awaiting review", + "reason": "Review reason (required to reject)", + "approve": "Approve", + "reject": "Reject", + "settingsTitle": "Avatar review settings", + "settingsHelp": "The machine provider is set at deployment; these switches control machine and manual review.", + "machineEnabled": "Enable machine review", + "manualEnabled": "Enable manual review", + "save": "Save settings", + "saved": "Saved", + "states": { + "PENDING": "Pending", + "ACTIVE": "Active", + "REJECTED": "Rejected", + "SUPERSEDED": "Superseded", + "REMOVED": "Removed" + }, + "machine": { + "SKIPPED": "Machine review off", + "PENDING": "Machine review pending", + "PASSED": "Machine review passed", + "BLOCKED": "Machine review blocked", + "ERROR": "Machine review failed" + } } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 5f7d8503..4cd4fdf5 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1820,6 +1820,8 @@ "save": "Сохранить отзыв", "cancel": "Отмена", "delete": "Удалить отзыв", + "takedown": "Снять аватар", + "takedownHelp": "Укажите причину снятия текущего аватара. Будет показана первая буква названия.", "saved": "Отзыв сохранён", "deleted": "Отзыв удалён, оценка сохранена", "textRequired": "Введите текст отзыва", @@ -2568,5 +2570,46 @@ "title": "Что-то пошло не так", "description": "На этой странице произошла ошибка. Можно вернуться назад или перезагрузить раздел.", "reset": "Попробовать снова" + }, + "skillAvatar": { + "title": "Аватар навыка", + "ownerHelp": "Загрузите и обрежьте квадратное изображение. Текущий аватар останется до одобрения нового.", + "status": "Статус", + "candidatePreview": "Новый аватар", + "choose": "Выбрать изображение", + "cropPreview": "Предпросмотр обрезки", + "zoom": "Масштаб", + "submit": "Отправить аватар", + "remove": "Удалить аватар", + "appeal": "Запросить пересмотр", + "retry": "Повторить автоматическую проверку", + "failed": "Ошибка. Повторите попытку.", + "invalid": "Выберите PNG или JPEG размером до 2 МБ.", + "reviewTitle": "Проверка аватаров", + "loading": "Загрузка", + "emptyQueue": "Нет аватаров на проверке", + "reason": "Причина (обязательна при отклонении)", + "approve": "Одобрить", + "reject": "Отклонить", + "settingsTitle": "Настройки проверки аватаров", + "settingsHelp": "Поставщик автоматической проверки задаётся при развертывании; здесь включаются виды проверки.", + "machineEnabled": "Автоматическая проверка", + "manualEnabled": "Ручная проверка", + "save": "Сохранить", + "saved": "Сохранено", + "states": { + "PENDING": "Ожидает", + "ACTIVE": "Активен", + "REJECTED": "Отклонён", + "SUPERSEDED": "Заменён", + "REMOVED": "Удалён" + }, + "machine": { + "SKIPPED": "Проверка выключена", + "PENDING": "Проверяется", + "PASSED": "Проверка пройдена", + "BLOCKED": "Заблокирован", + "ERROR": "Ошибка проверки" + } } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index ba4d66fa..3ba0f918 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1597,6 +1597,8 @@ "save": "保存评价", "cancel": "取消", "delete": "删除评价", + "takedown": "撤下头像", + "takedownHelp": "请输入原因后撤下当前头像,技能将显示默认首字母。", "saved": "评价已保存", "deleted": "评价已删除,星级评分已保留", "textRequired": "请先填写评价内容", @@ -2565,5 +2567,46 @@ "VERSION_UNAVAILABLE": "版本不可用", "VISIBILITY_INCOMPATIBLE": "可见性不兼容" } + }, + "skillAvatar": { + "title": "技能头像", + "ownerHelp": "上传并裁剪方形图片。新头像通过审核后才会替换当前头像。", + "status": "状态", + "candidatePreview": "待审核头像", + "choose": "选择头像图片", + "cropPreview": "裁剪预览", + "zoom": "放大", + "submit": "提交头像", + "remove": "删除当前头像", + "appeal": "申请复核", + "retry": "重试机器审核", + "failed": "操作失败,请稍后重试", + "invalid": "请选择不超过 2 MB 的 PNG 或 JPEG 图片", + "reviewTitle": "头像审核", + "loading": "加载中", + "emptyQueue": "暂无待审核头像", + "reason": "审核原因(驳回时必填)", + "approve": "通过", + "reject": "驳回", + "settingsTitle": "头像审核设置", + "settingsHelp": "机器审核服务在部署时指定;这里只控制是否启用机器与人工审核。", + "machineEnabled": "启用机器审核", + "manualEnabled": "启用人工审核", + "save": "保存设置", + "saved": "已保存", + "states": { + "PENDING": "待处理", + "ACTIVE": "已使用", + "REJECTED": "已拒绝", + "SUPERSEDED": "已替换", + "REMOVED": "已删除" + }, + "machine": { + "SKIPPED": "未启用机器审核", + "PENDING": "机器审核中", + "PASSED": "机器审核通过", + "BLOCKED": "机器审核阻止", + "ERROR": "机器审核失败" + } } } diff --git a/web/src/pages/admin/system-config.tsx b/web/src/pages/admin/system-config.tsx index b754387b..31c20ad2 100644 --- a/web/src/pages/admin/system-config.tsx +++ b/web/src/pages/admin/system-config.tsx @@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { AvatarReviewSettingsPanel } from '@/features/skill/avatar-review-settings' export function SystemConfigPage() { const { t } = useTranslation() @@ -105,6 +106,7 @@ export function SystemConfigPage() {
{error ?

{error}

: null} {message ?

{message}

: null} + {t('systemConfig.localAuth')} diff --git a/web/src/pages/dashboard/namespace-reviews.tsx b/web/src/pages/dashboard/namespace-reviews.tsx index fb995e8a..2981cc35 100644 --- a/web/src/pages/dashboard/namespace-reviews.tsx +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' import { useReviewList } from '@/features/review/use-review-list' +import { SkillAvatarReviewQueue } from '@/features/skill/skill-avatar-review' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Pagination } from '@/shared/components/pagination' import { NamespaceHeader } from '@/features/namespace/namespace-header' @@ -161,6 +162,7 @@ export function NamespaceReviewsPage() { {readOnlyMessage} ) : null} +
) diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index a1ed02bc..90cbc27b 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -25,6 +25,7 @@ import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Pagination } from '@/shared/components/pagination' import { formatLocalDateTime } from '@/shared/lib/date-time' import { ProfileReviewTable } from './profile-review-table' +import { SkillAvatarReviewQueue } from '@/features/skill/skill-avatar-review' type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' type TimeSortDirection = 'ASC' | 'DESC' @@ -265,6 +266,8 @@ export function ReviewsPage() {
+ {isSkillAdmin && } + {showTypeTabs ? ( diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index db17304d..fb912c30 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -4,6 +4,9 @@ import { Link, useParams, useNavigate, useRouterState, useSearch } from '@tansta import { useMutation, useQueryClient } from '@tanstack/react-query' import { ArrowLeft, ArrowUpCircle, Boxes, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' +import { SkillAvatar } from '@/features/skill/skill-avatar' +import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager' +import { SkillAvatarTakedown } from '@/features/skill/skill-avatar-takedown' import { resolvePackageRelativeLink } from '@/features/skill/package-relative-link' import { FileTree } from '@/features/skill/file-tree' import { FilePreviewDialog } from '@/features/skill/file-preview-dialog' @@ -29,6 +32,7 @@ import { StarButton } from '@/features/social/star-button' import { SubscribeButton } from '@/features/social/subscribe-button' import { SkillReviews } from '@/features/social/skill-reviews' import { useAuth } from '@/features/auth/use-auth' +import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { adminApi, ApiError, buildApiUrl, WEB_API_PREFIX } from '@/api/client' import { useSubmitSkillReport } from '@/features/report/use-skill-reports' import { SecurityAuditSummary } from '@/features/security-audit/security-audit-summary' @@ -166,6 +170,7 @@ export function SkillDetailPage() { const overviewQuietGenerationRef = useRef(0) const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' }) const { user, hasRole } = useAuth() + const { data: myNamespaces } = useMyNamespaces(Boolean(user)) const isPromotionAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const detailQueriesEnabled = isSkillDetailQueriesEnabled(skillDeleted) const qns = detailQueriesEnabled ? namespace : '' @@ -897,7 +902,12 @@ export function SkillDetailPage() { )}
-

{skill.displayName}

+

{skill.displayName}

+ {user?.userId === skill.ownerId && } + {skill.avatarUrl && user?.userId !== skill.ownerId && (namespace === 'global' + ? isPromotionAdmin + : myNamespaces?.some((item) => item.slug === namespace && (item.currentUserRole === 'OWNER' || item.currentUserRole === 'ADMIN'))) + && } {skill.ownerDisplayName && (
diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index aabd83c9..fb9582a9 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -78,10 +78,11 @@ function invalidateNamespaceQueries(queryClient: ReturnType Date: Mon, 12 Oct 2026 13:42:26 +0800 Subject: [PATCH 17/47] fix(avatar): recover pending reviews and cover moderation flows Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/26-skill-avatars.md | 4 +- server/skillhub-app/pom.xml | 6 + .../controller/portal/SkillController.java | 6 +- .../dto/SkillAvatarStatusResponse.java | 3 +- .../SkillAvatarScopeReviewListener.java | 43 + .../service/SkillAvatarAppService.java | 139 ++- .../service/SkillSearchAppService.java | 13 +- .../task/SkillAvatarMachineRecoveryTask.java | 54 ++ .../SkillAvatarScopeReviewRecoveryTask.java | 50 + .../db/migration/V69__skill_avatars.sql | 6 + .../SkillAvatarScopeReviewListenerTest.java | 45 + .../SkillAvatarMachineRecoveryQueryTest.java | 73 ++ .../SkillAvatarScopeRecoveryQueryTest.java | 125 +++ .../AvatarReviewSettingsServiceTest.java | 69 ++ .../service/SkillAvatarAppServiceTest.java | 395 ++++++++ .../skillhub/service/SkillAvatarUrlsTest.java | 20 + .../SkillAvatarMachineRecoveryTaskTest.java | 47 + ...killAvatarScopeReviewRecoveryTaskTest.java | 69 ++ .../policy/RouteSecurityPolicyRegistry.java | 4 + .../RouteSecurityPolicyRegistryTest.java | 14 + .../domain/skill/SkillAvatarCandidate.java | 59 +- .../skill/SkillAvatarCandidateTest.java | 32 + .../SkillAvatarCandidateJpaRepository.java | 21 + .../infra/jpa/SkillJpaRepository.java | 34 + web/src/api/generated/schema.d.ts | 881 +++++++++++++++++- web/src/api/types.ts | 6 +- .../skill/avatar-review-settings.test.tsx | 34 + .../features/skill/avatar-review-settings.tsx | 1 + .../skill/skill-avatar-manager.test.tsx | 39 + .../features/skill/skill-avatar-manager.tsx | 9 +- web/src/features/skill/skill-avatar.test.tsx | 22 + web/src/features/skill/skill-card.test.ts | 2 + web/src/features/skill/skill-card.tsx | 2 +- web/src/i18n/locales/en.json | 4 + web/src/i18n/locales/ru.json | 4 + web/src/i18n/locales/zh.json | 4 + web/src/pages/admin/system-config.test.tsx | 3 + .../pages/dashboard/namespace-reviews.test.ts | 2 + web/src/pages/dashboard/reviews.test.ts | 2 + web/src/pages/dashboard/reviews.tsx | 2 +- 40 files changed, 2293 insertions(+), 55 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillAvatarScopeReviewListener.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarMachineRecoveryTask.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarScopeReviewRecoveryTask.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillAvatarScopeReviewListenerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarMachineRecoveryQueryTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarScopeRecoveryQueryTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AvatarReviewSettingsServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarAppServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarUrlsTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillAvatarMachineRecoveryTaskTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillAvatarScopeReviewRecoveryTaskTest.java create mode 100644 web/src/features/skill/avatar-review-settings.test.tsx create mode 100644 web/src/features/skill/skill-avatar-manager.test.tsx create mode 100644 web/src/features/skill/skill-avatar.test.tsx diff --git a/docs/26-skill-avatars.md b/docs/26-skill-avatars.md index 958287f5..8854e55a 100644 --- a/docs/26-skill-avatars.md +++ b/docs/26-skill-avatars.md @@ -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 的差异 diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index 0dc91ad7..856939bd 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -28,6 +28,12 @@ com.aliyun.oss aliyun-sdk-oss 3.16.3 + + + commons-logging + commons-logging + + org.springframework.boot diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 1062db56..d81e1637 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -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); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java index d0439e72..4d62d69b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java @@ -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) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillAvatarScopeReviewListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillAvatarScopeReviewListener.java new file mode 100644 index 00000000..1781bce2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillAvatarScopeReviewListener.java @@ -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()); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java index 5183be87..fbb2c2ad 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java @@ -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) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 8af16c49..60bb9cdd 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -187,7 +187,7 @@ public class SkillSearchAppService { normalizeLabelSlugs(labelSlugs), requireInstallableLatest )); - List pageItems = mapVisibleSkillSummaries(result.skillIds()); + List pageItems = mapVisibleSkillSummaries(result.skillIds(), scope.userId()); return new SearchResponse(pageItems, result.total(), page, size); } @@ -202,7 +202,7 @@ public class SkillSearchAppService { .toList(); } - private List mapVisibleSkillSummaries(List skillIds) { + private List mapVisibleSkillSummaries(List 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 namespaceSlugsById, Map 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) ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarMachineRecoveryTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarMachineRecoveryTask.java new file mode 100644 index 00000000..ddc40e18 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarMachineRecoveryTask.java @@ -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 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 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)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarScopeReviewRecoveryTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarScopeReviewRecoveryTask.java new file mode 100644 index 00000000..cc2721e7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarScopeReviewRecoveryTask.java @@ -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 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 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)); + } +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql b/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql index 18e990de..19171b2a 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql @@ -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, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillAvatarScopeReviewListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillAvatarScopeReviewListenerTest.java new file mode 100644 index 00000000..51b3fbca --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillAvatarScopeReviewListenerTest.java @@ -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"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarMachineRecoveryQueryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarMachineRecoveryQueryTest.java new file mode 100644 index 00000000..36042c2a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarMachineRecoveryQueryTest.java @@ -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 recoverable(Instant now) { + return recoverable(now, 0L); + } + + private java.util.List 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)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarScopeRecoveryQueryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarScopeRecoveryQueryTest.java new file mode 100644 index 00000000..4b59a446 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarScopeRecoveryQueryTest.java @@ -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 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; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AvatarReviewSettingsServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AvatarReviewSettingsServiceTest.java new file mode 100644 index 00000000..a742bd84 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AvatarReviewSettingsServiceTest.java @@ -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); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarAppServiceTest.java new file mode 100644 index 00000000..5f6e22ee --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarAppServiceTest.java @@ -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 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 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)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarUrlsTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarUrlsTest.java new file mode 100644 index 00000000..33c6ef42 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarUrlsTest.java @@ -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(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillAvatarMachineRecoveryTaskTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillAvatarMachineRecoveryTaskTest.java new file mode 100644 index 00000000..4c913e36 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillAvatarMachineRecoveryTaskTest.java @@ -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; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillAvatarScopeReviewRecoveryTaskTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillAvatarScopeReviewRecoveryTaskTest.java new file mode 100644 index 00000000..33e92f13 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillAvatarScopeReviewRecoveryTaskTest.java @@ -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); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 4603ad11..3219db40 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -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"), diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 33fa4ec4..949a9710 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -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, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidate.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidate.java index db1c3205..7ae139fe 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidate.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidate.java @@ -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; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateTest.java index 7f52e2fc..2c65f209 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateTest.java @@ -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); + } } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java index cfc6971f..ee346211 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java @@ -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, SkillAvatarCandidateRepository { List findBySkillIdOrderByCreatedAtDesc(Long skillId); + boolean existsBySkillIdAndAudienceAndScopeReviewVersionIdAndStatus(Long skillId, + SkillVisibility audience, Long versionId, + SkillAvatarCandidate.Status status); List findByContentSha256AndMachineProviderAndMachineEnabledTrueAndManualEnabledAndMachineStatus( String contentSha256, String machineProvider, boolean manualEnabled, SkillAvatarCandidate.MachineStatus machineStatus); List findByStatusAndReviewStatusOrderByCreatedAtAsc( SkillAvatarCandidate.Status status, SkillAvatarCandidate.ReviewStatus reviewStatus); List 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 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); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java index a7cb8e40..c75e3d51 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java @@ -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, SkillRep @Query("SELECT s FROM Skill s WHERE s.id = :id") Optional findLockedById(@Param("id") Long id); List findByIdIn(List 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 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 findByNamespaceIdAndSlug(Long namespaceId, String slug); List findByNamespaceIdInAndSlugIn(List namespaceIds, List slugs); Optional findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 8884f9ca..91c94743 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -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; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 9120b4e7..9f36bbf6 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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[] diff --git a/web/src/features/skill/avatar-review-settings.test.tsx b/web/src/features/skill/avatar-review-settings.test.tsx new file mode 100644 index 00000000..9763eb8f --- /dev/null +++ b/web/src/features/skill/avatar-review-settings.test.tsx @@ -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() + 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() + fireEvent.click(await screen.findByRole('checkbox', { name: 'skillAvatar.manualEnabled' })) + expect(screen.getByRole('alert').textContent).toBe('skillAvatar.noReviewRisk') + }) +}) diff --git a/web/src/features/skill/avatar-review-settings.tsx b/web/src/features/skill/avatar-review-settings.tsx index 615c9c9a..4cfa58d4 100644 --- a/web/src/features/skill/avatar-review-settings.tsx +++ b/web/src/features/skill/avatar-review-settings.tsx @@ -26,6 +26,7 @@ export function AvatarReviewSettingsPanel() { {settings.isError &&

{t('skillAvatar.failed')}

} {settings.data && <> {(['machineEnabled', 'manualEnabled'] as const).map((field) => )} + {!draft.machineEnabled && !draft.manualEnabled &&

{t('skillAvatar.noReviewRisk')}

} } {saved &&

{t('skillAvatar.saved')}

} diff --git a/web/src/features/skill/skill-avatar-manager.test.tsx b/web/src/features/skill/skill-avatar-manager.test.tsx new file mode 100644 index 00000000..edbf10cc --- /dev/null +++ b/web/src/features/skill/skill-avatar-manager.test.tsx @@ -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() + 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() + + expect(await screen.findByText('skillAvatar.retryAfter')).toBeTruthy() + expect(screen.getByText('skillAvatar.automaticRetryStopped')).toBeTruthy() + expect(screen.getByRole('button', { name: 'skillAvatar.retry' }).hasAttribute('disabled')).toBe(true) + }) +}) diff --git a/web/src/features/skill/skill-avatar-manager.tsx b/web/src/features/skill/skill-avatar-manager.tsx index ba54ef50..cca3b279 100644 --- a/web/src/features/skill/skill-avatar-manager.tsx +++ b/web/src/features/skill/skill-avatar-manager.tsx @@ -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 (

{t('skillAvatar.title')}

{t('skillAvatar.ownerHelp')}

{candidate?.candidateStatus &&

{t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)} · {t(`skillAvatar.machine.${candidate.machineStatus ?? 'SKIPPED'}`)}

} {candidate?.reviewReason &&

{candidate.reviewReason}

} + {candidate?.machineStatus === 'ERROR' && retryCoolingDown &&

{t('skillAvatar.retryAfter', { time: new Date(candidate.machineNextRetryAt!).toLocaleString() })}

} + {candidate?.machineStatus === 'ERROR' && candidate.automaticRetryExhausted &&

{t('skillAvatar.automaticRetryStopped')}

} {candidate?.candidateUrl && {t('skillAvatar.candidatePreview')}} { const file = event.target.files?.[0] @@ -77,8 +80,10 @@ export function SkillAvatarManager({ namespace, slug }: { namespace: string; slu
}
{(candidate?.avatarUrl || candidate?.candidateStatus === 'PENDING') && } - {candidate?.candidateStatus === 'REJECTED' && candidate.candidateId && } - {candidate?.machineStatus === 'ERROR' && candidate.candidateId && } + {candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED' + && candidate.reviewStatus === 'NOT_REQUIRED' && candidate.candidateId + && } + {candidate?.machineStatus === 'ERROR' && candidate.candidateId && }
{error &&

{error}

} diff --git a/web/src/features/skill/skill-avatar.test.tsx b/web/src/features/skill/skill-avatar.test.tsx new file mode 100644 index 00000000..95161bec --- /dev/null +++ b/web/src/features/skill/skill-avatar.test.tsx @@ -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() + expect(screen.getByText('W')).toBeTruthy() + }) + + it('falls back to the initial if an approved image is unavailable', () => { + const { container } = render() + const image = container.querySelector('img')! + expect(image.getAttribute('src')).toBe('/proxy/avatar') + fireEvent.error(image) + expect(screen.getByText('W')).toBeTruthy() + }) +}) diff --git a/web/src/features/skill/skill-card.test.ts b/web/src/features/skill/skill-card.test.ts index 3b3a05fb..b4847b20 100644 --- a/web/src/features/skill/skill-card.test.ts +++ b/web/src/features/skill/skill-card.test.ts @@ -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, diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index 27098660..fef95a48 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -52,7 +52,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard >
-
+

{skill.displayName} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 2daf9d12..fb123666 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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" diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 4cd4fdf5..8f508ef6 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -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": "Ошибка проверки" diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 3ba0f918..4956cf6e 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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": "机器审核失败" diff --git a/web/src/pages/admin/system-config.test.tsx b/web/src/pages/admin/system-config.test.tsx index bf84dac6..6c0d125d 100644 --- a/web/src/pages/admin/system-config.test.tsx +++ b/web/src/pages/admin/system-config.test.tsx @@ -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() return client diff --git a/web/src/pages/dashboard/namespace-reviews.test.ts b/web/src/pages/dashboard/namespace-reviews.test.ts index 735307ca..9868c27d 100644 --- a/web/src/pages/dashboard/namespace-reviews.test.ts +++ b/web/src/pages/dashboard/namespace-reviews.test.ts @@ -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', () => { diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index 186c15eb..d5e181e5 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -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', () => { diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 90cbc27b..c14255b3 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -266,7 +266,7 @@ export function ReviewsPage() {
- {isSkillAdmin && } + {isSkillAdmin && } {showTypeTabs ? ( From c48b09d3a0b6c6dcc99b37e67e8994a4c5e3c0bb Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:12:00 +0800 Subject: [PATCH 18/47] fix(namespace): expose invitation confirmation errors Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../namespace/invitation-controls.test.tsx | 44 +++++++++++++++++++ .../namespace/invitation-controls.tsx | 9 +++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 web/src/features/namespace/invitation-controls.test.tsx diff --git a/web/src/features/namespace/invitation-controls.test.tsx b/web/src/features/namespace/invitation-controls.test.tsx new file mode 100644 index 00000000..9397b03e --- /dev/null +++ b/web/src/features/namespace/invitation-controls.test.tsx @@ -0,0 +1,44 @@ +/** @vitest-environment jsdom */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const api = vi.hoisted(() => ({ + invitationStatus: vi.fn(), + createInvitation: vi.fn(), + revokeInvitation: vi.fn(), +})) + +vi.mock('@/api/client', () => ({ namespaceApi: api })) +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) + +import { InvitationControls } from './invitation-controls' + +describe('InvitationControls', () => { + afterEach(() => { + cleanup() + Object.values(api).forEach((mock) => mock.mockReset()) + }) + + async function openConfirmation(action: 'replace' | 'revoke') { + api.invitationStatus.mockResolvedValue({ active: true }) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + const button = await screen.findByRole('button', { name: action === 'replace' ? 'invitation.create' : 'invitation.revoke' }) + await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false)) + fireEvent.click(button) + return screen.findByRole('dialog') + } + + it.each(['replace', 'revoke'] as const)('shows a reachable error after %s fails', async (action) => { + api.createInvitation.mockRejectedValue(new Error('unavailable')) + api.revokeInvitation.mockRejectedValue(new Error('unavailable')) + const dialog = await openConfirmation(action) + + fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.confirm' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + expect(screen.getByRole('alert').textContent).toBe('invitation.error') + expect(action === 'replace' ? api.createInvitation : api.revokeInvitation).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/features/namespace/invitation-controls.tsx b/web/src/features/namespace/invitation-controls.tsx index e5659799..c407aa3d 100644 --- a/web/src/features/namespace/invitation-controls.tsx +++ b/web/src/features/namespace/invitation-controls.tsx @@ -63,7 +63,14 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co title={t(confirm === 'revoke' ? 'invitation.revokeTitle' : 'invitation.replaceTitle')} description={t(confirm === 'revoke' ? 'invitation.revokeDescription' : 'invitation.replaceDescription')} confirmText={t('dialog.confirm')} - onConfirm={async () => { if (confirm === 'revoke') await revoke.mutateAsync(); else await create.mutateAsync(true) }} /> + onConfirm={async () => { + try { + if (confirm === 'revoke') await revoke.mutateAsync() + else await create.mutateAsync(true) + } catch { + setConfirm(null) + } + }} />
) } From bc2592c978221efd46305a316af8a83f28e909f6 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:16:15 +0800 Subject: [PATCH 19/47] test(avatar): verify recovery with PostgreSQL transactions Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../SkillAvatarRecoveryPostgresTest.java | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java new file mode 100644 index 00000000..f8bde110 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java @@ -0,0 +1,423 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse; +import com.iflytek.skillhub.infra.jpa.NamespaceJpaRepository; +import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository; +import com.iflytek.skillhub.infra.jpa.SkillJpaRepository; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.AvatarModerationGateway; +import com.iflytek.skillhub.service.AvatarModerationProvider; +import com.iflytek.skillhub.service.AvatarReviewSettingsService; +import com.iflytek.skillhub.service.SkillAvatarAppService; +import com.iflytek.skillhub.service.SkillAvatarImageProcessor; +import com.iflytek.skillhub.service.SkillAvatarUrls; +import com.iflytek.skillhub.listener.SkillAvatarScopeReviewListener; +import com.iflytek.skillhub.task.SkillAvatarMachineRecoveryTask; +import com.iflytek.skillhub.task.SkillAvatarScopeReviewRecoveryTask; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.io.ByteArrayInputStream; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** Real PostgreSQL recovery transactions with controlled policy, storage and external provider. */ +@DataJpaTest(showSql = false) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Import({SkillAvatarAppService.class, SkillAvatarMachineRecoveryTask.class, + SkillAvatarScopeReviewListener.class, SkillAvatarScopeReviewRecoveryTask.class, + com.iflytek.skillhub.task.SkillAvatarCleanupTask.class}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class SkillAvatarRecoveryPostgresTest { + @Container private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void postgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + + @Autowired private SkillJpaRepository skills; + @Autowired private NamespaceJpaRepository namespaces; + @Autowired private SkillAvatarCandidateJpaRepository candidates; + @Autowired private SkillAvatarAppService avatars; + @Autowired private SkillAvatarMachineRecoveryTask machineRecovery; + @Autowired private SkillAvatarScopeReviewRecoveryTask scopeRecovery; + @Autowired private PlatformTransactionManager transactions; + @Autowired private JdbcTemplate jdbc; + @Autowired private com.iflytek.skillhub.task.SkillAvatarCleanupTask cleanup; + @Autowired private com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository users; + @MockBean private ObjectStorageService storage; + @MockBean private SkillAvatarImageProcessor imageProcessor; + @MockBean private AvatarReviewSettingsService settings; + @MockBean private AvatarModerationGateway moderation; + @MockBean private VisibilityChecker visibility; + @MockBean private SkillQueryService skillQueries; + @MockBean private AuditLogService audit; + @MockBean private RequestIdAccessor requestIds; + + @BeforeEach + void providerAndStorage() { + if (((com.iflytek.skillhub.domain.user.UserAccountRepository) users).findById("owner").isEmpty()) { + users.saveAndFlush(new com.iflytek.skillhub.domain.user.UserAccount("owner", "owner", null, null)); + } + byte[] image = UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + when(storage.getObject(anyString())).thenAnswer(ignored -> new ByteArrayInputStream(image)); + when(moderation.requireConfiguredProvider()).thenReturn("xfyun"); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(AvatarModerationProvider.Verdict.PASS); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(true, false, 1, null)); + } + + @Test + void ordinaryCommittedPendingUploadIsRecoveredByScheduler() { + Long skillId = skill(false); + byte[] image = UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + when(imageProcessor.normalize(any())).thenAnswer(call -> call.getArgument(0)); + Long candidateId = avatars.submit(namespaceSlug(skillId), "skill", "owner", image, + new AuditRequestContext(null, null)).candidateId(); + machineRecovery.recover(); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineStatus()) + .isEqualTo(SkillAvatarCandidate.MachineStatus.PENDING); + assertThat(readSkill(skillId).getActiveAvatarId()).isNull(); + setTime(candidateId, "created_at", Instant.now().minusSeconds(660)); + Instant createdAt = candidates.findById(candidateId).orElseThrow().getCreatedAt(); + assertThat(createdAt).isBefore(Instant.now().minusSeconds(600)); + assertThat(abandoned(createdAt.minusSeconds(1), Instant.now())).extracting(SkillAvatarCandidate::getId).doesNotContain(candidateId); + assertThat(abandoned(createdAt, Instant.now())).extracting(SkillAvatarCandidate::getId).contains(candidateId); + machineRecovery.recover(); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineStatus()) + .isEqualTo(SkillAvatarCandidate.MachineStatus.PASSED); + assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(candidateId); + assertThat(readSkill(skillId).getPendingAvatarId()).isNull(); + } + + @Test + void expiredLeaseIsReclaimedAndLateFirstAttemptCannotOverwriteSecond() throws Exception { + Long skillId = skill(false); + Long candidateId = pending(skillId); + CountDownLatch providerEntered = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + AtomicInteger calls = new AtomicInteger(); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenAnswer(ignored -> { + if (calls.incrementAndGet() == 1) { + providerEntered.countDown(); + assertThat(releaseFirst.await(20, TimeUnit.SECONDS)).isTrue(); + return AvatarModerationProvider.Verdict.BLOCK; + } + return AvatarModerationProvider.Verdict.PASS; + }); + try (var pool = Executors.newFixedThreadPool(2)) { + var first = pool.submit(() -> avatars.processMachine(candidateId, new AuditRequestContext(null, null))); + try { + assertThat(providerEntered.await(10, TimeUnit.SECONDS)).isTrue(); + var otherSession = pool.submit(() -> new TransactionTemplate(transactions).execute(status -> { + jdbc.execute("SET LOCAL lock_timeout = '2s'"); + return skills.findLockedById(skillId).orElseThrow().getId(); + })); + assertThat(otherSession.get(5, TimeUnit.SECONDS)).isEqualTo(skillId); + setTime(candidateId, "machine_lease_until", Instant.now().minusSeconds(1)); + Instant lease = candidates.findById(candidateId).orElseThrow().getMachineLeaseUntil(); + assertThat(lease).isBefore(Instant.now()); + assertThat(abandoned(Instant.now().minusSeconds(600), lease.minusSeconds(1))) + .extracting(SkillAvatarCandidate::getId).doesNotContain(candidateId); + assertThat(abandoned(Instant.now().minusSeconds(600), lease)) + .extracting(SkillAvatarCandidate::getId).contains(candidateId); + machineRecovery.recover(); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(2); + } finally { + releaseFirst.countDown(); + } + assertThat(first.get(10, TimeUnit.SECONDS).machineStatus()).isEqualTo("PASSED"); + assertThat(calls.get()).isEqualTo(2); + } + assertThat(candidates.findById(candidateId).orElseThrow().getMachineStatus()) + .isEqualTo(SkillAvatarCandidate.MachineStatus.PASSED); + assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(candidateId); + } + + @Test + void missingScopeListenerIsCompensatedWithoutPublishingPrivateImage() { + Long skillId = skill(true); + Long oldAvatarId = readSkill(skillId).getActiveAvatarId(); + when(storage.getObject("old/" + skillId)).thenThrow(new IllegalStateException("temporary storage outage")); + scopeRecovery.recover(); + assertThat(readSkill(skillId).getPendingAvatarId()).isNull(); + assertThat(SkillAvatarUrls.current(readSkill(skillId), "test")).isNull(); + org.mockito.Mockito.doAnswer(ignored -> new ByteArrayInputStream(new byte[] {1, 2, 3})) + .when(storage).getObject("old/" + skillId); + scopeRecovery.recover(); + Skill recovered = readSkill(skillId); + assertThat(recovered.getActiveAvatarId()).isNotEqualTo(oldAvatarId); + assertThat(recovered.getActiveAvatarAudience()).isEqualTo(SkillVisibility.PUBLIC); + assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(skillId)).hasSize(2); + assertThat(candidates.findById(recovered.getActiveAvatarId()).orElseThrow().isScopeReview()).isTrue(); + } + + @Test + void committedScopeCandidateRecoversWithCooldownAndOwnerRetryAfterThreeAutomaticAttempts() { + // Independent candidates: a manually repeated ERROR cannot be applied to an already active PASS. + for (AvatarModerationProvider.Verdict manualVerdict : new AvatarModerationProvider.Verdict[] { + AvatarModerationProvider.Verdict.ERROR, AvatarModerationProvider.Verdict.PASS}) { + Long skillId = skill(true); + Long candidateId = avatars.prepareScopeReview(skillId, null).candidateId(); + assertThat(candidates.findById(candidateId).orElseThrow().isScopeReview()).isTrue(); + assertThat(candidates.findById(candidateId).orElseThrow().getScopeReviewVersionId()).isNull(); + assertThat(abandoned(Instant.now().plusSeconds(660), Instant.now().plusSeconds(660))) + .extracting(SkillAvatarCandidate::getId).doesNotContain(candidateId); + setTime(candidateId, "created_at", Instant.now().minusSeconds(660)); + assertThat(candidates.findById(candidateId).orElseThrow().getCreatedAt()).isBefore(Instant.now().minusSeconds(600)); + org.mockito.Mockito.clearInvocations(moderation); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(AvatarModerationProvider.Verdict.ERROR); + scopeRecovery.recover(); + SkillAvatarCandidate first = candidates.findById(candidateId).orElseThrow(); + assertThat(first.getMachineAttemptCount()).isEqualTo(1); + assertThat(first.getMachineNextRetryAt()).isBetween(Instant.now().plusSeconds(3500), Instant.now().plusSeconds(3601)); + assertThatThrownBy(() -> avatars.processMachine(candidateId, new AuditRequestContext(null, null))) + .isInstanceOf(DomainConflictException.class); + scopeRecovery.recover(); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(1); + for (int attempt = 2; attempt <= 3; attempt++) { + setTime(candidateId, "machine_next_retry_at", Instant.now().minusSeconds(1)); + scopeRecovery.recover(); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(attempt); + } + avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "owner"); + assertThatThrownBy(() -> avatars.processMachine(candidateId, new AuditRequestContext(null, null))) + .isInstanceOf(DomainConflictException.class); + assertThatThrownBy(() -> avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "stranger")) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(3)).moderate(eq("xfyun"), any(byte[].class)); + setTime(candidateId, "machine_next_retry_at", Instant.now().minusSeconds(1)); + scopeRecovery.recover(); + scopeRecovery.recover(); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(3); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(3)).moderate(eq("xfyun"), any(byte[].class)); + assertThat(readSkill(skillId).getPendingAvatarId()).isEqualTo(candidateId); + assertThat(SkillAvatarUrls.current(readSkill(skillId), "test")).isNull(); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(manualVerdict); + avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "owner"); + var retried = avatars.processMachine(candidateId, new AuditRequestContext(null, null)); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(4); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(4)).moderate(eq("xfyun"), any(byte[].class)); + if (manualVerdict == AvatarModerationProvider.Verdict.PASS) { + assertThat(retried.avatarUrl()).isNotNull(); + assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(candidateId); + assertThat(readSkill(skillId).getPendingAvatarId()).isNull(); + assertThatThrownBy(() -> avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "owner")) + .isInstanceOf(DomainConflictException.class); + } else { + assertThat(retried.avatarUrl()).isNull(); + assertThat(readSkill(skillId).getPendingAvatarId()).isEqualTo(candidateId); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineNextRetryAt()) + .isAfter(Instant.now().plusSeconds(3500)); + } + assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(skillId)).hasSize(2); + } + } + + @Test + void identicalImageReusesOnlyMatchingProviderAndManualPolicy() { + byte[] image = UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + when(imageProcessor.normalize(any())).thenAnswer(call -> call.getArgument(0)); + Long sourceId = skill(false); + String sourceNamespace = namespaceSlug(sourceId); + Long sourceCandidate = avatars.submit(sourceNamespace, "skill", "owner", image, new AuditRequestContext(null, null)).candidateId(); + avatars.processMachine(sourceCandidate, new AuditRequestContext(null, null)); + + Long sameId = skill(false); + var same = avatars.submit(namespaceSlug(sameId), "skill", "owner", image, new AuditRequestContext(null, null)); + assertThat(same.machineStatus()).isEqualTo("PASSED"); + assertThat(same.avatarUrl()).isNotNull(); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(true, true, 1, null)); + Long manualId = skill(false); + var manual = avatars.submit(namespaceSlug(manualId), "skill", "owner", image, new AuditRequestContext(null, null)); + assertThat(manual.machineStatus()).isEqualTo("PENDING"); + assertThat(manual.avatarUrl()).isNull(); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(true, false, 1, null)); + when(moderation.requireConfiguredProvider()).thenReturn("aliyun"); + Long otherProviderId = skill(false); + var other = avatars.submit(namespaceSlug(otherProviderId), "skill", "owner", image, new AuditRequestContext(null, null)); + assertThat(other.machineStatus()).isEqualTo("PENDING"); + assertThat(other.avatarUrl()).isNull(); + } + + @Test + void blockedAndProviderFailureNeverExposeAnActiveAvatar() { + for (AvatarModerationProvider.Verdict verdict : new AvatarModerationProvider.Verdict[] { + AvatarModerationProvider.Verdict.BLOCK, AvatarModerationProvider.Verdict.ERROR}) { + Long skillId = skill(false); + Long candidateId = pending(skillId); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(verdict); + var result = avatars.processMachine(candidateId, new AuditRequestContext(null, null)); + assertThat(result.machineStatus()).isEqualTo(verdict == AvatarModerationProvider.Verdict.BLOCK ? "BLOCKED" : "ERROR"); + assertThat(result.avatarUrl()).isNull(); + assertThat(readSkill(skillId).getActiveAvatarId()).isNull(); + assertThat(readSkill(skillId).getPendingAvatarId()).isEqualTo(candidateId); + } + Long skillId = skill(false); + Long candidateId = pending(skillId); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenThrow(new IllegalStateException("provider unavailable")); + assertThat(avatars.processMachine(candidateId, new AuditRequestContext(null, null)).machineStatus()).isEqualTo("ERROR"); + assertThat(readSkill(skillId).getActiveAvatarId()).isNull(); + } + + @Test + void globalReviewDoesNotAcceptTeamAdministratorRole() { + Long skillId = skill(false); + Skill skill = readSkill(skillId); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + Namespace ns = ((com.iflytek.skillhub.domain.namespace.NamespaceRepository) namespaces).findById(skill.getNamespaceId()).orElseThrow(); + ns.setType(com.iflytek.skillhub.domain.namespace.NamespaceType.GLOBAL); + }); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(false, true, 1, null)); + when(imageProcessor.normalize(any())).thenReturn(new byte[] {5, 6}); + Long candidateId = avatars.submit(namespaceSlug(skillId), "skill", "owner", new byte[] {5, 6}, new AuditRequestContext(null, null)).candidateId(); + assertThatThrownBy(() -> avatars.decide(namespaceSlug(skillId), "skill", candidateId, true, "reviewed", "reviewer", + java.util.Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.ADMIN), + java.util.Set.of(), new AuditRequestContext(null, null))) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class); + assertThat(readSkill(skillId).getActiveAvatarId()).isNull(); + var approved = avatars.decide(namespaceSlug(skillId), "skill", candidateId, true, "reviewed", "owner", + java.util.Map.of(), java.util.Set.of("SUPER_ADMIN"), new AuditRequestContext(null, null)); + assertThat(approved.avatarUrl()).isNotNull(); + } + + + @Test + void expiredImageCleanupRetriesFailureAndPreservesHistoryAndActiveObject() { + Long skillId = skill(true); + SkillAvatarCandidate active = candidates.findById(readSkill(skillId).getActiveAvatarId()).orElseThrow(); + Long rejectedId = new TransactionTemplate(transactions).execute(status -> { + SkillAvatarCandidate rejected = new SkillAvatarCandidate(skillId, "owner", "rejected/" + skillId, + "rejected-sha", SkillVisibility.PUBLIC, false, true, null); + rejected.reject("owner", "keep this textual review history"); + return candidates.saveAndFlush(rejected).getId(); + }); + SkillAvatarCandidate rejected = candidates.findById(rejectedId).orElseThrow(); + assertThat(candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(rejected.getDiscardAt().minusSeconds(1))) + .extracting(SkillAvatarCandidate::getId).doesNotContain(rejectedId); + assertThat(candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(rejected.getDiscardAt())) + .extracting(SkillAvatarCandidate::getId).doesNotContain(rejectedId); + assertThat(candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(rejected.getDiscardAt().plusSeconds(1))) + .extracting(SkillAvatarCandidate::getId).contains(rejectedId); + assertThat(rejected.getDiscardAt()).isBetween(Instant.now().plusSeconds(30L * 86400 - 60), + Instant.now().plusSeconds(30L * 86400 + 1)); + cleanup.removeExpiredImages(); + org.mockito.Mockito.verify(storage, org.mockito.Mockito.never()).deleteObject("rejected/" + skillId); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + SkillAvatarCandidate expired = candidates.findById(rejectedId).orElseThrow(); + org.springframework.test.util.ReflectionTestUtils.setField(expired, "discardAt", Instant.now().minusSeconds(1)); + }); + org.mockito.Mockito.doThrow(new IllegalStateException("temporary object store outage")) + .doNothing().when(storage).deleteObject("rejected/" + skillId); + assertThatThrownBy(cleanup::removeExpiredImages).isInstanceOf(IllegalStateException.class); + assertThat(candidates.findById(rejectedId).orElseThrow().getObjectKey()).isEqualTo("rejected/" + skillId); + cleanup.removeExpiredImages(); + SkillAvatarCandidate retained = candidates.findById(rejectedId).orElseThrow(); + assertThat(retained.getObjectKey()).isNull(); + assertThat(retained.getReviewReason()).isEqualTo("keep this textual review history"); + assertThat(retained.getReviewedBy()).isEqualTo("owner"); + assertThat(retained.getStatus()).isEqualTo(SkillAvatarCandidate.Status.REJECTED); + assertThat(candidates.findById(active.getId()).orElseThrow().getObjectKey()).isEqualTo(active.getObjectKey()); + org.mockito.Mockito.verify(storage, org.mockito.Mockito.never()).deleteObject(active.getObjectKey()); + } + + + + private java.util.List abandoned(Instant staleBefore, Instant now) { + return candidates.findAbandonedMachineCandidates(0L, SkillAvatarCandidate.Status.PENDING, + SkillAvatarCandidate.MachineStatus.PENDING, SkillAvatarCandidate.MachineStatus.PROCESSING, + staleBefore, now, org.springframework.data.domain.PageRequest.of(0, 100)); + } + + // Hibernate binds these timestamp-without-zone columns in UTC; use the same calendar in JDBC fixtures. + private void setTime(Long candidateId, String column, Instant value) { + jdbc.update(connection -> { + var statement = connection.prepareStatement("UPDATE skill_avatar_candidate SET " + column + " = ? WHERE id = ?"); + statement.setTimestamp(1, java.sql.Timestamp.from(value), + java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))); + statement.setLong(2, candidateId); + return statement; + }); + } + + private Skill readSkill(Long id) { + return ((com.iflytek.skillhub.domain.skill.SkillRepository) skills).findById(id).orElseThrow(); + } + + private String namespaceSlug(Long skillId) { + return ((com.iflytek.skillhub.domain.namespace.NamespaceRepository) namespaces).findById(readSkill(skillId).getNamespaceId()).orElseThrow().getSlug(); + } + + private Long skill(boolean expanded) { + return new TransactionTemplate(transactions).execute(status -> { + String suffix = UUID.randomUUID().toString().substring(0, 8); + Namespace ns = namespaces.saveAndFlush(new Namespace("avatar-" + suffix, "Avatar recovery", "owner")); + Skill skill = skills.saveAndFlush(new Skill(ns.getId(), "skill", "owner", expanded ? SkillVisibility.PRIVATE : SkillVisibility.PUBLIC)); + if (expanded) { + SkillAvatarCandidate old = new SkillAvatarCandidate(skill.getId(), "owner", "old/" + skill.getId(), "old-sha-" + suffix, + SkillVisibility.PRIVATE, false, false, null); + old.activate(); + candidates.saveAndFlush(old); + skill.activateAvatar(old.getId(), SkillVisibility.PRIVATE); + skill.setVisibility(SkillVisibility.PUBLIC); + skills.saveAndFlush(skill); + } + return skill.getId(); + }); + } + + private Long pending(Long skillId) { + return new TransactionTemplate(transactions).execute(status -> { + SkillAvatarCandidate candidate = new SkillAvatarCandidate(skillId, "owner", "pending/" + skillId, + "sha-" + skillId, SkillVisibility.PUBLIC, true, false, "xfyun"); + candidates.saveAndFlush(candidate); + Skill skill = skills.findLockedById(skillId).orElseThrow(); + skill.setPendingAvatarId(candidate.getId()); + return candidate.getId(); + }); + } +} From 3b3e5ed27c0831a700302f7d65f1aee256640b2b Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:39:25 +0800 Subject: [PATCH 20/47] test(avatar): cover policy and release generation boundaries Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../SkillAvatarRecoveryPostgresTest.java | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java index f8bde110..bffac195 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java @@ -82,6 +82,7 @@ class SkillAvatarRecoveryPostgresTest { @Autowired private SkillJpaRepository skills; @Autowired private NamespaceJpaRepository namespaces; + @Autowired private com.iflytek.skillhub.infra.jpa.SkillVersionJpaRepository versions; @Autowired private SkillAvatarCandidateJpaRepository candidates; @Autowired private SkillAvatarAppService avatars; @Autowired private SkillAvatarMachineRecoveryTask machineRecovery; @@ -282,6 +283,10 @@ class SkillAvatarRecoveryPostgresTest { var other = avatars.submit(namespaceSlug(otherProviderId), "skill", "owner", image, new AuditRequestContext(null, null)); assertThat(other.machineStatus()).isEqualTo("PENDING"); assertThat(other.avatarUrl()).isNull(); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(1)) + .moderate(eq("xfyun"), any(byte[].class)); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.never()) + .moderate(eq("aliyun"), any(byte[].class)); } @Test @@ -367,6 +372,146 @@ class SkillAvatarRecoveryPostgresTest { + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"false,false", "false,true", "true,false", "true,true"}) + void reviewPolicyMatrixAppliesToPublicUploadsButPrivateUploadsBypassReview(boolean machineEnabled, boolean manualEnabled) { + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(machineEnabled, manualEnabled, 1, null)); + when(imageProcessor.normalize(any())).thenAnswer(call -> call.getArgument(0)); + Long publicId = skill(false); + Skill publicSkill = readSkill(publicId); + var ownerRole = java.util.Map.of(publicSkill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER); + byte[] publicImage = UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + var submitted = avatars.submit(namespaceSlug(publicId), "skill", "owner", publicImage, new AuditRequestContext(null, null)); + assertThat(submitted.machineStatus()).isEqualTo(machineEnabled ? "PENDING" : "SKIPPED"); + assertThat(submitted.reviewStatus()).isEqualTo(manualEnabled ? "PENDING" : "NOT_REQUIRED"); + if (machineEnabled || manualEnabled) { + assertThat(submitted.avatarUrl()).isNull(); + assertThat(readSkill(publicId).getPendingAvatarId()).isEqualTo(submitted.candidateId()); + } else { + assertThat(submitted.avatarUrl()).isNotNull(); + } + if (machineEnabled) { + var machineResult = avatars.processMachine(submitted.candidateId(), new AuditRequestContext(null, null)); + assertThat(machineResult.machineStatus()).isEqualTo("PASSED"); + assertThat(machineResult.avatarUrl() == null).isEqualTo(manualEnabled); + } + if (manualEnabled) { + assertThat(avatars.reviewQueue("owner", ownerRole, java.util.Set.of())) + .extracting(com.iflytek.skillhub.dto.SkillAvatarStatusResponse::candidateId).contains(submitted.candidateId()); + var reviewed = avatars.decide(namespaceSlug(publicId), "skill", submitted.candidateId(), true, "reviewed", "owner", + ownerRole, java.util.Set.of(), new AuditRequestContext(null, null)); + assertThat(reviewed.avatarUrl()).isNotNull(); + } + assertThat(readSkill(publicId).getActiveAvatarId()).isEqualTo(submitted.candidateId()); + assertThat(readSkill(publicId).getPendingAvatarId()).isNull(); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(machineEnabled ? 1 : 0)) + .moderate(anyString(), any(byte[].class)); + + Long privateId = skill(false); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + Skill privateSkill = skills.findLockedById(privateId).orElseThrow(); + privateSkill.setVisibility(SkillVisibility.PRIVATE); + }); + var privateUploaded = avatars.submit(namespaceSlug(privateId), "skill", "owner", + UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8), new AuditRequestContext(null, null)); + assertThat(privateUploaded.machineStatus()).isEqualTo("SKIPPED"); + assertThat(privateUploaded.reviewStatus()).isEqualTo("NOT_REQUIRED"); + assertThat(privateUploaded.avatarUrl()).isNotNull(); + assertThat(readSkill(privateId).getActiveAvatarId()).isEqualTo(privateUploaded.candidateId()); + assertThat(readSkill(privateId).getPendingAvatarId()).isNull(); + assertThat(avatars.reviewQueue("owner", java.util.Map.of(readSkill(privateId).getNamespaceId(), + com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), java.util.Set.of())) + .extracting(com.iflytek.skillhub.dto.SkillAvatarStatusResponse::candidateId).doesNotContain(privateUploaded.candidateId()); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(machineEnabled ? 1 : 0)) + .moderate(anyString(), any(byte[].class)); + } + + @Test + void teamAdministratorCannotActAsOwnerOrReadPrivateAvatar() { + Long skillId = skill(false); + Skill skill = readSkill(skillId); + var adminRole = java.util.Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.ADMIN); + when(imageProcessor.normalize(any())).thenAnswer(call -> call.getArgument(0)); + assertThatThrownBy(() -> avatars.submit(namespaceSlug(skillId), "skill", "admin", new byte[] {7}, + new AuditRequestContext(null, null))) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class); + org.mockito.Mockito.verify(storage, org.mockito.Mockito.never()) + .putObject(anyString(), any(), org.mockito.ArgumentMatchers.anyLong(), anyString()); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + Skill privateSkill = skills.findLockedById(skillId).orElseThrow(); + privateSkill.setVisibility(SkillVisibility.PRIVATE); + }); + var uploaded = avatars.submit(namespaceSlug(skillId), "skill", "owner", new byte[] {8}, new AuditRequestContext(null, null)); + when(visibility.canAccess(any(), eq("admin"), any(), any())).thenReturn(true); + assertThatThrownBy(() -> avatars.readActive(namespaceSlug(skillId), "skill", "admin", adminRole, java.util.Set.of())) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException.class); + assertThatThrownBy(() -> avatars.readCandidate(namespaceSlug(skillId), "skill", uploaded.candidateId(), "admin", adminRole, java.util.Set.of())) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException.class); + assertThatThrownBy(() -> avatars.remove(namespaceSlug(skillId), "skill", "admin", false, null, adminRole, + java.util.Set.of(), new AuditRequestContext(null, null))) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class); + org.mockito.Mockito.verify(storage, org.mockito.Mockito.never()).getObject(anyString()); + assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(uploaded.candidateId()); + } + + + @Test + void scopeRejectionSuppressesCurrentPublicationAndNewPublicationCreatesOneFreshReview() { + Long skillId = skill(true); + publishVersion(skillId, "1.0.0"); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(false, true, 1, null)); + Long rejectedId = avatars.prepareScopeReview(skillId, null).candidateId(); + var roles = java.util.Map.of(readSkill(skillId).getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER); + avatars.decide(namespaceSlug(skillId), "skill", rejectedId, false, "rejected for this publication", "owner", + roles, java.util.Set.of(), new AuditRequestContext(null, null)); + scopeRecovery.recover(); + scopeRecovery.recover(); + assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(skillId)).hasSize(2); + assertThat(readSkill(skillId).getPendingAvatarId()).isNull(); + assertThat(SkillAvatarUrls.current(readSkill(skillId), "test")).isNull(); + publishVersion(skillId, "1.1.0"); + scopeRecovery.recover(); + scopeRecovery.recover(); + assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(skillId)).hasSize(3); + Long newCandidateId = readSkill(skillId).getPendingAvatarId(); + assertThat(newCandidateId).isNotNull().isNotEqualTo(rejectedId); + SkillAvatarCandidate newCandidate = candidates.findById(newCandidateId).orElseThrow(); + assertThat(newCandidate.getScopeReviewVersionId()).isEqualTo(readSkill(skillId).getLatestVersionId()); + assertThat(newCandidate.getReviewStatus()).isEqualTo(SkillAvatarCandidate.ReviewStatus.PENDING); + assertThat(newCandidate.getStatus()).isEqualTo(SkillAvatarCandidate.Status.PENDING); + assertThat(SkillAvatarUrls.current(readSkill(skillId), "test")).isNull(); + } + + @Test + void machineOnlyBlockCanBeAppealedAndExplicitlyApproved() { + Long skillId = skill(false); + Long candidateId = pending(skillId); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(AvatarModerationProvider.Verdict.BLOCK); + assertThat(avatars.processMachine(candidateId, new AuditRequestContext(null, null)).avatarUrl()).isNull(); + var appealed = avatars.appeal(namespaceSlug(skillId), "skill", candidateId, "owner", new AuditRequestContext(null, null)); + assertThat(appealed.reviewStatus()).isEqualTo("APPEAL_PENDING"); + assertThat(appealed.avatarUrl()).isNull(); + var approved = avatars.decide(namespaceSlug(skillId), "skill", candidateId, true, "machine false positive", "owner", + java.util.Map.of(readSkill(skillId).getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + java.util.Set.of(), new AuditRequestContext(null, null)); + assertThat(approved.avatarUrl()).isNotNull(); + SkillAvatarCandidate candidate = candidates.findById(candidateId).orElseThrow(); + assertThat(candidate.getReviewReason()).isEqualTo("machine false positive"); + assertThat(candidate.getReviewedBy()).isEqualTo("owner"); + assertThat(readSkill(skillId).getPendingAvatarId()).isNull(); + assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(candidateId); + } + + private void publishVersion(Long skillId, String version) { + new TransactionTemplate(transactions).executeWithoutResult(status -> { + var published = new com.iflytek.skillhub.domain.skill.SkillVersion(skillId, version, "owner"); + published.setStatus(com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED); + versions.saveAndFlush(published); + skills.findLockedById(skillId).orElseThrow().setLatestVersionId(published.getId()); + }); + } + private java.util.List abandoned(Instant staleBefore, Instant now) { return candidates.findAbandonedMachineCandidates(0L, SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.MachineStatus.PENDING, SkillAvatarCandidate.MachineStatus.PROCESSING, From 8c7ea2c41a3099978934f6db560537d94ad79739 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:15:44 +0800 Subject: [PATCH 21/47] docs: record avatar and invitation acceptance progress Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../add-namespace-invite-links/tasks.md | 4 ++- .../add-skill-avatars-and-moderation/tasks.md | 26 ++++++++++--------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/openspec/changes/add-namespace-invite-links/tasks.md b/openspec/changes/add-namespace-invite-links/tasks.md index 0b8b0f17..f4c5a35c 100644 --- a/openspec/changes/add-namespace-invite-links/tasks.md +++ b/openspec/changes/add-namespace-invite-links/tasks.md @@ -14,4 +14,6 @@ - [x] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。 - [x] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。 -- [ ] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。 +- [x] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。 + +验证结果以 PR #947 的检查和交付验收记录为准。真实讯飞/阿里云计费审核接口未发起调用。 diff --git a/openspec/changes/add-skill-avatars-and-moderation/tasks.md b/openspec/changes/add-skill-avatars-and-moderation/tasks.md index 4c14a4af..17d48d70 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/tasks.md +++ b/openspec/changes/add-skill-avatars-and-moderation/tasks.md @@ -1,23 +1,25 @@ ## 1. 领域与存储 -- [ ] 1.1 增加 Skill 头像指针、候选/审计持久化和向后兼容迁移。 -- [ ] 1.2 实现图片解码、大小及像素上限、规范化、私有写入、受控读取与 30 天清理。 -- [ ] 1.3 实现所有者提交/替换/删除、管理员审核/复核/撤下和旧候选失效保护。 +- [x] 1.1 增加 Skill 头像指针、候选/审计持久化和向后兼容迁移。 +- [x] 1.2 实现图片解码、大小及像素上限、规范化、私有写入、受控读取与 30 天清理。 +- [x] 1.3 实现所有者提交/替换/删除、管理员审核/复核/撤下和旧候选失效保护。 ## 2. 审核配置与供应商 -- [ ] 2.1 在 `system_setting` 增加独立头像审核开关,验证四种策略及未配置供应商时拒绝开启。 -- [ ] 2.2 依据官方接口实现讯飞和阿里云适配器,部署只选择一家,凭证仅使用部署 Secret。 -- [ ] 2.3 处理超时、未知结果、重试、人工改判和例外申诉。 +- [x] 2.1 在 `system_setting` 增加独立头像审核开关,验证四种策略及未配置供应商时拒绝开启。 +- [x] 2.2 依据官方接口实现讯飞和阿里云适配器,部署只选择一家,凭证仅使用部署 Secret。 +- [x] 2.3 处理超时、未知结果、重试、人工改判和例外申诉。 ## 3. API 与 Web -- [ ] 3.1 扩展 Skill 摘要/详情和头像操作/审核接口,重新生成 OpenAPI 类型。 -- [ ] 3.2 实现统一头像兜底、所有者裁剪/提交/状态及审核员队列。 -- [ ] 3.3 更新中文、英文、俄文文案和操作说明。 +- [x] 3.1 扩展 Skill 摘要/详情和头像操作/审核接口,重新生成 OpenAPI 类型。 +- [x] 3.2 实现统一头像兜底、所有者裁剪/提交/状态及审核员队列。 +- [x] 3.3 更新中文、英文、俄文文案和操作说明。 ## 4. 验证与交付 -- [ ] 4.1 覆盖权限、文件安全、状态并发、策略矩阵、可见范围变化和清理边界。 -- [ ] 4.2 运行相关后端、前端、API 类型检查、容器化本地预览及浏览器操作。 -- [ ] 4.3 独立 Review、Draft PR、CI 与验收报告。 +- [x] 4.1 覆盖权限、文件安全、状态并发、策略矩阵、可见范围变化和清理边界。 +- [x] 4.2 运行相关后端、前端、API 类型检查、容器化本地预览及浏览器操作。 +- [x] 4.3 独立 Review、Draft PR、CI 与验收报告。 + +验证结果以 PR #947 的检查和交付验收记录为准。真实讯飞/阿里云计费审核接口未发起调用。 From 1a7ffbe6e20c618351b3fe5356d0c9852949c705 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:15:35 +0800 Subject: [PATCH 22/47] feat(avatar): stage images before skill publication Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/26-skill-avatars.md | 10 +- docs/skillhub/guide/skill-publish.md | 4 + .../design.md | 16 + .../proposal.md | 4 +- .../specs/skill-avatars/spec.md | 53 ++- .../add-skill-avatars-and-moderation/tasks.md | 14 + .../portal/SkillAvatarController.java | 32 +- .../portal/SkillPublishController.java | 16 +- .../dto/SkillAvatarSubmitRequest.java | 5 + .../dto/SkillAvatarUploadResponse.java | 6 + .../SkillAvatarSubmissionListener.java | 28 ++ .../service/SkillAvatarAppService.java | 104 +++++- .../service/SkillAvatarUploadService.java | 139 +++++++ .../service/SkillPublishAppService.java | 61 +++ .../skillhub/task/SkillAvatarCleanupTask.java | 10 +- .../V71__staged_skill_avatar_uploads.sql | 9 + .../src/main/resources/messages.properties | 4 + .../src/main/resources/messages_ru.properties | 4 + .../src/main/resources/messages_zh.properties | 4 + .../SkillAvatarRecoveryPostgresTest.java | 4 + .../SkillAvatarUploadPostgresTest.java | 351 ++++++++++++++++++ .../service/SkillAvatarAppServiceTest.java | 36 +- .../policy/RouteSecurityPolicyRegistry.java | 4 + .../domain/skill/SkillAvatarUpload.java | 43 +++ .../skill/service/SkillPublishService.java | 25 ++ ...illStorageDeletionCompensationService.java | 7 + .../jpa/SkillAvatarUploadJpaRepository.java | 22 ++ web/src/api/client.ts | 15 +- web/src/api/generated/schema.d.ts | 216 ++++++++++- web/src/app/router.tsx | 4 + .../skill/skill-avatar-editor.test.tsx | 106 ++++++ .../features/skill/skill-avatar-editor.tsx | 100 +++++ .../skill/skill-avatar-manager.test.tsx | 30 +- .../features/skill/skill-avatar-manager.tsx | 74 ++-- web/src/i18n/locales/en.json | 18 + web/src/i18n/locales/ru.json | 18 + web/src/i18n/locales/zh.json | 18 + web/src/pages/dashboard/my-skills.test.ts | 4 + web/src/pages/dashboard/my-skills.tsx | 22 +- .../pages/dashboard/publish-avatar.test.tsx | 83 +++++ web/src/pages/dashboard/publish.test.ts | 5 + web/src/pages/dashboard/publish.tsx | 28 +- web/src/pages/skill-detail.tsx | 2 +- web/src/shared/hooks/use-skill-queries.ts | 5 +- 44 files changed, 1635 insertions(+), 128 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarSubmitRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarUploadResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillAvatarSubmissionListener.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUploadService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java create mode 100644 server/skillhub-app/src/main/resources/db/migration/V71__staged_skill_avatar_uploads.sql create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarUploadPostgresTest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarUpload.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarUploadJpaRepository.java create mode 100644 web/src/features/skill/skill-avatar-editor.test.tsx create mode 100644 web/src/features/skill/skill-avatar-editor.tsx create mode 100644 web/src/pages/dashboard/publish-avatar.test.tsx diff --git a/docs/26-skill-avatars.md b/docs/26-skill-avatars.md index 8854e55a..5dc7810f 100644 --- a/docs/26-skill-avatars.md +++ b/docs/26-skill-avatars.md @@ -1,6 +1,14 @@ # 技能头像与审核 -技能所有者可在技能详情页上传 PNG/JPEG,网页提供方形裁剪和缩放。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。 +技能所有者可在新增/更新技能页面选择自定义 PNG/JPEG 头像,网页提供方形裁剪和缩放;不提供预设图标。更新默认保留头像,也可替换或移除。“我的技能”提供单独修改头像的入口,无需发布新版本;详情页展示头像及审核状态。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。 + +## 上传与提交 + +选择图片后先裁剪并上传到私有存储,上传成功后才允许提交技能或头像修改;未选择头像也可提交新技能。上传失败可重试,但不能把失败或尚未完成的上传随技能提交。上传成功仅表示文件已保存,不表示审核通过。 + +提交成功后图片才关联到技能并进入对应审核流程。更新时未修改头像会保留原头像;选择移除时先预览首字母,提交成功后才恢复兜底并取消待审头像申请,历史记录保留。取消编辑或提交失败不会改变原头像及已有待审申请。未关联的临时图片保持私有;上传引用 24 小时后过期,过期未关联图片由清理任务定期回收,提交时过期需要重新上传。 + +技能内容与头像分别显示审核状态。技能发布或内容审核通过不能代替头像审核;新头像通过审核前仍显示旧头像或首字母。单独修改头像不创建新的技能版本。 ## 审核策略 diff --git a/docs/skillhub/guide/skill-publish.md b/docs/skillhub/guide/skill-publish.md index 8d77fcf8..34e21100 100644 --- a/docs/skillhub/guide/skill-publish.md +++ b/docs/skillhub/guide/skill-publish.md @@ -91,6 +91,10 @@ npx @astron-team/skillhub@latest publish ./my-skill --namespace my-team 访问 `http://localhost:3000/dashboard/publish`,选择命名空间、上传 zip 文件、选择可见性后点击「发布」。 +可选上传自定义技能头像:选择 PNG/JPEG、裁剪并等待私有上传成功,再提交技能。头像上传中或失败时不能提交;没有选择头像则正常提交并使用技能名首字母。更新技能时默认保留原头像,可选择替换或移除;移除仅在提交成功后生效,取消或提交失败不影响原头像。 + +技能和头像各有审核状态。文件上传成功不代表头像审核通过,头像所需审核在提交后进行,通过前显示旧头像或首字母。仅需修改头像时,可从“我的技能”操作,不需要发布新的技能版本。 + 4. **使用 REST API 发布** ```bash diff --git a/openspec/changes/add-skill-avatars-and-moderation/design.md b/openspec/changes/add-skill-avatars-and-moderation/design.md index 9e668b08..7fe8a300 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/design.md +++ b/openspec/changes/add-skill-avatars-and-moderation/design.md @@ -20,3 +20,19 @@ - V68 后迁移在已有数据上通过;旧 Skill 无头像时无需回填。 - 服务端图片解码处理需设置资源上限;机器供应商凭证不得进入日志、数据库或前端。 - 原 Issue 的“机器不得直接拒绝”与本轮方案不同。机器单开模式的质量和误判处理需在运行验证中单列,不以外部 API 返回成功代替有效审核验证。 + + +## 已确认的发布交互调整(2026-10-13,待实施) + +本节补充原实现,需求以 `specs/skill-avatars/spec.md` 的 REQ-AV-09/10/11 为准。 + +- 新增和更新页面使用可选自定义上传,不提供预设图标;更新默认保留,支持替换及移除。 +- “我的技能”提供单独修改头像入口,不要求新版本;详情页保留展示与审核状态,移除完整上传表单。统一复用裁剪和上传组件。 +- 先私有上传、后业务提交:新增技能尚无 Skill ID,临时上传不能提前创建已绑定的头像申请。上传引用绑定提交者并校验可用性,临时阶段不改变当前头像或待审申请。 +- 技能提交成功与头像引用关联须共同成功。失败不改变当前头像或已有候选;数据库关联与业务状态共同提交,外部对象写入提前完成,不把对象存储或供应商请求放入长事务。重复提交不重复创建申请。 +- 头像审核在关联提交后执行,遵循原可见范围和策略规则;机器和人工开关在候选创建时快照。上传成功不意味着审核通过,技能与头像分别展示审核状态。 +- 更新页移除仅在提交成功后执行,立即恢复兜底并使待审候选失效,申请历史和失效图片保留策略不变。 +- 未关联临时图片始终私有,按有界任务定期清理;关联与清理并发须保证不会删除已绑定图片。临时引用使用独立 `skill_avatar_upload` 表,24 小时过期;上传接口为 `/api/web/skills/avatar-uploads`,提交技能携带 `avatarAction`(默认 KEEP,可选 REPLACE/REMOVE)和替换时的 `avatarUploadId`。单独维护使用现有头像路径的 JSON 提交;更新携带 `expectedSlug` 校验技能包目标。清理和引用消费均锁定临时上传记录,发布与独立修改均采用技能→上传记录的锁顺序。不引入业务状态 SQL CHECK 约束。 +- 唯一机器供应商的部署选择、后台两个审核开关、PRIVATE 直接使用规则保持不变。 + +原验收只覆盖详情页独立提交流程,不能证明上述新增/更新流程;实施后须补充对应测试和浏览器验收。真实供应商调用仍未验证。 diff --git a/openspec/changes/add-skill-avatars-and-moderation/proposal.md b/openspec/changes/add-skill-avatars-and-moderation/proposal.md index d8cb2f5c..77a74b4c 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/proposal.md +++ b/openspec/changes/add-skill-avatars-and-moderation/proposal.md @@ -5,7 +5,9 @@ Skill 目前没有稳定的图像标识,卡片和详情难以快速区分。 ## What Changes - 为个人、团队和全局 Skill 增加由 Skill 所有者维护的可选头像;所有展示位置使用一致的首字兜底。 -- 上传最终裁剪图片,经服务端规范化后存为私有候选;头像属于 Skill,不随版本更新重置。 +- 在新增/更新技能页面支持可选自定义头像与裁剪,先私有上传成功后随业务提交关联;更新默认保留,可替换或移除,不提供预设图标。 +- “我的技能”支持单独修改头像,无需新版本;详情页展示头像和审核状态。头像属于 Skill,未明确替换或移除时不随版本更新重置。 +- 临时图片与正式申请分离,未关联图片定期清理;技能与头像分别显示审核状态。 - 按实际可见范围处理审核:仅本人可见的 PRIVATE 头像可直接使用;团队可见或公开头像按部署方的机器、人工审核开关处理;全局头像由平台管理员审核。 - 部署时选择唯一机器审核供应商并配置凭证,提供讯飞和阿里云适配;后台仅管理审核开关。机器失败不放行,机器误判可申请人工复核。 - 仅 Skill 所有者可提交、更换或主动删除;管理员可以审核、复核和撤下违规头像。旧候选失效后不能被迟到的审核结果激活。 diff --git a/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md index a47375e0..8f97d8d0 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md +++ b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md @@ -8,14 +8,14 @@ Skill SHALL 支持可选头像和统一兜底。 -头像属于 Skill 容器而非 SkillVersion。摘要与详情只返回当前已激活的稳定头像访问地址;无头像时所有 Skill 展示位置使用展示名称首字符的大写兜底。新版本发布不得重置头像。 +头像属于 Skill 容器而非 SkillVersion。摘要与详情只返回当前已激活的稳定头像访问地址;无头像时所有 Skill 展示位置使用展示名称首字符的大写兜底。新版本发布未明确选择替换或移除头像时不得重置头像。 #### Scenario: 旧 Skill 没有头像 - **WHEN** 客户端读取无头像的 Skill - **THEN** 不需数据回填即可展示一致的首字兜底 #### Scenario: 发布新版本 -- **WHEN** 已有头像的 Skill 发布新版本 +- **WHEN** 已有头像的 Skill 发布新版本,且未选择替换或移除头像 - **THEN** 原头像保持不变 ### Requirement: REQ-AV-02 仅 Skill 所有者 SHALL 维护头像 @@ -110,8 +110,55 @@ PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使 前后端 SHALL 提供可理解的头像提交与审核闭环。 -所有者可以在 Skill 详情查看当前头像、提交候选、状态和驳回原因;审核员可以读取其管辖范围候选并处理,机器单开时可以处理例外申诉。所有展示 Skill 身份的位置使用同一头像与兜底组件。 +新增和更新 Skill 页面提供可选自定义头像、方形裁剪、上传预览及上传状态,不提供预设图标选择;未上传时使用首字母兜底。更新页面展示当前头像并提供保留、替换、移除三种动作,默认保留。“我的技能”提供单独修改头像的入口,复用同一套上传与裁剪组件,不要求发布新的 SkillVersion。详情页展示头像、审核状态和驳回原因,不再承载完整上传表单。技能内容与头像分别显示提交和审核状态,技能内容审核通过不代表头像审核通过。审核员可以读取其管辖范围候选并处理,机器单开时可以处理例外申诉。所有展示 Skill 身份的位置使用同一头像与兜底组件。 #### Scenario: 申请等待审核 - **WHEN** 所有者上传候选后需人工决定 - **THEN** 页面展示待审状态且已发布头像维持不变 + + +### Requirement: REQ-AV-10 头像 SHALL 先私有上传再随提交关联 + +新增、更新或单独修改头像时,客户端 SHALL 先裁剪并上传图片到私有存储,上传成功后取得可用于提交的临时引用。选择了新图片但仍在上传或上传失败时,客户端不得提交该次操作;服务端亦须校验引用对应的文件上传成功、未过期、属于当前提交者且不可越权关联。未选择头像的新技能可正常提交。 + +上传成功不等于审核通过。临时上传不改变 Skill 当前头像、待审申请或公开状态;技能提交成功时才关联上传引用并创建头像申请,按当时实际可见范围及审核配置执行,PRIVATE 仍沿用直接使用规则。需要的机器或人工审核在提交后进行,不要求用户停留在发布表单等待审核。关联失败不得留下已成功提交技能但丢失本次头像操作的部分结果;重复提交同一引用不得产生重复申请。 + +未关联、过期或用户放弃提交的临时图片保持私有并定期清理;清理不得删除已成功关联的候选或当前头像。临时图片仅供上传者预览,不因已取得引用而允许公众读取。 + +#### Scenario: 上传失败时提交 +- **WHEN** 所有者选择了新头像,但图片尚未上传成功 +- **THEN** 页面明确显示上传状态或失败原因,并阻止提交,允许重试 +- **AND** 服务端拒绝不存在、未就绪或无权使用的临时引用 + +#### Scenario: 新技能同时提交头像 +- **WHEN** 所有者上传成功并提交有效技能包和头像引用 +- **THEN** 技能提交与头像关联共同成功,头像进入其所需审核流程 +- **AND** 页面分别显示技能和头像的审核状态,候选未通过所需审核时不公开 + +#### Scenario: 取消编辑或提交失败 +- **WHEN** 图片已上传,但所有者取消编辑或技能提交失败 +- **THEN** 当前头像与现有待审申请不变,未关联临时图片可由清理任务回收 + +#### Scenario: 使用他人的上传引用 +- **WHEN** 提交者尝试关联他人的临时图片引用 +- **THEN** 服务端拒绝提交,不更改技能或头像状态 + +#### Scenario: 单独修改头像 +- **WHEN** 所有者从“我的技能”上传并提交新头像 +- **THEN** 复用相同上传、关联及审核流程,不创建新的 SkillVersion + +### Requirement: REQ-AV-11 更新页移除 SHALL 在提交成功后生效 + +更新页的移除操作 SHALL 先提供首字母兜底预览,仅在提交成功后执行。取消编辑或提交失败时,原头像及待审申请不变。执行移除无需头像审核,立即恢复兜底,并取消尚未完成的头像申请;旧机器结果或审核动作不得恢复已移除头像。图片私有保留与申请历史沿用 REQ-AV-08。 + +#### Scenario: 成功提交移除 +- **WHEN** 所有者选择移除头像并成功提交更新 +- **THEN** Skill 恢复首字母兜底,现有待审头像申请失效且历史保留 + +#### Scenario: 移除后旧结果到达 +- **WHEN** 移除已成功执行,而旧申请的机器结果或人工审核动作随后到达 +- **THEN** 已移除头像不被恢复 + +#### Scenario: 放弃移除或提交失败 +- **WHEN** 所有者选择移除,但取消编辑或提交失败 +- **THEN** 原头像及待审申请保持原状 diff --git a/openspec/changes/add-skill-avatars-and-moderation/tasks.md b/openspec/changes/add-skill-avatars-and-moderation/tasks.md index 17d48d70..5d3a4a15 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/tasks.md +++ b/openspec/changes/add-skill-avatars-and-moderation/tasks.md @@ -23,3 +23,17 @@ - [x] 4.3 独立 Review、Draft PR、CI 与验收报告。 验证结果以 PR #947 的检查和交付验收记录为准。真实讯飞/阿里云计费审核接口未发起调用。 + + +## 5. 发布交互调整(2026-10-13,已确认需求,待实施) + +前述勾选项表示原实现及其验证完成,不代表本节完成。PR #947 的原合并就绪结论需要在本节实施后重新验证;当前预览仍为原详情页上传流程。 + +- [x] 5.1 实现所有者临时私有上传、预览、引用校验及未关联文件清理,覆盖越权引用与关联/清理并发。 +- [x] 5.2 扩展技能提交以处理保留、关联新头像和移除;保证技能提交与头像操作共同成功、失败不改变旧状态、重复提交不重复申请。 +- [x] 5.3 在新增/更新页接入统一自定义上传裁剪组件,上传未成功禁止提交,默认保留旧头像,支持移除预览。 +- [x] 5.4 在“我的技能”增加无需新版本的头像维护入口;详情页改为展示及审核状态,技能与头像状态分别呈现,更新三种语言文案。 +- [x] 5.5 覆盖新增、更新保留/替换/移除、取消、上传失败、提交失败、单独维护、旧审核失效、临时清理及原审核策略回归。 +- [ ] 5.6 更新相关用户文档、OpenAPI 类型及独立 Review,构建当前 SHA 的预览并进行真实浏览器验收,更新同一个 PR #947 的验收记录。 + +真实讯飞/阿里云接入是独立的部署联调项,需要已开通能力及部署凭证;模拟测试不得标记为真实调用通过。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java index df2efd81..b8d30ffb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java @@ -11,6 +11,10 @@ import com.iflytek.skillhub.dto.SkillAvatarReasonRequest; import com.iflytek.skillhub.dto.SkillAvatarStatusResponse; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.SkillAvatarAppService; +import com.iflytek.skillhub.service.SkillAvatarUploadService; +import com.iflytek.skillhub.dto.SkillAvatarUploadResponse; +import com.iflytek.skillhub.dto.SkillAvatarSubmitRequest; +import com.iflytek.skillhub.ratelimit.RateLimit; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.io.IOException; @@ -38,10 +42,36 @@ import org.springframework.web.multipart.MultipartFile; @RequestMapping({"/api/v1/skills", "/api/web/skills"}) public class SkillAvatarController extends BaseApiController { private final SkillAvatarAppService service; + private final SkillAvatarUploadService uploads; - public SkillAvatarController(SkillAvatarAppService service, ApiResponseFactory responses) { + public SkillAvatarController(SkillAvatarAppService service, SkillAvatarUploadService uploads, ApiResponseFactory responses) { super(responses); this.service = service; + this.uploads = uploads; + } + + @PostMapping(value = "/avatar-uploads", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @RateLimit(category = "publish", authenticated = 10, anonymous = 0) + public ApiResponse upload(@RequestParam("file") MultipartFile file, + @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { + if (file.getSize() < 1 || file.getSize() > com.iflytek.skillhub.service.SkillAvatarImageProcessor.MAX_BYTES) { + throw new DomainBadRequestException("error.skill.avatar.invalidImage"); + } + return ok("response.success.created", uploads.upload(userId(principal), file.getBytes())); + } + + @GetMapping("/avatar-uploads/{uploadId}/image") + public ResponseEntity uploadPreview(@PathVariable String uploadId, + @AuthenticationPrincipal PlatformPrincipal principal) { + return png(uploads.preview(uploadId, userId(principal))); + } + + @PostMapping(value = "/{namespace}/{slug}/avatar", consumes = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse submitUpload(@PathVariable String namespace, @PathVariable String slug, + @Valid @RequestBody SkillAvatarSubmitRequest input, @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) { + return ok("response.success.created", uploads.submit(namespace, slug, userId(principal), input.uploadId(), + AuditRequestContext.from(request))); } @GetMapping("/{namespace}/{slug}/avatar") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index a061afc1..9b9235e5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -11,6 +11,9 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PublishResponse; import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.service.SkillPublishAppService; +import com.iflytek.skillhub.service.AuditRequestContext; +import jakarta.servlet.http.HttpServletRequest; import com.iflytek.skillhub.ratelimit.RateLimit; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -29,11 +32,11 @@ import java.util.List; @RequestMapping({"/api/v1/skills", "/api/web/skills"}) public class SkillPublishController extends BaseApiController { - private final SkillPublishService skillPublishService; + private final SkillPublishAppService skillPublishService; private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; private final SkillHubMetrics skillHubMetrics; - public SkillPublishController(SkillPublishService skillPublishService, + public SkillPublishController(SkillPublishAppService skillPublishService, SkillPackageArchiveExtractor skillPackageArchiveExtractor, ApiResponseFactory responseFactory, SkillHubMetrics skillHubMetrics) { @@ -54,7 +57,10 @@ public class SkillPublishController extends BaseApiController { @RequestParam("file") MultipartFile file, @RequestParam("visibility") String visibility, @RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings, - @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { + @RequestParam(value = "avatarAction", defaultValue = "KEEP") SkillPublishAppService.AvatarAction avatarAction, + @RequestParam(value = "avatarUploadId", required = false) String avatarUploadId, + @RequestParam(value = "expectedSlug", required = false) String expectedSlug, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); @@ -75,13 +81,13 @@ public class SkillPublishController extends BaseApiController { String.join("\n", extractionWarnings)); } - SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( + SkillPublishService.PublishResult publishResult = skillPublishService.publish( namespace, entries, principal.userId(), skillVisibility, principal.platformRoles(), - confirmWarnings + confirmWarnings, avatarAction, avatarUploadId, expectedSlug, AuditRequestContext.from(request) ); PublishResponse response = new PublishResponse( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarSubmitRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarSubmitRequest.java new file mode 100644 index 00000000..73a40968 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarSubmitRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SkillAvatarSubmitRequest(@NotBlank String uploadId) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarUploadResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarUploadResponse.java new file mode 100644 index 00000000..6adaa66e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarUploadResponse.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; +import jakarta.validation.constraints.NotNull; + +public record SkillAvatarUploadResponse(@NotNull String uploadId, @NotNull String previewUrl, @NotNull Instant expiresAt) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillAvatarSubmissionListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillAvatarSubmissionListener.java new file mode 100644 index 00000000..fd379115 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillAvatarSubmissionListener.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.listener; + +import com.iflytek.skillhub.service.SkillAvatarAppService; +import com.iflytek.skillhub.service.SkillAvatarUploadService.AvatarSubmittedEvent; +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 SkillAvatarSubmissionListener { + private static final Logger log = LoggerFactory.getLogger(SkillAvatarSubmissionListener.class); + private final SkillAvatarAppService avatars; + public SkillAvatarSubmissionListener(SkillAvatarAppService avatars) { this.avatars = avatars; } + + @Async("skillhubEventExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void submitted(AvatarSubmittedEvent event) { + try { + avatars.processMachine(event.candidateId(), event.context()); + } catch (Exception failure) { + log.warn("Avatar submission moderation failed for candidate {}, failureType={}", + event.candidateId(), failure.getClass().getSimpleName()); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java index fbb2c2ad..59621df3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java @@ -86,7 +86,7 @@ public class SkillAvatarAppService { @Transactional public SkillAvatarStatusResponse submit(String namespaceSlug, String slug, String actor, byte[] image, AuditRequestContext context) { - Skill skill = lockedSkill(namespaceSlug, slug); + Skill skill = lockedOwnedSkill(namespaceSlug, slug, actor); requireOwner(skill, actor); byte[] normalized = imageProcessor.normalize(image); return createCandidate(skill, namespaceSlug, actor, actor, "SKILL_AVATAR_SUBMIT", normalized, context); @@ -102,13 +102,12 @@ public class SkillAvatarAppService { } if (skill.getPendingAvatarId() != null) { SkillAvatarCandidate pending = candidates.findById(skill.getPendingAvatarId()).orElse(null); - if (pending != null && pending.getStatus() == SkillAvatarCandidate.Status.PENDING - && pending.getAudience() == skill.getVisibility()) { + if (pending != null && pending.getStatus() == SkillAvatarCandidate.Status.PENDING) { 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) + return pending.getAudience() == skill.getVisibility() && ((pending.canRetryMachine(now) && pending.getMachineAttemptCount() < 3) || abandonedBeforeClaim) ? status(skill, pending, namespaceSlug(skill)) : null; } } @@ -135,11 +134,6 @@ public class SkillAvatarAppService { 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(); - boolean manualEnabled = !privateOnly && policy.manualEnabled(); - String provider = machineEnabled ? moderation.requireConfiguredProvider() : null; String sha256 = sha256(normalized); String key = "skill-avatars/" + skill.getId() + "/" + UUID.randomUUID() + ".png"; storage.putObject(key, new ByteArrayInputStream(normalized), normalized.length, "image/png"); @@ -151,11 +145,35 @@ public class SkillAvatarAppService { } }); + return createStoredCandidate(skill, namespaceSlug, submittedBy, auditActor, action, key, sha256, skill.getVisibility(), context); + } + + @Transactional + public void lockOwnedSkill(String namespaceSlug, String slug, String actor) { + requireOwner(lockedOwnedSkill(namespaceSlug, slug, actor), actor); + } + + @Transactional + public SkillAvatarStatusResponse submitStored(String namespaceSlug, String slug, String actor, + String objectKey, String sha256, SkillVisibility audience, AuditRequestContext context) { + Skill skill = lockedOwnedSkill(namespaceSlug, slug, actor); + requireOwner(skill, actor); + return createStoredCandidate(skill, namespaceSlug, actor, actor, "SKILL_AVATAR_SUBMIT", objectKey, sha256, + audience == null ? skill.getVisibility() : audience, context); + } + + private SkillAvatarStatusResponse createStoredCandidate(Skill skill, String namespaceSlug, String submittedBy, + String auditActor, String action, String key, String sha256, SkillVisibility audience, AuditRequestContext context) { + AvatarReviewSettingsResponse policy = settings.current(); + boolean privateOnly = audience == SkillVisibility.PRIVATE; + boolean machineEnabled = !privateOnly && policy.machineEnabled(); + boolean manualEnabled = !privateOnly && policy.manualEnabled(); + String provider = machineEnabled ? moderation.requireConfiguredProvider() : null; if (skill.getPendingAvatarId() != null) { candidates.findById(skill.getPendingAvatarId()).ifPresent(SkillAvatarCandidate::supersede); } SkillAvatarCandidate candidate = new SkillAvatarCandidate(skill.getId(), submittedBy, key, sha256, - skill.getVisibility(), machineEnabled, manualEnabled, provider); + audience, machineEnabled, manualEnabled, provider); if ("SKILL_AVATAR_SCOPE_REVIEW".equals(action)) { candidate.markScopeReviewVersion(skill.getLatestVersionId()); } @@ -184,6 +202,17 @@ public class SkillAvatarAppService { return status(skill, candidate, namespaceSlug); } + @Transactional(readOnly = true) + public SkillAvatarStatusResponse submittedStatus(String namespaceSlug, String slug, String actor, long candidateId) { + Skill skill = findOwnedSkill(namespaceSlug, slug, actor); + requireOwner(skill, actor); + SkillAvatarCandidate candidate = findCandidate(candidateId); + if (!skill.getId().equals(candidate.getSkillId())) { + throw notFound(); + } + return status(skill, candidate, namespaceSlug); + } + /** Claims and finishes in short transactions; the external call holds no database lock. */ public SkillAvatarStatusResponse processMachine(long candidateId, AuditRequestContext context) { MachineClaim claim = machineTransactions.execute(ignored -> { @@ -231,7 +260,7 @@ public class SkillAvatarAppService { @Transactional(readOnly = true) public SkillAvatarStatusResponse ownerStatus(String namespaceSlug, String slug, String actor) { - Skill skill = findSkill(namespaceSlug, slug); + Skill skill = findOwnedSkill(namespaceSlug, slug, actor); requireOwner(skill, actor); return status(skill, candidates.findBySkillIdOrderByCreatedAtDesc(skill.getId()).stream().findFirst().orElse(null), namespaceSlug); @@ -240,8 +269,12 @@ public class SkillAvatarAppService { @Transactional(readOnly = true) public InputStream readActive(String namespaceSlug, String slug, String actor, Map namespaceRoles, Set platformRoles) { - skillQueries.getSkillDetail(namespaceSlug, slug, actor, namespaceRoles == null ? Map.of() : namespaceRoles); - Skill skill = findSkill(namespaceSlug, slug); + SkillQueryService.SkillDetailDTO detail = skillQueries.getSkillDetail(namespaceSlug, slug, actor, + namespaceRoles == null ? Map.of() : namespaceRoles); + Skill skill = ((SkillRepository) skills).findById(detail.id()).orElseThrow(() -> notFound()); + if (skill.getStatus() != SkillStatus.ACTIVE) { + throw notFound(); + } if (!visibility.canAccess(skill, actor, namespaceRoles, platformRoles) || skill.getVisibility() == SkillVisibility.PRIVATE && !skill.getOwnerId().equals(actor) || SkillAvatarUrls.current(skill, namespaceSlug) == null) { @@ -257,8 +290,8 @@ public class SkillAvatarAppService { @Transactional(readOnly = true) public InputStream readCandidate(String namespaceSlug, String slug, long candidateId, String actor, Map namespaceRoles, Set platformRoles) { - Skill skill = findSkill(namespaceSlug, slug); SkillAvatarCandidate candidate = findCandidate(candidateId); + Skill skill = findCandidateSkill(namespaceSlug, slug, candidate); 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)) { @@ -270,7 +303,7 @@ public class SkillAvatarAppService { @Transactional public SkillAvatarStatusResponse appeal(String namespaceSlug, String slug, long candidateId, String actor, AuditRequestContext context) { - Skill skill = lockedSkill(namespaceSlug, slug); + Skill skill = lockedOwnedSkill(namespaceSlug, slug, actor); requireOwner(skill, actor); SkillAvatarCandidate candidate = currentPending(skill, candidateId); candidate.appeal(); @@ -280,7 +313,7 @@ public class SkillAvatarAppService { @Transactional(readOnly = true) public void requireCurrentOwnerCandidate(String namespaceSlug, String slug, long candidateId, String actor) { - Skill skill = findSkill(namespaceSlug, slug); + Skill skill = findOwnedSkill(namespaceSlug, slug, actor); requireOwner(skill, actor); if (!Objects.equals(skill.getPendingAvatarId(), candidateId)) { throw new DomainConflictException("error.skill.avatar.notPending"); @@ -291,7 +324,7 @@ public class SkillAvatarAppService { public SkillAvatarStatusResponse decide(String namespaceSlug, String slug, long candidateId, boolean approved, String reason, String actor, Map namespaceRoles, Set platformRoles, AuditRequestContext context) { - Skill skill = lockedSkill(namespaceSlug, slug); + Skill skill = lockAndRefresh(findCandidateSkill(namespaceSlug, slug, findCandidate(candidateId))); requireReviewer(skill, actor, namespaceRoles, platformRoles); SkillAvatarCandidate candidate = currentPending(skill, candidateId); if (candidate.isMachineEnabled() && candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PASSED @@ -317,7 +350,7 @@ public class SkillAvatarAppService { public void remove(String namespaceSlug, String slug, String actor, boolean byAdmin, String reason, Map namespaceRoles, Set platformRoles, AuditRequestContext context) { - Skill skill = lockedSkill(namespaceSlug, slug); + Skill skill = byAdmin ? lockedSkill(namespaceSlug, slug) : lockedOwnedSkill(namespaceSlug, slug, actor); if (byAdmin) { requireReviewer(skill, actor, namespaceRoles, platformRoles); if (reason == null || reason.isBlank()) { @@ -387,9 +420,40 @@ public class SkillAvatarAppService { .orElseThrow(() -> notFound()); } + private Skill findCandidateSkill(String namespaceSlug, String slug, SkillAvatarCandidate candidate) { + Namespace namespace = namespaces.findBySlug(namespaceSlug).orElseThrow(() -> notFound()); + Skill skill = ((SkillRepository) skills).findById(candidate.getSkillId()).orElseThrow(() -> notFound()); + if (!skill.getNamespaceId().equals(namespace.getId()) || !skill.getSlug().equals(slug) + || skill.getStatus() != SkillStatus.ACTIVE) { + throw notFound(); + } + return skill; + } + + private Skill findOwnedSkill(String namespaceSlug, String slug, String actor) { + if (actor == null) { + throw new DomainForbiddenException("error.skill.avatar.ownerOnly"); + } + Namespace namespace = namespaces.findBySlug(namespaceSlug).orElseThrow(() -> notFound()); + return skills.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), slug, actor) + .filter(skill -> skill.getStatus() == SkillStatus.ACTIVE) + .orElseThrow(() -> new DomainForbiddenException("error.skill.avatar.ownerOnly")); + } + + private Skill lockedOwnedSkill(String namespaceSlug, String slug, String actor) { + return lockAndRefresh(findOwnedSkill(namespaceSlug, slug, actor)); + } + private Skill lockedSkill(String namespaceSlug, String slug) { - Skill skill = findSkill(namespaceSlug, slug); - return skills.findLockedById(skill.getId()).orElseThrow(() -> notFound()); + return lockAndRefresh(findSkill(namespaceSlug, slug)); + } + + private Skill lockAndRefresh(Skill skill) { + Skill locked = skills.findLockedById(skill.getId()).orElseThrow(() -> notFound()); + // Publication may have local changes; flush them before refreshing a previously loaded row after the lock. + skills.flush(); + entityManager.refresh(locked); + return locked; } private SkillAvatarCandidate findCandidate(Long candidateId) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUploadService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUploadService.java new file mode 100644 index 00000000..7ccbcc3c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUploadService.java @@ -0,0 +1,139 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillAvatarUpload; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.dto.SkillAvatarStatusResponse; +import com.iflytek.skillhub.dto.SkillAvatarUploadResponse; +import com.iflytek.skillhub.infra.jpa.SkillAvatarUploadJpaRepository; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.UUID; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Service +public class SkillAvatarUploadService { + private final SkillAvatarUploadJpaRepository uploads; + private final SkillAvatarImageProcessor images; + private final ObjectStorageService storage; + private final SkillAvatarAppService avatars; + private final ApplicationEventPublisher events; + + public SkillAvatarUploadService(SkillAvatarUploadJpaRepository uploads, SkillAvatarImageProcessor images, + ObjectStorageService storage, SkillAvatarAppService avatars, ApplicationEventPublisher events) { + this.uploads = uploads; + this.images = images; + this.storage = storage; + this.avatars = avatars; + this.events = events; + } + + @Transactional + public SkillAvatarUploadResponse upload(String actor, byte[] image) { + requireAuthenticated(actor); + byte[] normalized = images.normalize(image); + String id = UUID.randomUUID().toString(); + String key = "skill-avatars/uploads/" + id + ".png"; + storage.putObject(key, new ByteArrayInputStream(normalized), normalized.length, "image/png"); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCompletion(int result) { + if (result != STATUS_COMMITTED) { + storage.deleteObject(key); + } + } + }); + Instant expiresAt = Instant.now().plusSeconds(24 * 60 * 60); + uploads.saveAndFlush(new SkillAvatarUpload(id, actor, key, sha256(normalized), expiresAt)); + return new SkillAvatarUploadResponse(id, "/api/web/skills/avatar-uploads/" + id + "/image", expiresAt); + } + + @Transactional(readOnly = true) + public InputStream preview(String id, String actor) { + SkillAvatarUpload upload = uploads.findById(id).orElseThrow(() -> notFound()); + requireUploader(upload, actor); + if (upload.getObjectKey() == null || !upload.getExpiresAt().isAfter(Instant.now())) { + throw notFound(); + } + return storage.getObject(upload.getObjectKey()); + } + + @Transactional(readOnly = true) + public void validateReference(String namespace, String slug, String actor, String id) { + SkillAvatarUpload upload = uploads.findById(id).orElseThrow(() -> notFound()); + requireUploader(upload, actor); + if (upload.getCandidateId() != null) { + avatars.submittedStatus(namespace, slug, actor, upload.getCandidateId()); + } else if (upload.getObjectKey() == null || !upload.getExpiresAt().isAfter(Instant.now())) { + throw new DomainBadRequestException("error.skill.avatar.uploadExpired"); + } else if (!storage.exists(upload.getObjectKey())) { + throw new DomainBadRequestException("error.skill.avatar.invalidUpload"); + } + } + + @Transactional + public SkillAvatarStatusResponse submit(String namespace, String slug, String actor, String id, + AuditRequestContext context) { + return submitForAudience(namespace, slug, actor, id, null, context); + } + + @Transactional + public SkillAvatarStatusResponse submitForAudience(String namespace, String slug, String actor, String id, + SkillVisibility audience, AuditRequestContext context) { + // Publication already holds this Skill lock; standalone submission follows the same lock order. + avatars.lockOwnedSkill(namespace, slug, actor); + SkillAvatarUpload upload = uploads.findLockedById(id).orElseThrow(() -> notFound()); + requireUploader(upload, actor); + if (upload.getCandidateId() != null) { + // Owner and exact Skill binding are rechecked even for a repeated successful submission. + return avatars.submittedStatus(namespace, slug, actor, upload.getCandidateId()); + } + if (upload.getObjectKey() == null || !upload.getExpiresAt().isAfter(Instant.now())) { + throw new DomainBadRequestException("error.skill.avatar.uploadExpired"); + } + if (!storage.exists(upload.getObjectKey())) { + throw new DomainBadRequestException("error.skill.avatar.invalidUpload"); + } + SkillAvatarStatusResponse status = avatars.submitStored(namespace, slug, actor, + upload.getObjectKey(), upload.getContentSha256(), audience, context); + upload.consume(status.candidateId()); + if ("PENDING".equals(status.machineStatus()) && "PENDING".equals(status.candidateStatus())) { + events.publishEvent(new AvatarSubmittedEvent(status.candidateId(), context)); + } + return status; + } + + public record AvatarSubmittedEvent(long candidateId, AuditRequestContext context) {} + + private void requireAuthenticated(String actor) { + if (actor == null) { + throw new DomainForbiddenException("error.skill.avatar.ownerOnly"); + } + } + private void requireUploader(SkillAvatarUpload upload, String actor) { + requireAuthenticated(actor); + if (!upload.getUploadedBy().equals(actor)) { + throw new DomainForbiddenException("error.skill.avatar.ownerOnly"); + } + } + private String sha256(byte[] image) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(image)); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } + private DomainNotFoundException notFound() { + return new DomainNotFoundException("error.skill.avatar.notFound"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java new file mode 100644 index 00000000..5f9828bb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java @@ -0,0 +1,61 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import java.nio.charset.StandardCharsets; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Commits the skill version and the requested avatar action as one database operation. */ +@Service +public class SkillPublishAppService { + public enum AvatarAction { KEEP, REPLACE, REMOVE } + private final SkillPublishService publications; + private final SkillAvatarUploadService uploads; + private final SkillAvatarAppService avatars; + private final SkillMetadataParser metadata; + + public SkillPublishAppService(SkillPublishService publications, SkillAvatarUploadService uploads, + SkillAvatarAppService avatars, SkillMetadataParser metadata) { + this.publications = publications; + this.uploads = uploads; + this.avatars = avatars; + this.metadata = metadata; + } + + @Transactional + public SkillPublishService.PublishResult publish(String namespace, List entries, String actor, + SkillVisibility visibility, Set roles, boolean confirmWarnings, AvatarAction action, + String uploadId, String expectedSlug, AuditRequestContext context) { + if (action == AvatarAction.REPLACE && (uploadId == null || uploadId.isBlank()) + || action != AvatarAction.REPLACE && uploadId != null && !uploadId.isBlank()) { + throw new DomainBadRequestException("error.skill.avatar.invalidUpload"); + } + if (action == AvatarAction.REPLACE || expectedSlug != null && !expectedSlug.isBlank()) { + PackageEntry skillMd = entries.stream().filter(entry -> "SKILL.md".equals(entry.path())).findFirst() + .orElseThrow(() -> new DomainBadRequestException("error.skill.publish.skillMd.notFound")); + String targetSlug = SlugValidator.slugify(metadata.parse(new String(skillMd.content(), StandardCharsets.UTF_8)).name()); + if (expectedSlug != null && !expectedSlug.isBlank() && !expectedSlug.equals(targetSlug)) { + throw new DomainBadRequestException("error.skill.avatar.skillMismatch"); + } + if (action == AvatarAction.REPLACE) { + uploads.validateReference(namespace, targetSlug, actor, uploadId); + } + } + SkillPublishService.PublishResult result = publications.publishFromEntries( + namespace, entries, actor, visibility, roles, confirmWarnings); + if (action == AvatarAction.REPLACE) { + uploads.submitForAudience(namespace, result.slug(), actor, uploadId, visibility, context); + } else if (action == AvatarAction.REMOVE) { + avatars.remove(namespace, result.slug(), actor, false, null, Map.of(), roles, context); + } + return result; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarCleanupTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarCleanupTask.java index 9a4c6ef9..73c62c96 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarCleanupTask.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarCleanupTask.java @@ -2,6 +2,8 @@ package com.iflytek.skillhub.task; import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository; +import com.iflytek.skillhub.infra.jpa.SkillAvatarUploadJpaRepository; +import org.springframework.data.domain.PageRequest; import com.iflytek.skillhub.storage.ObjectStorageService; import java.time.Instant; import org.springframework.scheduling.annotation.Scheduled; @@ -12,15 +14,21 @@ import org.springframework.transaction.annotation.Transactional; public class SkillAvatarCleanupTask { private final SkillAvatarCandidateJpaRepository candidates; private final ObjectStorageService storage; + private final SkillAvatarUploadJpaRepository uploads; - public SkillAvatarCleanupTask(SkillAvatarCandidateJpaRepository candidates, ObjectStorageService storage) { + public SkillAvatarCleanupTask(SkillAvatarCandidateJpaRepository candidates, ObjectStorageService storage, SkillAvatarUploadJpaRepository uploads) { this.candidates = candidates; this.storage = storage; + this.uploads = uploads; } @Scheduled(cron = "0 35 2 * * ?") @Transactional public void removeExpiredImages() { + for (var upload : uploads.findExpired(Instant.now(), PageRequest.of(0, 100))) { + storage.deleteObject(upload.getObjectKey()); + upload.clearObjectKey(); + } for (SkillAvatarCandidate candidate : candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(Instant.now())) { storage.deleteObject(candidate.getObjectKey()); candidate.clearObjectKey(); diff --git a/server/skillhub-app/src/main/resources/db/migration/V71__staged_skill_avatar_uploads.sql b/server/skillhub-app/src/main/resources/db/migration/V71__staged_skill_avatar_uploads.sql new file mode 100644 index 00000000..21946d18 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V71__staged_skill_avatar_uploads.sql @@ -0,0 +1,9 @@ +CREATE TABLE skill_avatar_upload ( + id VARCHAR(36) PRIMARY KEY, + uploaded_by VARCHAR(128) NOT NULL, + object_key VARCHAR(512), + content_sha256 VARCHAR(64) NOT NULL, + expires_at TIMESTAMP NOT NULL, + candidate_id BIGINT +); +CREATE INDEX idx_avatar_upload_expiry ON skill_avatar_upload(expires_at) WHERE candidate_id IS NULL AND object_key IS NOT NULL; diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 34809943..23347650 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -294,3 +294,7 @@ promotion.revocation.required=Use the reviewed revocation workflow to remove a p promotion.revocation.page_invalid=Page must be nonnegative and size must be between 1 and 100 error.auth.settings.unavailable=Authentication settings are unavailable error.system.roleGrant.unverifiedProvider=This identity provider does not attest verified email and cannot use initial role rules + +error.skill.avatar.uploadExpired=The uploaded avatar expired. Please upload it again. +error.skill.avatar.invalidUpload=Select a valid uploaded avatar before submitting. +error.skill.avatar.skillMismatch=The uploaded package does not match the skill being updated. diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 47b58c94..71dc7ca2 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -235,3 +235,7 @@ error.suite.bundle.member.stateChanged=Состояние связанного S error.suite.bundle.actor.inactive=Пользователь операции Skill Suite Bundle больше не активен error.auth.settings.unavailable=Настройки аутентификации временно недоступны error.system.roleGrant.unverifiedProvider=Этот поставщик удостоверений не подтверждает адрес почты и не поддерживает правила первоначального назначения роли + +error.skill.avatar.uploadExpired=Срок действия загрузки аватара истёк. Загрузите изображение снова. +error.skill.avatar.invalidUpload=Перед отправкой выберите успешно загруженный аватар. +error.skill.avatar.skillMismatch=Загруженный пакет не соответствует обновляемому навыку. diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 2647dd00..ea711eba 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -294,3 +294,7 @@ promotion.revocation.required=已提升的技能须通过审核撤销流程删 promotion.revocation.page_invalid=页码不能为负数,每页数量须在 1 到 100 之间 error.auth.settings.unavailable=认证配置暂时不可用 error.system.roleGrant.unverifiedProvider=该身份来源不提供已验证邮箱,不能用于首次角色授权规则 + +error.skill.avatar.uploadExpired=头像上传引用已过期,请重新上传。 +error.skill.avatar.invalidUpload=请选择已上传成功的头像后再提交。 +error.skill.avatar.skillMismatch=上传的技能包与正在更新的技能不一致。 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java index bffac195..e8b2fd57 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java @@ -443,6 +443,10 @@ class SkillAvatarRecoveryPostgresTest { privateSkill.setVisibility(SkillVisibility.PRIVATE); }); var uploaded = avatars.submit(namespaceSlug(skillId), "skill", "owner", new byte[] {8}, new AuditRequestContext(null, null)); + SkillQueryService.SkillDetailDTO detail = new SkillQueryService.SkillDetailDTO( + skillId, null, null, null, null, null, null, null, null, null, null, null, null, + false, null, null, null, false, false, false, false, null, null, null, null, null, null, null); + when(skillQueries.getSkillDetail(anyString(), eq("skill"), eq("admin"), any())).thenReturn(detail); when(visibility.canAccess(any(), eq("admin"), any(), any())).thenReturn(true); assertThatThrownBy(() -> avatars.readActive(namespaceSlug(skillId), "skill", "admin", adminRole, java.util.Set.of())) .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException.class); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarUploadPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarUploadPostgresTest.java new file mode 100644 index 00000000..075c162d --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarUploadPostgresTest.java @@ -0,0 +1,351 @@ +package com.iflytek.skillhub.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.skill.service.SkillStorageDeletionCompensationService; +import com.iflytek.skillhub.domain.skill.validation.*; +import com.iflytek.skillhub.domain.security.SecurityScanService; +import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse; +import com.iflytek.skillhub.infra.jpa.*; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.service.*; +import com.iflytek.skillhub.task.SkillAvatarCleanupTask; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real publication and staged-image transactions, with controlled external dependencies. */ +@DataJpaTest(showSql = false) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Import({SkillAvatarAppService.class, SkillAvatarUploadService.class, SkillPublishAppService.class, + SkillPublishService.class, SkillStorageDeletionCompensationService.class, JpaSkillStorageDeletionCompensationRepositoryAdapter.class, SkillAvatarCleanupTask.class, + SkillAvatarUploadPostgresTest.Configuration.class}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class SkillAvatarUploadPostgresTest { + @Container static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + @DynamicPropertySource static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + @TestConfiguration static class Configuration { + @Bean SkillMetadataParser metadata() { return new SkillMetadataParser(); } + @Bean SkillPackageValidator validator(SkillMetadataParser metadata) { return new SkillPackageValidator(metadata); } + @Bean PrePublishValidator preValidator() { return new NoOpPrePublishValidator(); } + @Bean ObjectMapper mapper() { return new ObjectMapper(); } + @Bean Clock clock() { return Clock.systemUTC(); } + } + @Autowired SkillAvatarUploadService uploads; + @Autowired SkillPublishAppService publications; + @Autowired SkillAvatarAppService avatars; + @Autowired SkillAvatarCleanupTask cleanup; + @Autowired SkillAvatarUploadJpaRepository uploadRows; + @Autowired SkillJpaRepository skills; + @Autowired SkillVersionJpaRepository versions; + @Autowired SkillAvatarCandidateJpaRepository candidates; + @Autowired NamespaceJpaRepository namespaces; + @Autowired UserAccountJpaRepository users; + @Autowired PlatformTransactionManager transactions; + @Autowired JdbcTemplate jdbc; + @MockBean ObjectStorageService storage; + @MockBean SkillAvatarImageProcessor images; + @MockBean AvatarReviewSettingsService settings; + @MockBean AvatarModerationGateway gateway; + @MockBean VisibilityChecker visibility; + @MockBean SkillQueryService queries; + @MockBean AuditLogService audit; + @MockBean RequestIdAccessor requestIds; + @MockBean SecurityScanService scanner; + private String namespace; + private final AuditRequestContext context = new AuditRequestContext(null, null); + + @BeforeEach void fixtures() { + for (String actor : List.of("owner", "stranger")) { + if (((com.iflytek.skillhub.domain.user.UserAccountRepository) users).findById(actor).isEmpty()) { + users.saveAndFlush(new com.iflytek.skillhub.domain.user.UserAccount(actor, actor, null, null)); + } + } + namespace = "upload-" + UUID.randomUUID().toString().substring(0, 8); + namespaces.saveAndFlush(new Namespace(namespace, "Upload testing", "owner")); + when(images.normalize(any())).thenAnswer(call -> call.getArgument(0)); + when(storage.exists(anyString())).thenReturn(true); + when(storage.getObject(anyString())).thenAnswer(call -> new ByteArrayInputStream(new byte[]{1,2,3})); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(false, true, 1, null)); + when(scanner.isEnabled()).thenReturn(true); + } + + @Test void sameSlugPrivateDraftOwnedByAnotherUserDoesNotHijackAvatarBindingOrRemoval() { + Long otherId = new TransactionTemplate(transactions).execute(ignored -> + skills.saveAndFlush(new Skill(namespaceId(), "skill", "stranger", SkillVisibility.PRIVATE)).getId()); + String id = upload(); + var mine = publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, id, "skill"); + Long myCandidate = uploadRows.findById(id).orElseThrow().getCandidateId(); + assertThat(candidates.findById(myCandidate).orElseThrow().getSkillId()).isEqualTo(mine.skillId()); + assertThat(avatars.ownerStatus(namespace, "skill", "owner").candidateId()).isEqualTo(myCandidate); + assertThat(avatars.ownerStatus(namespace, "skill", "stranger").candidateId()).isNull(); + assertThat(avatars.readCandidate(namespace, "skill", myCandidate, "owner", java.util.Map.of(), Set.of())).isNotNull(); + avatars.decide(namespace, "skill", myCandidate, true, "approved", "owner", + java.util.Map.of(namespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), Set.of(), context); + SkillQueryService.SkillDetailDTO detail = new SkillQueryService.SkillDetailDTO( + mine.skillId(), null, null, null, null, null, null, null, null, null, null, null, null, + false, null, null, null, false, false, false, false, null, null, null, null, null, null, null); + when(queries.getSkillDetail(eq(namespace), eq("skill"), eq("owner"), any())).thenReturn(detail); + when(visibility.canAccess(any(Skill.class), eq("owner"), any(), any())).thenReturn(true); + assertThat(avatars.readActive(namespace, "skill", "owner", java.util.Map.of(), Set.of())).isNotNull(); + publish("1.0.1", SkillPublishAppService.AvatarAction.REMOVE, null, "skill"); + assertThat(read(mine.skillId()).getPendingAvatarId()).isNull(); + assertThat(read(otherId).getPendingAvatarId()).isNull(); + assertThat(read(otherId).getVisibility()).isEqualTo(SkillVisibility.PRIVATE); + } + + @Test void uploadDoesNotCreateSkillOrCandidateAndPreviewIsUploaderOnly() { + long initialCandidates = candidates.count(); + String id = upload(); + assertThat(candidates.count()).isEqualTo(initialCandidates); + assertThat(skills.findByNamespaceIdAndSlug(namespaceId(), "skill")).isEmpty(); + assertThat(uploads.preview(id, "owner")).isNotNull(); + assertThatThrownBy(() -> uploads.preview(id, "stranger")).isInstanceOf(DomainForbiddenException.class); + } + + @Test void publishConsumesUploadAndRepeatedReferenceDoesNotCreateAnotherCandidate() { + String id = upload(); + var published = publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, id, null); + var candidate = candidates.findById(uploadRows.findById(id).orElseThrow().getCandidateId()).orElseThrow(); + assertThat(candidate.getSkillId()).isEqualTo(published.skillId()); + assertThat(candidate.getReviewStatus()).isEqualTo(SkillAvatarCandidate.ReviewStatus.PENDING); + assertThat(candidate.getObjectKey()).isEqualTo(uploadRows.findById(id).orElseThrow().getObjectKey()); + assertThat(uploads.submit(namespace, "skill", "owner", id, context).candidateId()).isEqualTo(candidate.getId()); + assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(published.skillId())).hasSize(1); + assertThat(read(published.skillId()).getActiveAvatarId()).isNull(); + verify(storage, times(1)).putObject(startsWith("skill-avatars/"), any(), anyLong(), anyString()); + } + + @Test void invalidReferencesAndWrongTargetRejectBeforePackageWrites() { + String foreign = uploads.upload("stranger", new byte[]{5}).uploadId(); + assertThatThrownBy(() -> publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, foreign, null)) + .isInstanceOf(DomainForbiddenException.class); + String expired = upload(); + jdbc.update("UPDATE skill_avatar_upload SET expires_at = TIMESTAMP '2000-01-01 00:00:00' WHERE id = ?", expired); + assertThatThrownBy(() -> publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, expired, null)) + .isInstanceOf(DomainBadRequestException.class); + String missingObject = upload(); + when(storage.exists(uploadRows.findById(missingObject).orElseThrow().getObjectKey())).thenReturn(false); + assertThatThrownBy(() -> publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, missingObject, null)) + .isInstanceOf(DomainBadRequestException.class); + assertThatThrownBy(() -> publish("1.0.0", SkillPublishAppService.AvatarAction.KEEP, null, "different-skill")) + .isInstanceOf(DomainBadRequestException.class); + verify(storage, never()).putObject(startsWith("skills/"), any(), anyLong(), anyString()); + assertThat(skills.findByNamespaceIdAndSlug(namespaceId(), "skill")).isEmpty(); + } + + @Test void lateAvatarFailureRollsBackVersionReplacementAndPreservesOldCandidateAndObjects() { + var old = publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, upload(), null); + Long oldCandidate = read(old.skillId()).getPendingAvatarId(); + new TransactionTemplate(transactions).executeWithoutResult(ignored -> + ((SkillVersionRepository) versions).findById(old.version().getId()).orElseThrow().setStatus(SkillVersionStatus.UPLOADED)); + String fresh = upload(); + when(audit.record(anyString(), eq("SKILL_AVATAR_SUBMIT"), anyString(), anyLong(), any(), any(), any(), anyString())) + .thenThrow(new IllegalStateException("audit unavailable")); + clearInvocations(storage); + assertThatThrownBy(() -> publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, fresh, null)) + .isInstanceOf(IllegalStateException.class); + assertThat(versions.findBySkillIdAndVersion(old.skillId(), "1.0.0").orElseThrow().getId()).isEqualTo(old.version().getId()); + assertThat(read(old.skillId()).getPendingAvatarId()).isEqualTo(oldCandidate); + assertThat(candidates.findById(oldCandidate).orElseThrow().getStatus()).isEqualTo(SkillAvatarCandidate.Status.PENDING); + assertThat(uploadRows.findById(fresh).orElseThrow().getCandidateId()).isNull(); + @SuppressWarnings("unchecked") + var deleted = (org.mockito.ArgumentCaptor>) (org.mockito.ArgumentCaptor) org.mockito.ArgumentCaptor.forClass(List.class); + verify(storage).deleteObjects(deleted.capture()); + assertThat(deleted.getValue()).hasSize(2).allMatch(key -> !key.contains("/" + old.version().getId() + "/")); + verify(storage, never()).deleteObject(uploadRows.findById(fresh).orElseThrow().getObjectKey()); + } + + @Test void failedNewPublicationLeavesNoSkillVersionOrCandidate() { + String id = upload(); + when(settings.current()).thenThrow(new IllegalStateException("settings unavailable")); + assertThatThrownBy(() -> publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, id, null)) + .isInstanceOf(IllegalStateException.class); + assertThat(skills.findByNamespaceIdAndSlug(namespaceId(), "skill")).isEmpty(); + assertThat(uploadRows.findById(id).orElseThrow().getCandidateId()).isNull(); + assertThat(jdbc.queryForObject("SELECT count(*) FROM skill_avatar_candidate WHERE object_key = ?", Long.class, + uploadRows.findById(id).orElseThrow().getObjectKey())).isZero(); + verify(storage).deleteObjects(argThat(keys -> keys.size() == 2)); + } + + @Test void failedRollbackObjectDeletionIsPersistedForRetryWithoutOriginalErrorText() { + String id = upload(); + when(settings.current()).thenThrow(new IllegalStateException("settings unavailable")); + doThrow(new IllegalStateException("sensitive-storage-error")).when(storage).deleteObjects(anyList()); + assertThatThrownBy(() -> publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, id, null)) + .isInstanceOf(IllegalStateException.class); + assertThat(jdbc.queryForObject("SELECT count(*) FROM skill_storage_delete_compensation WHERE namespace = ? AND status = 'PENDING'", Long.class, namespace)) + .isEqualTo(1L); + assertThat(jdbc.queryForObject("SELECT last_error FROM skill_storage_delete_compensation WHERE namespace = ?", String.class, namespace)) + .isEqualTo("IllegalStateException"); + } + + @Test void removeOnlyTakesEffectOnSuccessfulCommitAndOldReviewCannotRestoreIt() { + var first = publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, upload(), null); + Long candidate = read(first.skillId()).getPendingAvatarId(); + assertThatThrownBy(() -> publish("1.0.1", SkillPublishAppService.AvatarAction.REMOVE, null, "wrong")) + .isInstanceOf(DomainBadRequestException.class); + assertThat(read(first.skillId()).getPendingAvatarId()).isEqualTo(candidate); + publish("1.0.1", SkillPublishAppService.AvatarAction.REMOVE, null, "skill"); + assertThat(read(first.skillId()).getPendingAvatarId()).isNull(); + assertThat(candidates.findById(candidate).orElseThrow().getStatus()).isEqualTo(SkillAvatarCandidate.Status.REMOVED); + assertThatThrownBy(() -> avatars.decide(namespace, "skill", candidate, true, "late", "owner", + java.util.Map.of(namespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), Set.of(), context)) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainConflictException.class); + } + + @Test void cleanupExcludesLinkedImagesAndWaitsForConsumptionLock() throws Exception { + String abandoned = upload(); + String linked = upload(); + var skill = publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, linked, null); + String abandonedKey = uploadRows.findById(abandoned).orElseThrow().getObjectKey(); + String linkedKey = uploadRows.findById(linked).orElseThrow().getObjectKey(); + jdbc.update("UPDATE skill_avatar_upload SET expires_at = TIMESTAMP '2000-01-01 00:00:00' WHERE id IN (?, ?)", abandoned, linked); + cleanup.removeExpiredImages(); + verify(storage).deleteObject(abandonedKey); + verify(storage, never()).deleteObject(linkedKey); + assertThat(uploadRows.findById(abandoned).orElseThrow().getObjectKey()).isNull(); + assertThat(candidates.findById(read(skill.skillId()).getPendingAvatarId()).orElseThrow().getObjectKey()).isEqualTo(linkedKey); + String racing = upload(); + CountDownLatch locked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var consuming = pool.submit(() -> new TransactionTemplate(transactions).execute(status -> { + uploads.submit(namespace, "skill", "owner", racing, context); + jdbc.update("UPDATE skill_avatar_upload SET expires_at = TIMESTAMP '2000-01-01 00:00:00' WHERE id = ?", racing); + locked.countDown(); + try { assertThat(release.await(10, TimeUnit.SECONDS)).isTrue(); } + catch (InterruptedException failure) { throw new RuntimeException(failure); } + return null; + })); + try { + assertThat(locked.await(10, TimeUnit.SECONDS)).isTrue(); + var scanning = pool.submit(cleanup::removeExpiredImages); + release.countDown(); + consuming.get(10, TimeUnit.SECONDS); + scanning.get(10, TimeUnit.SECONDS); + } finally { release.countDown(); } + } + assertThat(uploadRows.findById(racing).orElseThrow().getCandidateId()).isNotNull(); + verify(storage, never()).deleteObject(uploadRows.findById(racing).orElseThrow().getObjectKey()); + } + + @Test void expiredCleanupLocksExcludeConcurrentConsumption() throws Exception { + var published = publish("1.0.0", SkillPublishAppService.AvatarAction.KEEP, null, null); + String id = upload(); + String key = uploadRows.findById(id).orElseThrow().getObjectKey(); + jdbc.update("UPDATE skill_avatar_upload SET expires_at = TIMESTAMP '2000-01-01 00:00:00' WHERE id = ?", id); + CountDownLatch deleting = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + doAnswer(call -> { + deleting.countDown(); + assertThat(release.await(10, TimeUnit.SECONDS)).isTrue(); + return null; + }).when(storage).deleteObject(key); + try (var pool = Executors.newFixedThreadPool(2)) { + var cleaning = pool.submit(cleanup::removeExpiredImages); + try { + assertThat(deleting.await(10, TimeUnit.SECONDS)).isTrue(); + var consuming = pool.submit(() -> uploads.submit(namespace, "skill", "owner", id, context)); + assertThatThrownBy(() -> consuming.get(250, TimeUnit.MILLISECONDS)) + .isInstanceOf(java.util.concurrent.TimeoutException.class); + release.countDown(); + cleaning.get(10, TimeUnit.SECONDS); + assertThatThrownBy(() -> consuming.get(10, TimeUnit.SECONDS)) + .hasCauseInstanceOf(DomainBadRequestException.class); + } finally { release.countDown(); } + } + assertThat(read(published.skillId()).getPendingAvatarId()).isNull(); + assertThat(uploadRows.findById(id).orElseThrow().getCandidateId()).isNull(); + assertThat(uploadRows.findById(id).orElseThrow().getObjectKey()).isNull(); + } + + @Test void concurrentSameReferenceIsConsumedOnceWithoutDeadlock() throws Exception { + var published = publish("1.0.0", SkillPublishAppService.AvatarAction.KEEP, null, null); + String id = upload(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var first = pool.submit(() -> { start.await(); return uploads.submit(namespace, "skill", "owner", id, context); }); + var second = pool.submit(() -> { start.await(); return uploads.submit(namespace, "skill", "owner", id, context); }); + start.countDown(); + assertThat(first.get(10, TimeUnit.SECONDS).candidateId()).isEqualTo(second.get(10, TimeUnit.SECONDS).candidateId()); + } + assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(published.skillId())).hasSize(1); + } + + @Test void targetPublicAudienceCannotBypassReviewWhilePrivateVersionWaits() { + var first = publications.publish(namespace, entries("1.0.0"), "owner", SkillVisibility.PRIVATE, Set.of("SUPER_ADMIN"), + false, SkillPublishAppService.AvatarAction.REPLACE, upload(), null, context); + Long active = read(first.skillId()).getActiveAvatarId(); + // The non-admin publisher must have namespace membership for the real domain service. + jdbc.update("INSERT INTO namespace_member(namespace_id, user_id, role) VALUES (?, 'owner', 'OWNER')", namespaceId()); + var expanded = publications.publish(namespace, entries("1.0.1"), "owner", SkillVisibility.PUBLIC, Set.of(), + false, SkillPublishAppService.AvatarAction.REPLACE, upload(), null, context); + var pending = candidates.findById(read(expanded.skillId()).getPendingAvatarId()).orElseThrow(); + assertThat(pending.getAudience()).isEqualTo(SkillVisibility.PUBLIC); + assertThat(pending.isManualEnabled()).isTrue(); + assertThat(read(first.skillId()).getActiveAvatarId()).isEqualTo(active); + assertThat(avatars.prepareScopeReview(first.skillId(), "owner")).isNull(); + assertThat(read(first.skillId()).getPendingAvatarId()).isEqualTo(pending.getId()); + } + + private String upload() { return uploads.upload("owner", UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8)).uploadId(); } + private Long namespaceId() { return namespaces.findBySlug(namespace).orElseThrow().getId(); } + private Skill read(Long id) { return ((SkillRepository) skills).findById(id).orElseThrow(); } + private SkillPublishService.PublishResult publish(String version, SkillPublishAppService.AvatarAction action, String id, String expected) { + return publications.publish(namespace, entries(version), "owner", SkillVisibility.PUBLIC, Set.of("SUPER_ADMIN"), + false, action, id, expected, context); + } + private List entries(String version) { + byte[] markdown = ("---\nname: skill\ndescription: Test skill\nversion: " + version + "\n---\n# Skill\nInstructions\n").getBytes(StandardCharsets.UTF_8); + return List.of(new PackageEntry("SKILL.md", markdown, markdown.length, "text/markdown")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarAppServiceTest.java index 5f6e22ee..bfefd6ec 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarAppServiceTest.java @@ -17,6 +17,7 @@ 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.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.VisibilityChecker; @@ -73,6 +74,8 @@ class SkillAvatarAppServiceTest { ReflectionTestUtils.setField(namespace, "type", NamespaceType.TEAM); skill = new Skill(10L, "foo", "owner", SkillVisibility.PUBLIC); ReflectionTestUtils.setField(skill, "id", 11L); + org.mockito.Mockito.lenient().when(skills.findByNamespaceIdAndSlugAndOwnerId(10L, "foo", "owner")) + .thenReturn(Optional.of(skill)); } @AfterEach @@ -86,8 +89,11 @@ class SkillAvatarAppServiceTest { 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)); + SkillQueryService.SkillDetailDTO detail = new SkillQueryService.SkillDetailDTO( + 11L, null, null, null, null, null, null, null, null, null, null, null, null, + false, null, null, null, false, false, false, false, null, null, null, null, null, null, null); + when(skillQueries.getSkillDetail(eq("team"), eq("foo"), eq("admin"), any())).thenReturn(detail); + when(((SkillRepository) skills).findById(11L)).thenReturn(Optional.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())) @@ -98,7 +104,7 @@ class SkillAvatarAppServiceTest { @Test void privateCandidateCannotBeReadByNamespaceAdmin() { when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace)); - when(skills.findByNamespaceIdAndSlug(10L, "foo")).thenReturn(List.of(skill)); + when(((SkillRepository) skills).findById(11L)).thenReturn(Optional.of(skill)); SkillAvatarCandidate candidate = new SkillAvatarCandidate(11L, "owner", "avatar/private.png", "abc", SkillVisibility.PRIVATE, false, false, null); ReflectionTestUtils.setField(candidate, "id", 21L); @@ -319,7 +325,7 @@ class SkillAvatarAppServiceTest { @Test void nonOwnerCannotSubmitBeforeWritingToStorage() { - stubSkillLookup(); + when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace)); assertThatThrownBy(() -> service.submit("team", "foo", "stranger", new byte[] { 1 }, new AuditRequestContext(null, null))).isInstanceOf(DomainForbiddenException.class); verifyNoInteractions(storage); @@ -327,17 +333,28 @@ class SkillAvatarAppServiceTest { @Test void staleApprovalCannotReplaceNewerCandidate() { - stubSkillLookup(); + when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(((SkillRepository) skills).findById(11L)).thenReturn(Optional.of(skill)); + when(skills.findLockedById(11L)).thenReturn(Optional.of(skill)); + SkillAvatarCandidate candidate = new SkillAvatarCandidate(11L, "owner", "avatar/old.png", "abc", + SkillVisibility.PUBLIC, false, true, null); + when(candidates.findById(21L)).thenReturn(Optional.of(candidate)); 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); + org.assertj.core.api.Assertions.assertThat(candidate.getReviewStatus()) + .isEqualTo(SkillAvatarCandidate.ReviewStatus.PENDING); } @Test void reviewRequiresMatchingNamespaceRole() { - stubSkillLookup(); + when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(((SkillRepository) skills).findById(11L)).thenReturn(Optional.of(skill)); + when(skills.findLockedById(11L)).thenReturn(Optional.of(skill)); + SkillAvatarCandidate candidate = new SkillAvatarCandidate(11L, "owner", "avatar/old.png", "abc", + SkillVisibility.PUBLIC, false, true, null); + when(candidates.findById(21L)).thenReturn(Optional.of(candidate)); 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))) @@ -387,9 +404,4 @@ class SkillAvatarAppServiceTest { 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)); - } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 3219db40..6f9daa3b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -55,6 +55,8 @@ 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.POST, "/api/v1/skills/avatar-uploads"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/avatar-uploads/*/image"), 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"), @@ -72,6 +74,8 @@ 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.POST, "/api/web/skills/avatar-uploads"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/skills/avatar-uploads/*/image"), 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"), diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarUpload.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarUpload.java new file mode 100644 index 00000000..a464833a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarUpload.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.domain.skill; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; + +/** Private, uploader-owned image awaiting a successful skill submission. */ +@Entity +@Table(name = "skill_avatar_upload") +public class SkillAvatarUpload { + @Id + @Column(length = 36) + private String id; + @Column(name = "uploaded_by", nullable = false, length = 128) + private String uploadedBy; + @Column(name = "object_key", length = 512) + private String objectKey; + @Column(name = "content_sha256", nullable = false, length = 64) + private String contentSha256; + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + @Column(name = "candidate_id") + private Long candidateId; + + protected SkillAvatarUpload() {} + public SkillAvatarUpload(String id, String uploadedBy, String objectKey, String sha256, Instant expiresAt) { + this.id = id; + this.uploadedBy = uploadedBy; + this.objectKey = objectKey; + this.contentSha256 = sha256; + this.expiresAt = expiresAt; + } + public String getId() { return id; } + public String getUploadedBy() { return uploadedBy; } + public String getObjectKey() { return objectKey; } + public String getContentSha256() { return contentSha256; } + public Instant getExpiresAt() { return expiresAt; } + public Long getCandidateId() { return candidateId; } + public void consume(Long candidateId) { this.candidateId = candidateId; } + public void clearObjectKey() { objectKey = null; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 8fb01137..5d668db3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -565,6 +565,29 @@ public class SkillPublishService { // 9. Upload each file to storage and compute SHA-256 List skillFiles = new ArrayList<>(); + List uploadedKeys = new ArrayList<>(); + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCompletion(int result) { + if (result != STATUS_COMMITTED && !uploadedKeys.isEmpty()) { + try { + objectStorageService.deleteObjects(List.copyOf(uploadedKeys)); + } catch (RuntimeException failure) { + // Keys contain fresh version IDs only. A failed cleanup must never expose provider secrets. + try { + compensationService.recordRollbackFailure(namespaceSlug, skill.getSlug(), + List.copyOf(uploadedKeys), failure.getClass().getSimpleName()); + } catch (RuntimeException compensationFailure) { + log.warn("Failed to record rolled-back publication cleanup, failureType={}", + compensationFailure.getClass().getSimpleName()); + } + log.warn("Failed to clean rolled-back publication objects, failureType={}", + failure.getClass().getSimpleName()); + } + } + } + }); + } long totalSize = 0; Path bundleZip = null; @@ -615,6 +638,7 @@ public class SkillPublishService { zipOutput.finish(); } for (StagedPackageFile staged : stagedFiles) { + uploadedKeys.add(staged.storageKey()); try (InputStream stagedInput = Files.newInputStream(staged.path())) { objectStorageService.putObject( staged.storageKey(), stagedInput, staged.size(), staged.contentType()); @@ -626,6 +650,7 @@ public class SkillPublishService { } String bundleKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); long bundleSize = Files.size(bundleZip); + uploadedKeys.add(bundleKey); try (InputStream bundleInput = Files.newInputStream(bundleZip)) { objectStorageService.putObject( bundleKey, bundleInput, bundleSize, "application/zip"); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillStorageDeletionCompensationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillStorageDeletionCompensationService.java index 3c7b2df0..4c811c73 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillStorageDeletionCompensationService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillStorageDeletionCompensationService.java @@ -10,6 +10,7 @@ import com.iflytek.skillhub.storage.ObjectStorageService; import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; @Service public class SkillStorageDeletionCompensationService { @@ -43,6 +44,12 @@ public class SkillStorageDeletionCompensationService { )); } + /** Persists cleanup after a publication transaction rolled back, including a newly-created Skill. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordRollbackFailure(String namespace, String slug, List storageKeys, String failureType) { + recordFailure(null, namespace, slug, storageKeys, failureType); + } + @Transactional public int retryPendingCleanup() { List records = diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarUploadJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarUploadJpaRepository.java new file mode 100644 index 00000000..acd51a34 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarUploadJpaRepository.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.skill.SkillAvatarUpload; +import jakarta.persistence.LockModeType; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface SkillAvatarUploadJpaRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM SkillAvatarUpload u WHERE u.id = :id") + Optional findLockedById(@Param("id") String id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM SkillAvatarUpload u WHERE u.candidateId IS NULL AND u.objectKey IS NOT NULL AND u.expiresAt <= :now ORDER BY u.expiresAt") + List findExpired(@Param("now") Instant now, Pageable page); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 7570404e..1e62dd8a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,6 +1,6 @@ import createClient from 'openapi-fetch' import { BASE_PATH } from '@/shared/lib/base-path' -import type { paths } from './generated/schema' +import type { components, paths } from './generated/schema' import type { ChangePasswordRequest, PasswordResetConfirmRequest, @@ -521,6 +521,19 @@ function avatarPath(namespace: string, slug: string): string { } export const skillAvatarApi = { + stage(file: Blob): Promise { + const body = new FormData() + body.append('file', file, 'avatar.png') + return fetchJson(`${WEB_API_PREFIX}/skills/avatar-uploads`, { + method: 'POST', headers: getCsrfHeaders(), body, + }) + }, + submit(namespace: string, slug: string, uploadId: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/skills/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/avatar`, { + method: 'POST', headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ uploadId }), + }) + }, status(namespace: string, slug: string): Promise { return fetchJson(`${avatarPath(namespace, slug)}/status`) }, diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 25d639ef..9e6e46cf 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1509,7 +1509,7 @@ export interface paths { }; get: operations["current"]; put?: never; - post: operations["submit"]; + post: operations["submit_2"]; delete: operations["delete"]; options?: never; head?: never; @@ -1525,7 +1525,7 @@ export interface paths { }; get: operations["current_1"]; put?: never; - post: operations["submit_1"]; + post: operations["submit_1_1"]; delete: operations["delete_1"]; options?: never; head?: never; @@ -1596,6 +1596,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/skills/avatar-uploads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["upload"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/avatar-uploads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["upload_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/reviews/{id}/withdraw": { parameters: { query?: never; @@ -1925,7 +1957,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["submit_2"]; + post: operations["submit"]; delete?: never; options?: never; head?: never; @@ -1941,7 +1973,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["submit_3"]; + post: operations["submit_1"]; delete?: never; options?: never; head?: never; @@ -2115,7 +2147,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["status"]; + get: operations["status_2"]; put?: never; post: operations["create"]; delete: operations["revoke"]; @@ -2131,7 +2163,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["status_1"]; + get: operations["status_3"]; put?: never; post: operations["create_1"]; delete: operations["revoke_1"]; @@ -3915,6 +3947,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/skills/avatar-uploads/{uploadId}/image": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["uploadPreview"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/avatar-uploads/{uploadId}/image": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["uploadPreview_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/skills/avatar-review/queue": { parameters: { query?: never; @@ -6298,6 +6362,9 @@ export interface components { approved?: boolean; reason: string; }; + SkillAvatarSubmitRequest: { + uploadId: string; + }; ApiResponsePublishResponse: { /** Format: int32 */ code?: number; @@ -6319,6 +6386,21 @@ export interface components { /** Format: int64 */ totalSize?: number; }; + ApiResponseSkillAvatarUploadResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillAvatarUploadResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillAvatarUploadResponse: { + uploadId: string; + previewUrl: string; + /** Format: date-time */ + expiresAt: string; + }; ReviewActionRequest: { comment?: string; }; @@ -11644,7 +11726,7 @@ export interface operations { }; }; }; - submit: { + submit_2: { parameters: { query?: never; header?: never; @@ -11654,12 +11736,13 @@ export interface operations { }; cookie?: never; }; - requestBody?: { + requestBody: { content: { "multipart/form-data": { /** Format: binary */ file: string; }; + "application/json": components["schemas"]["SkillAvatarSubmitRequest"]; }; }; responses: { @@ -11720,7 +11803,7 @@ export interface operations { }; }; }; - submit_1: { + submit_1_1: { parameters: { query?: never; header?: never; @@ -11730,12 +11813,13 @@ export interface operations { }; cookie?: never; }; - requestBody?: { + requestBody: { content: { "multipart/form-data": { /** Format: binary */ file: string; }; + "application/json": components["schemas"]["SkillAvatarSubmitRequest"]; }; }; responses: { @@ -11832,6 +11916,9 @@ export interface operations { query: { visibility: string; confirmWarnings?: boolean; + avatarAction?: "KEEP" | "REPLACE" | "REMOVE"; + avatarUploadId?: string; + expectedSlug?: string; }; header?: never; path: { @@ -11864,6 +11951,9 @@ export interface operations { query: { visibility: string; confirmWarnings?: boolean; + avatarAction?: "KEEP" | "REPLACE" | "REMOVE"; + avatarUploadId?: string; + expectedSlug?: string; }; header?: never; path: { @@ -11891,6 +11981,60 @@ export interface operations { }; }; }; + upload: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillAvatarUploadResponse"]; + }; + }; + }; + }; + upload_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillAvatarUploadResponse"]; + }; + }; + }; + }; withdrawReview_2: { parameters: { query?: never; @@ -12499,7 +12643,7 @@ export interface operations { }; }; }; - submit_2: { + submit: { parameters: { query?: never; header?: never; @@ -12523,7 +12667,7 @@ export interface operations { }; }; }; - submit_3: { + submit_1: { parameters: { query?: never; header?: never; @@ -12839,7 +12983,7 @@ export interface operations { }; }; }; - status: { + status_2: { parameters: { query?: never; header?: never; @@ -12909,7 +13053,7 @@ export interface operations { }; }; }; - status_1: { + status_3: { parameters: { query?: never; header?: never; @@ -15945,6 +16089,50 @@ export interface operations { }; }; }; + uploadPreview: { + parameters: { + query?: never; + header?: never; + path: { + uploadId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + uploadPreview_1: { + parameters: { + query?: never; + header?: never; + path: { + uploadId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; queue: { parameters: { query?: never; diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 55e332ea..0bf68da7 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -381,11 +381,15 @@ const dashboardPublishRoute = createRoute({ validateSearch: (search: Record): { namespace?: string visibility?: string + updateName?: string + updateSkill?: string resubmitSkill?: string resubmitVersion?: string } => ({ namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace : undefined, visibility: typeof search.visibility === 'string' && search.visibility ? search.visibility : undefined, + updateName: typeof search.updateName === 'string' && search.updateName ? search.updateName : undefined, + updateSkill: typeof search.updateSkill === 'string' && search.updateSkill ? search.updateSkill : undefined, resubmitSkill: typeof search.resubmitSkill === 'string' && search.resubmitSkill ? search.resubmitSkill : undefined, diff --git a/web/src/features/skill/skill-avatar-editor.test.tsx b/web/src/features/skill/skill-avatar-editor.test.tsx new file mode 100644 index 00000000..c7a06b62 --- /dev/null +++ b/web/src/features/skill/skill-avatar-editor.test.tsx @@ -0,0 +1,106 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const api = vi.hoisted(() => ({ stage: vi.fn() })) +vi.mock('@/api/client', () => ({ skillAvatarApi: api, buildApiUrl: (url: string) => url })) +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) +import { SkillAvatarEditor } from './skill-avatar-editor' + +function selectImage() { + fireEvent.change(screen.getByLabelText('skillAvatar.choose'), { target: { files: [new File(['image'], 'avatar.png', { type: 'image/png' })] } }) +} +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} +const uploaded = { uploadId: 'private-upload-1', previewUrl: '/private/preview', expiresAt: '2099-01-01T00:00:00Z' } + +describe('SkillAvatarEditor staged uploads', () => { + beforeEach(() => { + api.stage.mockReset() + vi.stubGlobal('Image', class { src = ''; naturalWidth = 1024; naturalHeight = 768; decode() { return Promise.resolve() } }) + vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:chosen'), revokeObjectURL: vi.fn() }) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage: vi.fn() } as unknown as CanvasRenderingContext2D) + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(callback => callback(new Blob(['cropped'], { type: 'image/png' }))) + }) + afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) + + it('requires upload before submit and stages the cropped file without associating a skill', async () => { + const change = vi.fn() + const task = deferred() + api.stage.mockReturnValue(task.promise) + render() + selectImage() + expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false }) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + await waitFor(() => expect(api.stage).toHaveBeenCalledOnce()) + expect(api.stage.mock.calls[0][0]).toBeInstanceOf(Blob) + expect(screen.getByRole('status').textContent).toBe('skillAvatar.uploadStates.UPLOADING') + task.resolve(uploaded) + await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true })) + expect(screen.getByAltText('skillAvatar.cropPreview').getAttribute('src')).toBe(uploaded.previewUrl) + }) + + it('blocks invalid selections until canceled without touching the active avatar', () => { + const change = vi.fn() + render() + fireEvent.change(screen.getByLabelText('skillAvatar.choose'), { target: { files: [new File(['bad'], 'bad.svg', { type: 'image/svg+xml' })] } }) + expect(screen.getByRole('alert').textContent).toBe('skillAvatar.invalid') + expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false }) + expect(api.stage).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.discardChange' })) + expect(change).toHaveBeenLastCalledWith({ action: 'KEEP', ready: true }) + expect(screen.getByRole('status').textContent).toBe('skillAvatar.uploadStates.KEEP') + }) + + it('keeps submission blocked after upload failure and allows a successful retry', async () => { + const change = vi.fn() + api.stage.mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValueOnce(uploaded) + render() + selectImage() + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + expect((await screen.findByRole('alert')).textContent).toBe('storage unavailable') + expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false }) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.uploadRetry' })) + await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true })) + }) + + it.each(['discardChange', 'remove'] as const)('ignores an old upload completion after %s', async action => { + const change = vi.fn() + const task = deferred() + api.stage.mockReturnValue(task.promise) + render() + selectImage() + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + await waitFor(() => expect(api.stage).toHaveBeenCalledOnce()) + fireEvent.click(screen.getByRole('button', { name: `skillAvatar.${action}` })) + task.resolve(uploaded) + await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: action === 'remove' ? 'REMOVE' : 'KEEP', ready: true })) + expect(change.mock.calls.some(([value]) => value.uploadId)).toBe(false) + expect(screen.queryByAltText('skillAvatar.cropPreview')).toBeNull() + }) + + it('invalidates a staged reference when a different image is selected', async () => { + const change = vi.fn() + api.stage.mockResolvedValue(uploaded) + render() + selectImage() + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true })) + selectImage() + expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false }) + expect(screen.getByRole('status').textContent).toBe('skillAvatar.uploadStates.SELECTED') + }) + + it('previews removal as a draft and cancel restores the original image', () => { + const change = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.remove' })) + expect(change).toHaveBeenLastCalledWith({ action: 'REMOVE', ready: true }) + expect(screen.getByText('F')).toBeTruthy() + expect(api.stage).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.discardChange' })) + expect(document.querySelector('img')?.getAttribute('src')).toBe('/active') + }) +}) diff --git a/web/src/features/skill/skill-avatar-editor.tsx b/web/src/features/skill/skill-avatar-editor.tsx new file mode 100644 index 00000000..70757bb6 --- /dev/null +++ b/web/src/features/skill/skill-avatar-editor.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { buildApiUrl, skillAvatarApi } from '@/api/client' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { SkillAvatar } from './skill-avatar' + +export type AvatarEdit = { action: 'KEEP' | 'REPLACE' | 'REMOVE'; uploadId?: string; ready: boolean } +export const KEEP_AVATAR: AvatarEdit = { action: 'KEEP', ready: true } + +async function cropSquare(source: string, zoom: number): Promise { + const image = new Image() + image.src = source + await image.decode() + const canvas = document.createElement('canvas') + canvas.width = canvas.height = 512 + const context = canvas.getContext('2d') + if (!context) throw new Error('Canvas unavailable') + const side = Math.min(image.naturalWidth, image.naturalHeight) / zoom + context.drawImage(image, (image.naturalWidth - side) / 2, (image.naturalHeight - side) / 2, side, side, 0, 0, 512, 512) + return new Promise((resolve, reject) => canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Image conversion failed')), 'image/png')) +} + +/** A private upload is staged here; only the parent's successful submit associates it. */ +export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disabled = false, onChange }: { + name: string; currentUrl?: string | null; canRemove?: boolean; disabled?: boolean; onChange: (edit: AvatarEdit) => void +}) { + const { t } = useTranslation() + const [sourceUrl, setSourceUrl] = useState(null) + const [zoom, setZoom] = useState(1) + const [previewUrl, setPreviewUrl] = useState(null) + const [state, setState] = useState<'KEEP' | 'SELECTED' | 'UPLOADING' | 'READY' | 'ERROR' | 'REMOVE'>('KEEP') + const [error, setError] = useState('') + const generation = useRef(0) + const input = useRef(null) + useEffect(() => () => { generation.current += 1 }, []) + useEffect(() => () => { if (sourceUrl) URL.revokeObjectURL(sourceUrl) }, [sourceUrl]) + + function reset(remove = false) { + generation.current += 1 + setSourceUrl(null) + setPreviewUrl(null) + setError('') + setState(remove ? 'REMOVE' : 'KEEP') + if (input.current) input.current.value = '' + onChange(remove ? { action: 'REMOVE', ready: true } : KEEP_AVATAR) + } + + async function upload() { + if (!sourceUrl) return + const attempt = ++generation.current + setState('UPLOADING') + setError('') + onChange({ action: 'REPLACE', ready: false }) + try { + const staged = await skillAvatarApi.stage(await cropSquare(sourceUrl, zoom)) + if (attempt !== generation.current) return + setPreviewUrl(staged.previewUrl) + setState('READY') + onChange({ action: 'REPLACE', uploadId: staged.uploadId, ready: true }) + } catch (cause) { + if (attempt !== generation.current) return + setState('ERROR') + setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')) + } + } + + return
+

{t('skillAvatar.optionalTitle')}

+

{t('skillAvatar.stagedHelp')}

+
+ {state === 'READY' && previewUrl ? {t('skillAvatar.cropPreview')} + : } +

{t(`skillAvatar.uploadStates.${state}`)}

+
+ { + const file = event.target.files?.[0] + if (!file) return + generation.current += 1 + setPreviewUrl(null) + setSourceUrl(null) + onChange({ action: 'REPLACE', ready: false }) + if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 2 * 1024 * 1024) { + setState('ERROR'); setError(t('skillAvatar.invalid')); return + } + setSourceUrl(URL.createObjectURL(file)) + setZoom(1); setError(''); setState('SELECTED') + }} /> + {sourceUrl && state !== 'READY' &&
+
{t('skillAvatar.cropPreview')}
+ + +
} +
+ {state !== 'KEEP' && } + {canRemove && state !== 'REMOVE' && } +
+ {error &&

{error}

} +
+} diff --git a/web/src/features/skill/skill-avatar-manager.test.tsx b/web/src/features/skill/skill-avatar-manager.test.tsx index edbf10cc..6c8aa0c1 100644 --- a/web/src/features/skill/skill-avatar-manager.test.tsx +++ b/web/src/features/skill/skill-avatar-manager.test.tsx @@ -1,15 +1,15 @@ /** @vitest-environment jsdom */ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -const api = vi.hoisted(() => ({ status: vi.fn() })) +const api = vi.hoisted(() => ({ status: vi.fn(), delete: vi.fn(), submit: 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() }) + afterEach(() => { cleanup(); api.status.mockReset(); api.delete.mockReset(); api.submit.mockReset() }) it.each([ ['PENDING', 'BLOCKED', 'NOT_REQUIRED', true], @@ -21,7 +21,7 @@ describe('SkillAvatarManager appeal', () => { api.status.mockResolvedValue({ avatarUrl: null, candidateId: 7, candidateStatus, machineStatus, reviewStatus, candidateUrl: null, reviewReason: null }) const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render() - expect((await screen.findByRole('status')).textContent).toContain(`skillAvatar.states.${candidateStatus}`) + expect(await screen.findByText(new RegExp(`skillAvatar.states.${candidateStatus}`))).toBeTruthy() expect(screen.queryByRole('button', { name: 'skillAvatar.appeal' }) !== null).toBe(expected) }) @@ -36,4 +36,26 @@ describe('SkillAvatarManager appeal', () => { expect(screen.getByText('skillAvatar.automaticRetryStopped')).toBeTruthy() expect(screen.getByRole('button', { name: 'skillAvatar.retry' }).hasAttribute('disabled')).toBe(true) }) + it('renders detail status without upload, removal or retry controls', async () => { + api.status.mockResolvedValue({ avatarUrl: '/active', candidateId: 7, candidateStatus: 'REJECTED', machineStatus: 'BLOCKED', reviewStatus: 'REJECTED', reviewReason: 'not suitable' }) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + expect(await screen.findByText('not suitable')).toBeTruthy() + expect(screen.queryByLabelText('skillAvatar.choose')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('does not remove an avatar until save and preserves the draft when the server rejects it', async () => { + api.status.mockResolvedValue({ avatarUrl: '/active', candidateId: 7, candidateStatus: 'ACTIVE', machineStatus: 'SKIPPED', reviewStatus: 'APPROVED' }) + api.delete.mockRejectedValue(new Error('save failed')) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + fireEvent.click(await screen.findByRole('button', { name: 'skillAvatar.remove' })) + expect(api.delete).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.saveChanges' })) + expect((await screen.findByRole('alert')).textContent).toBe('save failed') + await waitFor(() => expect(api.delete).toHaveBeenCalledWith('team', 'foo')) + expect(screen.getByText('skillAvatar.uploadStates.REMOVE')).toBeTruthy() + }) + }) diff --git a/web/src/features/skill/skill-avatar-manager.tsx b/web/src/features/skill/skill-avatar-manager.tsx index cca3b279..fe63b349 100644 --- a/web/src/features/skill/skill-avatar-manager.tsx +++ b/web/src/features/skill/skill-avatar-manager.tsx @@ -1,89 +1,57 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { buildApiUrl, skillAvatarApi } from '@/api/client' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' -import { Input } from '@/shared/ui/input' +import { SkillAvatarEditor, KEEP_AVATAR, type AvatarEdit } from './skill-avatar-editor' -function cropSquare(image: HTMLImageElement, zoom: number): Promise { - const canvas = document.createElement('canvas') - canvas.width = 512 - canvas.height = 512 - const context = canvas.getContext('2d') - if (!context) return Promise.reject(new Error('Canvas unavailable')) - const side = Math.min(image.naturalWidth, image.naturalHeight) / zoom - context.drawImage(image, (image.naturalWidth - side) / 2, (image.naturalHeight - side) / 2, side, side, 0, 0, 512, 512) - return new Promise((resolve, reject) => canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error('Image conversion failed')), 'image/png')) -} - -export function SkillAvatarManager({ namespace, slug }: { namespace: string; slug: string }) { +export function SkillAvatarManager({ namespace, slug, name = slug, readOnly = false }: { namespace: string; slug: string; name?: string; readOnly?: boolean }) { const { t } = useTranslation() const queryClient = useQueryClient() const key = ['skill-avatar', namespace, slug] const status = useQuery({ queryKey: key, queryFn: () => skillAvatarApi.status(namespace, slug) }) - const [sourceUrl, setSourceUrl] = useState(null) - const [zoom, setZoom] = useState(1) + const [edit, setEdit] = useState(KEEP_AVATAR) const [error, setError] = useState('') - const [busy, setBusy] = useState(false) - useEffect(() => () => { if (sourceUrl) URL.revokeObjectURL(sourceUrl) }, [sourceUrl]) + const [editorVersion, setEditorVersion] = useState(0) const mutation = useMutation({ - mutationFn: async (action: { kind: 'upload'; blob: Blob } | { kind: 'remove' | 'appeal' | 'retry'; id?: number }) => { - if (action.kind === 'upload') return skillAvatarApi.upload(namespace, slug, action.blob) + mutationFn: async (action: { kind: 'upload'; uploadId: string } | { kind: 'remove' | 'appeal' | 'retry'; id?: number }) => { + if (action.kind === 'upload') return skillAvatarApi.submit(namespace, slug, action.uploadId) if (action.kind === 'remove') return skillAvatarApi.delete(namespace, slug) if (action.kind === 'appeal') return skillAvatarApi.appeal(namespace, slug, action.id!) return skillAvatarApi.retry(namespace, slug, action.id!) }, - onSuccess: async () => { + onSuccess: async (_data, action) => { setError('') - setSourceUrl(null) + if (action.kind === 'upload' || action.kind === 'remove') { + setEdit(KEEP_AVATAR) + setEditorVersion(value => value + 1) + } await Promise.all([queryClient.invalidateQueries({ queryKey: key }), queryClient.invalidateQueries({ queryKey: ['skill'] }), queryClient.invalidateQueries({ queryKey: ['skills'] })]) }, onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')), }) - async function submit() { - if (!sourceUrl) return - setBusy(true) - try { - const image = new Image() - image.src = sourceUrl - await image.decode() - await mutation.mutateAsync({ kind: 'upload', blob: await cropSquare(image, zoom) }) - } catch (cause) { - setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')) - } finally { setBusy(false) } - } const candidate = status.data const retryCoolingDown = !!candidate?.machineNextRetryAt && Date.parse(candidate.machineNextRetryAt) > Date.now() return (

{t('skillAvatar.title')}

-

{t('skillAvatar.ownerHelp')}

+

{t(readOnly ? 'skillAvatar.detailHelp' : 'skillAvatar.ownerHelp')}

{candidate?.candidateStatus &&

{t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)} · {t(`skillAvatar.machine.${candidate.machineStatus ?? 'SKIPPED'}`)}

} {candidate?.reviewReason &&

{candidate.reviewReason}

} {candidate?.machineStatus === 'ERROR' && retryCoolingDown &&

{t('skillAvatar.retryAfter', { time: new Date(candidate.machineNextRetryAt!).toLocaleString() })}

} {candidate?.machineStatus === 'ERROR' && candidate.automaticRetryExhausted &&

{t('skillAvatar.automaticRetryStopped')}

} {candidate?.candidateUrl && {t('skillAvatar.candidatePreview')}} - { - const file = event.target.files?.[0] - if (!file) return - if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 2 * 1024 * 1024) { setError(t('skillAvatar.invalid')); return } - if (sourceUrl) URL.revokeObjectURL(sourceUrl) - setSourceUrl(URL.createObjectURL(file)) - setZoom(1) - setError('') - }} /> - {sourceUrl &&
-
{t('skillAvatar.cropPreview')}
- - -
} + {!readOnly && <> + + + } + {status.isError &&

{t('skillAvatar.failed')}

}
- {(candidate?.avatarUrl || candidate?.candidateStatus === 'PENDING') && } - {candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED' + {!readOnly && candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED' && candidate.reviewStatus === 'NOT_REQUIRED' && candidate.candidateId - && } - {candidate?.machineStatus === 'ERROR' && candidate.candidateId && } + && } + {!readOnly && candidate?.machineStatus === 'ERROR' && candidate.candidateId && }
{error &&

{error}

}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 75816c58..100920db 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2596,6 +2596,24 @@ } }, "skillAvatar": { + "submittedNotice": "Avatar submitted separately. Check its review status under My skills → Manage avatar.", + "removedNotice": "Avatar removed; the first letter is shown.", + "detailHelp": "Avatar and skill content are reviewed separately. To change, appeal or retry, select Manage avatar in My skills.", + "optionalTitle": "Skill avatar (optional)", + "stagedHelp": "Choose a PNG or JPEG under 2 MB, crop and upload it first. Changes take effect only after submission succeeds. Avatar review is separate from skill review.", + "cropUpload": "Upload cropped image", + "uploadRetry": "Retry upload", + "discardChange": "Cancel avatar change", + "saveChanges": "Save avatar changes", + "manage": "Manage avatar", + "uploadStates": { + "KEEP": "Current avatar will be kept. No avatar uses the first letter.", + "SELECTED": "Crop and upload before submitting.", + "UPLOADING": "Uploading image…", + "READY": "Uploaded privately. Submit to apply and start avatar review.", + "ERROR": "Upload failed. Retry or cancel this change before submitting.", + "REMOVE": "Avatar will be removed after successful submission." + }, "title": "Skill avatar", "ownerHelp": "Upload and crop a square image. The current avatar stays visible until the new one is approved.", "status": "Status", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 2fafb321..3765d5bd 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -2598,6 +2598,24 @@ "reset": "Попробовать снова" }, "skillAvatar": { + "submittedNotice": "Аватар отправлен отдельно. Статус проверки доступен в «Мои навыки → Управление аватаром».", + "removedNotice": "Аватар удалён; отображается первая буква.", + "detailHelp": "Аватар и содержимое навыка проверяются отдельно. Для изменения, апелляции или повторной проверки выберите «Управление аватаром» в «Мои навыки».", + "optionalTitle": "Аватар навыка (необязательно)", + "stagedHelp": "Выберите PNG или JPEG до 2 МБ, обрежьте и загрузите. Изменения применяются после успешной отправки. Аватар и содержимое навыка проверяются отдельно.", + "cropUpload": "Загрузить обрезанное изображение", + "uploadRetry": "Повторить загрузку", + "discardChange": "Отменить изменение аватара", + "saveChanges": "Сохранить аватар", + "manage": "Управление аватаром", + "uploadStates": { + "KEEP": "Текущий аватар сохраняется. Без аватара отображается первая буква.", + "SELECTED": "Обрежьте и загрузите изображение перед отправкой.", + "UPLOADING": "Загрузка изображения…", + "READY": "Изображение загружено в закрытое хранилище. Отправьте для применения и проверки.", + "ERROR": "Ошибка загрузки. Повторите или отмените изменение перед отправкой.", + "REMOVE": "Аватар будет удалён после успешной отправки." + }, "title": "Аватар навыка", "ownerHelp": "Загрузите и обрежьте квадратное изображение. Текущий аватар останется до одобрения нового.", "status": "Статус", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 64b1cd46..d2a4f3a4 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2595,6 +2595,24 @@ } }, "skillAvatar": { + "submittedNotice": "头像修改已单独提交,可在“我的技能 → 管理头像”查看结果。", + "removedNotice": "头像已移除,恢复首字母。", + "detailHelp": "头像与技能内容分别审核。如需修改、申诉或重试,请在“我的技能”选择“管理头像”。", + "optionalTitle": "技能头像(选填)", + "stagedHelp": "选择 2 MB 以下的 PNG 或 JPEG,裁剪后先上传。提交成功后才应用修改,头像审核与技能内容审核分别进行。", + "cropUpload": "上传裁剪图片", + "uploadRetry": "重试上传", + "discardChange": "取消头像修改", + "saveChanges": "保存头像修改", + "manage": "管理头像", + "uploadStates": { + "KEEP": "保留当前头像;未设置时显示名称首字母。", + "SELECTED": "请先裁剪并上传,成功后才能提交。", + "UPLOADING": "图片上传中…", + "READY": "已上传至私有存储,提交后才应用并进入头像审核。", + "ERROR": "上传失败,请重试或取消头像修改后再提交。", + "REMOVE": "提交成功后移除头像,恢复首字母。" + }, "title": "技能头像", "ownerHelp": "上传并裁剪方形图片。新头像通过审核后才会替换当前头像。", "status": "状态", diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts index 80b38107..81c2bb43 100644 --- a/web/src/pages/dashboard/my-skills.test.ts +++ b/web/src/pages/dashboard/my-skills.test.ts @@ -156,6 +156,8 @@ describe('MySkillsPage', () => { to: '/dashboard/publish', search: { namespace: 'team-ai', + updateSkill: 'team-agent', + updateName: 'Team Agent', visibility: 'PRIVATE', }, }) @@ -219,6 +221,8 @@ describe('MySkillsPage', () => { to: '/dashboard/publish', search: { namespace: 'team-ai', + updateSkill: 'default-visibility-agent', + updateName: 'Default Visibility Agent', visibility: 'PUBLIC', }, }) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 2cd714e5..6b05d2a8 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -18,6 +18,8 @@ import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPen import { formatCompactCount } from '@/shared/lib/number-format' import { toast } from '@/shared/lib/toast' import { buildReturnTo } from '@/shared/lib/auth-route' +import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/shared/ui/dialog' import { ApiError } from '@/api/client' import { getMySkillEmptyStateKey, getMySkillFilters, type MySkillFilter } from './my-skill-filters' @@ -57,6 +59,7 @@ export function MySkillsPage() { // Input remains local until the user explicitly submits the search. const [keywordInput, setKeywordInput] = useState(keyword) + const [avatarTarget, setAvatarTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null) @@ -109,10 +112,10 @@ export function MySkillsPage() { updateSearch({ q: undefined, namespace: undefined, page: 0 }) } - const handleUpdateSkill = (namespace: string, visibility?: string) => { + const handleUpdateSkill = (namespace: string, slug: string, name: string, visibility?: string) => { navigate({ to: '/dashboard/publish', - search: { namespace, visibility: visibility || 'PUBLIC' }, + search: { namespace, updateSkill: slug, updateName: name, visibility: visibility || 'PUBLIC' }, }) } @@ -420,7 +423,11 @@ export function MySkillsPage() { ) : null}

-
+
+ {filter !== 'HIDDEN' && } {canRestoreHidden ? ( })) +vi.mock('@/features/skill/skill-avatar-editor', () => ({ + KEEP_AVATAR: { action: 'KEEP', ready: true }, + SkillAvatarEditor: ({ onChange }: { onChange: (value: { action: 'KEEP' | 'REPLACE' | 'REMOVE'; ready: boolean; uploadId?: string }) => void }) => <> + + + + , +})) +vi.mock('@/shared/lib/navigate-after-overlays', () => ({ navigateAfterOverlays: (fn: () => void) => fn() })) +vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +import { PublishPage } from './publish' + +describe('Publish avatar submission', () => { + beforeEach(() => { mocks.publish.mockReset(); mocks.navigate.mockReset(); mocks.search.updateSkill = 'foo' }) + afterEach(cleanup) + + it('blocks publishing a selected image until staged upload completes', async () => { + mocks.publish.mockResolvedValue({ namespace: 'team', slug: 'foo', version: '1.1', status: 'PENDING_REVIEW' }) + render() + fireEvent.click(screen.getByText('choose package')) + fireEvent.click(screen.getByText('image pending')) + expect(screen.getByRole('button', { name: 'publish.confirm' }).hasAttribute('disabled')).toBe(true) + expect(mocks.publish).not.toHaveBeenCalled() + fireEvent.click(screen.getByText('image uploaded')) + fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' })) + await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(expect.objectContaining({ avatarAction: 'REPLACE', avatarUploadId: 'private-reference', expectedSlug: 'foo' }))) + }) + + it('preserves the uploaded reference after a failed skill submission so retry does not reupload', async () => { + mocks.publish.mockRejectedValueOnce(new Error('package invalid')).mockResolvedValueOnce({ namespace: 'team', slug: 'foo', version: '1.1', status: 'PUBLISHED' }) + render() + fireEvent.click(screen.getByText('choose package')) + fireEvent.click(screen.getByText('image uploaded')) + fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' })) + await waitFor(() => expect(mocks.publish).toHaveBeenCalledTimes(1)) + expect(mocks.navigate).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' })) + await waitFor(() => expect(mocks.publish).toHaveBeenCalledTimes(2)) + expect(mocks.publish.mock.calls[1][0]).toEqual(mocks.publish.mock.calls[0][0]) + }) + + it('sends removal only with the successful update request, not while editing', async () => { + mocks.publish.mockResolvedValue({ namespace: 'team', slug: 'foo', version: '1.1', status: 'PENDING_REVIEW' }) + render() + fireEvent.click(screen.getByText('remove draft')) + expect(mocks.publish).not.toHaveBeenCalled() + fireEvent.click(screen.getByText('choose package')) + fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' })) + await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(expect.objectContaining({ avatarAction: 'REMOVE', expectedSlug: 'foo' }))) + }) + + it('keeps the existing avatar by default when updating only skill content', async () => { + mocks.publish.mockResolvedValue({ namespace: 'team', slug: 'foo', version: '1.1', status: 'PENDING_REVIEW' }) + render() + fireEvent.click(screen.getByText('choose package')) + fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' })) + await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(expect.objectContaining({ avatarAction: 'KEEP', avatarUploadId: undefined, expectedSlug: 'foo' }))) + }) + it('clears the avatar draft when navigating to another skill in the same namespace', async () => { + mocks.publish.mockResolvedValue({ namespace: 'team', slug: 'bar', version: '1.1', status: 'PENDING_REVIEW' }) + const view = render() + fireEvent.click(screen.getByText('choose package')) + fireEvent.click(screen.getByText('remove draft')) + mocks.search.updateSkill = 'bar' + view.rerender() + fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' })) + await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(expect.objectContaining({ avatarAction: 'KEEP', avatarUploadId: undefined, expectedSlug: 'bar' }))) + }) + +}) diff --git a/web/src/pages/dashboard/publish.test.ts b/web/src/pages/dashboard/publish.test.ts index 932a2f7d..08d4d761 100644 --- a/web/src/pages/dashboard/publish.test.ts +++ b/web/src/pages/dashboard/publish.test.ts @@ -5,6 +5,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const useSearchMock = vi.fn() const selectRecords: Array<{ value?: string }> = [] +vi.mock('@tanstack/react-query', () => ({ useQuery: () => ({ data: null, isLoading: false, isError: false }) })) + vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), useSearch: () => useSearchMock(), @@ -20,6 +22,8 @@ vi.mock('react-i18next', async () => { } }) +vi.mock('@/features/skill/skill-avatar-editor', () => ({ SkillAvatarEditor: () => null, KEEP_AVATAR: { action: 'KEEP', ready: true } })) + vi.mock('@/features/publish/upload-zone', () => ({ UploadZone: () => null, })) @@ -49,6 +53,7 @@ vi.mock('@/shared/ui/card', () => ({ })) vi.mock('@/shared/hooks/use-skill-queries', () => ({ + useSkillDetail: () => ({ data: null, isLoading: false, isError: false }), usePublishSkill: () => ({ mutateAsync: vi.fn(), isPending: false }), })) diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index eba31285..962a76c1 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { useQuery } from '@tanstack/react-query' import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { UploadZone } from '@/features/publish/upload-zone' @@ -22,13 +23,14 @@ import { } from '@/shared/ui/select' import { Label } from '@/shared/ui/label' import { Card } from '@/shared/ui/card' +import { SkillAvatarEditor, KEEP_AVATAR, type AvatarEdit } from '@/features/skill/skill-avatar-editor' import { usePublishSkill } from '@/shared/hooks/use-skill-queries' import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { navigateAfterOverlays } from '@/shared/lib/navigate-after-overlays' import { toast } from '@/shared/lib/toast' -import { ApiError } from '@/api/client' +import { ApiError, skillAvatarApi } from '@/api/client' const EMPTY_NAMESPACE_VALUE = '__select_namespace__' @@ -37,6 +39,8 @@ export function PublishPage() { const navigate = useNavigate() const search = useSearch({ from: '/dashboard/publish' }) const prefill = normalizePublishPrefill(search) + const updateSlug = typeof search.updateSkill === 'string' ? search.updateSkill : prefill.resubmitSkill + const [avatarEdit, setAvatarEdit] = useState(KEEP_AVATAR) const [selectedFile, setSelectedFile] = useState(null) const [namespaceSlug, setNamespaceSlug] = useState(prefill.namespace) const [visibility, setVisibility] = useState(prefill.visibility) @@ -46,6 +50,8 @@ export function PublishPage() { const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const publishMutation = usePublishSkill() + const avatarTarget = useQuery({ queryKey: ['skill-avatar', namespaceSlug, updateSlug], queryFn: () => skillAvatarApi.status(namespaceSlug, updateSlug), enabled: !!namespaceSlug && !!updateSlug }) + const avatarUnavailable = !!updateSlug && (avatarTarget.isLoading || avatarTarget.isError) const selectedNamespace = namespaces?.find((ns) => ns.slug === namespaceSlug) const namespaceOnlyLabel = selectedNamespace?.type === 'GLOBAL' ? t('publish.visibilityOptions.loggedInUsersOnly') @@ -54,7 +60,8 @@ export function PublishPage() { useEffect(() => { setNamespaceSlug(prefill.namespace) setVisibility(prefill.visibility) - }, [prefill.namespace, prefill.visibility]) + setAvatarEdit(KEEP_AVATAR) + }, [prefill.namespace, prefill.visibility, updateSlug]) const handleRemoveSelectedFile = () => { setSelectedFile(null) @@ -81,7 +88,7 @@ export function PublishPage() { } const publishSkill = async (confirmWarnings = false) => { - if (!selectedFile || !namespaceSlug) { + if (!selectedFile || !namespaceSlug || !avatarEdit.ready || avatarUnavailable) { toast.error(t('publish.selectRequired')) return } @@ -92,19 +99,23 @@ export function PublishPage() { file: selectedFile, visibility, confirmWarnings, + avatarAction: avatarEdit.action, + avatarUploadId: avatarEdit.uploadId, + expectedSlug: updateSlug || undefined, }) setPrecheckWarnings([]) setWarningDialogOpen(false) + const avatarNotice = avatarEdit.action === 'REPLACE' ? t('skillAvatar.submittedNotice') : avatarEdit.action === 'REMOVE' ? t('skillAvatar.removedNotice') : '' const skillLabel = `${result.namespace}/${result.slug}@${result.version}` if (result.status === 'PUBLISHED') { toast.success( t('publish.publishedTitle'), - t('publish.publishedDescription', { skill: skillLabel }) + [t('publish.publishedDescription', { skill: skillLabel }), avatarNotice].filter(Boolean).join(' ') ) } else { toast.success( t('publish.pendingReviewTitle'), - t('publish.pendingReviewDescription', { skill: skillLabel }) + [t('publish.pendingReviewDescription', { skill: skillLabel }), avatarNotice].filter(Boolean).join(' ') ) } navigateAfterOverlays(() => { @@ -187,9 +198,11 @@ export function PublishPage() {
) : ( { - const file = event.target.files?.[0] - if (!file) return - generation.current += 1 - setPreviewUrl(null) - setSourceUrl(null) - onChange({ action: 'REPLACE', ready: false }) - if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 2 * 1024 * 1024) { - setState('ERROR'); setError(t('skillAvatar.invalid')); return - } - setSourceUrl(URL.createObjectURL(file)) - setZoom(1); setError(''); setState('SELECTED') - }} /> +
+ +
{sourceUrl && state !== 'READY' &&
-
{t('skillAvatar.cropPreview')}
- - + setCrop(percentCrop)} + ariaLabels={{ + cropArea: t('skillAvatar.cropArea'), + nwDragHandle: t('skillAvatar.cropHandles.nw'), nDragHandle: t('skillAvatar.cropHandles.n'), + neDragHandle: t('skillAvatar.cropHandles.ne'), eDragHandle: t('skillAvatar.cropHandles.e'), + seDragHandle: t('skillAvatar.cropHandles.se'), sDragHandle: t('skillAvatar.cropHandles.s'), + swDragHandle: t('skillAvatar.cropHandles.sw'), wDragHandle: t('skillAvatar.cropHandles.w'), + }}> + {t('skillAvatar.cropPreview')} { + const { naturalWidth, naturalHeight } = event.currentTarget + setCrop(centerCrop(makeAspectCrop({ unit: '%', width: Math.min(75, 75 * naturalHeight / naturalWidth) }, 1, naturalWidth, naturalHeight), naturalWidth, naturalHeight)) + }} onError={() => { setState('ERROR'); setError(t('skillAvatar.invalid')); setCrop(undefined) }} /> + +

{t('skillAvatar.cropHelp')}

+
}
{state !== 'KEEP' && } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 100920db..3843f05c 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2620,7 +2620,18 @@ "candidatePreview": "Avatar candidate", "choose": "Choose avatar image", "cropPreview": "Crop preview", - "zoom": "Zoom", + "cropArea": "Crop area", + "cropHelp": "Drag the selection or its handles to adjust. Focus the selection and use arrow keys to move it.", + "cropHandles": { + "nw": "Resize top left", + "n": "Resize top", + "ne": "Resize top right", + "e": "Resize right", + "se": "Resize bottom right", + "s": "Resize bottom", + "sw": "Resize bottom left", + "w": "Resize left" + }, "submit": "Submit avatar", "remove": "Remove current avatar", "appeal": "Request review", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 3765d5bd..37ced423 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -2622,7 +2622,18 @@ "candidatePreview": "Новый аватар", "choose": "Выбрать изображение", "cropPreview": "Предпросмотр обрезки", - "zoom": "Масштаб", + "cropArea": "Область обрезки", + "cropHelp": "Перетащите выделение или его края. Выберите область и перемещайте её клавишами со стрелками.", + "cropHandles": { + "nw": "Изменить верхний левый угол", + "n": "Изменить верхний край", + "ne": "Изменить верхний правый угол", + "e": "Изменить правый край", + "se": "Изменить нижний правый угол", + "s": "Изменить нижний край", + "sw": "Изменить нижний левый угол", + "w": "Изменить левый край" + }, "submit": "Отправить аватар", "remove": "Удалить аватар", "appeal": "Запросить пересмотр", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index d2a4f3a4..6ee65bf7 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2619,7 +2619,18 @@ "candidatePreview": "待审核头像", "choose": "选择头像图片", "cropPreview": "裁剪预览", - "zoom": "放大", + "cropArea": "裁剪区域", + "cropHelp": "拖动选区或边角调整;聚焦选区后可用方向键移动。", + "cropHandles": { + "nw": "调整左上角", + "n": "调整上边", + "ne": "调整右上角", + "e": "调整右边", + "se": "调整右下角", + "s": "调整下边", + "sw": "调整左下角", + "w": "调整左边" + }, "submit": "提交头像", "remove": "删除当前头像", "appeal": "申请复核", From 4a2a1f78f6f0e82313cca2034fd7faf6d5450920 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:16:59 +0800 Subject: [PATCH 28/47] docs(avatar): record open-source crop acceptance Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- openspec/changes/add-skill-avatars-and-moderation/tasks.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openspec/changes/add-skill-avatars-and-moderation/tasks.md b/openspec/changes/add-skill-avatars-and-moderation/tasks.md index bd605423..682285fd 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/tasks.md +++ b/openspec/changes/add-skill-avatars-and-moderation/tasks.md @@ -40,10 +40,12 @@ 本轮浏览器验收使用 Chromium 145 与 Playwright 1.58.2,分别通过新增/更新/独立维护头像生命周期及无头像正常发布与下载流程;前端 958 项测试、类型检查、Lint、生产构建、真实 PostgreSQL 临时上传与原子提交回归通过。CI 状态及完整验收证据随 PR #947 更新,未自动合并。 -## 6. 复用开源头像裁剪组件(2026-10-13,实施中) +## 6. 复用开源头像裁剪组件(2026-10-13,已实施并验证) 前述验收对应原居中裁剪实现;本节完成后重新验证受影响的交互及预览,不把旧结果视为新组件验证。 - [x] 6.1 复用 react-dropzone 文件选择,并用 react-image-crop 替换自写裁剪交互,保留既有私有上传与头像提交状态。 - [x] 6.2 验证可移动方形选区、键盘、实际导出位置、文件错误、取消/迟到响应及上传失败重试,完成独立复审。 -- [ ] 6.3 构建当前提交的唯一预览,实际验证新增/更新/独立维护并更新 PR #947 验收记录。 +- [x] 6.3 构建当前提交的唯一预览,实际验证新增/更新/独立维护并更新 PR #947 验收记录。 + +开源组件替换的验收:真实组件专项14项、全量前端965项及类型/Lint/构建通过;独立Review无阻塞。Chromium实际拖动红蓝测试图到左半,上传512 PNG三个采样点均为红色;中俄键盘操作断言选区位置改变;头像生命周期和无头像发布/下载两个轨道通过。机器供应商真实调用未执行,唯一预览继续保留供人工验收。 From 66571f709b3974bdf23ab6b20037ce71da5d6ab4 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:15:52 +0800 Subject: [PATCH 29/47] fix(avatar): unify update flow and crop in a compact dialog Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../design.md | 9 ++ .../specs/skill-avatars/spec.md | 10 +- .../add-skill-avatars-and-moderation/tasks.md | 6 + .../skill/skill-avatar-cache.test.tsx | 15 +-- .../skill/skill-avatar-editor.test.tsx | 105 ++++++++++++--- .../features/skill/skill-avatar-editor.tsx | 122 ++++++++++++------ .../skill/skill-avatar-manager.test.tsx | 38 +++--- .../features/skill/skill-avatar-manager.tsx | 46 ++----- web/src/i18n/locales/en.json | 14 +- web/src/i18n/locales/ru.json | 14 +- web/src/i18n/locales/zh.json | 14 +- web/src/pages/dashboard/my-skills.test.ts | 9 ++ web/src/pages/dashboard/my-skills.tsx | 30 ++--- .../pages/dashboard/publish-avatar.test.tsx | 70 +++++++++- web/src/pages/dashboard/publish.test.ts | 2 +- web/src/pages/dashboard/publish.tsx | 53 ++++++-- web/src/pages/skill-detail.test.tsx | 9 ++ web/src/pages/skill-detail.tsx | 2 - 18 files changed, 402 insertions(+), 166 deletions(-) diff --git a/openspec/changes/add-skill-avatars-and-moderation/design.md b/openspec/changes/add-skill-avatars-and-moderation/design.md index 1796a5df..9bab90e0 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/design.md +++ b/openspec/changes/add-skill-avatars-and-moderation/design.md @@ -42,3 +42,12 @@ ## 裁剪组件选型调整(2026-10-13) 头像文件选择复用已有 `react-dropzone`,裁剪交互采用 `react-image-crop`(ISC,兼容项目 React 版本)。方形选区的拖动、缩放选区、触屏和键盘行为交由开源组件处理,移除自写居中裁剪与缩放交互。仅保留选区图像导出及私有上传/提交状态衔接,不改变审核、引用消费、保留/替换/移除规则。组件适配须验证非居中选区的实际导出结果、取消与迟到响应隔离,以及新增/更新/独立维护入口。 + +## 头像入口与弹窗简化(2026-10-13,用户人工反馈) + +- 详情页仅保留标题旁头像,删除专门头像状态卡片、重复候选图、供应商开关说明及已批准审核理由。审核治理能力不因展示简化而移除。 +- 我的技能自绘列表补齐名称旁头像,删除独立“管理头像”按钮与入口;统一“更新”页同时支持仅修改头像或上传新技能包。无技能包时只提交既有头像维护接口,不新增版本;有包时保留原子发布/头像关联流程。新建及驳回重提仍必须提供技能包。 +- 使用已有 Tailwind CSS 视觉样式及 Radix Dialog 封装承载 React Image Crop,不引入新的整体 UI 框架。父表单为64像素头像和简短操作;裁剪弹窗画布有界、自适应,错误和重试在弹窗内。 +- 弹窗取消/关闭/ESC恢复打开前草稿,上传确认成功后关闭弹窗并展示小预览;成功上传仍需父页面最终提交。更新页仅展示所有者需要处理的待审/失败/驳回信息及原有重试、申诉动作,保持治理闭环。 + +组件接入方式:仓库已有 `web/components.json`,本地 `shared/ui/Button`、`Dialog` 沿用 shadcn 的源码分发/组件组合方式,底层为 Radix 与 Tailwind。当前为 Tailwind 3 和旧 `default` 风格;本轮复用现有封装与主题,不重新执行 init,也不覆盖已有 Portal/焦点/主题定制。头像选择由本地预览、Button、Dialog 和 React Image Crop 组合,shadcn Avatar 的展示职责与图片裁剪/上传业务分开。 diff --git a/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md index 8f97d8d0..62723d7a 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md +++ b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md @@ -110,13 +110,19 @@ PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使 前后端 SHALL 提供可理解的头像提交与审核闭环。 -新增和更新 Skill 页面提供可选自定义头像、方形裁剪、上传预览及上传状态,不提供预设图标选择;未上传时使用首字母兜底。更新页面展示当前头像并提供保留、替换、移除三种动作,默认保留。“我的技能”提供单独修改头像的入口,复用同一套上传与裁剪组件,不要求发布新的 SkillVersion。详情页展示头像、审核状态和驳回原因,不再承载完整上传表单。技能内容与头像分别显示提交和审核状态,技能内容审核通过不代表头像审核通过。审核员可以读取其管辖范围候选并处理,机器单开时可以处理例外申诉。所有展示 Skill 身份的位置使用同一头像与兜底组件。 +新增和更新 Skill 页面提供可选自定义头像、方形裁剪、上传预览及上传状态,不提供预设图标选择;未上传时使用首字母兜底。更新页面展示当前头像并提供保留、替换、移除三种动作,默认保留。“我的技能”仅保留统一的“更新”入口,列表在技能名称旁显示头像。更新页复用同一套自定义头像组件,可只提交头像修改而不要求上传技能包或发布新的 SkillVersion;上传技能包时仍按原流程发布版本。选择图片后在项目已有弹窗内裁剪,父表单仅保留小头像预览与必要操作,不展开大图。关闭、取消或按 ESC 退出裁剪时恢复打开前的头像草稿。详情页仅在标题旁显示当前头像,不再显示单独的头像管理面板、重复图片、机器开关说明或已批准理由;所有者需要处理的头像待审状态和驳回原因集中在更新页。技能内容与头像分别显示提交和审核状态,技能内容审核通过不代表头像审核通过。审核员可以读取其管辖范围候选并处理,机器单开时可以处理例外申诉。所有展示 Skill 身份的位置使用同一头像与兜底组件。 #### Scenario: 申请等待审核 - **WHEN** 所有者上传候选后需人工决定 - **THEN** 页面展示待审状态且已发布头像维持不变 + +#### Scenario: 裁剪取消 +- **WHEN** 所有者已选择头像草稿,再选择另一张图并取消裁剪弹窗 +- **THEN** 恢复弹窗打开前的草稿,包括原临时上传引用或移除选择 +- **AND** 迟到的上传结果不得替换恢复后的草稿 + ### Requirement: REQ-AV-10 头像 SHALL 先私有上传再随提交关联 新增、更新或单独修改头像时,客户端 SHALL 先裁剪并上传图片到私有存储,上传成功后取得可用于提交的临时引用。选择了新图片但仍在上传或上传失败时,客户端不得提交该次操作;服务端亦须校验引用对应的文件上传成功、未过期、属于当前提交者且不可越权关联。未选择头像的新技能可正常提交。 @@ -144,7 +150,7 @@ PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使 - **THEN** 服务端拒绝提交,不更改技能或头像状态 #### Scenario: 单独修改头像 -- **WHEN** 所有者从“我的技能”上传并提交新头像 +- **WHEN** 所有者从“我的技能”的“更新”入口上传并提交新头像 - **THEN** 复用相同上传、关联及审核流程,不创建新的 SkillVersion ### Requirement: REQ-AV-11 更新页移除 SHALL 在提交成功后生效 diff --git a/openspec/changes/add-skill-avatars-and-moderation/tasks.md b/openspec/changes/add-skill-avatars-and-moderation/tasks.md index 682285fd..d9835511 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/tasks.md +++ b/openspec/changes/add-skill-avatars-and-moderation/tasks.md @@ -49,3 +49,9 @@ - [x] 6.3 构建当前提交的唯一预览,实际验证新增/更新/独立维护并更新 PR #947 验收记录。 开源组件替换的验收:真实组件专项14项、全量前端965项及类型/Lint/构建通过;独立Review无阻塞。Chromium实际拖动红蓝测试图到左半,上传512 PNG三个采样点均为红色;中俄键盘操作断言选区位置改变;头像生命周期和无头像发布/下载两个轨道通过。机器供应商真实调用未执行,唯一预览继续保留供人工验收。 + +## 7. 人工验收后的入口与展示优化(2026-10-13,实施中) + +- [x] 7.1 简化详情展示,补齐我的技能列表头像,合并为唯一更新入口,保留仅改头像不新增版本能力。 +- [x] 7.2 复用 Radix 弹窗承载开源裁剪组件,父表单紧凑展示,取消恢复草稿并隔离迟到结果。 +- [ ] 7.3 验证更新页头像单独提交/带技能包提交、审核恢复、列表/详情、桌面/移动弹窗和键盘焦点,独立Review后更新同一预览供人工验收。 diff --git a/web/src/features/skill/skill-avatar-cache.test.tsx b/web/src/features/skill/skill-avatar-cache.test.tsx index c7f7f5ef..aa3e8d1a 100644 --- a/web/src/features/skill/skill-avatar-cache.test.tsx +++ b/web/src/features/skill/skill-avatar-cache.test.tsx @@ -2,13 +2,9 @@ import { QueryClient, QueryClientProvider, useQuery } 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(() => ({ status: vi.fn(), submit: vi.fn(), queue: vi.fn(), decide: vi.fn() })) +const api = vi.hoisted(() => ({ status: vi.fn(), appeal: vi.fn(), queue: vi.fn(), decide: vi.fn() })) vi.mock('@/api/client', () => ({ skillAvatarApi: api, buildApiUrl: (url: string) => url })) vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) -vi.mock('./skill-avatar-editor', () => ({ - KEEP_AVATAR: { action: 'KEEP', ready: true }, - SkillAvatarEditor: ({ onChange }: { onChange: (value: { action: 'REPLACE'; ready: boolean; uploadId: string }) => void }) => , -})) import { SkillAvatarManager } from './skill-avatar-manager' import { SkillAvatarReviewQueue } from './skill-avatar-review' @@ -20,19 +16,18 @@ const active = { ...pending, avatarUrl: '/active', candidateStatus: 'ACTIVE', re describe('avatar cache across owner and reviewer flows', () => { afterEach(() => { cleanup(); Object.values(api).forEach(fn => fn.mockReset()) }) - it('refreshes a previously empty fresh review queue when the owner submits a staged image', async () => { + it('refreshes a previously empty fresh review queue when the owner appeals a blocked candidate', async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }) - client.setQueryData(ownerKey, { ...active, candidateId: 20 }) + client.setQueryData(ownerKey, { ...pending, candidateId: 20, machineStatus: 'BLOCKED', reviewStatus: 'NOT_REQUIRED' }) client.setQueryData(queueKey, []) api.status.mockResolvedValue(pending) - api.submit.mockResolvedValue(pending) + api.appeal.mockResolvedValue(pending) api.queue.mockResolvedValue([pending]) render() expect(screen.getByText('skillAvatar.emptyQueue')).toBeTruthy() expect(api.queue).not.toHaveBeenCalled() - fireEvent.click(screen.getByText('Use staged image')) - fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.saveChanges' })) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.appeal' })) expect(await screen.findByText('@team/foo')).toBeTruthy() expect(client.getQueryData(ownerKey)).toEqual(pending) diff --git a/web/src/features/skill/skill-avatar-editor.test.tsx b/web/src/features/skill/skill-avatar-editor.test.tsx index d67a0b39..6fe4832c 100644 --- a/web/src/features/skill/skill-avatar-editor.test.tsx +++ b/web/src/features/skill/skill-avatar-editor.test.tsx @@ -1,5 +1,5 @@ /** @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const api = vi.hoisted(() => ({ stage: vi.fn() })) vi.mock('@/api/client', () => ({ skillAvatarApi: api, buildApiUrl: (url: string) => url })) @@ -8,7 +8,8 @@ import { SkillAvatarEditor } from './skill-avatar-editor' async function selectImage() { fireEvent.change(screen.getByLabelText('skillAvatar.choose'), { target: { files: [new File(['image'], 'avatar.png', { type: 'image/png' })] } }) - const image = await screen.findByAltText('skillAvatar.cropPreview') + const dialog = await screen.findByRole('dialog') + const image = within(dialog).getByAltText('skillAvatar.cropPreview') fireEvent.load(image) } function deferred() { @@ -41,7 +42,7 @@ describe('SkillAvatarEditor staged uploads', () => { const initialLeft = area.style.left fireEvent.keyDown(area, { key: 'ArrowRight', shiftKey: true }) expect(area.style.left).not.toBe(initialLeft) - fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' })) await waitFor(() => expect(api.stage).toHaveBeenCalledOnce()) const context = vi.mocked(HTMLCanvasElement.prototype.getContext).mock.results[0].value as unknown as { translate: ReturnType; drawImage: ReturnType } // Initial square is 576px at x=224. Library Shift+Right moves 10 display px = 40 source px. @@ -58,7 +59,7 @@ describe('SkillAvatarEditor staged uploads', () => { fireEvent.pointerDown(area, { clientX: 128, clientY: 96 }) fireEvent.pointerMove(document, { clientX: 148, clientY: 106 }) fireEvent.pointerUp(document) - fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' })) await waitFor(() => expect(api.stage).toHaveBeenCalledOnce()) const context = vi.mocked(HTMLCanvasElement.prototype.getContext).mock.results[0].value as unknown as { translate: ReturnType } expect(context.translate.mock.calls[0][0]).toBeCloseTo(-304) @@ -71,9 +72,9 @@ describe('SkillAvatarEditor staged uploads', () => { vi.mocked(HTMLCanvasElement.prototype.toBlob).mockImplementation(callback => { finish = callback }) render() await selectImage() - fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' })) await waitFor(() => expect(finish).toBeTypeOf('function')) - fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.discardChange' })) + fireEvent.click(screen.getByRole('button', { name: 'common.cancel' })) finish(new Blob(['cropped'], { type: 'image/png' })) await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'KEEP', ready: true })) expect(api.stage).not.toHaveBeenCalled() @@ -83,10 +84,13 @@ describe('SkillAvatarEditor staged uploads', () => { api.stage.mockReturnValue(new Promise(() => {})) render() const input = screen.getByLabelText('skillAvatar.choose') as HTMLInputElement - expect(input.tabIndex).toBe(0) + expect(input.tabIndex).toBe(-1) + const choose = screen.getByRole('button', { name: 'skillAvatar.choose' }) as HTMLButtonElement + expect(choose.disabled).toBe(false) + expect(input.className).toContain('sr-only') expect(input.disabled).toBe(false) await selectImage() - fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' })) expect(input.disabled).toBe(true) }) @@ -103,7 +107,7 @@ describe('SkillAvatarEditor staged uploads', () => { render() fireEvent.change(screen.getByLabelText('skillAvatar.choose'), { target: { files: [new File(['image'], 'avatar.png', { type: 'image/png' })] } }) const image = await screen.findByAltText('skillAvatar.cropPreview') - expect((screen.getByRole('button', { name: 'skillAvatar.cropUpload' }) as HTMLButtonElement).disabled).toBe(true) + expect((screen.getByRole('button', { name: 'skillAvatar.cropConfirm' }) as HTMLButtonElement).disabled).toBe(true) fireEvent.error(image) expect(screen.getByRole('alert').textContent).toBe('skillAvatar.invalid') expect(api.stage).not.toHaveBeenCalled() @@ -116,7 +120,7 @@ describe('SkillAvatarEditor staged uploads', () => { render() await selectImage() expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false }) - fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' })) await waitFor(() => expect(api.stage).toHaveBeenCalledOnce()) expect(api.stage.mock.calls[0][0]).toBeInstanceOf(Blob) expect(screen.getByRole('status').textContent).toBe('skillAvatar.uploadStates.UPLOADING') @@ -134,7 +138,8 @@ describe('SkillAvatarEditor staged uploads', () => { expect(api.stage).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.discardChange' })) expect(change).toHaveBeenLastCalledWith({ action: 'KEEP', ready: true }) - expect(screen.getByRole('status').textContent).toBe('skillAvatar.uploadStates.KEEP') + expect(screen.queryByRole('status')).toBeNull() + expect(document.querySelector('img')?.getAttribute('src')).toBe('/active') }) it('keeps submission blocked after upload failure and allows a successful retry', async () => { @@ -142,26 +147,28 @@ describe('SkillAvatarEditor staged uploads', () => { api.stage.mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValueOnce(uploaded) render() await selectImage() - fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' })) expect((await screen.findByRole('alert')).textContent).toBe('storage unavailable') expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false }) fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.uploadRetry' })) await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true })) }) - it.each(['discardChange', 'remove'] as const)('ignores an old upload completion after %s', async action => { + it.each(['cancel', 'escape'] as const)('ignores an old upload completion after %s', async action => { const change = vi.fn() const task = deferred() api.stage.mockReturnValue(task.promise) render() await selectImage() - fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' })) await waitFor(() => expect(api.stage).toHaveBeenCalledOnce()) - fireEvent.click(screen.getByRole('button', { name: `skillAvatar.${action}` })) + if (action === 'cancel') fireEvent.click(screen.getByRole('button', { name: 'common.cancel' })) + else fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) task.resolve(uploaded) - await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: action === 'remove' ? 'REMOVE' : 'KEEP', ready: true })) + await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'KEEP', ready: true })) expect(change.mock.calls.some(([value]) => value.uploadId)).toBe(false) expect(screen.queryByAltText('skillAvatar.cropPreview')).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() }) it('invalidates a staged reference when a different image is selected', async () => { @@ -169,11 +176,73 @@ describe('SkillAvatarEditor staged uploads', () => { api.stage.mockResolvedValue(uploaded) render() await selectImage() - fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' })) await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true })) await selectImage() expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false }) - expect(screen.getByRole('status').textContent).toBe('skillAvatar.uploadStates.SELECTED') + expect(screen.getByRole('dialog')).toBeTruthy() + expect(screen.queryByRole('status')).toBeNull() + }) + + + it('uses the shared portal dialog and restores chooser focus when Escape cancels', async () => { + const { container } = render() + const chooser = screen.getByRole('button', { name: 'skillAvatar.choose' }) + chooser.focus() + await selectImage() + const dialog = screen.getByRole('dialog', { name: 'skillAvatar.cropTitle' }) + expect(container.contains(dialog)).toBe(false) + expect(within(dialog).getByText('skillAvatar.cropHelp')).toBeTruthy() + expect(container.querySelector('.ReactCrop')).toBeNull() + fireEvent.keyDown(dialog, { key: 'Escape' }) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + await waitFor(() => expect(document.activeElement).toBe(chooser)) + expect(api.stage).not.toHaveBeenCalled() + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:chosen') + }) + + it('restores an already uploaded replacement when a second crop is canceled', async () => { + const change = vi.fn() + api.stage.mockResolvedValue(uploaded) + render() + await selectImage() + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + await selectImage() + fireEvent.click(screen.getByRole('button', { name: 'common.cancel' })) + expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true }) + expect(screen.getByAltText('skillAvatar.cropPreview').getAttribute('src')).toBe(uploaded.previewUrl) + expect(api.stage).toHaveBeenCalledOnce() + }) + + it('does not overwrite a prior staged draft with a late second upload', async () => { + const change = vi.fn() + const late = deferred() + api.stage.mockResolvedValueOnce(uploaded).mockReturnValueOnce(late.promise) + render() + await selectImage() + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + await selectImage() + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropConfirm' })) + await waitFor(() => expect(api.stage).toHaveBeenCalledTimes(2)) + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + late.resolve({ ...uploaded, uploadId: 'stale-second-upload', previewUrl: '/private/second' }) + await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true })) + expect(change.mock.calls.some(([value]) => value.uploadId === 'stale-second-upload')).toBe(false) + expect(screen.getByAltText('skillAvatar.cropPreview').getAttribute('src')).toBe(uploaded.previewUrl) + }) + + it('restores a removal draft when the crop dialog close button is used', async () => { + const change = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.remove' })) + await selectImage() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(change).toHaveBeenLastCalledWith({ action: 'REMOVE', ready: true }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByText('F')).toBeTruthy() + expect(api.stage).not.toHaveBeenCalled() }) it('previews removal as a draft and cancel restores the original image', () => { diff --git a/web/src/features/skill/skill-avatar-editor.tsx b/web/src/features/skill/skill-avatar-editor.tsx index c5e7923a..dce9bd08 100644 --- a/web/src/features/skill/skill-avatar-editor.tsx +++ b/web/src/features/skill/skill-avatar-editor.tsx @@ -5,7 +5,7 @@ import 'react-image-crop/dist/ReactCrop.css' import { useTranslation } from 'react-i18next' import { buildApiUrl, skillAvatarApi } from '@/api/client' import { Button } from '@/shared/ui/button' -import { Input } from '@/shared/ui/input' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/shared/ui/dialog' import { SkillAvatar } from './skill-avatar' export type AvatarEdit = { action: 'KEEP' | 'REPLACE' | 'REMOVE'; uploadId?: string; ready: boolean } @@ -32,6 +32,10 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable const [previewUrl, setPreviewUrl] = useState(null) const [state, setState] = useState<'KEEP' | 'SELECTED' | 'UPLOADING' | 'READY' | 'ERROR' | 'REMOVE'>('KEEP') const [error, setError] = useState('') + const [dialogOpen, setDialogOpen] = useState(false) + const edit = useRef(KEEP_AVATAR) + const beforeSelection = useRef({ edit: KEEP_AVATAR, previewUrl: null as string | null, state: 'KEEP' as typeof state }) + const chooseButton = useRef(null) const generation = useRef(0) const image = useRef(null) useEffect(() => () => { generation.current += 1 }, []) @@ -45,7 +49,22 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable setState(remove ? 'REMOVE' : 'KEEP') setCrop(undefined) if (inputRef.current) inputRef.current.value = '' - onChange(remove ? { action: 'REMOVE', ready: true } : KEEP_AVATAR) + setDialogOpen(false) + edit.current = remove ? { action: 'REMOVE', ready: true } : KEEP_AVATAR + onChange(edit.current) + } + + function cancelSelection() { + generation.current += 1 + setDialogOpen(false) + setSourceUrl(null) + setCrop(undefined) + setError('') + setPreviewUrl(beforeSelection.current.previewUrl) + setState(beforeSelection.current.state) + edit.current = beforeSelection.current.edit + onChange(edit.current) + if (inputRef.current) inputRef.current.value = '' } async function upload() { @@ -61,7 +80,12 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable if (attempt !== generation.current) return setPreviewUrl(staged.previewUrl) setState('READY') - onChange({ action: 'REPLACE', uploadId: staged.uploadId, ready: true }) + edit.current = { action: 'REPLACE', uploadId: staged.uploadId, ready: true } + onChange(edit.current) + setDialogOpen(false) + setSourceUrl(null) + setCrop(undefined) + if (inputRef.current) inputRef.current.value = '' } catch (cause) { if (attempt !== generation.current) return setState('ERROR') @@ -69,7 +93,7 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable } } - const { getRootProps, getInputProps, inputRef } = useDropzone({ + const { getRootProps, getInputProps, inputRef, open } = useDropzone({ accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg'] }, maxSize: 2 * 1024 * 1024, maxFiles: 1, @@ -78,6 +102,9 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable noClick: true, noKeyboard: true, onDrop: (accepted, rejected) => { + if (state === 'KEEP' || state === 'READY' || state === 'REMOVE') { + beforeSelection.current = { edit: edit.current, previewUrl, state } + } generation.current += 1 setPreviewUrl(null) setSourceUrl(null) @@ -88,45 +115,60 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable setState('ERROR'); setError(t('skillAvatar.invalid')); return } setSourceUrl(URL.createObjectURL(file)) - setError(''); setState('SELECTED') + setError(''); setState('SELECTED'); setDialogOpen(true) }, }) - return
-

{t('skillAvatar.optionalTitle')}

-

{t('skillAvatar.stagedHelp')}

-
- {state === 'READY' && previewUrl ? {t('skillAvatar.cropPreview')} - : } -

{t(`skillAvatar.uploadStates.${state}`)}

+ return
+

{t('skillAvatar.optionalTitle')}

+
+ {state === 'READY' && previewUrl ? {t('skillAvatar.cropPreview')} + : } + +
+
+ + {(canRemove || state === 'READY') && state !== 'REMOVE' && } + {state !== 'KEEP' && !dialogOpen && } +
+ {state !== 'KEEP' && !dialogOpen &&

{t(state === 'READY' ? 'skillAvatar.stagedReady' : state === 'REMOVE' ? 'skillAvatar.removedDraft' : `skillAvatar.uploadStates.${state}`)}

} +
-
- -
- {sourceUrl && state !== 'READY' &&
- setCrop(percentCrop)} - ariaLabels={{ - cropArea: t('skillAvatar.cropArea'), - nwDragHandle: t('skillAvatar.cropHandles.nw'), nDragHandle: t('skillAvatar.cropHandles.n'), - neDragHandle: t('skillAvatar.cropHandles.ne'), eDragHandle: t('skillAvatar.cropHandles.e'), - seDragHandle: t('skillAvatar.cropHandles.se'), sDragHandle: t('skillAvatar.cropHandles.s'), - swDragHandle: t('skillAvatar.cropHandles.sw'), wDragHandle: t('skillAvatar.cropHandles.w'), - }}> - {t('skillAvatar.cropPreview')} { - const { naturalWidth, naturalHeight } = event.currentTarget - setCrop(centerCrop(makeAspectCrop({ unit: '%', width: Math.min(75, 75 * naturalHeight / naturalWidth) }, 1, naturalWidth, naturalHeight), naturalWidth, naturalHeight)) - }} onError={() => { setState('ERROR'); setError(t('skillAvatar.invalid')); setCrop(undefined) }} /> - -

{t('skillAvatar.cropHelp')}

- -
} -
- {state !== 'KEEP' && } - {canRemove && state !== 'REMOVE' && } -
- {error &&

{error}

} + {!dialogOpen && error &&

{error}

} + { if (!value) cancelSelection() }}> + { event.preventDefault(); chooseButton.current?.focus() }}> + + {t('skillAvatar.cropTitle')} + {t('skillAvatar.cropHelp')} + + {sourceUrl &&
+ setCrop(percentCrop)} + ariaLabels={{ + cropArea: t('skillAvatar.cropArea'), + nwDragHandle: t('skillAvatar.cropHandles.nw'), nDragHandle: t('skillAvatar.cropHandles.n'), + neDragHandle: t('skillAvatar.cropHandles.ne'), eDragHandle: t('skillAvatar.cropHandles.e'), + seDragHandle: t('skillAvatar.cropHandles.se'), sDragHandle: t('skillAvatar.cropHandles.s'), + swDragHandle: t('skillAvatar.cropHandles.sw'), wDragHandle: t('skillAvatar.cropHandles.w'), + }}> + {t('skillAvatar.cropPreview')} { + const { naturalWidth, naturalHeight } = event.currentTarget + setCrop(centerCrop(makeAspectCrop({ unit: '%', width: Math.min(75, 75 * naturalHeight / naturalWidth) }, 1, naturalWidth, naturalHeight), naturalWidth, naturalHeight)) + }} onError={() => { setState('ERROR'); setError(t('skillAvatar.invalid')); setCrop(undefined) }} /> + +
} + {state === 'UPLOADING' &&

{t('skillAvatar.uploadStates.UPLOADING')}

} + {error &&

{error}

} + + + + +
+
} diff --git a/web/src/features/skill/skill-avatar-manager.test.tsx b/web/src/features/skill/skill-avatar-manager.test.tsx index 6c8aa0c1..5dd1b298 100644 --- a/web/src/features/skill/skill-avatar-manager.test.tsx +++ b/web/src/features/skill/skill-avatar-manager.test.tsx @@ -3,13 +3,13 @@ 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(() => ({ status: vi.fn(), delete: vi.fn(), submit: vi.fn() })) +const api = vi.hoisted(() => ({ status: vi.fn(), delete: vi.fn(), submit: vi.fn(), retry: vi.fn(), appeal: 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(); api.delete.mockReset(); api.submit.mockReset() }) + afterEach(() => { cleanup(); Object.values(api).forEach(fn => fn.mockReset()) }) it.each([ ['PENDING', 'BLOCKED', 'NOT_REQUIRED', true], @@ -36,26 +36,24 @@ describe('SkillAvatarManager appeal', () => { expect(screen.getByText('skillAvatar.automaticRetryStopped')).toBeTruthy() expect(screen.getByRole('button', { name: 'skillAvatar.retry' }).hasAttribute('disabled')).toBe(true) }) - it('renders detail status without upload, removal or retry controls', async () => { - api.status.mockResolvedValue({ avatarUrl: '/active', candidateId: 7, candidateStatus: 'REJECTED', machineStatus: 'BLOCKED', reviewStatus: 'REJECTED', reviewReason: 'not suitable' }) - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - render() - expect(await screen.findByText('not suitable')).toBeTruthy() - expect(screen.queryByLabelText('skillAvatar.choose')).toBeNull() - expect(screen.queryByRole('button')).toBeNull() - }) - - it('does not remove an avatar until save and preserves the draft when the server rejects it', async () => { - api.status.mockResolvedValue({ avatarUrl: '/active', candidateId: 7, candidateStatus: 'ACTIVE', machineStatus: 'SKIPPED', reviewStatus: 'APPROVED' }) - api.delete.mockRejectedValue(new Error('save failed')) + it('calls the existing retry API and preserves the recoverable state on failure', async () => { + api.status.mockResolvedValue({ candidateId: 7, candidateStatus: 'PENDING', machineStatus: 'ERROR', reviewStatus: 'NOT_REQUIRED', machineNextRetryAt: null, automaticRetryExhausted: true }) + api.retry.mockRejectedValue(new Error('service unavailable')) const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render() - fireEvent.click(await screen.findByRole('button', { name: 'skillAvatar.remove' })) - expect(api.delete).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.saveChanges' })) - expect((await screen.findByRole('alert')).textContent).toBe('save failed') - await waitFor(() => expect(api.delete).toHaveBeenCalledWith('team', 'foo')) - expect(screen.getByText('skillAvatar.uploadStates.REMOVE')).toBeTruthy() + fireEvent.click(await screen.findByRole('button', { name: 'skillAvatar.retry' })) + expect((await screen.findByRole('alert')).textContent).toBe('service unavailable') + expect(api.retry).toHaveBeenCalledWith('team', 'foo', 7) + expect(screen.getByRole('button', { name: 'skillAvatar.retry' }).hasAttribute('disabled')).toBe(false) }) + it('does not render approved reasons, duplicate image or editing controls', async () => { + api.status.mockResolvedValue({ avatarUrl: '/active', candidateId: 7, candidateStatus: 'ACTIVE', machineStatus: 'SKIPPED', reviewStatus: 'APPROVED', reviewReason: 'approved reason' }) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + await waitFor(() => expect(api.status).toHaveBeenCalled()) + expect(screen.queryByText('approved reason')).toBeNull() + expect(screen.queryByRole('img')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() + }) }) diff --git a/web/src/features/skill/skill-avatar-manager.tsx b/web/src/features/skill/skill-avatar-manager.tsx index 2cfa428f..18e62114 100644 --- a/web/src/features/skill/skill-avatar-manager.tsx +++ b/web/src/features/skill/skill-avatar-manager.tsx @@ -1,59 +1,41 @@ import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { buildApiUrl, skillAvatarApi } from '@/api/client' +import { skillAvatarApi } from '@/api/client' import { Button } from '@/shared/ui/button' -import { Card } from '@/shared/ui/card' -import { SkillAvatarEditor, KEEP_AVATAR, type AvatarEdit } from './skill-avatar-editor' -export function SkillAvatarManager({ namespace, slug, name = slug, readOnly = false }: { namespace: string; slug: string; name?: string; readOnly?: boolean }) { +export function SkillAvatarManager({ namespace, slug, disabled = false }: { namespace: string; slug: string; disabled?: boolean }) { const { t } = useTranslation() const queryClient = useQueryClient() const key = ['skill-avatar', namespace, slug] - const status = useQuery({ queryKey: key, queryFn: () => skillAvatarApi.status(namespace, slug) }) - const [edit, setEdit] = useState(KEEP_AVATAR) + const status = useQuery({ queryKey: key, queryFn: () => skillAvatarApi.status(namespace, slug), enabled: !!namespace && !!slug }) const [error, setError] = useState('') - const [editorVersion, setEditorVersion] = useState(0) const mutation = useMutation({ - mutationFn: async (action: { kind: 'upload'; uploadId: string } | { kind: 'remove' | 'appeal' | 'retry'; id?: number }) => { - if (action.kind === 'upload') return skillAvatarApi.submit(namespace, slug, action.uploadId) - if (action.kind === 'remove') return skillAvatarApi.delete(namespace, slug) - if (action.kind === 'appeal') return skillAvatarApi.appeal(namespace, slug, action.id!) - return skillAvatarApi.retry(namespace, slug, action.id!) - }, - onSuccess: async (_data, action) => { + mutationFn: async (action: { kind: 'appeal' | 'retry'; id: number }) => action.kind === 'appeal' + ? skillAvatarApi.appeal(namespace, slug, action.id) + : skillAvatarApi.retry(namespace, slug, action.id), + onSuccess: async () => { setError('') - if (action.kind === 'upload' || action.kind === 'remove') { - setEdit(KEEP_AVATAR) - setEditorVersion(value => value + 1) - } await Promise.all([queryClient.invalidateQueries({ queryKey: key }), queryClient.invalidateQueries({ queryKey: ['skill'] }), queryClient.invalidateQueries({ queryKey: ['skills'] }), queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] })]) }, onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')), }) const candidate = status.data const retryCoolingDown = !!candidate?.machineNextRetryAt && Date.parse(candidate.machineNextRetryAt) > Date.now() + if (!candidate || !['PENDING', 'REJECTED'].includes(candidate.candidateStatus ?? '')) return null return ( - -

{t('skillAvatar.title')}

-

{t(readOnly ? 'skillAvatar.detailHelp' : 'skillAvatar.ownerHelp')}

- {candidate?.candidateStatus &&

{t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)} · {t(`skillAvatar.machine.${candidate.machineStatus ?? 'SKIPPED'}`)}

} +
+ {candidate?.candidateStatus &&

{t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)}

} {candidate?.reviewReason &&

{candidate.reviewReason}

} {candidate?.machineStatus === 'ERROR' && retryCoolingDown &&

{t('skillAvatar.retryAfter', { time: new Date(candidate.machineNextRetryAt!).toLocaleString() })}

} {candidate?.machineStatus === 'ERROR' && candidate.automaticRetryExhausted &&

{t('skillAvatar.automaticRetryStopped')}

} - {candidate?.candidateUrl && {t('skillAvatar.candidatePreview')}} - {!readOnly && <> - - - } - {status.isError &&

{t('skillAvatar.failed')}

}
- {!readOnly && candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED' + {candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED' && candidate.reviewStatus === 'NOT_REQUIRED' && candidate.candidateId - && } - {!readOnly && candidate?.machineStatus === 'ERROR' && candidate.candidateId && } + && } + {candidate?.machineStatus === 'ERROR' && candidate.candidateId && }
{error &&

{error}

} - +
) } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3843f05c..62949c2e 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1833,7 +1833,12 @@ "frontmatterFailedTitle": "SKILL.md format is invalid", "frontmatterFailedDescription": "Please check the YAML frontmatter at the top of SKILL.md. If a field value contains a colon, wrap it in quotes.", "selectRequired": "Please select namespace and file", - "folderPackagingFailed": "Could not package the selected folder. Make sure it contains files." + "folderPackagingFailed": "Could not package the selected folder. Make sure it contains files.", + "updateTitle": "Update skill", + "updateSubtitle": "Save avatar changes independently, or upload a package to publish a new version.", + "optionalPackage": "A package is optional when changing only the avatar.", + "saveAvatar": "Save avatar", + "avatarSaved": "Avatar changes submitted" }, "toast": { "success": "Success", @@ -2667,6 +2672,11 @@ "PASSED": "Machine review passed", "BLOCKED": "Machine review blocked", "ERROR": "Machine review failed" - } + }, + "cropTitle": "Crop avatar", + "cropConfirm": "Confirm and upload", + "change": "Change image", + "stagedReady": "Uploaded; applied after submission", + "removedDraft": "Removed after submission" } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 37ced423..65b95619 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1824,7 +1824,12 @@ "frontmatterFailedTitle": "Неверный формат SKILL.md", "frontmatterFailedDescription": "Проверьте YAML frontmatter в начале SKILL.md. Если значение поля содержит двоеточие, заключите его в кавычки.", "selectRequired": "Выберите пространство имён и файл", - "folderPackagingFailed": "Не удалось упаковать выбранную папку. Убедитесь, что она содержит файлы." + "folderPackagingFailed": "Не удалось упаковать выбранную папку. Убедитесь, что она содержит файлы.", + "updateTitle": "Обновить навык", + "updateSubtitle": "Сохраните аватар отдельно или загрузите пакет для публикации новой версии.", + "optionalPackage": "Для изменения только аватара пакет не требуется.", + "saveAvatar": "Сохранить аватар", + "avatarSaved": "Изменения аватара отправлены" }, "ratingInput": { "yourRating": "Ваша оценка: {{score}} зв." @@ -2669,6 +2674,11 @@ "PASSED": "Проверка пройдена", "BLOCKED": "Заблокирован", "ERROR": "Ошибка проверки" - } + }, + "cropTitle": "Обрезка аватара", + "cropConfirm": "Подтвердить и загрузить", + "change": "Заменить изображение", + "stagedReady": "Загружено; применится после отправки", + "removedDraft": "Удалится после отправки" } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 6ee65bf7..3ee4790c 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1832,7 +1832,12 @@ "frontmatterFailedTitle": "SKILL.md 格式有误", "frontmatterFailedDescription": "请检查 SKILL.md 顶部 frontmatter 的 YAML 格式。若字段值中包含冒号,请用引号包裹。", "selectRequired": "请选择命名空间和文件", - "folderPackagingFailed": "无法打包所选文件夹,请确认其中包含文件。" + "folderPackagingFailed": "无法打包所选文件夹,请确认其中包含文件。", + "updateTitle": "更新技能", + "updateSubtitle": "头像可单独保存;上传技能包将发布新版本。", + "optionalPackage": "仅更新头像时无需上传技能包。", + "saveAvatar": "保存头像", + "avatarSaved": "头像修改已提交" }, "toast": { "success": "成功", @@ -2666,6 +2671,11 @@ "PASSED": "机器审核通过", "BLOCKED": "机器审核阻止", "ERROR": "机器审核失败" - } + }, + "cropTitle": "裁剪头像", + "cropConfirm": "确认并上传", + "change": "更换图片", + "stagedReady": "已上传,提交后生效", + "removedDraft": "提交后移除头像" } } diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts index 81c2bb43..28501665 100644 --- a/web/src/pages/dashboard/my-skills.test.ts +++ b/web/src/pages/dashboard/my-skills.test.ts @@ -101,6 +101,7 @@ vi.mock('@/shared/lib/toast', () => ({ })) vi.mock('@/api/client', () => ({ + buildApiUrl: (url: string) => url, ApiError: class ApiError extends Error { serverMessageKey?: string }, @@ -163,6 +164,14 @@ describe('MySkillsPage', () => { }) }) + it('displays the active avatar beside the skill name and exposes only the existing update entry', () => { + useMySkillsMock.mockReturnValue({ data: { items: [{ id: 1, displayName: 'Team Agent', namespace: 'team-ai', slug: 'team-agent', avatarUrl: '/skill-avatar.png', status: 'PUBLISHED', downloadCount: 0 }], total: 1 }, isLoading: false }) + const html = renderToStaticMarkup(createElement(MySkillsPage)) + expect(html).toContain('src="/skill-avatar.png"') + expect(buttonRecords.some(button => button.label === 'skillAvatar.manage')).toBe(false) + expect(buttonRecords.some(button => button.label === 'mySkills.update')).toBe(true) + }) + it('does not render update action for archived skills', () => { useMySkillsMock.mockReturnValue({ data: { diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 6b05d2a8..14fdcc17 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -18,8 +18,7 @@ import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPen import { formatCompactCount } from '@/shared/lib/number-format' import { toast } from '@/shared/lib/toast' import { buildReturnTo } from '@/shared/lib/auth-route' -import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/shared/ui/dialog' +import { SkillAvatar } from '@/features/skill/skill-avatar' import { ApiError } from '@/api/client' import { getMySkillEmptyStateKey, getMySkillFilters, type MySkillFilter } from './my-skill-filters' @@ -59,7 +58,6 @@ export function MySkillsPage() { // Input remains local until the user explicitly submits the search. const [keywordInput, setKeywordInput] = useState(keyword) - const [avatarTarget, setAvatarTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null) @@ -382,11 +380,14 @@ export function MySkillsPage() { className={`p-5 group animate-fade-up delay-${Math.min(idx + 1, 6)} ${filter === 'HIDDEN' ? '' : 'cursor-pointer'}`} onClick={filter === 'HIDDEN' ? undefined : () => handleSkillClick(skill.namespace, skill.slug)} > -
-
-

- {skill.displayName} -

+
+
+
+ +

+ {skill.displayName} +

+
{skill.summary && (

{skill.summary}

)} @@ -423,11 +424,7 @@ export function MySkillsPage() { ) : null}
-
- {filter !== 'HIDDEN' && } +
{canRestoreHidden ? ( - -
-
-
)} - {error &&

{error}

} - -} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index bcbee396..48489ab8 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1854,6 +1854,22 @@ "close": "Close" }, "review": { + "avatarUpdate": "Avatar update", + "avatarRemoval": "Remove avatar", + "metadataChange": "Avatar change", + "avatarUnifiedDecision": "The avatar shares this application’s decision. Approval applies the change; rejection keeps the current avatar.", + "currentAvatar": "Current avatar", + "requestedAvatar": "Requested avatar", + "avatarPreviewUnavailable": "Preview unavailable", + "approveDisabledAvatar": "Avatar moderation is not ready for approval.", + "avatarOverrideReason": "Machine moderation blocked this image. Enter a review comment to override it.", + "avatarReviewStatus": { + "NOT_REQUIRED": "Manual review not required", + "PENDING": "Awaiting manual review", + "APPROVED": "Manual review approved", + "REJECTED": "Manual review rejected", + "APPEAL_PENDING": "Awaiting manual appeal" + }, "detail": "Review Detail", "id": "Review ID", "backToList": "Back to List", @@ -2602,10 +2618,10 @@ }, "skillAvatar": { "submittedNotice": "Avatar changes submitted. View the review status under My skills → Update.", - "removedNotice": "Avatar removed; the first letter is shown.", - "detailHelp": "Avatar and skill content are reviewed separately. To change, appeal or retry, select Manage avatar in My skills.", + "removedNotice": "Avatar removal submitted. View the review status under My skills → Update.", + "detailHelp": "The avatar shares this application’s review. To change, appeal or retry, select Update in My skills.", "optionalTitle": "Skill avatar (optional)", - "stagedHelp": "Choose a PNG or JPEG under 2 MB, crop and upload it first. Changes take effect only after submission succeeds. Avatar review is separate from skill review.", + "stagedHelp": "Choose a PNG or JPEG under 2 MB, crop and upload it. After submission, the avatar and skill package share one review decision.", "cropUpload": "Upload cropped image", "uploadRetry": "Retry upload", "discardChange": "Cancel avatar change", @@ -2676,7 +2692,25 @@ "cropTitle": "Crop avatar", "cropConfirm": "Confirm and upload", "change": "Change image", - "stagedReady": "Uploaded; applied after submission", - "removedDraft": "Removed after submission" + "stagedReady": "Image uploaded; ready to submit", + "removedDraft": "Pending change: remove avatar" + }, + "namespaceManagement": { + "back": "Back to my spaces", + "enter": "Enter space", + "navigation": "Space management", + "skills": "Skills", + "members": "Members", + "reviews": "Reviews", + "settings": "Settings", + "invite": "Invite members", + "lifecycle": "Space lifecycle", + "globalReadOnly": "The global space cannot be frozen, archived or deleted. Skill reviews remain available.", + "lifecycleHint": "Check the impact on members, reviews and skill access before changing the status.", + "freezeHint": "Pause member management, publishing and reviews. Published skills remain visible.", + "unfreezeHint": "Resume member management, publishing and reviews.", + "archiveHint": "Stop normal access to the space and its skills. Archiving can be reversed.", + "restoreHint": "Restore the archived space.", + "deleteHint": "Permanently delete the space. Handle its skills and members first." } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index b2dc6478..681ab552 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1873,6 +1873,22 @@ "next": "Далее" }, "review": { + "avatarUpdate": "Обновление аватара", + "avatarRemoval": "Удаление аватара", + "metadataChange": "Изменение аватара", + "avatarUnifiedDecision": "Аватар проверяется вместе с этой заявкой. Одобрение применяет изменение; отклонение сохраняет текущий аватар.", + "currentAvatar": "Текущий аватар", + "requestedAvatar": "Новый аватар", + "avatarPreviewUnavailable": "Предпросмотр недоступен", + "approveDisabledAvatar": "Проверка аватара ещё не готова к одобрению.", + "avatarOverrideReason": "Автоматическая проверка заблокировала изображение. Для одобрения укажите комментарий.", + "avatarReviewStatus": { + "NOT_REQUIRED": "Ручная проверка не требуется", + "PENDING": "Ожидает ручной проверки", + "APPROVED": "Ручная проверка одобрена", + "REJECTED": "Ручная проверка отклонена", + "APPEAL_PENDING": "Ожидает рассмотрения апелляции" + }, "detail": "Детали ревью", "id": "ID ревью", "backToList": "К списку", @@ -2604,10 +2620,10 @@ }, "skillAvatar": { "submittedNotice": "Изменения аватара отправлены. Проверьте статус проверки в «Мои навыки → Обновить».", - "removedNotice": "Аватар удалён; отображается первая буква.", - "detailHelp": "Аватар и содержимое навыка проверяются отдельно. Для изменения, апелляции или повторной проверки выберите «Управление аватаром» в «Мои навыки».", + "removedNotice": "Запрос на удаление аватара отправлен. Проверьте статус в «Мои навыки → Обновить».", + "detailHelp": "Аватар проверяется вместе с заявкой. Для изменения, апелляции или повтора выберите «Обновить» в «Мои навыки».", "optionalTitle": "Аватар навыка (необязательно)", - "stagedHelp": "Выберите PNG или JPEG до 2 МБ, обрежьте и загрузите. Изменения применяются после успешной отправки. Аватар и содержимое навыка проверяются отдельно.", + "stagedHelp": "Выберите PNG или JPEG до 2 МБ, обрежьте и загрузите. После отправки аватар и пакет навыка проверяются одним решением.", "cropUpload": "Загрузить обрезанное изображение", "uploadRetry": "Повторить загрузку", "discardChange": "Отменить изменение аватара", @@ -2678,7 +2694,25 @@ "cropTitle": "Обрезка аватара", "cropConfirm": "Подтвердить и загрузить", "change": "Заменить изображение", - "stagedReady": "Загружено; применится после отправки", - "removedDraft": "Удалится после отправки" + "stagedReady": "Изображение загружено; готово к отправке", + "removedDraft": "Изменение ожидает отправки: удалить аватар" + }, + "namespaceManagement": { + "back": "Вернуться к моим пространствам", + "enter": "Открыть пространство", + "navigation": "Управление пространством", + "skills": "Навыки", + "members": "Участники", + "reviews": "Проверки", + "settings": "Настройки", + "invite": "Пригласить участников", + "lifecycle": "Состояние пространства", + "globalReadOnly": "Глобальное пространство нельзя заморозить, архивировать или удалить. Проверки навыков доступны.", + "lifecycleHint": "Проверьте влияние на участников, проверки и доступ к навыкам.", + "freezeHint": "Приостановить управление участниками, публикацию и проверки. Опубликованные навыки остаются видимыми.", + "unfreezeHint": "Возобновить управление участниками, публикацию и проверки.", + "archiveHint": "Остановить доступ к пространству и навыкам. Архивацию можно отменить.", + "restoreHint": "Восстановить архивированное пространство.", + "deleteHint": "Удалить пространство навсегда. Сначала обработайте его навыки и участников." } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index cfecbdf7..aaa42c94 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1853,6 +1853,22 @@ "close": "关闭" }, "review": { + "avatarUpdate": "头像更新", + "avatarRemoval": "移除头像", + "metadataChange": "头像变更", + "avatarUnifiedDecision": "头像与本申请使用同一次审核结果。通过后应用修改;驳回后保留当前头像。", + "currentAvatar": "当前头像", + "requestedAvatar": "申请后的头像", + "avatarPreviewUnavailable": "暂无可用预览", + "approveDisabledAvatar": "头像机器审核尚未准备好,暂不能通过此申请。", + "avatarOverrideReason": "机器审核未通过;人工放行须填写审核意见。", + "avatarReviewStatus": { + "NOT_REQUIRED": "无需人工审核", + "PENDING": "待人工审核", + "APPROVED": "人工审核通过", + "REJECTED": "人工审核驳回", + "APPEAL_PENDING": "待人工复核" + }, "detail": "审核详情", "id": "审核 ID", "backToList": "返回列表", @@ -2601,10 +2617,10 @@ }, "skillAvatar": { "submittedNotice": "头像修改已提交,可在“我的技能 → 更新”查看审核状态。", - "removedNotice": "头像已移除,恢复首字母。", - "detailHelp": "头像与技能内容分别审核。如需修改、申诉或重试,请在“我的技能”选择“管理头像”。", + "removedNotice": "头像移除请求已提交,可在“我的技能 → 更新”查看审核状态。", + "detailHelp": "头像随本次申请审核。如需修改、申诉或重试,请在“我的技能”选择“更新”。", "optionalTitle": "技能头像(选填)", - "stagedHelp": "选择 2 MB 以下的 PNG 或 JPEG,裁剪后先上传。提交成功后才应用修改,头像审核与技能内容审核分别进行。", + "stagedHelp": "选择 2 MB 以下的 PNG 或 JPEG,裁剪并上传。提交后,头像与技能包使用同一次审核结果。", "cropUpload": "上传裁剪图片", "uploadRetry": "重试上传", "discardChange": "取消头像修改", @@ -2675,7 +2691,25 @@ "cropTitle": "裁剪头像", "cropConfirm": "确认并上传", "change": "更换图片", - "stagedReady": "已上传,提交后生效", - "removedDraft": "提交后移除头像" + "stagedReady": "图片已上传,待提交", + "removedDraft": "待提交:移除头像" + }, + "namespaceManagement": { + "back": "返回我的空间", + "enter": "进入空间", + "navigation": "空间管理", + "skills": "技能", + "members": "成员", + "reviews": "审核", + "settings": "设置", + "invite": "邀请成员", + "lifecycle": "空间状态管理", + "globalReadOnly": "全局空间由系统维护,不能冻结、归档或删除。技能审核不受此限制。", + "lifecycleHint": "变更空间状态前,请确认对成员、审核和技能访问的影响。", + "freezeHint": "暂停成员管理、技能发布与审核,已发布技能仍可查看。", + "unfreezeHint": "恢复空间的成员管理、发布和审核。", + "archiveHint": "归档后停止空间及其中技能的正常访问。可恢复归档。", + "restoreHint": "恢复已归档空间。", + "deleteHint": "永久删除空间,请先处理其中的技能和成员。" } } diff --git a/web/src/pages/dashboard/my-namespaces.test.ts b/web/src/pages/dashboard/my-namespaces.test.ts index 7e3511b9..631d12b6 100644 --- a/web/src/pages/dashboard/my-namespaces.test.ts +++ b/web/src/pages/dashboard/my-namespaces.test.ts @@ -125,12 +125,14 @@ describe('MyNamespacesPage', () => { expect(typeof MyNamespacesPage).toBe('function') }) - it('renders the delete action when the namespace is deletable', () => { + it('keeps lifecycle actions out of cards even when permitted', () => { mockNamespaces = [buildNamespace({ canDelete: true })] const html = renderToStaticMarkup(createElement(MyNamespacesPage)) - expect(html).toContain('myNamespaces.delete') + expect(html).not.toContain('myNamespaces.delete') + expect(html).not.toContain('myNamespaces.freeze') + expect(html).toContain('namespaceManagement.enter') }) it('hides the delete action when the namespace is not deletable', () => { diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index 48f60f7d..6de8b527 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -7,145 +7,12 @@ import { Card } from '@/shared/ui/card' import { NamespaceBadge } from '@/shared/components/namespace-badge' import { EmptyState } from '@/shared/components/empty-state' import { Pagination } from '@/shared/components/pagination' -import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { CreateNamespaceDialog } from '@/features/namespace/create-namespace-dialog' -import { InvitationControls } from '@/features/namespace/invitation-controls' -import { - useArchiveNamespace, - useDeleteNamespace, - useFreezeNamespace, - useMyNamespacesPage, - useRestoreNamespace, - useUnfreezeNamespace, -} from '@/shared/hooks/use-namespace-queries' -import { toast } from '@/shared/lib/toast' +import { useMyNamespacesPage } from '@/shared/hooks/use-namespace-queries' +export { executeNamespaceAction, resolveNamespaceActionCopy } from '@/features/namespace/namespace-actions' -type PendingNamespaceAction = - | { action: 'freeze'; slug: string; name: string } - | { action: 'unfreeze'; slug: string; name: string } - | { action: 'archive'; slug: string; name: string } - | { action: 'restore'; slug: string; name: string } - | { action: 'delete'; slug: string; name: string } - -type NamespaceActionCopy = { - title: string - description: string - confirmText: string - successTitle: string - successDescription: string - errorTitle: string - variant: 'default' | 'destructive' -} - -type NamespaceActionMutation = { - mutateAsync: (input: { slug: string }) => Promise -} - -type NamespaceActionMutations = { - freeze: NamespaceActionMutation - unfreeze: NamespaceActionMutation - archive: NamespaceActionMutation - restore: NamespaceActionMutation - delete: NamespaceActionMutation -} - -type NamespaceActionToast = { - success: (title: string, description?: string) => void - error: (title: string, description?: string) => void -} - -export function resolveNamespaceActionCopy( - t: (key: string, options?: Record) => string, - action: PendingNamespaceAction['action'], - name: string, -): NamespaceActionCopy { - if (action === 'freeze') { - return { - title: t('myNamespaces.freezeConfirmTitle'), - description: t('myNamespaces.freezeConfirmDescription', { name }), - confirmText: t('myNamespaces.freeze'), - successTitle: t('myNamespaces.freezeSuccessTitle'), - successDescription: t('myNamespaces.freezeSuccessDescription', { name }), - errorTitle: t('myNamespaces.freezeErrorTitle'), - variant: 'default', - } - } - if (action === 'unfreeze') { - return { - title: t('myNamespaces.unfreezeConfirmTitle'), - description: t('myNamespaces.unfreezeConfirmDescription', { name }), - confirmText: t('myNamespaces.unfreeze'), - successTitle: t('myNamespaces.unfreezeSuccessTitle'), - successDescription: t('myNamespaces.unfreezeSuccessDescription', { name }), - errorTitle: t('myNamespaces.unfreezeErrorTitle'), - variant: 'default', - } - } - if (action === 'archive') { - return { - title: t('myNamespaces.archiveConfirmTitle'), - description: t('myNamespaces.archiveConfirmDescription', { name }), - confirmText: t('myNamespaces.archive'), - successTitle: t('myNamespaces.archiveSuccessTitle'), - successDescription: t('myNamespaces.archiveSuccessDescription', { name }), - errorTitle: t('myNamespaces.archiveErrorTitle'), - variant: 'destructive', - } - } - if (action === 'delete') { - return { - title: t('myNamespaces.deleteConfirmTitle'), - description: t('myNamespaces.deleteConfirmDescription', { name }), - confirmText: t('myNamespaces.delete'), - successTitle: t('myNamespaces.deleteSuccessTitle'), - successDescription: t('myNamespaces.deleteSuccessDescription', { name }), - errorTitle: t('myNamespaces.deleteErrorTitle'), - variant: 'destructive', - } - } - return { - title: t('myNamespaces.restoreConfirmTitle'), - description: t('myNamespaces.restoreConfirmDescription', { name }), - confirmText: t('myNamespaces.restore'), - successTitle: t('myNamespaces.restoreSuccessTitle'), - successDescription: t('myNamespaces.restoreSuccessDescription', { name }), - errorTitle: t('myNamespaces.restoreErrorTitle'), - variant: 'default', - } -} - -export async function executeNamespaceAction( - pendingAction: PendingNamespaceAction, - mutations: NamespaceActionMutations, - copy: NamespaceActionCopy, - notifications: NamespaceActionToast, -) { - try { - if (pendingAction.action === 'freeze') { - await mutations.freeze.mutateAsync({ slug: pendingAction.slug }) - } else if (pendingAction.action === 'unfreeze') { - await mutations.unfreeze.mutateAsync({ slug: pendingAction.slug }) - } else if (pendingAction.action === 'archive') { - await mutations.archive.mutateAsync({ slug: pendingAction.slug }) - } else if (pendingAction.action === 'delete') { - await mutations.delete.mutateAsync({ slug: pendingAction.slug }) - } else { - await mutations.restore.mutateAsync({ slug: pendingAction.slug }) - } - - notifications.success(copy.successTitle, copy.successDescription) - } catch (error) { - notifications.error(copy.errorTitle, error instanceof Error ? error.message : '') - throw error - } -} - -/** - * Dashboard page for namespaces the current user can manage or review. It owns - * namespace lifecycle actions because each action combines permissions, copy, - * and optimistic follow-up behavior that are specific to this route. - */ +/** Namespace summaries; management actions live inside the namespace. */ const PAGE_SIZE = 10 export function MyNamespacesPage() { @@ -153,7 +20,6 @@ export function MyNamespacesPage() { const { t } = useTranslation() const { hasRole } = useAuth() const canCreateNamespace = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') - const [pendingAction, setPendingAction] = useState(null) const [page, setPage] = useState(0) const { data: namespacePage, isLoading } = useMyNamespacesPage({ page, size: PAGE_SIZE }) const namespaces = namespacePage?.items ?? [] @@ -164,24 +30,8 @@ export function MyNamespacesPage() { setPage(Math.max(totalPages - 1, 0)) } }, [page, totalPages]) - const freezeMutation = useFreezeNamespace() - const unfreezeMutation = useUnfreezeNamespace() - const archiveMutation = useArchiveNamespace() - const restoreMutation = useRestoreNamespace() - const deleteMutation = useDeleteNamespace() - const handleNamespaceClick = (slug: string) => { - navigate({ to: `/space/${encodeURIComponent(slug)}` }) - } - - const handleMembersClick = (slug: string, e: React.MouseEvent) => { - e.stopPropagation() - navigate({ to: `/dashboard/namespaces/${encodeURIComponent(slug)}/members` }) - } - - const handleReviewsClick = (slug: string, e: React.MouseEvent) => { - e.stopPropagation() - navigate({ to: `/dashboard/namespaces/${encodeURIComponent(slug)}/reviews` }) + navigate({ to: `/dashboard/namespaces/${encodeURIComponent(slug)}` }) } const resolveStatusLabel = (status: string) => { @@ -217,27 +67,6 @@ export function MyNamespacesPage() { return t('myNamespaces.activeHint') } - const handleNamespaceAction = async () => { - if (!pendingAction) { - return - } - - const copy = resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name) - await executeNamespaceAction( - pendingAction, - { - freeze: freezeMutation, - unfreeze: unfreezeMutation, - archive: archiveMutation, - restore: restoreMutation, - delete: deleteMutation, - }, - copy, - toast, - ) - setPendingAction(null) - } - if (isLoading) { return (
@@ -267,8 +96,7 @@ export function MyNamespacesPage() { handleNamespaceClick(namespace.slug)} + className={`flex h-full flex-col p-5 group animate-fade-up delay-${Math.min(idx + 1, 6)}`} >
@@ -293,92 +121,14 @@ export function MyNamespacesPage() { {resolveHint(namespace.status, namespace.type)}
- {t('myNamespaces.roleLabel')}: {namespace.currentUserRole ?? t('myNamespaces.roleUnknown')} + {t('myNamespaces.roleLabel')}: {namespace.currentUserRole === 'OWNER' ? t('members.roleOwner') : namespace.currentUserRole === 'ADMIN' ? t('members.roleAdmin') : namespace.currentUserRole === 'MEMBER' ? t('members.roleMember') : t('myNamespaces.roleUnknown')}
-
- {namespace.type === 'TEAM' && ( - - )} - {namespace.type === 'TEAM' && namespace.status === 'ACTIVE' - && (namespace.currentUserRole === 'OWNER' || namespace.currentUserRole === 'ADMIN') && ( - - )} - - {namespace.canFreeze && ( - - )} - {namespace.canUnfreeze && ( - - )} - {namespace.canArchive && ( - - )} - {namespace.canRestore && ( - - )} - {namespace.canDelete && ( - - )}
@@ -392,29 +142,14 @@ export function MyNamespacesPage() { - )} + ) : undefined} /> )} - { - if (!open) { - setPendingAction(null) - } - }} - title={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).title : ''} - description={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).description : ''} - confirmText={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).confirmText : undefined} - variant={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).variant : 'default'} - contentTestId={pendingAction ? `namespace-action-dialog-${pendingAction.action}` : undefined} - confirmButtonTestId={pendingAction ? `namespace-action-confirm-${pendingAction.action}` : undefined} - onConfirm={handleNamespaceAction} - />
) } diff --git a/web/src/pages/dashboard/namespace-management.tsx b/web/src/pages/dashboard/namespace-management.tsx new file mode 100644 index 00000000..618c738c --- /dev/null +++ b/web/src/pages/dashboard/namespace-management.tsx @@ -0,0 +1,17 @@ +import { useParams } from '@tanstack/react-router' +import { NamespaceHeader } from '@/features/namespace/namespace-header' +import { NamespaceManagementNav } from '@/features/namespace/namespace-management-nav' +import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' +import { NamespaceSkills } from '@/features/namespace/namespace-skills' + +export function NamespaceManagementPage() { + const { slug } = useParams({ from: '/dashboard/namespaces/$slug' }) + const { data: namespace } = useNamespaceDetail(slug) + return ( +
+ {namespace ? : null} + + +
+ ) +} diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx index 4b660726..fbe79014 100644 --- a/web/src/pages/dashboard/namespace-members.tsx +++ b/web/src/pages/dashboard/namespace-members.tsx @@ -5,10 +5,11 @@ import { formatLocalDateTime } from '@/shared/lib/date-time' import { AddNamespaceMemberDialog } from '@/features/namespace/add-namespace-member-dialog' import { BatchImportMembersDialog } from '@/features/namespace/batch-import-members-dialog' import { InvitationControls } from '@/features/namespace/invitation-controls' +import { NamespaceManagementNav } from '@/features/namespace/namespace-management-nav' +import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from '@/shared/ui/dialog' import { NamespaceHeader } from '@/features/namespace/namespace-header' import { TransferOwnershipDialog } from '@/features/namespace/transfer-ownership-dialog' import { ConfirmDialog } from '@/shared/components/confirm-dialog' -import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Pagination } from '@/shared/components/pagination' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' @@ -152,11 +153,8 @@ export function NamespaceMembersPage() { return (
- +
{readOnlyMessage ? ( @@ -165,8 +163,17 @@ export function NamespaceMembersPage() { ) : null} -
- {canManageMembers ? : null} +
+ {canManageMembers ? ( + + + + {t('namespaceManagement.invite')} + {t('invitation.linkHint')} + + + + ) : null} {canTransferOwnership ? ( diff --git a/web/src/pages/dashboard/namespace-reviews.test.ts b/web/src/pages/dashboard/namespace-reviews.test.ts index 9868c27d..ff1a86c3 100644 --- a/web/src/pages/dashboard/namespace-reviews.test.ts +++ b/web/src/pages/dashboard/namespace-reviews.test.ts @@ -67,7 +67,7 @@ vi.mock('@/features/namespace/namespace-header', () => ({ NamespaceHeader: () => null, })) -vi.mock('@/features/skill/skill-avatar-review', () => ({ SkillAvatarReviewQueue: () => null })) +vi.mock('@/features/namespace/namespace-management-nav', () => ({ NamespaceManagementNav: ({ slug, active }: { slug: string; active: string }) => createElement('nav', { 'data-slug': slug, 'data-active': active }) })) import { NamespaceReviewsPage } from './namespace-reviews' @@ -129,6 +129,8 @@ describe('NamespaceReviewsPage', () => { it('renders pagination for namespace review list when totalPages > 1', () => { const html = renderToStaticMarkup(createElement(NamespaceReviewsPage)) + expect(html).toContain('data-active="reviews"') + expect(html).toContain('data-slug="test-ns"') expect(html).toContain('nsReviews.pageSummary') expect(html).toContain('/dashboard/namespaces/test-ns/reviews/1') expect(html).toContain('nsReviews.openReview') @@ -173,4 +175,13 @@ describe('NamespaceReviewsPage', () => { expect(call[5]).toBe(false) } }) + + it('lists avatar-only tasks in the same queue with their existing review detail link', () => { + useReviewListMock.mockReturnValue({ data: { items: [{ ...createReviewItem(9), subjectType: 'SKILL_METADATA', version: null, avatarRemoval: true }], totalElements: 1, totalPages: 1 }, isLoading: false }) + const html = renderToStaticMarkup(createElement(NamespaceReviewsPage)) + expect(html).toContain('review.avatarRemoval') + expect(html).toContain('/dashboard/namespaces/test-ns/reviews/9') + expect(html).not.toContain('skillAvatar.reviewTitle') + }) + }) diff --git a/web/src/pages/dashboard/namespace-reviews.tsx b/web/src/pages/dashboard/namespace-reviews.tsx index 2981cc35..f95a138e 100644 --- a/web/src/pages/dashboard/namespace-reviews.tsx +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' import { useReviewList } from '@/features/review/use-review-list' -import { SkillAvatarReviewQueue } from '@/features/skill/skill-avatar-review' +import { NamespaceManagementNav } from '@/features/namespace/namespace-management-nav' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Pagination } from '@/shared/components/pagination' import { NamespaceHeader } from '@/features/namespace/namespace-header' @@ -81,7 +81,7 @@ function ReviewListSection({ namespaceId, slug }: { namespaceId?: number; slug:
{review.namespace}/{review.skillSlug}
-
{t('nsReviews.version', { version: review.version })}
+
{review.subjectType === 'SKILL_METADATA' ? t(review.avatarRemoval ? 'review.avatarRemoval' : 'review.avatarUpdate') : t('nsReviews.version', { version: review.version })}
{formatLocalDateTime( @@ -162,7 +162,7 @@ export function NamespaceReviewsPage() { {readOnlyMessage} ) : null} - +
) diff --git a/web/src/pages/dashboard/namespace-settings.test.tsx b/web/src/pages/dashboard/namespace-settings.test.tsx new file mode 100644 index 00000000..3419ae73 --- /dev/null +++ b/web/src/pages/dashboard/namespace-settings.test.tsx @@ -0,0 +1,63 @@ +import { createElement, type ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedNamespace } from '@/api/types' + +const { state } = vi.hoisted(() => ({ state: { namespace: null as ManagedNamespace | null, roles: [] as string[] } })) +vi.mock('@tanstack/react-router', () => ({ + useParams: () => ({ slug: 'team' }), + useNavigate: () => vi.fn(), + Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => createElement('a', { ...props, href: to }, children), +})) +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) +vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => ({ hasRole: (role: string) => state.roles.includes(role) }) })) +vi.mock('@/features/namespace/namespace-header', () => ({ NamespaceHeader: () => null })) +vi.mock('@/shared/components/confirm-dialog', () => ({ ConfirmDialog: () => null })) +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useNamespaceDetail: () => ({ data: state.namespace, isLoading: false }), + useMyNamespaces: () => ({ data: state.namespace ? [state.namespace] : [] }), + useArchiveNamespace: () => ({ mutateAsync: vi.fn() }), + useDeleteNamespace: () => ({ mutateAsync: vi.fn() }), + useFreezeNamespace: () => ({ mutateAsync: vi.fn() }), + useRestoreNamespace: () => ({ mutateAsync: vi.fn() }), + useUnfreezeNamespace: () => ({ mutateAsync: vi.fn() }), +})) +import { NamespaceSettingsPage } from './namespace-settings' +import { NamespaceManagementNav } from '@/features/namespace/namespace-management-nav' + +beforeEach(() => { + state.roles = [] + state.namespace = { id: 2, slug: 'team', displayName: 'Team', type: 'TEAM', status: 'ACTIVE', createdAt: '', immutable: false, currentUserRole: 'OWNER', canFreeze: true, canUnfreeze: false, canArchive: true, canRestore: false, canDelete: false } +}) + +describe('Namespace management permissions', () => { + it('shows only lifecycle actions the server permits in settings', () => { + const html = renderToStaticMarkup(createElement(NamespaceSettingsPage)) + expect(html).toContain('freeze-namespace-team') + expect(html).toContain('archive-namespace-team') + expect(html).not.toContain('delete-namespace-team') + expect(html).not.toContain('restore-namespace-team') + expect(html).not.toContain('unfreeze-namespace-team') + }) + it('never exposes lifecycle changes for the global namespace', () => { + Object.assign(state.namespace!, { type: 'GLOBAL', canDelete: true, canFreeze: true, canArchive: true }) + const html = renderToStaticMarkup(createElement(NamespaceSettingsPage)) + expect(html).toContain('namespaceManagement.globalReadOnly') + expect(html).not.toContain('freeze-namespace-team') + expect(html).not.toContain('archive-namespace-team') + expect(html).not.toContain('delete-namespace-team') + }) + it('lets platform skill reviewers access reviews without exposing member management', () => { + Object.assign(state.namespace!, { currentUserRole: undefined }) + state.roles = ['SKILL_ADMIN'] + const html = renderToStaticMarkup(createElement(NamespaceManagementNav, { slug: 'team', active: 'reviews' })) + expect(html).toContain('namespaceManagement.reviews') + expect(html).not.toContain('namespaceManagement.members') + }) + it('does not offer team review access to ordinary members', () => { + Object.assign(state.namespace!, { currentUserRole: 'MEMBER' }) + const html = renderToStaticMarkup(createElement(NamespaceManagementNav, { slug: 'team', active: 'members' })) + expect(html).toContain('namespaceManagement.members') + expect(html).not.toContain('namespaceManagement.reviews') + }) +}) diff --git a/web/src/pages/dashboard/namespace-settings.tsx b/web/src/pages/dashboard/namespace-settings.tsx new file mode 100644 index 00000000..06a0e2d2 --- /dev/null +++ b/web/src/pages/dashboard/namespace-settings.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react' +import { useNavigate, useParams } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { NamespaceHeader } from '@/features/namespace/namespace-header' +import { NamespaceManagementNav } from '@/features/namespace/namespace-management-nav' +import { executeNamespaceAction, resolveNamespaceActionCopy, type PendingNamespaceAction } from '@/features/namespace/namespace-actions' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { EmptyState } from '@/shared/components/empty-state' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' +import { useArchiveNamespace, useDeleteNamespace, useFreezeNamespace, useMyNamespaces, useNamespaceDetail, useRestoreNamespace, useUnfreezeNamespace } from '@/shared/hooks/use-namespace-queries' +import { toast } from '@/shared/lib/toast' + +export function NamespaceSettingsPage() { + const { slug } = useParams({ from: '/dashboard/namespaces/$slug/settings' }) + const { t } = useTranslation() + const navigate = useNavigate() + const { data: namespace, isLoading } = useNamespaceDetail(slug) + const { data: namespaces } = useMyNamespaces() + const managed = namespaces?.find((item) => item.slug === slug) + const [pending, setPending] = useState(null) + const mutations = { freeze: useFreezeNamespace(), unfreeze: useUnfreezeNamespace(), archive: useArchiveNamespace(), restore: useRestoreNamespace(), delete: useDeleteNamespace() } + const copy = pending ? resolveNamespaceActionCopy(t, pending.action, pending.name) : null + const actions = [ + { action: 'freeze', allowed: managed?.canFreeze }, + { action: 'unfreeze', allowed: managed?.canUnfreeze }, + { action: 'archive', allowed: managed?.canArchive }, + { action: 'restore', allowed: managed?.canRestore }, + { action: 'delete', allowed: managed?.canDelete }, + ] as const + if (isLoading) return
+ if (!namespace) return + const canEdit = namespace.type === 'TEAM' && namespace.status === 'ACTIVE' && (managed?.currentUserRole === 'OWNER' || managed?.currentUserRole === 'ADMIN') + return ( +
+ + + +

{t('namespaceManagement.lifecycle')}

+

{t(namespace.type === 'GLOBAL' ? 'namespaceManagement.globalReadOnly' : 'namespaceManagement.lifecycleHint')}

+ {namespace.type === 'TEAM' ? actions.filter((item) => item.allowed).map(({ action }) => ( +
+

{t(`namespaceManagement.${action}Hint`)}

+ +
+ )) : null} +
+ { if (!open) setPending(null) }} + title={copy?.title ?? ''} description={copy?.description ?? ''} confirmText={copy?.confirmText} variant={copy?.variant} + contentTestId={pending ? `namespace-action-dialog-${pending.action}` : undefined} + confirmButtonTestId={pending ? `namespace-action-confirm-${pending.action}` : undefined} + onConfirm={async () => { + if (!pending || !copy) return + await executeNamespaceAction(pending, mutations, copy, toast) + setPending(null) + if (pending.action === 'delete') await navigate({ to: '/dashboard/namespaces' }) + }} /> +
+ ) +} diff --git a/web/src/pages/dashboard/publish-avatar.test.tsx b/web/src/pages/dashboard/publish-avatar.test.tsx index 9526a269..6fff0678 100644 --- a/web/src/pages/dashboard/publish-avatar.test.tsx +++ b/web/src/pages/dashboard/publish-avatar.test.tsx @@ -92,7 +92,7 @@ describe('Publish avatar submission', () => { expect(mocks.submit).toHaveBeenCalledWith('team', 'foo', 'private-reference') expect(mocks.publish).not.toHaveBeenCalled() expect(mocks.refresh.mock.calls.map(([arg]) => arg.queryKey)).toEqual([ - ['skills'], ['skill-avatar', 'team', 'foo'], ['skill-avatar-review'], ['reviews'], + ['skills'], ['skill-avatar', 'team', 'foo'], ['reviews'], ]) }) diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 01619474..c02b5574 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -111,7 +111,6 @@ export function PublishPage() { await Promise.all([ queryClient.invalidateQueries({ queryKey: ['skills'], refetchType: avatarEdit.action === 'REMOVE' ? 'all' : 'active' }), queryClient.invalidateQueries({ queryKey: ['skill-avatar', namespaceSlug, updateSlug] }), - queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] }), queryClient.invalidateQueries({ queryKey: ['reviews'] }), ]) toast.success(t('publish.avatarSaved'), t(avatarEdit.action === 'REMOVE' ? 'skillAvatar.removedNotice' : 'skillAvatar.submittedNotice')) diff --git a/web/src/pages/dashboard/review-detail.test.tsx b/web/src/pages/dashboard/review-detail.test.tsx index da76b1d3..7ab5d70c 100644 --- a/web/src/pages/dashboard/review-detail.test.tsx +++ b/web/src/pages/dashboard/review-detail.test.tsx @@ -138,8 +138,9 @@ vi.mock('@/features/auth/use-auth', () => ({ })) // Mock hooks used directly by the review-detail page for file browser sidebar +const reviewFileMock = vi.fn((_taskId?: number, _path?: string | null, _enabled?: boolean) => ({ data: null, isLoading: false, error: null })) vi.mock('@/features/review/use-review-file', () => ({ - useReviewFile: () => ({ data: null, isLoading: false, error: null }), + useReviewFile: (...args: [number?, (string | null)?, boolean?]) => reviewFileMock(...args), })) vi.mock('@/api/client', () => ({ @@ -152,6 +153,7 @@ import { NamespaceReviewDetailPage, ReviewDetailPage } from './review-detail' describe('ReviewDetailPage', () => { beforeEach(() => { navigateMock.mockReset() + reviewFileMock.mockClear() userMock.platformRoles = ['SKILL_ADMIN'] useReviewDetailMock.mockReset() useReviewSkillDetailMock.mockReset() @@ -371,4 +373,58 @@ describe('ReviewDetailPage', () => { expect(html).toContain('review.approveDisabledScanning') expect(html).toContain('disabled=""') }) + + function metadataDetail(readyForApproval = true, removal = false) { + useReviewDetailMock.mockReturnValue({ data: { + id: 13, namespace: 'global', skillSlug: 'demo-skill', subjectType: 'SKILL_METADATA', + skillVersionId: null, version: null, status: 'PENDING', submittedBy: 'owner', submittedAt: '2026-03-19T00:00:00Z', + }, isLoading: false }) + useReviewSkillDetailMock.mockReturnValue({ data: { + skill: { id: 1, displayName: 'Demo Skill', slug: 'demo-skill', namespace: 'global' }, + versions: [], files: [], documentationPath: null, documentationContent: null, downloadUrl: null, activeVersion: null, + avatarChange: { candidateId: removal ? null : 42, currentAvatarUrl: '/current', candidatePreviewUrl: removal ? null : '/candidate', machineStatus: readyForApproval ? 'PASSED' : 'PROCESSING', reviewStatus: 'PENDING', readyForApproval, removal }, + }, isLoading: false, error: null }) + } + + it('renders metadata in the same task detail without package, file or version actions', () => { + metadataDetail() + const html = renderToStaticMarkup() + expect(html.match(/]*>review.avatarUpdate<\/h2>/g)).toHaveLength(1) + expect(html).not.toMatch(/]*>(?:(?!<\/p>)[\s\S])* { + metadataDetail(false) + const html = renderToStaticMarkup() + expect(html).toContain('review.approveDisabledAvatar') + expect(html).toMatch(/]*disabled=""[^>]*>review.approve<\/button>/) + expect(html).toContain('review.reject') + }) + + it('shows avatar removal as returning to the default avatar without a candidate preview', () => { + metadataDetail(true, true) + const html = renderToStaticMarkup() + expect(html).toContain('review.avatarRemoval') + expect(html).not.toContain('src="/candidate"') + expect(html).not.toContain('review.downloadSkillZip') + }) + + it('requires an explicit comment to override a machine-blocked avatar', () => { + metadataDetail() + const detail = useReviewSkillDetailMock() as { data: { avatarChange: Record }; isLoading: boolean; error: null } + useReviewSkillDetailMock.mockReturnValue({ ...detail, data: { ...detail.data, avatarChange: { ...detail.data.avatarChange, machineStatus: 'BLOCKED' } } }) + const html = renderToStaticMarkup() + expect(html).toContain('review.avatarOverrideReason') + expect(html).toMatch(/]*disabled=""[^>]*>review.approve<\/button>/) + }) + }) diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx index 8f5b8119..65c953ca 100644 --- a/web/src/pages/dashboard/review-detail.tsx +++ b/web/src/pages/dashboard/review-detail.tsx @@ -18,6 +18,7 @@ import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { toast } from '@/shared/lib/toast' import { cn } from '@/shared/lib/utils' import { resolveReviewActionErrorDescription } from '@/features/review/review-error' +import { ReviewAvatarChangeSection } from '@/features/review/review-avatar-change-section' import { ReviewSkillDetailSection } from '@/features/review/review-skill-detail-section' import { ReviewAttemptTimeline } from '@/features/review/review-attempt-timeline' import { SecurityAuditSection } from '@/features/security-audit/security-audit-section' @@ -62,6 +63,7 @@ function ReviewDetailScreen({ isError: isReviewAttemptsError, } = useReviewAttempts(taskId) const isSuiteReview = review?.subjectType === 'SUITE_VERSION' + const isMetadataReview = review?.subjectType === 'SKILL_METADATA' const { data: reviewSkillDetail, isLoading: isLoadingReviewSkillDetail, @@ -70,7 +72,7 @@ function ReviewDetailScreen({ const { data: reviewSuiteDetail, isLoading: isLoadingReviewSuiteDetail } = useSuiteDetail( review?.namespace || '', review?.subjectSlug || '', - review?.version, + review?.version ?? undefined, isSuiteReview, ) const approveMutation = useApproveReview({ @@ -111,7 +113,7 @@ function ReviewDetailScreen({ const { data: previewContent, isLoading: isLoadingPreview, error: previewError } = useReviewFile( taskId, previewNode?.path || null, - previewDialogOpen && !!previewNode + previewDialogOpen && !!previewNode && !isMetadataReview ) const handleFileClick = (node: FileTreeNode) => { @@ -146,6 +148,7 @@ function ReviewDetailScreen({ }, [navigate, review, shouldRedirectToNamespaceRoute]) const handleApprove = async () => { + if (isApprovalBlocked) return approveMutation.mutate({ taskId, comment: comment || undefined }) } @@ -199,7 +202,11 @@ function ReviewDetailScreen({ const activeReviewVersion = reviewSkillDetail?.versions?.find( (version) => version.version === reviewSkillDetail.activeVersion ) - const isApprovalBlockedByScanning = activeReviewVersion?.status === 'SCANNING' + const isApprovalBlockedByScanning = !isMetadataReview && activeReviewVersion?.status === 'SCANNING' + const isApprovalBlockedByAvatar = reviewSkillDetail?.avatarChange?.readyForApproval === false + const isAvatarOverrideReasonRequired = reviewSkillDetail?.avatarChange?.machineStatus === 'BLOCKED' && !comment.trim() + const isApprovalBlocked = isApprovalBlockedByScanning || isApprovalBlockedByAvatar || isAvatarOverrideReasonRequired + || (!isSuiteReview && (isLoadingReviewSkillDetail || !!reviewSkillDetailError || !reviewSkillDetail)) return (
@@ -230,7 +237,7 @@ function ReviewDetailScreen({

- {review.version} + {isMetadataReview ? t('review.metadataChange') : review.version}

@@ -291,6 +298,8 @@ function ReviewDetailScreen({ )} + {reviewSkillDetail?.avatarChange && } + {review.status === 'PENDING' && (

{t('review.actions')}

@@ -309,12 +318,12 @@ function ReviewDetailScreen({
@@ -356,13 +365,17 @@ function ReviewDetailScreen({

{t('review.approveDisabledScanning')}

)} + {isAvatarOverrideReasonRequired &&

{t('review.avatarOverrideReason')}

} + + {isApprovalBlockedByAvatar &&

{t('review.approveDisabledAvatar')}

} + {showRejectForm && !comment.trim() && (

{t('review.rejectReasonRequired')}

)} )} - {!isSuiteReview && (() => { + {!isSuiteReview && !isMetadataReview && (() => { const skillId = reviewSkillDetail?.skill?.id const versionId = reviewSkillDetail?.versions?.find((v) => v.version === review.version)?.id ?? @@ -404,14 +417,14 @@ function ReviewDetailScreen({

{t('suite.reviewSnapshotFailed')}

)} - ) : ( + ) : !isMetadataReview ? ( - )} + ) : null} {/* Sidebar — file browser for the review-bound active version */} - } {/* File preview dialog */} - {!isSuiteReview ? } {t(isSuite ? 'suite.workspace.resourceSuite' : 'suite.workspace.resourceSkill')} - v{item.skillVersion} + {isMetadata ? t('review.metadataChange') : `v${item.skillVersion}`} {t(`reviewProgress.status${status === 'PENDING' ? 'Pending' : status === 'APPROVED' ? 'Approved' : 'Rejected'}`)} @@ -262,8 +263,7 @@ function ProgressItem({ to="/dashboard/publish" search={{ namespace: item.namespace, - resubmitSkill: slug, - resubmitVersion: item.skillVersion, + ...(isMetadata ? { updateSkill: slug } : { resubmitSkill: slug, resubmitVersion: item.skillVersion }), }} className={cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'gap-2')} > diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index d5e181e5..b52605fa 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -91,7 +91,6 @@ vi.mock('./profile-review-table', () => ({ ProfileReviewTable: () => null, })) -vi.mock('@/features/skill/skill-avatar-review', () => ({ SkillAvatarReviewQueue: () => null })) import { ReviewsPage } from './reviews' diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index c14255b3..a518c3a2 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -25,7 +25,6 @@ import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Pagination } from '@/shared/components/pagination' import { formatLocalDateTime } from '@/shared/lib/date-time' import { ProfileReviewTable } from './profile-review-table' -import { SkillAvatarReviewQueue } from '@/features/skill/skill-avatar-review' type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' type TimeSortDirection = 'ASC' | 'DESC' @@ -165,7 +164,7 @@ export function ReviewsPage() { {review.namespace}/{review.subjectSlug || review.skillSlug} - {review.version} + {review.subjectType === 'SKILL_METADATA' ? t(review.avatarRemoval ? 'review.avatarRemoval' : 'review.avatarUpdate') : review.version} {review.submittedByName || review.submittedBy} {status === 'PENDING' ? ( {formatDate(review.submittedAt)} @@ -266,7 +265,6 @@ export function ReviewsPage() {
- {isSkillAdmin && } {showTypeTabs ? ( diff --git a/web/src/pages/join.test.tsx b/web/src/pages/join.test.tsx index c2452441..fc4698e4 100644 --- a/web/src/pages/join.test.tsx +++ b/web/src/pages/join.test.tsx @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -25,6 +25,7 @@ vi.mock('@/shared/lib/api-error', () => ({ import { ApiError } from '@/shared/lib/api-error' import { JoinPage } from './join' +import { captureInvitationFragment, storedInvitationToken } from '@/shared/lib/namespace-invitation' const token = 'A'.repeat(43) @@ -44,8 +45,8 @@ describe('JoinPage', () => { afterEach(cleanup) - it('clears the token and navigates once with replace when preview finds an existing member', async () => { - window.history.replaceState(null, '', `/join#${token}`) + it.each([token, `invite=${token}`])('clears fragment %s and navigates once for an existing member', async fragment => { + window.history.replaceState(null, '', `/join#${fragment}`) mocks.preview.mockResolvedValue({ namespaceSlug: 'team', namespaceDisplayName: 'Team', namespaceAvatarUrl: null, outcome: 'ALREADY_MEMBER', expiresAt: '2026-10-13T00:00:00Z', @@ -56,6 +57,8 @@ describe('JoinPage', () => { await waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith({ to: '/space/$namespace', params: { namespace: 'team' }, replace: true, })) + expect(mocks.preview).toHaveBeenCalledWith(token) + expect(mocks.accept).not.toHaveBeenCalled() expect(window.sessionStorage.length).toBe(0) expect(window.location.hash).toBe('') view.rerender() @@ -81,4 +84,38 @@ describe('JoinPage', () => { expect((await screen.findByRole('alert')).textContent).toBe('invitation.invalid') expect(window.sessionStorage.length).toBe(0) }) + + it('uses the named token for preview and only joins after explicit confirmation', async () => { + window.history.replaceState(null, '', `/join#invite=${token}`) + mocks.preview.mockResolvedValue({ + namespaceSlug: 'team', namespaceDisplayName: 'Team', namespaceAvatarUrl: null, + outcome: 'CONFIRM_REQUIRED', expiresAt: '2026-10-13T00:00:00Z', + }) + mocks.accept.mockResolvedValue({ namespaceSlug: 'team' }) + renderJoin() + const confirm = await screen.findByRole('button', { name: 'invitation.confirm' }) + expect(window.location.hash).toBe('') + expect(storedInvitationToken()).toBe(token) + expect(mocks.preview).toHaveBeenCalledWith(token) + expect(mocks.accept).not.toHaveBeenCalled() + fireEvent.click(confirm) + await waitFor(() => expect(mocks.accept).toHaveBeenCalledWith(token)) + await waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith({ + to: '/space/$namespace', params: { namespace: 'team' }, replace: true, + })) + expect(storedInvitationToken()).toBeNull() + }) + + it('rejects a new malformed named fragment instead of joining with a stored older token', () => { + window.history.replaceState(null, '', `/join#invite=${token}`) + captureInvitationFragment() + window.history.replaceState(null, '', '/join#invite=invalid') + renderJoin() + expect(screen.getByRole('alert').textContent).toBe('invitation.invalid') + expect(storedInvitationToken()).toBeNull() + expect(window.location.hash).toBe('') + expect(mocks.preview).not.toHaveBeenCalled() + expect(mocks.accept).not.toHaveBeenCalled() + }) + }) diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx index 275f7ba5..ab14839e 100644 --- a/web/src/pages/namespace.tsx +++ b/web/src/pages/namespace.tsx @@ -1,88 +1,8 @@ -import { useState, useEffect } from 'react' -import { useNavigate, useParams } from '@tanstack/react-router' -import { useTranslation } from 'react-i18next' -import { NamespaceHeader } from '@/features/namespace/namespace-header' -import { SkillCard } from '@/features/skill/skill-card' -import { SkeletonList } from '@/shared/components/skeleton-loader' -import { EmptyState } from '@/shared/components/empty-state' -import { Pagination } from '@/shared/components/pagination' -import { useSearchSkills } from '@/shared/hooks/use-skill-queries' -import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' +import { useParams } from '@tanstack/react-router' +import { NamespaceSkills } from '@/features/namespace/namespace-skills' -const PAGE_SIZE = 20 - -/** - * Public namespace page showing namespace metadata and the skills currently discoverable inside it. - */ +/** Public namespace metadata and discoverable skills. */ export function NamespacePage() { - const { t } = useTranslation() - const navigate = useNavigate() const { namespace } = useParams({ from: '/space/$namespace' }) - const [page, setPage] = useState(0) - - // Reset page when namespace changes - useEffect(() => { - setPage(0) - }, [namespace]) - - const { data: namespaceData, isLoading: isLoadingNamespace } = useNamespaceDetail(namespace) - const { data: skillsData, isLoading: isLoadingSkills } = useSearchSkills({ - namespace, - page, - size: PAGE_SIZE, - }) - - const totalPages = skillsData ? Math.max(Math.ceil(skillsData.total / skillsData.size), 1) : 1 - - const handleSkillClick = (slug: string) => { - navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}` }) - } - - if (isLoadingNamespace) { - return ( -
-
-
-
- ) - } - - if (!namespaceData) { - return - } - - return ( -
- - -
-

{t('namespace.skillList')}

- {isLoadingSkills ? ( - - ) : skillsData && skillsData.items.length > 0 ? ( - <> -
- {skillsData.items.map((skill, idx) => ( -
- handleSkillClick(skill.slug)} - /> -
- ))} -
- - {skillsData.total > PAGE_SIZE ? ( - - ) : null} - - ) : ( - - )} -
-
- ) + return } diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 03c0a523..cd4a0c8d 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -149,7 +149,6 @@ export function usePublishSkill() { await Promise.all([ queryClient.invalidateQueries({ queryKey: ['skills'], refetchType: variables.avatarAction === 'REMOVE' ? 'all' : 'active' }), queryClient.invalidateQueries({ queryKey: ['skill-avatar', result.namespace, result.slug] }), - queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] }), queryClient.invalidateQueries({ queryKey: ['reviews'] }), ]) }, diff --git a/web/src/shared/lib/namespace-invitation.test.ts b/web/src/shared/lib/namespace-invitation.test.ts index 09aa5b05..85b090dc 100644 --- a/web/src/shared/lib/namespace-invitation.test.ts +++ b/web/src/shared/lib/namespace-invitation.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { captureInvitationFragment, clearInvitationToken, invitationUrl, storedInvitationToken } from './namespace-invitation' const token = 'A'.repeat(43) @@ -10,8 +10,10 @@ describe('namespace invitation URL handoff', () => { window.history.replaceState(null, '', '/') }) - it('moves a token from the fragment to tab-scoped storage before login routing', () => { - window.history.replaceState(null, '', `/join?from=mail#${token}`) + afterEach(() => { vi.unstubAllEnvs(); vi.resetModules() }) + + it.each([token, `invite=${token}`])('captures fragment %s in tab storage before login routing', fragment => { + window.history.replaceState(null, '', `/join?from=mail#${fragment}`) captureInvitationFragment() expect(storedInvitationToken()).toBe(token) expect(window.location.pathname).toBe('/join') @@ -27,6 +29,66 @@ describe('namespace invitation URL handoff', () => { }) it('uses a resolved public base path in the shared link', () => { - expect(invitationUrl('https://example.com/skillhub', token)).toBe(`https://example.com/skillhub/join#${token}`) + expect(invitationUrl('https://example.com/skillhub', token)).toBe(`https://example.com/skillhub/join#invite=${token}`) }) + + it.each(['bad-token', 'invite=bad-token', `invite=${token}&extra=1`, `invite=${token}&invite=${token}`, `other=${token}`, `invite=%41${'A'.repeat(42)}`])('clears an earlier stored invitation when a new fragment is malformed: %s', fragment => { + window.history.replaceState(null, '', `/join#${token}`) + captureInvitationFragment() + expect(storedInvitationToken()).toBe(token) + window.history.replaceState(null, '', `/join?from=mail#${fragment}`) + captureInvitationFragment() + expect(storedInvitationToken()).toBeNull() + expect(window.location.hash).toBe('') + expect(window.location.search).toBe('?from=mail') + }) + + it('clears an earlier stored token for an explicit empty fragment', () => { + window.history.replaceState(null, '', `/join#invite=${token}`) + captureInvitationFragment() + window.history.replaceState(null, '', '/join#') + expect(window.location.hash).toBe('') + expect(window.location.href.endsWith('#')).toBe(true) + captureInvitationFragment() + expect(storedInvitationToken()).toBeNull() + expect(window.location.href.endsWith('#')).toBe(false) + }) + + it('replaces an earlier invitation with the new named token', () => { + window.history.replaceState(null, '', `/join#${token}`) + captureInvitationFragment() + const next = '_'.repeat(43) + window.history.replaceState(null, '', `/join#invite=${next}`) + captureInvitationFragment() + expect(storedInvitationToken()).toBe(next) + }) + + it('keeps the stored invitation after returning from login without a fragment', () => { + window.history.replaceState(null, '', `/join#invite=${token}`) + captureInvitationFragment() + window.history.replaceState(null, '', '/join') + captureInvitationFragment() + expect(storedInvitationToken()).toBe(token) + }) + + it('does not capture or clear fragments belonging to other routes', () => { + window.history.replaceState(null, '', `/search#invite=${token}`) + captureInvitationFragment() + expect(storedInvitationToken()).toBeNull() + expect(window.location.hash).toBe(`#invite=${token}`) + }) + + it('captures and clears the named token under a deployment base path', async () => { + vi.stubEnv('BASE_URL', '/skillhub/') + vi.resetModules() + const invitation = await import('./namespace-invitation') + window.history.replaceState({ from: 'mail' }, '', `/skillhub/join?from=mail#invite=${token}`) + invitation.captureInvitationFragment() + expect(invitation.storedInvitationToken()).toBe(token) + expect(window.location.pathname).toBe('/skillhub/join') + expect(window.location.search).toBe('?from=mail') + expect(window.location.hash).toBe('') + expect(window.history.state).toEqual({ from: 'mail' }) + }) + }) diff --git a/web/src/shared/lib/namespace-invitation.ts b/web/src/shared/lib/namespace-invitation.ts index 9a1b9140..9bdc7231 100644 --- a/web/src/shared/lib/namespace-invitation.ts +++ b/web/src/shared/lib/namespace-invitation.ts @@ -4,11 +4,13 @@ const STORAGE_KEY = 'skillhub.namespace.invitation' // Run before route guards inspect the location: a login redirect must never copy the fragment. export function captureInvitationFragment() { - if (typeof window === 'undefined' || !window.location.hash) return + if (typeof window === 'undefined' || !window.location.href.includes('#')) return if (toRouterPath(window.location.pathname) !== '/join') return - const token = window.location.hash.slice(1) + const fragment = window.location.hash.slice(1) + const token = fragment.startsWith('invite=') ? fragment.slice('invite='.length) : fragment try { if (/^[A-Za-z0-9_-]{43}$/.test(token)) window.sessionStorage.setItem(STORAGE_KEY, token) + else window.sessionStorage.removeItem(STORAGE_KEY) } finally { window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}`) } @@ -24,5 +26,5 @@ export function clearInvitationToken() { } export function invitationUrl(publicBase: string, token: string): string { - return `${publicBase}/join#${token}` + return `${publicBase}/join#invite=${token}` } From 3de13c033faa4365542519b76c2d9bdcc9cb5146 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:39:00 +0800 Subject: [PATCH 33/47] style(namespace): remove trailing empty line Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- web/src/features/namespace/namespace-actions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/features/namespace/namespace-actions.ts b/web/src/features/namespace/namespace-actions.ts index c2f23da5..f8bdb48f 100644 --- a/web/src/features/namespace/namespace-actions.ts +++ b/web/src/features/namespace/namespace-actions.ts @@ -117,4 +117,3 @@ export async function executeNamespaceAction( throw error } } - From efbb50f4aa6df06d28814593d40e923159ccf59f Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:16:01 +0800 Subject: [PATCH 34/47] fix(namespace): refresh invitation state before creating link Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../specs/namespace-invitations/spec.md | 5 +++ .../namespace/invitation-controls.test.tsx | 37 +++++++++++++++++++ .../namespace/invitation-controls.tsx | 8 ++-- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md index c384d4d6..da305791 100644 --- a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md +++ b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md @@ -116,3 +116,8 @@ API SHALL 使用 Issue #911 的路径与响应合同。 - **THEN** 按原方式安全捕获并清除片段,保持兼容 - **WHEN** 新的具名片段格式无效 - **THEN** 清除先前暂存凭证,不能误用上一条邀请;无片段的登录回跳仍恢复合法暂存邀请 + +#### Scenario: 他人消费邀请后生成新链接 +- **WHEN** 他人已消费邀请,而管理员页面还缓存旧的可兑换状态 +- **THEN** 点击生成先刷新服务端状态,已消费或过期的邀请不误弹替换确认,仍可兑换的邀请必须确认 +- **AND** 状态刷新失败时不沿用旧缓存生成或替换,显示可重试错误 diff --git a/web/src/features/namespace/invitation-controls.test.tsx b/web/src/features/namespace/invitation-controls.test.tsx index 9397b03e..df80128e 100644 --- a/web/src/features/namespace/invitation-controls.test.tsx +++ b/web/src/features/namespace/invitation-controls.test.tsx @@ -34,6 +34,10 @@ describe('InvitationControls', () => { api.createInvitation.mockRejectedValue(new Error('unavailable')) api.revokeInvitation.mockRejectedValue(new Error('unavailable')) const dialog = await openConfirmation(action) + if (action === 'replace') { + expect(api.invitationStatus).toHaveBeenCalledTimes(2) + expect(api.createInvitation).not.toHaveBeenCalled() + } fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.confirm' })) @@ -41,4 +45,37 @@ describe('InvitationControls', () => { expect(screen.getByRole('alert').textContent).toBe('invitation.error') expect(action === 'replace' ? api.createInvitation : api.revokeInvitation).toHaveBeenCalledTimes(1) }) + + it.each(['consumed', 'expired'])('creates without a replacement prompt when the cached link was %s', async () => { + api.invitationStatus.mockResolvedValueOnce({ active: true }).mockResolvedValue({ active: false }) + api.createInvitation.mockImplementation(() => new Promise(() => {})) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + const createButton = screen.getByRole('button', { name: 'invitation.create' }) + await waitFor(() => expect((createButton as HTMLButtonElement).disabled).toBe(false)) + expect(screen.getByRole('button', { name: 'invitation.revoke' })).toBeTruthy() + + fireEvent.click(createButton) + + await waitFor(() => expect(api.createInvitation).toHaveBeenCalledWith('team', false)) + expect(api.invitationStatus).toHaveBeenCalledTimes(2) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('does not create or confirm replacement when the fresh status fails despite cached active data', async () => { + api.invitationStatus.mockResolvedValueOnce({ active: true }).mockRejectedValue(new Error('unavailable')) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + const createButton = screen.getByRole('button', { name: 'invitation.create' }) + await waitFor(() => expect((createButton as HTMLButtonElement).disabled).toBe(false)) + + fireEvent.click(createButton) + + await screen.findByRole('alert') + expect(api.invitationStatus).toHaveBeenCalledTimes(2) + expect(api.createInvitation).not.toHaveBeenCalled() + expect(api.revokeInvitation).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog')).toBeNull() + }) + }) diff --git a/web/src/features/namespace/invitation-controls.tsx b/web/src/features/namespace/invitation-controls.tsx index c407aa3d..043e59c5 100644 --- a/web/src/features/namespace/invitation-controls.tsx +++ b/web/src/features/namespace/invitation-controls.tsx @@ -41,9 +41,11 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co }, }) const handleCreate = async () => { - const current = compact ? (await status.refetch()).data : status.data - if (current?.active) setConfirm('replace') - else if (current) create.mutate(false) + const result = await status.refetch() + if (result.isError || !result.data) return + const current = result.data + if (current.active) setConfirm('replace') + else create.mutate(false) } return (
event.stopPropagation() : undefined}> From be1529aedab4cd81da4c5d833adb95d4a472f322 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:29:03 +0800 Subject: [PATCH 35/47] fix(review): refresh moderation queue on entry Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../specs/skill-avatars/spec.md | 5 ++ .../features/review/use-review-list.test.ts | 65 +++++++++++++++++++ web/src/features/review/use-review-list.ts | 1 + 3 files changed, 71 insertions(+) diff --git a/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md index 4981b7be..db4b0d4b 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md +++ b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md @@ -197,3 +197,8 @@ PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使 - **WHEN** 已有相同图片的机器通过/阻止记录,所有者提交新申请或审核策略已改变 - **THEN** 新申请重新调用配置的供应商,不命中跨候选缓存 - **AND** 每次调用及结果记录实际服务/API版本和策略revision,未知模型版本不虚假回填 + +#### Scenario: 跨浏览器提交后重新进入审核列表 +- **WHEN** 审核员曾看到空列表,其他登录会话随后提交新申请,审核员重新进入列表 +- **THEN** 重新获取当前申请,不能仅以此前缓存的空列表显示“暂无审核记录” +- **AND** 命名空间、状态、分页和排序仍各自隔离,不新增轮询或扩大权限 diff --git a/web/src/features/review/use-review-list.test.ts b/web/src/features/review/use-review-list.test.ts index 737d333c..d4e69a84 100644 --- a/web/src/features/review/use-review-list.test.ts +++ b/web/src/features/review/use-review-list.test.ts @@ -1,6 +1,15 @@ +/** @vitest-environment jsdom */ import { afterEach, describe, expect, it, vi } from 'vitest' +import { createElement, type ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, renderHook, waitFor } from '@testing-library/react' +import { reviewApi } from '@/api/client' +import type { ReviewTask } from '@/api/types' +import { useReviewList } from './use-review-list' afterEach(() => { + cleanup() + vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -49,3 +58,59 @@ describe('reviewApi.list response mapping', () => { expect(response.items).toEqual([]) }) }) + + +function reviewTask(id: number): ReviewTask { + return { id, skillVersionId: id, namespace: 'team', version: '1.0', status: 'PENDING', submittedBy: 'owner', submittedAt: '2026-10-13T00:00:00Z' } +} + +function queryWrapper(client: QueryClient) { + return ({ children }: { children: ReactNode }) => createElement(QueryClientProvider, { client }, children) +} + +describe('review list cache behavior', () => { + it('fetches externally submitted tasks on remount despite the application 30-second freshness default', async () => { + const list = vi.spyOn(reviewApi, 'list').mockResolvedValueOnce({ items: [], total: 0, page: 0, size: 20 }) + const client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: false } } }) + const wrapper = queryWrapper(client) + const first = renderHook(() => useReviewList('PENDING', 2), { wrapper }) + await waitFor(() => expect(first.result.current.isSuccess).toBe(true)) + expect(first.result.current.data?.items).toEqual([]) + first.unmount() + + list.mockResolvedValueOnce({ items: [reviewTask(79)], total: 1, page: 0, size: 20 }) + const second = renderHook(() => useReviewList('PENDING', 2), { wrapper }) + await waitFor(() => expect(second.result.current.data?.items.map((task) => task.id)).toEqual([79])) + expect(list).toHaveBeenCalledTimes(2) + expect(second.result.current.data?.totalElements).toBe(1) + client.clear() + }) + + it('keeps namespace, status, page and sorting caches separate during navigation', async () => { + const list = vi.spyOn(reviewApi, 'list') + const client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: false } } }) + const scopes = [ + { status: 'PENDING', namespaceId: 2, page: 0, sortDirection: 'DESC' as const }, + { status: 'PENDING', namespaceId: 3, page: 0, sortDirection: 'DESC' as const }, + { status: 'APPROVED', namespaceId: 3, page: 0, sortDirection: 'DESC' as const }, + { status: 'APPROVED', namespaceId: 3, page: 1, sortDirection: 'DESC' as const }, + { status: 'APPROVED', namespaceId: 3, page: 1, sortDirection: 'ASC' as const }, + ] + list.mockResolvedValueOnce({ items: [reviewTask(1)], total: 45, page: 0, size: 20 }) + const hook = renderHook((scope) => useReviewList(scope.status, scope.namespaceId, scope.page, 20, scope.sortDirection), { initialProps: scopes[0], wrapper: queryWrapper(client) }) + await waitFor(() => expect(hook.result.current.data?.items[0]?.id).toBe(1)) + for (let index = 1; index < scopes.length; index++) { + list.mockResolvedValueOnce({ items: [reviewTask(index + 1)], total: 45, page: scopes[index].page, size: 20 }) + hook.rerender(scopes[index]) + await waitFor(() => expect(hook.result.current.data?.items[0]?.id).toBe(index + 1)) + expect(list).toHaveBeenLastCalledWith({ ...scopes[index], size: 20 }) + } + for (const [index, scope] of scopes.entries()) { + const cached = client.getQueryData<{ items: ReviewTask[] }>(['reviews', scope.status, scope.namespaceId, scope.page, 20, scope.sortDirection]) + expect(cached?.items[0]?.id).toBe(index + 1) + } + expect(hook.result.current.data?.totalPages).toBe(3) + hook.unmount() + client.clear() + }) +}) diff --git a/web/src/features/review/use-review-list.ts b/web/src/features/review/use-review-list.ts index c3984b26..013b8a36 100644 --- a/web/src/features/review/use-review-list.ts +++ b/web/src/features/review/use-review-list.ts @@ -34,6 +34,7 @@ export function useReviewList( return useQuery({ queryKey: ['reviews', status, namespaceId, page, size, sortDirection], queryFn: () => getReviewList(status, namespaceId, page, size, sortDirection), + staleTime: 0, enabled: enabled && (namespaceId === undefined || namespaceId > 0), }) } From 5916771237fb4c7d4fd535c65da7dc8de6e29356 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:44:03 +0800 Subject: [PATCH 36/47] fix(web): clarify pending avatars and space management Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/26-skill-avatars.md | 2 +- .../add-namespace-invite-links/design.md | 6 + .../specs/namespace-invitations/spec.md | 9 +- .../specs/skill-avatars/spec.md | 8 + .../namespace/invitation-controls.test.tsx | 142 +++++++++++++++++- .../namespace/invitation-controls.tsx | 77 +++++++--- .../namespace/namespace-management-nav.tsx | 14 +- .../skill/skill-avatar-manager.test.tsx | 69 +++++++++ .../features/skill/skill-avatar-manager.tsx | 29 +++- web/src/i18n/locales/en.json | 20 ++- web/src/i18n/locales/ru.json | 20 ++- web/src/i18n/locales/zh.json | 20 ++- web/src/pages/dashboard/my-skills.test.ts | 15 ++ web/src/pages/dashboard/my-skills.tsx | 2 + .../pages/dashboard/namespace-management.tsx | 3 +- .../pages/dashboard/namespace-members.test.ts | 72 --------- .../dashboard/namespace-members.test.tsx | 93 ++++++++++++ web/src/pages/dashboard/namespace-members.tsx | 88 +++++++---- .../pages/dashboard/namespace-reviews.test.ts | 10 +- web/src/pages/dashboard/namespace-reviews.tsx | 39 +++-- .../pages/dashboard/namespace-settings.tsx | 3 +- 21 files changed, 573 insertions(+), 168 deletions(-) delete mode 100644 web/src/pages/dashboard/namespace-members.test.ts create mode 100644 web/src/pages/dashboard/namespace-members.test.tsx diff --git a/docs/26-skill-avatars.md b/docs/26-skill-avatars.md index c06f9a02..674a7c8b 100644 --- a/docs/26-skill-avatars.md +++ b/docs/26-skill-avatars.md @@ -1,6 +1,6 @@ # 技能头像与审核 -技能所有者可在新增/更新技能页面选择自定义 PNG/JPEG 头像,网页提供方形裁剪和缩放;不提供预设图标。更新默认保留头像,也可替换或移除。“我的技能”统一从“更新”入口维护,也可只改头像而不发布新版本;详情页仅在标题旁展示当前头像。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。 +技能所有者可在新增/更新技能页面选择自定义 PNG/JPEG 头像,网页提供方形裁剪和缩放;不提供预设图标。更新默认保留头像,也可替换或移除。“我的技能”统一从“更新”入口维护,也可只改头像而不发布新版本;详情页仅在标题旁展示当前头像。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。所有者在“我的技能”和更新页可查看已提交头像的缩略图及审核状态,待审头像不会替代公开头像;加载失败会提示重试。 ## 上传与提交 diff --git a/openspec/changes/add-namespace-invite-links/design.md b/openspec/changes/add-namespace-invite-links/design.md index 20b848be..27bf8ac2 100644 --- a/openspec/changes/add-namespace-invite-links/design.md +++ b/openspec/changes/add-namespace-invite-links/design.md @@ -24,3 +24,9 @@ 新分享地址为 `/join#invite=`,兼容原 `/join#`。进入页仍在登录跳转前同步捕获并清片段,Token不进入HTTP路径、查询、returnTo或日志。新片段无效时清旧暂存Token,无片段登录回跳才恢复暂存值。 命名空间卡片不平铺成员、邀请、作废、冻结、归档按钮;管理详情按技能/成员/审核/设置集中导航。成员页弹窗管理邀请,治理操作进入设置并保留旧权限与危险动作确认。公共空间技能浏览及旧成员/审核链接保持可用。平台审核权不等于成员管理权;GLOBAL不可冻结归档,但合法技能审核仍可用。 + +### 2026-10-14 人工核验交互修正 + +返回我的空间放在标题上方。普通成员不显示变更操作;角色编辑仍在角色列,保存/取消/移除使用操作列图标,提供可访问名称和悬停说明,保存成功或取消后恢复角色选择框焦点。审核状态用下拉筛选区分空间主导航。 + +邀请生成原文仍不存服务器;创建者以 userId+slug 隔离保存在当前标签页 sessionStorage。重开及刷新时先检查 active 再预览当前 Token,成功才显示;失效/替换时清除,网络失败不展示旧缓存但可重试。跨浏览器或标签页无法恢复时明确说明,替换须确认。存储不可用时仅保留当前弹窗的内存副本并提示立即复制。 diff --git a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md index da305791..630d0c33 100644 --- a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md +++ b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md @@ -37,7 +37,7 @@ Token SHALL 避免出现在日志、持久化明文和登录 URL 中。 -Token 使用安全随机数,数据库仅保存哈希;原文只在生成响应中出现一次。分享 URL 使用 `/join#invite=`,页面立即移入当前标签页 sessionStorage 并清除 fragment;登录回跳只包含应用内 `/join`,不带 Token。创建、预览、兑换、作废响应禁止缓存;日志和审计不得包含原文或哈希。四类操作仅允许浏览器 Session,变更请求遵守 CSRF。 +Token 使用安全随机数,数据库仅保存哈希;原文只在生成响应中出现一次。创建者页面可将原文保存在按账号和空间隔离的当前标签页 sessionStorage,供重开弹窗或刷新后恢复;展示前须重新确认服务端 active 状态并预览该 Token,失效或替换时清除。不得存入 localStorage 或数据库明文;其他标签页或设备无法恢复时明确说明,并保留确认替换入口。分享 URL 使用 `/join#invite=`,页面立即移入当前标签页 sessionStorage 并清除 fragment;登录回跳只包含应用内 `/join`,不带 Token。创建、预览、兑换、作废响应禁止缓存;日志和审计不得包含原文或哈希。四类操作仅允许浏览器 Session,变更请求遵守 CSRF。 Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码;数据库仅保存 SHA-256 哈希。 #### Scenario: 经过登录回跳 @@ -121,3 +121,10 @@ API SHALL 使用 Issue #911 的路径与响应合同。 - **WHEN** 他人已消费邀请,而管理员页面还缓存旧的可兑换状态 - **THEN** 点击生成先刷新服务端状态,已消费或过期的邀请不误弹替换确认,仍可兑换的邀请必须确认 - **AND** 状态刷新失败时不沿用旧缓存生成或替换,显示可重试错误 + +#### Scenario: 重开邀请弹窗与成员页布局 +- **WHEN** 同一账号在同一标签页重开弹窗或刷新成员页 +- **THEN** 重新校验有效的已保存链接后显示,并提供复制;失效或替换的链接不展示 +- **AND** 无保存链接时说明限制,不自动替换邀请 +- **AND** 返回我的空间位于空间标题上方;无管理权限不显示成员变更按钮;有权限的角色保存、取消和移除放在操作列,图标有可访问名称及悬停提示 +- **AND** 审核状态采用带标签的下拉筛选,与空间主导航区分 diff --git a/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md index db4b0d4b..e06670f3 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md +++ b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md @@ -202,3 +202,11 @@ PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使 - **WHEN** 审核员曾看到空列表,其他登录会话随后提交新申请,审核员重新进入列表 - **THEN** 重新获取当前申请,不能仅以此前缓存的空列表显示“暂无审核记录” - **AND** 命名空间、状态、分页和排序仍各自隔离,不新增轮询或扩大权限 + +### Requirement: 提交者 SHALL 可查看已提交头像 + +技能所有者在我的技能及更新页 SHALL 可查看已提交头像的紧凑缩略图及当前审核状态。待审候选与当前公开头像须明确区分;通过前公开展示保持旧头像。候选图片只使用现有所有者受保护接口,不公开未审核内容;无法加载候选时显示明确提示,不冒充现用头像。 + +#### Scenario: 头像申请等待审核 +- **WHEN** 所有者提交新头像并返回我的技能或重开更新页 +- **THEN** 可看到所提交的新头像及审核状态,而公共列表和详情继续展示旧头像 diff --git a/web/src/features/namespace/invitation-controls.test.tsx b/web/src/features/namespace/invitation-controls.test.tsx index df80128e..3cfecb6d 100644 --- a/web/src/features/namespace/invitation-controls.test.tsx +++ b/web/src/features/namespace/invitation-controls.test.tsx @@ -7,16 +7,25 @@ const api = vi.hoisted(() => ({ invitationStatus: vi.fn(), createInvitation: vi.fn(), revokeInvitation: vi.fn(), + previewInvitation: vi.fn(), })) -vi.mock('@/api/client', () => ({ namespaceApi: api })) +vi.mock('@/api/client', () => ({ namespaceApi: api, getAppBaseUrl: () => undefined })) +const auth = vi.hoisted(() => ({ user: { userId: 'owner' } })) +vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => auth })) +vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), info: vi.fn() } })) +vi.mock('@/i18n/config', () => ({ default: { exists: () => false } })) vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) +import { ApiError } from '@/shared/lib/api-error' import { InvitationControls } from './invitation-controls' describe('InvitationControls', () => { afterEach(() => { cleanup() + vi.restoreAllMocks() + window.sessionStorage.clear() + auth.user = { userId: 'owner' } Object.values(api).forEach((mock) => mock.mockReset()) }) @@ -78,4 +87,135 @@ describe('InvitationControls', () => { expect(screen.queryByRole('dialog')).toBeNull() }) + const token = 'a'.repeat(43) + const storageKey = 'skillhub.namespace.issued-invitation:owner:team' + const validPreview = { namespaceSlug: 'team', expiresAt: '2999-01-01T00:00:00Z', outcome: 'ALREADY_MEMBER' } + + function mount(client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) { + return render() + } + + it('keeps the generated link after dialog reopen and a new QueryClient (tab reload)', async () => { + api.invitationStatus.mockResolvedValueOnce({ active: false }).mockResolvedValueOnce({ active: false }).mockResolvedValue({ active: true }) + api.createInvitation.mockResolvedValue({ token, replaced: false }) + api.previewInvitation.mockResolvedValue(validPreview) + const view = mount() + const button = screen.getByRole('button', { name: 'invitation.create' }) + await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false)) + fireEvent.click(button) + await screen.findByRole('textbox') + expect(window.sessionStorage.getItem(storageKey)).toBe(token) + view.unmount() + mount() + expect((await screen.findByRole('textbox') as HTMLInputElement).value).toContain(`/join#invite=${token}`) + expect(api.createInvitation).toHaveBeenCalledTimes(1) + expect(api.previewInvitation).toHaveBeenCalledWith(token) + expect(screen.getByRole('button', { name: 'invitation.copy' })).toBeTruthy() + }) + + it.each(['consumed', 'expired', 'revoked'])('clears the remembered link when server reports %s/inactive', async () => { + window.sessionStorage.setItem(storageKey, token) + api.invitationStatus.mockResolvedValue({ active: false }) + mount() + await waitFor(() => expect(window.sessionStorage.getItem(storageKey)).toBeNull()) + expect(screen.queryByRole('textbox')).toBeNull() + expect(api.previewInvitation).not.toHaveBeenCalled() + }) + + it('clears a replaced token and explains why the active link cannot be recovered', async () => { + window.sessionStorage.setItem(storageKey, token) + api.invitationStatus.mockResolvedValue({ active: true }) + api.previewInvitation.mockRejectedValue(new ApiError('invalid', 404)) + mount() + await screen.findByText('invitation.activeUnavailable') + expect(window.sessionStorage.getItem(storageKey)).toBeNull() + expect(screen.queryByRole('textbox')).toBeNull() + expect(api.createInvitation).not.toHaveBeenCalled() + }) + + it('hides remembered links on validation network failure but retains them for retry', async () => { + window.sessionStorage.setItem(storageKey, token) + api.invitationStatus.mockResolvedValue({ active: true }) + api.previewInvitation.mockRejectedValue(new ApiError('network', 0)) + mount() + await screen.findByRole('alert') + expect(window.sessionStorage.getItem(storageKey)).toBe(token) + expect(screen.queryByRole('textbox')).toBeNull() + }) + + it('does not display a token saved by a different authenticated user', async () => { + window.sessionStorage.setItem(storageKey, token) + auth.user = { userId: 'another-admin' } + api.invitationStatus.mockResolvedValue({ active: true }) + mount() + await screen.findByText('invitation.activeUnavailable') + expect(screen.queryByRole('textbox')).toBeNull() + expect(api.previewInvitation).not.toHaveBeenCalled() + }) + + it('clears the token after confirmed revoke', async () => { + window.sessionStorage.setItem(storageKey, token) + api.invitationStatus.mockResolvedValue({ active: true }) + api.previewInvitation.mockResolvedValue(validPreview) + api.revokeInvitation.mockImplementation(async () => { api.invitationStatus.mockResolvedValue({ active: false }) }) + mount() + await screen.findByRole('textbox') + fireEvent.click(screen.getByRole('button', { name: 'invitation.revoke' })) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'dialog.confirm' })) + await waitFor(() => expect(window.sessionStorage.getItem(storageKey)).toBeNull()) + await waitFor(() => expect(screen.queryByRole('textbox')).toBeNull()) + }) + + it('keeps only an in-memory copy and explains the limitation when storage is unavailable', async () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('storage unavailable') }) + api.invitationStatus.mockResolvedValueOnce({ active: false }).mockResolvedValueOnce({ active: false }).mockResolvedValue({ active: true }) + api.createInvitation.mockResolvedValue({ token, replaced: false }) + api.previewInvitation.mockResolvedValue(validPreview) + mount() + const button = screen.getByRole('button', { name: 'invitation.create' }) + await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false)) + fireEvent.click(button) + await screen.findByRole('textbox') + expect(screen.getByText('invitation.storageUnavailable')).toBeTruthy() + expect(window.sessionStorage.getItem(storageKey)).toBeNull() + }) + + it('does not show a previously validated cached link while reopening validation fails', async () => { + window.sessionStorage.setItem(storageKey, token) + api.invitationStatus.mockResolvedValue({ active: true }) + api.previewInvitation.mockResolvedValue(validPreview) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const view = mount(client) + await screen.findByRole('textbox') + view.unmount() + api.previewInvitation.mockRejectedValue(new ApiError('network', 0)) + mount(client) + expect(screen.queryByRole('textbox')).toBeNull() + await screen.findByRole('alert') + expect(screen.queryByRole('textbox')).toBeNull() + expect(window.sessionStorage.getItem(storageKey)).toBe(token) + }) + + it('replaces the remembered link only after confirmation and restores the new link on reopen', async () => { + window.sessionStorage.setItem(storageKey, token) + const nextToken = 'b'.repeat(43) + api.invitationStatus.mockResolvedValue({ active: true }) + api.previewInvitation.mockResolvedValue(validPreview) + api.createInvitation.mockResolvedValue({ token: nextToken, replaced: true }) + const view = mount() + await screen.findByRole('textbox') + fireEvent.click(screen.getByRole('button', { name: 'invitation.create' })) + const dialog = await screen.findByRole('dialog') + expect(api.createInvitation).not.toHaveBeenCalled() + expect(window.sessionStorage.getItem(storageKey)).toBe(token) + fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.confirm' })) + await waitFor(() => expect(window.sessionStorage.getItem(storageKey)).toBe(nextToken)) + await waitFor(() => expect((screen.getByRole('textbox') as HTMLInputElement).value).toContain(nextToken)) + expect(api.createInvitation).toHaveBeenCalledWith('team', true) + view.unmount() + mount() + expect((await screen.findByRole('textbox') as HTMLInputElement).value).toContain(nextToken) + expect(api.createInvitation).toHaveBeenCalledTimes(1) + }) + }) diff --git a/web/src/features/namespace/invitation-controls.tsx b/web/src/features/namespace/invitation-controls.tsx index 043e59c5..fe37ae60 100644 --- a/web/src/features/namespace/invitation-controls.tsx +++ b/web/src/features/namespace/invitation-controls.tsx @@ -1,7 +1,9 @@ -import { useId, useState } from 'react' +import { useId, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { getAppBaseUrl, namespaceApi } from '@/api/client' +import { useAuth } from '@/features/auth/use-auth' +import { ApiError } from '@/shared/lib/api-error' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { BASE_PATH } from '@/shared/lib/base-path' import { invitationUrl } from '@/shared/lib/namespace-invitation' @@ -14,28 +16,65 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co const { t } = useTranslation() const linkId = useId() const queryClient = useQueryClient() + const { user } = useAuth() + const storageKey = `skillhub.namespace.issued-invitation:${user?.userId ?? ''}:${slug}` + const inMemoryToken = useRef<{ key: string; token: string } | null>(null) + const [storageUnavailable, setStorageUnavailable] = useState(false) const [confirm, setConfirm] = useState<'replace' | 'revoke' | null>(null) - const [link, setLink] = useState(null) - const status = useQuery({ queryKey: ['namespace-invitation-status', slug], queryFn: () => namespaceApi.invitationStatus(slug), enabled: !compact }) + const clearStoredToken = () => { + inMemoryToken.current = null + try { window.sessionStorage.removeItem(storageKey) } catch { /* Storage may be unavailable. */ } + } + const status = useQuery({ + queryKey: ['namespace-invitation-status', slug, user?.userId], + enabled: !compact && !!user, + staleTime: 0, + queryFn: async () => { + const current = await namespaceApi.invitationStatus(slug) + if (!current.active) { + clearStoredToken() + return { active: false, token: null } + } + let token = inMemoryToken.current?.key === storageKey ? inMemoryToken.current.token : null + try { token = window.sessionStorage.getItem(storageKey) ?? token } catch { /* Use the current dialog's copy. */ } + if (!token) return { active: true, token: null } + try { + const preview = await namespaceApi.previewInvitation(token) + if (preview.namespaceSlug !== slug || Date.parse(preview.expiresAt) <= Date.now()) { + clearStoredToken() + return { active: true, token: null } + } + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 404) throw error + clearStoredToken() + return { active: true, token: null } + } + return { active: true, token } + }, + }) + const link = !status.isFetching && !status.isError && status.data?.token + ? invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), status.data.token) + : null + const copyLink = async (url: string, replaced = false) => { + try { + await navigator.clipboard.writeText(url) + toast.success(t(replaced ? 'invitation.replacedCopied' : 'invitation.copied')) + } catch { toast.info(t('invitation.manualCopy')) } + } const create = useMutation({ mutationFn: (replace: boolean) => namespaceApi.createInvitation(slug, replace), - onSuccess: ({ token, replaced }) => { + onSuccess: async ({ token, replaced }) => { + inMemoryToken.current = { key: storageKey, token } + try { window.sessionStorage.setItem(storageKey, token) } catch { setStorageUnavailable(true) } const url = invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), token) - setLink(url) - try { - void navigator.clipboard.writeText(url) - .then(() => toast.success(t(replaced ? 'invitation.replacedCopied' : 'invitation.copied'))) - .catch(() => toast.info(t('invitation.manualCopy'))) - } catch { - toast.info(t('invitation.manualCopy')) - } - void queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }) + await copyLink(url, replaced) + await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }) }, }) const revoke = useMutation({ mutationFn: () => namespaceApi.revokeInvitation(slug), onSuccess: async () => { - setLink(null) + clearStoredToken() await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }) toast.success(t('invitation.revoked')) }, @@ -50,16 +89,20 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co return (
event.stopPropagation() : undefined}>
- + {status.data?.active ? : null}
{link ? (
- event.currentTarget.select()} /> -

{t('invitation.linkHint')}

+
+ event.currentTarget.select()} /> + +
+

{t(storageUnavailable ? 'invitation.storageUnavailable' : 'invitation.localLinkHint')}

) : null} + {!link && !status.isFetching && !status.isError && status.data?.active ?

{t('invitation.activeUnavailable')}

: null} {(create.error || revoke.error || status.error) ?

{t('invitation.error')}

: null} { if (!open) setConfirm(null) }} title={t(confirm === 'revoke' ? 'invitation.revokeTitle' : 'invitation.replaceTitle')} diff --git a/web/src/features/namespace/namespace-management-nav.tsx b/web/src/features/namespace/namespace-management-nav.tsx index 022c613a..c542cfd3 100644 --- a/web/src/features/namespace/namespace-management-nav.tsx +++ b/web/src/features/namespace/namespace-management-nav.tsx @@ -1,9 +1,20 @@ +import { ArrowLeft } from 'lucide-react' import { Link } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { cn } from '@/shared/lib/utils' +export function NamespaceManagementBack() { + const { t } = useTranslation() + return ( + +