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 01/12] 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 000000000..3b6ac7a46 --- /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 000000000..eb97b7c16 --- /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 000000000..cf54afb3b --- /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 000000000..877b94eb6 --- /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 cdedbfec8..d5bbedbcf 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 @@ */ 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 000000000..a2d786071 --- /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 000000000..074fa0355 --- /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 ed9a67246..0f909e58f 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 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 @@ @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 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 02/12] 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 b693cc6e6..8c2f391ad 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 8af0d2ec2..e26d78903 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 000000000..b9ebfc72d --- /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 000000000..f67f0a5cc --- /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 000000000..5d8db8be5 --- /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 000000000..5d8b25da1 --- /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 000000000..ebefc1b57 --- /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 000000000..aa07b4aff --- /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 000000000..ed2470020 --- /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 000000000..3f87ff6fa --- /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 e67cd87f8..1286096de 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.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 AdminNamespaceAppService(AdminNamespaceQueryRepository adminNamespaceQuer NamespaceGovernanceService namespaceGovernanceService, NamespaceMemberService namespaceMemberService, NamespaceMemberRepository namespaceMemberRepository, + NamespaceRepository namespaceRepository, NamespaceMemberCandidateService namespaceMemberCandidateService, NamespaceAccessPolicy namespaceAccessPolicy, UserAccountRepository userAccountRepository) { @@ -65,6 +68,7 @@ public AdminNamespaceAppService(AdminNamespaceQueryRepository adminNamespaceQuer 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 List searchMemberCandidates(String slug, @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 MemberResponse addMember(String slug, MemberRequest request, String actor @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 BatchMemberResponse batchAddMembers(String slug, BatchMemberRequest reque @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 MemberResponse updateMemberRole(String slug, String userId, UpdateMemberR @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 MessageResponse removeMember(String slug, String userId, String actorUser @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 @@ private Map loadRoles(List namespaceIds, String userI .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 4bf8af917..b5ef64684 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 NamespaceResponse updateNamespace(String slug, NamespaceRequest request, @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 NamespaceResponse restoreNamespace(String slug, String userId, AuditReque @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 MemberResponse addMember(String slug, String memberUserId, com.iflytek.sk // 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 @@ private String mapBatchError(Exception e) { @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 MemberResponse updateMemberRole(String slug, 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 MemberResponse updateMemberRole(String slug, @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 @@ private boolean canCreateNamespace(PlatformPrincipal principal) { 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 3b6ac7a46..ed42c0be4 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 24bf9324d..b75550868 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 566d443e8..496c2c314 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 90bad54b1..046a459be 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 000000000..8ff1e84a9 --- /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 000000000..6fdbc8d4b --- /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 149f885d9..16012dbaf 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.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 @@ void addMemberAllowsSuperAdminToManageNonMemberTeamNamespace() { 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 @@ void addMemberAllowsSuperAdminToManageNonMemberTeamNamespace() { 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 @@ void transferOwnershipDemotesCurrentOwnerAndPromotesExistingMember() { 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 @@ void transferOwnershipDemotesCurrentOwnerAndPromotesExistingMember() { 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 1f4ad1ac4..b069b1b0f 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 @@ void freezeNamespace_mapsAuditContextAndReturnsResponse() { @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 @@ void addMember_populatesDisplayNameAndEmail() { 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 @@ void addMember_withoutUserAccount_degradesGracefully() { 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 @@ void updateMemberRole_populatesDisplayNameAndEmail() { 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 @@ void updateMemberRole_withoutUserAccount_degradesGracefully() { 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 @@ void updateMemberRole_withoutUserAccount_degradesGracefully() { @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 @@ void transferOwnership_success() { @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 @@ void transferOwnership_nonOwnerFails() { @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 @@ void transferOwnership_targetNotFoundFails() { 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 b5de4dbe4..ecfd37943 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.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 AccountMergeService(AccountMergeRequestRepository mergeRequestRepository, UserRoleBindingRepository userRoleBindingRepository, ApiTokenRepository apiTokenRepository, NamespaceMemberRepository namespaceMemberRepository, + NamespaceRepository namespaceRepository, Clock clock) { this.mergeRequestRepository = mergeRequestRepository; this.userAccountRepository = userAccountRepository; @@ -66,6 +69,7 @@ public AccountMergeService(AccountMergeRequestRepository mergeRequestRepository, this.userRoleBindingRepository = userRoleBindingRepository; this.apiTokenRepository = apiTokenRepository; this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceRepository = namespaceRepository; this.clock = clock; } @@ -287,6 +291,8 @@ private void migrateUserRoles(String primaryUserId, String secondaryUserId) { 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 08a6d4807..49d678b5d 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.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 @@ void setUp() { 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 1ef7d2470..5b251a917 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 984fe4fc7..4e0072fdf 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.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 Namespace freezeNamespace(String slug, 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 Namespace freezeNamespaceByPlatformAdmin(String slug, 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 Namespace unfreezeNamespace(String slug, 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 Namespace unfreezeNamespaceByPlatformAdmin(String slug, 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 Namespace archiveNamespace(String slug, 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 Namespace archiveNamespaceByPlatformAdmin(String slug, 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 Namespace restoreNamespace(String slug, 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 Namespace restoreNamespaceByPlatformAdmin(String slug, 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 Namespace restoreNamespaceByPlatformAdmin(String slug, } 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 cf54afb3b..36e0f99bf 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 @@ 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 877b94eb6..0706f1219 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 @@ 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 record Preview(String namespaceSlug, String namespaceName, boolean alread @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 Created create(String slug, String actorId) { 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 Created create(String slug, String actorId) { @Transactional public void revoke(String slug, String actorId) { + requireActiveLockedUser(actorId); Namespace namespace = lock(slug); requireManager(namespace, actorId); requireActiveTeam(namespace); @@ -97,8 +109,11 @@ public Preview preview(String token, String userId) { @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 Preview accept(String token, String userId) { } 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 @@ private NamespaceInvitation current(String token) { 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 @@ private void requireActiveUser(String userId) { 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 d8e8bc741..040f048b0 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 NamespaceMember addMember(Long namespaceId, String userId, NamespaceRole @Transactional public void removeMember(Long namespaceId, String userId, String operatorUserId) { + lockUserThenNamespace(userId, namespaceId); assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); @@ -61,6 +70,7 @@ public void removeMember(Long namespaceId, String userId, String operatorUserId) @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 NamespaceMember updateMemberRole(Long namespaceId, String userId, Namespa @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 @@ private void assertMemberMutationAllowed(Long namespaceId) { 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 d5bbedbcf..b235d2f50 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 e54379e01..764f30f34 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.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 NamespaceService(NamespaceRepository namespaceRepository, */ @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 Namespace getNamespace(Long namespaceId) { */ @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 c4e211bb1..95ae6ad11 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 @@ void ensureMember_createsGlobalMembershipWhenMissing() throws Exception { 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 @@ void ensureMember_keepsExistingGlobalMembership() throws Exception { 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 652902702..f164cc2e6 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 @@ 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 @@ void freezeNamespace_allowsAdminOnActiveTeamNamespace() { 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 @@ void archiveNamespace_rejectsAdminAndAllowsOnlyOwner() { 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 @@ void restoreNamespace_movesArchivedNamespaceBackToActive() { @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 @@ void freezeNamespace_rejectsGlobalNamespace() { 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 a2d786071..9e4895e27 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 @@ void setup() { 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 @@ void setup() { 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 @@ void revokedTokenCannotBePreviewed() { .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 84d823c1f..5d897a3d5 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 @@ 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 12c555f38..1631fa6c6 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.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 @@ void deleteNamespace_shouldDeleteMembersAndNamespaceForOwner() { 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 @@ void deleteNamespace_shouldRejectAdminUser() { 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 @@ void deleteNamespace_shouldRejectNamespaceWithDependentData() { 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 074fa0355..b305ef6e8 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 @@ 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 @@ @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 0f909e58f..fd112bf38 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 09f2fd9d2..5b33c645a 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 54573332c..6a706d030 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 000000000..b0e4fdc5b --- /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 a863e68d2..378617d47 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 5f7d8503c..1c1182565 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 ba4d66fa5..be47e8b5a 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 97afd0bfe..4b660726d 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 000000000..e43b135ff --- /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 000000000..0b3892817 --- /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 000000000..9a1b9140c --- /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 03/12] 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 f67f0a5cc..fd3c405d3 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 5d8b25da1..34b2244f7 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 04/12] 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 34b2244f7..c793af052 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 6fdbc8d4b..a64c8cdca 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 @@ void twoConcurrentAcceptsCreateOnlyOneMemberAndOneConsumption() throws Exception 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 0706f1219..ae8bfc95e 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 NamespaceInvitationService(NamespaceRepository namespaces, NamespaceInvit 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 Created create(String slug, String actorId, boolean replace) { 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 Created create(String slug, String actorId, boolean replace) { } 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 9e4895e27..1c4d1ad3d 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 @@ void createsSingleUseTokenWithoutPersistingRawValue() { 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 @@ void replacingCurrentInvitationMakesOldTokenInvalidAfterPreview() { 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 @@ void revokeDeletesCurrentInvitationWithoutRemovingMembers() { 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 5b33c645a..b35dd8a64 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 8884f9caf..979714c14 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 b0e4fdc5b..058fa75a9 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 378617d47..7669e0d34 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 1c1182565..1515824b8 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 be47e8b5a..2efc716e1 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 b50f6717b..afa08d222 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 05/12] 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 c793af052..5d370443d 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 ebefc1b57..1e2f9f3df 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 a64c8cdca..b677e1bc3 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.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 @@ @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 @@ static class ClockConfig { } @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 @@ void revokeAndReplaceSerializeWithAcceptance() throws Exception { } } + @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 058fa75a9..fc0143710 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 afa08d222..48f60f7d7 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 07/12] 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 1c4d1ad3d..bfa1a6705 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 @@ void createsSingleUseTokenWithoutPersistingRawValue() { 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 @@ void renewingConsumedInvitationDoesNotClaimToReplaceUsableLink() { 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 08/12] 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 bfa1a6705..550c2e260 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.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 @@ void acceptCreatesMemberAndConsumesExactlyTheCurrentInvitation() { .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 f8a95847f..50c879cbc 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 6a706d030..55e332ea0 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 ada9d746b..6988445f7 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 0b3892817..09aa5b055 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 09/12] 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 8ff1e84a9..f3727e927 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 @@ void refusesApiTokenEvenWhenItCarriesAnAdminPlatformRole() { 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 550c2e260..1e98a4c50 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 @@ void namespaceAdminCanCreateInvitation() { 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 10/12] 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 fd3c405d3..624597ce5 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 5d370443d..f1f2769c6 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 1e2f9f3df..0b8b0f173 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 aa07b4aff..02879c68b 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 NamespaceInvitationController(NamespaceInvitationService invitations, Api 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 ApiResponse status(@PathVariable String slug, @Authenti 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 ApiResponse create(@PathVariable Stri 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 ApiResponse revoke(@PathVariable String slug, 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 ApiResponse preview(@Valid @RequestBo 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 b75550868..1635516d2 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 496c2c314..dbaefe0ce 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 046a459be..1a38634bf 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 f3727e927..8da407541 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 @@ void sendsNoStoreHeadersBeforeReturningAnInvitation() { 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 b677e1bc3..74d5a5548 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 @@ void directAddAndAcceptProduceOneMembershipWithoutConsumingUnusedLink() throws E 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 @@ void overlappingNamespaceAccountMergeAndAcceptKeepOnePrimaryMembership() throws 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 ae8bfc95e..fa50f5cb2 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 NamespaceInvitationService(NamespaceRepository namespaces, NamespaceInvit } 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 void revoke(String slug, String actorId) { @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 Preview preview(String token, String userId) { 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 Preview accept(String token, String userId) { 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 Preview accept(String token, String userId) { 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 @@ private void requireValid(Namespace namespace, NamespaceInvitation invitation) { 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 1e98a4c50..940c67d6d 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 @@ void ownerCanCreateInvitationForPersonalNamespaceRepresentedAsTeam() { @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 @@ void acceptCreatesMemberAndConsumesExactlyTheCurrentInvitation() { 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 @@ void existingMemberCanOpenCurrentConsumedInvitationWithoutReconsuming() { 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 @@ void existingMemberCanPreviewAndAcceptCurrentExpiredInvitationWhileNamespaceFroz 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 da659024c..d1d25158f 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 b35dd8a64..c1968dda1 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 979714c14..8423d97c9 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 7669e0d34..2a8ce3d77 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 1515824b8..fbfe0e39a 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 2efc716e1..320a9e5ad 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 a8ca210af..74b672b1a 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 11/12] 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 f1f2769c6..9f7277f54 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 000000000..c24524417 --- /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 74b672b1a..60dc8d6aa 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 12/12] 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 8ac331680..8a8145af5 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.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.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 d7dc9142c..ac60aad1b 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.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 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);