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

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

+

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

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

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

{t('invitation.alreadyMember')}