diff --git a/docs/skillhub/en/guide/namespace.md b/docs/skillhub/en/guide/namespace.md index b693cc6e..8c2f391a 100644 --- a/docs/skillhub/en/guide/namespace.md +++ b/docs/skillhub/en/guide/namespace.md @@ -73,6 +73,15 @@ Discover a security issue in the namespace and temporarily freeze all publish op 5. Select a role (Owner / Admin / Member) 6. Confirm to add +**Invite with a link**: + +1. An Owner or Admin opens the Members page of an ACTIVE team space and selects "Invite member". +2. Share the copied link securely. It expires after 24 hours and can be redeemed once by one person who is not already a member. +3. The recipient must first pass the usual SkillHub login and account access rules. The page shows the space, current account, and MEMBER permissions. Joining happens only after explicit confirmation. +4. "Revoke invitation" invalidates the link immediately. Creating a new link asks for confirmation before replacing a still usable one; existing members remain members. + +Anyone who obtains the link can use it after normal account admission. Do not post it in a public channel. The login return URL does not contain the invitation token; the current browser tab stores it temporarily until joining or terminal invalidation. + **Manage Permissions**: 1. Find the target user in the member list diff --git a/docs/skillhub/guide/namespace.md b/docs/skillhub/guide/namespace.md index 8af0d2ec..e26d7890 100644 --- a/docs/skillhub/guide/namespace.md +++ b/docs/skillhub/guide/namespace.md @@ -73,6 +73,15 @@ 5. 选择角色(Owner / Admin / Member) 6. 确认添加 +**通过链接邀请成员**: + +1. Owner 或 Admin 在处于 ACTIVE 状态的团队空间「成员」页点击「邀请成员」。 +2. 复制链接并安全地发给要加入的人。链接 24 小时内有效,只能由一名尚未加入的用户使用一次。 +3. 对方先按现有登录及账号准入规则进入 SkillHub。打开链接后会看到目标空间、当前账号和 MEMBER 权限;点击「确认加入」才会成为成员。 +4. 如需提前失效,点击「作废邀请」。重新生成链接时,页面会要求确认旧链接立即失效;已加入的成员不受影响。 + +邀请链接是持有者可用的加入凭证,不限定邮箱或账号。不要在公开频道分享。登录回跳不会携带邀请 Token;同一浏览器标签页会暂存链接直到确认、已加入或确认失效。 + **管理权限**: 1. 在成员列表中找到目标用户 diff --git a/openspec/changes/add-namespace-invite-links/.openspec.yaml b/openspec/changes/add-namespace-invite-links/.openspec.yaml new file mode 100644 index 00000000..b9ebfc72 --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/add-namespace-invite-links/design.md b/openspec/changes/add-namespace-invite-links/design.md new file mode 100644 index 00000000..f67f0a5c --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/design.md @@ -0,0 +1,19 @@ +## Context + +现有 Namespace 成员只能由管理员直接添加。`NamespaceMemberService`、平台管理服务、生命周期服务和账号合并均会修改同一 Namespace 的状态或成员,但目前没有共同的 Namespace 行锁。前端已有 Session/CSRF、登录 returnTo 与 BASE_PATH 逻辑;当前 returnTo helper 会保留 fragment,所以 `/join` 必须先移除 Token 再触发登录跳转。 + +## Decisions + +1. 每个 Namespace 只保存一条当前邀请:Token SHA-256、签发人和时间、过期时间、消费人和时间。原文只在生成响应中返回一次。作废删除当前邀请记录或等价地使其不可匹配,写审计;不删除已加入成员。 +2. 涉及用户的写操作先按 userId 升序锁参与者用户行,再按 Namespace ID 升序锁目标 Namespace 行,最后读取或写入邀请与成员;账号合并沿用先锁用户的现有顺序,以免与成员或审计外键检查形成锁环。邀请创建、替换、作废锁操作者后锁 Namespace;接受锁接收者后锁 Namespace。纯 Namespace 生命周期与删除操作锁 Namespace。批量操作预先收集 ID、去重并排序。接受在锁内重检,成员创建、消费和审计原子提交。 +3. 前端在 `/join` 入口同步读取 fragment,放入当前标签页 sessionStorage,保留 path/query 清除 fragment,再应用原登录逻辑。登录 `returnTo=/join` 不传 Token。分享链接复用 `resolvePublicRegistryUrl` 构造并兼容 BASE_PATH。 +4. Preview/Accept 复用 Issue 的判定顺序:先验证 Token 为当前邀请,再查已有成员;已有成员返回 ALREADY_MEMBER。非成员必须面对 ACTIVE TEAM、未消费且未过期的邀请。失效统一 404,不区分随机、旧、作废、过期或已用 Token。Accept 锁前只按 Token 哈希查询标量 Namespace ID;取得 Namespace 锁后重新加载当前邀请并校验,不能沿用锁前已经进入 ORM 一级缓存的邀请实体,否则并发兑换可能都看到未消费状态。 +5. 只有可用浏览器 Session 能创建、预览、接受、作废;现有账号准入不能由邀请绕过。浏览器请求使用既有 CSRF。创建/预览/接受/作废与缓存或日志配置均不得泄露原文和哈希。 +6. 管理页面保留原直接添加成员入口,新增邀请按钮和单独作废按钮。仍可兑换的旧链接替换前先确认;剪贴板失败时展示本次生成的同一链接。 + +## Risks and verification + +- 账号合并已先锁 UserAccount;邀请创建/替换/作废/接受、直接成员变更、平台成员管理及账号合并要按相同的用户后 Namespace 顺序加锁,避免与成员或审计外键检查形成环。 +- 并发兑换、直接添加与兑换、替换/作废与兑换、冻结/归档/删除与兑换均需事务级验证。 +- 根路径及子路径部署都要验证分享 URL、登录回跳和 fragment 清理。 +- API Token、跨团队管理员、平台角色和禁用账号的越权路径需明确拒绝。 diff --git a/openspec/changes/add-namespace-invite-links/proposal.md b/openspec/changes/add-namespace-invite-links/proposal.md new file mode 100644 index 00000000..5d8db8be --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/proposal.md @@ -0,0 +1,30 @@ +## Why + +Namespace 管理员目前只能逐个选择现有用户添加成员。短期、单次邀请链接能让成员自行加入,同时让管理员控制链接失效和成员权限边界。 + +## What Changes + +- ACTIVE TEAM 的 OWNER/ADMIN 可以生成一条 24 小时内有效、仅一名非成员可兑换的邀请链接;替换未使用链接前须确认。 +- 管理员可以单独作废当前邀请,不需要生成新链接;不影响已经加入的成员。 +- 持链接者先按现有注册、登录和账号准入策略获得可用账号,再看到空间、当前账号和 MEMBER 权限,明确确认后加入。 +- Token 不存明文、不进 URL path/query 或登录回跳参数;创建、预览、兑换、作废均受权限及事务约束。 +- 邀请兑换与成员及 Namespace 生命周期操作并发时保持一致,不出现重复成员或部分消费。 + +## Capabilities + +### New Capabilities + +- `namespace-invitations`: 邀请创建、替换、作废、预览、确认加入、安全与并发规则。 + +### Modified Capabilities + +无。 + +## Impact + +- Namespace 领域、成员管理、账号合并事务、数据库迁移、审计、Session/CSRF 接口和 OpenAPI 类型。 +- Namespace 管理页面、邀请页、登录回跳与子路径部署、多语言文案及浏览器验证。 + +## 相对 Issue #911 的调整 + +原 Issue 把独立撤销入口列为范围外;本方案增加“作废邀请”按钮和相应后端动作。邀请不绕过现有账号准入策略。 diff --git a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md new file mode 100644 index 00000000..5d8b25da --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md @@ -0,0 +1,96 @@ +## Purpose + +让 Namespace 管理员使用可控的短期单次链接邀请新成员。 + +## ADDED Requirements + +### Requirement: REQ-INV-01 管理员 SHALL 创建、替换及作废邀请 + +有权管理员 SHALL 可创建、替换及作废邀请。 + +只有 ACTIVE TEAM 的 OWNER/ADMIN 可以创建邀请;平台角色不能绕过成员角色限制。每个 Namespace 仅有一条当前邀请,24 小时内仅一名非成员可兑换一次。替换仍可用的邀请前须确认;管理员可以单独作废当前邀请,且已加入成员不受影响。 + +#### Scenario: 替换未使用链接 +- **WHEN** 管理员再次点击邀请且当前链接仍可兑换 +- **THEN** 页面先说明旧链接将失效并要求确认 +- **AND** 仅确认后生成新链接,旧链接立即失效 + +#### Scenario: 单独作废 +- **WHEN** 管理员点击作废并确认 +- **THEN** 当前链接立即失效,不会创建新链接或移除成员 + +### Requirement: REQ-INV-02 邀请 SHALL 服从现有账号准入 + +邀请 SHALL 服从现有账号准入规则。 + +邀请只授予目标 Namespace 的 MEMBER 权限,不使用户绕过现有注册、登录、外部身份准入和账号审批。接受者必须先拥有可用登录会话,看到目标 Namespace、当前账号和 MEMBER 可访问及发布资源的说明,再明确确认加入。页面加载不得自动兑换。 + +#### Scenario: 新用户未通过准入 +- **WHEN** 持链接者无法按既有策略获得可用账号 +- **THEN** 邀请不能使其进入目标 Namespace + +#### Scenario: 已登录非成员 +- **WHEN** 非成员打开有效链接 +- **THEN** 页面展示 Namespace、账号和权限,只有点击确认才新增 MEMBER + +### Requirement: REQ-INV-03 Token SHALL 避免意外泄露 + +Token SHALL 避免出现在日志、持久化明文和登录 URL 中。 + +Token 使用安全随机数,数据库仅保存哈希;原文只在生成响应中出现一次。分享 URL 使用 `/join#token`,页面立即移入当前标签页 sessionStorage 并清除 fragment;登录回跳只包含应用内 `/join`,不带 Token。创建、预览、兑换、作废响应禁止缓存;日志和审计不得包含原文或哈希。四类操作仅允许浏览器 Session,变更请求遵守 CSRF。 +Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码;数据库仅保存 SHA-256 哈希。 + +#### Scenario: 经过登录回跳 +- **WHEN** 未登录者打开邀请后登录 +- **THEN** Token 不出现在登录回跳 URL,回到原标签页后才从 sessionStorage 恢复邀请 + +#### Scenario: 子路径部署 +- **WHEN** 应用部署在 `/skillhub/` +- **THEN** 分享链接指向公开基地址下 `/skillhub/join#token`,应用内 returnTo 仍为 `/join` + +### Requirement: REQ-INV-04 服务端 SHALL 在接受时重新判断状态 + +服务端 SHALL 在接受邀请时重新判断全部状态。 + +预览不消费邀请。接受时重新检查 Token 是否仍为当前、用户是否已有成员关系、Namespace 是否 ACTIVE TEAM、邀请是否未消费且未过期。Token 匹配当前邀请且用户已是成员时返回 ALREADY_MEMBER,不再检查过期、消费或 Namespace 冻结归档状态,也不消费。未知、过期、替换、作废或已用 Token 对非成员返回统一失效响应。冻结或归档时非成员不能加入也不消费,恢复后仅在原有效期内可兑换。 +统一失效响应使用 HTTP 404、envelope `code=404` 与 `data=null`;前端只按状态处理,不解析本地化文案。 + +#### Scenario: 预览后链接被替换 +- **WHEN** 用户看到预览后管理员替换或作废了链接 +- **THEN** 接受请求失败且不新增成员 + +#### Scenario: 已有成员打开当前链接 +- **WHEN** Token 仍是当前邀请且当前用户已经是成员 +- **THEN** 返回 ALREADY_MEMBER,直接进入 Namespace,不消耗邀请 + +#### Scenario: 被移除者重用已消费链接 +- **WHEN** 曾兑换的成员后来被移除 +- **THEN** 原链接仍保持已消费,不能再次加入 + +### Requirement: REQ-INV-05 并发操作 SHALL 保持成员和邀请原子一致 + +并发操作 SHALL 保持成员和邀请原子一致。 + +邀请创建、替换、作废和接受与成员添加/删除/转交、Namespace 冻结/归档/恢复/删除、账号合并在相同 Namespace 上须串行化。涉及用户和 Namespace 的写操作先按用户 ID 升序、再按 Namespace ID 升序加锁;创建/替换/作废锁操作者,接受锁接收者;纯 Namespace 生命周期与删除只锁 Namespace。接受时成员创建、邀请消费和审计同一事务完成;并发兑换最多一名非成员成功,不能产生“已消费但无成员”。批量涉及多个 Namespace 的操作按去重 ID 升序锁定。 + +#### Scenario: 两人同时兑换 +- **WHEN** 两个非成员同时确认同一链接 +- **THEN** 最多一人新增 MEMBER,另一人收到统一失效结果 + +#### Scenario: 管理员直接添加与邀请并发 +- **WHEN** 管理员直接添加同一用户和该用户兑换邀请同时发生 +- **THEN** 不出现未处理的唯一约束异常、重复成员或错误消费 + +### Requirement: REQ-INV-06 前端 SHALL 完成链接分享和状态闭环 + +前端 SHALL 完成链接分享、登录回跳和邀请状态闭环。 + +管理页提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。 + +#### Scenario: 剪贴板写入失败 +- **WHEN** 创建成功但浏览器拒绝写剪贴板 +- **THEN** 页面显示同一链接供手动复制,不再次生成邀请 + +#### Scenario: 网络错误 +- **WHEN** 预览或接受遇到可重试网络错误 +- **THEN** 页面保留 Token,用户可重试而无需新邀请 diff --git a/openspec/changes/add-namespace-invite-links/tasks.md b/openspec/changes/add-namespace-invite-links/tasks.md new file mode 100644 index 00000000..ebefc1b5 --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/tasks.md @@ -0,0 +1,17 @@ +## 1. 持久化与事务 + +- [ ] 1.1 增加当前邀请表、Token 哈希、时效和审计迁移。 +- [ ] 1.2 实现 Namespace 根锁,并让成员、生命周期、删除和账号合并路径遵循统一锁顺序。 +- [ ] 1.3 实现创建/替换/作废/预览/接受,接受时原子新增成员、消费邀请与写审计。 + +## 2. API 与页面 + +- [ ] 2.1 新增仅 Session 可调用且受 CSRF 保护的邀请接口,重新生成 OpenAPI 类型。 +- [ ] 2.2 在成员管理入口添加邀请和作废动作,替换前确认,复制失败展示原链接。 +- [ ] 2.3 实现 `/join` 的 fragment 捕获、同标签页登录回跳、确认加入和失效状态;覆盖 BASE_PATH 与三语文案。 + +## 3. 验证与交付 + +- [ ] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。 +- [ ] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。 +- [ ] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java new file mode 100644 index 00000000..aa07b4af --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java @@ -0,0 +1,88 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.MessageResponse; +import com.iflytek.skillhub.dto.NamespaceInvitationTokenRequest; +import com.iflytek.skillhub.dto.NamespaceInvitationCreateRequest; +import com.iflytek.skillhub.exception.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping({"/api/v1", "/api/web"}) +public class NamespaceInvitationController extends BaseApiController { + private final NamespaceInvitationService invitations; + + public NamespaceInvitationController(NamespaceInvitationService invitations, ApiResponseFactory responseFactory) { + super(responseFactory); + this.invitations = invitations; + } + + @GetMapping("/namespaces/{slug}/invitation") + public ApiResponse status(@PathVariable String slug, @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + return ok("response.success.read", new InvitationStatus(invitations.hasActiveInvitation(slug, principal.userId()))); + } + + @PostMapping("/namespaces/{slug}/invitation") + public ApiResponse create(@PathVariable String slug, + @RequestBody(required = false) NamespaceInvitationCreateRequest body, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + return ok("response.success.created", invitations.create(slug, principal.userId(), body != null && body.replace())); + } + + @DeleteMapping("/namespaces/{slug}/invitation") + public ApiResponse revoke(@PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + invitations.revoke(slug, principal.userId()); + return ok("response.success.deleted", new MessageResponse("Invitation revoked")); + } + + @PostMapping("/invitations/preview") + public ApiResponse preview(@Valid @RequestBody NamespaceInvitationTokenRequest body, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + return ok("response.success.read", invitations.preview(body.token(), principal.userId())); + } + + @PostMapping("/invitations/accept") + public ApiResponse accept(@Valid @RequestBody NamespaceInvitationTokenRequest body, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + return ok("response.success.updated", invitations.accept(body.token(), principal.userId())); + } + + private void requireSession(PlatformPrincipal principal, HttpServletRequest request) { + if (principal == null || "api_token".equals(principal.oauthProvider()) || request.getSession(false) == null) { + throw new UnauthorizedException("error.auth.required"); + } + } + + private void noStore(HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store, private"); + response.setHeader("Pragma", "no-cache"); + } + + public record InvitationStatus(boolean active) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationCreateRequest.java new file mode 100644 index 00000000..ed247002 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationCreateRequest.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.dto; + +public record NamespaceInvitationCreateRequest(boolean replace) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationTokenRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationTokenRequest.java new file mode 100644 index 00000000..3f87ff6f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationTokenRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record NamespaceInvitationTokenRequest(@NotBlank String token) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java index e67cd87f..1286096d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; @@ -48,6 +49,7 @@ public class AdminNamespaceAppService { private final NamespaceGovernanceService namespaceGovernanceService; private final NamespaceMemberService namespaceMemberService; private final NamespaceMemberRepository namespaceMemberRepository; + private final NamespaceRepository namespaceRepository; private final NamespaceMemberCandidateService namespaceMemberCandidateService; private final NamespaceAccessPolicy namespaceAccessPolicy; private final UserAccountRepository userAccountRepository; @@ -57,6 +59,7 @@ public class AdminNamespaceAppService { NamespaceGovernanceService namespaceGovernanceService, NamespaceMemberService namespaceMemberService, NamespaceMemberRepository namespaceMemberRepository, + NamespaceRepository namespaceRepository, NamespaceMemberCandidateService namespaceMemberCandidateService, NamespaceAccessPolicy namespaceAccessPolicy, UserAccountRepository userAccountRepository) { @@ -65,6 +68,7 @@ public class AdminNamespaceAppService { this.namespaceGovernanceService = namespaceGovernanceService; this.namespaceMemberService = namespaceMemberService; this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceRepository = namespaceRepository; this.namespaceMemberCandidateService = namespaceMemberCandidateService; this.namespaceAccessPolicy = namespaceAccessPolicy; this.userAccountRepository = userAccountRepository; @@ -139,7 +143,7 @@ public class AdminNamespaceAppService { @Transactional public MemberResponse addMember(String slug, MemberRequest request, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Namespace namespace = lockedMutableTeamNamespace(slug, List.of(request.userId())); if (request.role() == NamespaceRole.OWNER) { throw new DomainBadRequestException("error.namespace.member.owner.assignDirect"); } @@ -152,7 +156,7 @@ public class AdminNamespaceAppService { @Transactional public BatchMemberResponse batchAddMembers(String slug, BatchMemberRequest request, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Namespace namespace = lockedMutableTeamNamespace(slug, request.members().stream().map(MemberRequest::userId).toList()); List results = new ArrayList<>(); int successCount = 0; int failureCount = 0; @@ -178,7 +182,7 @@ public class AdminNamespaceAppService { @Transactional public MemberResponse updateMemberRole(String slug, String userId, UpdateMemberRoleRequest request, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Namespace namespace = lockedMutableTeamNamespace(slug, List.of(userId)); if (request.role() == NamespaceRole.OWNER) { throw new DomainBadRequestException("error.namespace.member.owner.setDirect"); } @@ -194,7 +198,7 @@ public class AdminNamespaceAppService { @Transactional public MessageResponse removeMember(String slug, String userId, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Namespace namespace = lockedMutableTeamNamespace(slug, List.of(userId)); NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), userId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.member.notFound")); if (member.getRole() == NamespaceRole.OWNER) { @@ -206,7 +210,12 @@ public class AdminNamespaceAppService { @Transactional public MessageResponse transferOwnership(String slug, String newOwnerId, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Long namespaceId = namespaceRepository.findIdBySlug(slug) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + String oldOwnerId = namespaceMemberRepository.findByNamespaceIdAndRoleIn(namespaceId, List.of(NamespaceRole.OWNER)) + .stream().findFirst().map(NamespaceMember::getUserId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.current.notFound")); + Namespace namespace = lockedMutableTeamNamespace(slug, List.of(oldOwnerId, newOwnerId)); NamespaceMember currentOwner = namespaceMemberRepository.findByNamespaceIdAndRoleIn(namespace.getId(), List.of(NamespaceRole.OWNER)) .stream() .findFirst() @@ -295,15 +304,19 @@ public class AdminNamespaceAppService { .collect(Collectors.toMap(NamespaceMember::getNamespaceId, NamespaceMember::getRole)); } - private Namespace requireMutableTeamNamespace(String slug) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - if (namespaceAccessPolicy.isImmutable(namespace)) { - throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug()); + private Namespace lockedMutableTeamNamespace(String slug, List userIds) { + Long namespaceId = namespaceRepository.findIdBySlug(slug) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + userIds.stream().distinct().sorted().forEach(userAccountRepository::findLockedById); + Namespace locked = namespaceRepository.findLockedById(namespaceId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + if (namespaceAccessPolicy.isImmutable(locked)) { + throw new DomainBadRequestException("error.namespace.system.immutable", slug); } - if (!namespaceAccessPolicy.canManageMembers(namespace)) { - throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + if (!namespaceAccessPolicy.canManageMembers(locked)) { + throw new DomainBadRequestException("error.namespace.readonly", slug); } - return namespace; + return locked; } private NamespaceStatus parseStatus(String status) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java index 4bf8af91..b5ef6468 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java @@ -84,8 +84,7 @@ public class NamespacePortalCommandAppService { @Transactional public MessageResponse deleteNamespace(String slug, String userId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - namespaceService.deleteNamespace(namespace.getId(), userId); + namespaceService.deleteNamespace(namespaceId(slug), userId); return new MessageResponse("Namespace deleted successfully"); } @@ -147,9 +146,8 @@ public class NamespacePortalCommandAppService { @Transactional public MemberResponse addMember(String slug, String memberUserId, com.iflytek.skillhub.domain.namespace.NamespaceRole role, String operatorUserId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); NamespaceMember member = namespaceMemberService.addMember( - namespace.getId(), + namespaceId(slug), memberUserId, role, operatorUserId @@ -161,8 +159,7 @@ public class NamespacePortalCommandAppService { // Intentionally not @Transactional: each addMember runs in its own transaction // so partial success is possible (some members added even if others fail). public BatchMemberResponse batchAddMembers(String slug, List members, String operatorUserId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - Long namespaceId = namespace.getId(); + Long namespaceId = namespaceId(slug); List results = new ArrayList<>(); int successCount = 0; @@ -195,8 +192,7 @@ public class NamespacePortalCommandAppService { @Transactional public MessageResponse removeMember(String slug, String memberUserId, String operatorUserId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - namespaceMemberService.removeMember(namespace.getId(), memberUserId, operatorUserId); + namespaceMemberService.removeMember(namespaceId(slug), memberUserId, operatorUserId); return new MessageResponse("Member removed successfully"); } @@ -205,9 +201,8 @@ public class NamespacePortalCommandAppService { String userId, UpdateMemberRoleRequest request, String operatorUserId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); NamespaceMember member = namespaceMemberService.updateMemberRole( - namespace.getId(), + namespaceId(slug), userId, request.role(), operatorUserId @@ -217,8 +212,7 @@ public class NamespacePortalCommandAppService { @Transactional public MessageResponse transferOwnership(String slug, String newOwnerId, String currentOwnerId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - namespaceMemberService.transferOwnership(namespace.getId(), currentOwnerId, newOwnerId); + namespaceMemberService.transferOwnership(namespaceId(slug), currentOwnerId, newOwnerId); return new MessageResponse("Ownership transferred successfully"); } @@ -226,4 +220,10 @@ public class NamespacePortalCommandAppService { return principal.platformRoles().contains("SKILL_ADMIN") || principal.platformRoles().contains("SUPER_ADMIN"); } + + private Long namespaceId(String slug) { + return namespaceRepository.findIdBySlug(slug) + .orElseThrow(() -> new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException( + "error.namespace.slug.notFound", slug)); + } } diff --git a/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql b/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql index 3b6ac7a4..ed42c0be 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V70__namespace_invitation.sql @@ -1,6 +1,6 @@ CREATE TABLE namespace_invitation ( namespace_id BIGINT PRIMARY KEY REFERENCES namespace(id) ON DELETE CASCADE, - token_hash CHAR(64) NOT NULL UNIQUE, + token_hash VARCHAR(64) NOT NULL UNIQUE, issued_by VARCHAR(128) NOT NULL, issued_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL, diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 24bf9324..b7555086 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -88,6 +88,8 @@ label.translation.locale.conflict=Duplicate label translation locale error.namespace.slug.exists=Namespace slug ''{0}'' already exists error.namespace.id.notFound=Namespace not found: {0} error.namespace.slug.notFound=Namespace not found: {0} +error.namespace.invite.invalid=Invitation not found or expired +error.namespace.invite.replaceConfirmationRequired=Confirm replacement of the current invitation error.namespace.membership.required=Namespace membership required error.namespace.global.members.platformAdmin.required=Only platform user administrators can list global namespace members error.namespace.admin.required=Namespace owner or admin role required diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 566d443e..496c2c31 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -85,6 +85,8 @@ label.translation.locale.conflict=Дублирующаяся локаль пер error.namespace.slug.exists=Slug пространства имён ''{0}'' уже существует error.namespace.id.notFound=Пространство имён не найдено: {0} error.namespace.slug.notFound=Пространство имён не найдено: {0} +error.namespace.invite.invalid=Приглашение не найдено или недействительно +error.namespace.invite.replaceConfirmationRequired=Подтвердите замену текущего приглашения error.namespace.membership.required=Требуется членство в пространстве имён error.namespace.global.members.platformAdmin.required=Только администраторы пользователей платформы могут просматривать участников глобального пространства имён error.namespace.admin.required=Требуется роль владельца или администратора пространства имён diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 90bad54b..046a459b 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -88,6 +88,8 @@ label.translation.locale.conflict=标签翻译 locale 重复 error.namespace.slug.exists=命名空间 slug ''{0}'' 已存在 error.namespace.id.notFound=未找到命名空间:{0} error.namespace.slug.notFound=未找到命名空间:{0} +error.namespace.invite.invalid=邀请不存在或已失效 +error.namespace.invite.replaceConfirmationRequired=请先确认替换当前邀请 error.namespace.membership.required=需要先加入该命名空间 error.namespace.global.members.platformAdmin.required=只有平台用户管理员可以查看 global 命名空间成员 error.namespace.admin.required=需要命名空间管理员或所有者权限 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java new file mode 100644 index 00000000..8ff1e84a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java @@ -0,0 +1,52 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.portal.NamespaceInvitationController; +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.NamespaceInvitationCreateRequest; +import com.iflytek.skillhub.exception.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +class NamespaceInvitationControllerTest { + private final NamespaceInvitationService invitations = mock(NamespaceInvitationService.class); + private final ApiResponseFactory responses = mock(ApiResponseFactory.class); + private final NamespaceInvitationController controller = new NamespaceInvitationController(invitations, responses); + private final HttpServletRequest request = mock(HttpServletRequest.class); + private final HttpServletResponse response = mock(HttpServletResponse.class); + + @Test + void refusesApiTokenEvenWhenItCarriesAnAdminPlatformRole() { + when(request.getSession(false)).thenReturn(mock(HttpSession.class)); + PlatformPrincipal token = new PlatformPrincipal("admin", "Admin", null, null, "api_token", Set.of("SUPER_ADMIN")); + assertThatThrownBy(() -> controller.create("team", new NamespaceInvitationCreateRequest(true), token, request, response)) + .isInstanceOf(UnauthorizedException.class); + verifyNoInteractions(invitations); + } + + @Test + void refusesAuthenticationWithoutAnExistingBrowserSession() { + PlatformPrincipal principal = new PlatformPrincipal("owner", "Owner", null, null, "local", Set.of()); + assertThatThrownBy(() -> controller.preview(new com.iflytek.skillhub.dto.NamespaceInvitationTokenRequest("secret"), + principal, request, response)).isInstanceOf(UnauthorizedException.class); + verifyNoInteractions(invitations); + } + + @Test + void sendsNoStoreHeadersBeforeReturningAnInvitation() { + when(request.getSession(false)).thenReturn(mock(HttpSession.class)); + PlatformPrincipal principal = new PlatformPrincipal("owner", "Owner", null, null, "local", Set.of()); + controller.create("team", new NamespaceInvitationCreateRequest(false), principal, request, response); + verify(response).setHeader("Cache-Control", "no-store, private"); + verify(response).setHeader("Pragma", "no-cache"); + verify(invitations).create("team", "owner", false); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java new file mode 100644 index 00000000..6fdbc8d4 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java @@ -0,0 +1,108 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.infra.jpa.NamespaceInvitationJpaRepository; +import com.iflytek.skillhub.infra.jpa.NamespaceJpaRepository; +import com.iflytek.skillhub.infra.jpa.NamespaceMemberJpaRepository; +import com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository; +import java.time.Clock; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Import({NamespaceInvitationService.class, AuditLogService.class, NamespaceInvitationPostgresTest.ClockConfig.class}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class NamespaceInvitationPostgresTest { + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void postgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + + @TestConfiguration + static class ClockConfig { + @Bean Clock clock() { return Clock.systemUTC(); } + } + + @Autowired private NamespaceInvitationService invitations; + @Autowired private NamespaceInvitationJpaRepository invitationRows; + @Autowired private NamespaceJpaRepository namespaces; + @Autowired private NamespaceMemberJpaRepository members; + @Autowired private UserAccountJpaRepository users; + @Autowired private PlatformTransactionManager transactionManager; + + @Test + void twoConcurrentAcceptsCreateOnlyOneMemberAndOneConsumption() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String first = "inv-a-" + suffix; + String second = "inv-b-" + suffix; + Long namespaceId = new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(first, first, null, null)); + users.saveAndFlush(new UserAccount(second, second, null, null)); + Namespace namespace = namespaces.saveAndFlush(new Namespace("inv-" + suffix, "Invitation test", owner)); + members.saveAndFlush(new NamespaceMember(namespace.getId(), owner, NamespaceRole.OWNER)); + return namespace.getId(); + }); + String token = invitations.create("inv-" + suffix, owner).token(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var one = pool.submit(() -> attempt(start, token, first)); + var two = pool.submit(() -> attempt(start, token, second)); + start.countDown(); + assertThat(one.get(20, TimeUnit.SECONDS) + two.get(20, TimeUnit.SECONDS)).isEqualTo(1); + } + assertThat(members.findByNamespaceId(namespaceId, org.springframework.data.domain.Pageable.unpaged()) + .getContent().stream().filter(member -> member.getRole() == NamespaceRole.MEMBER)).hasSize(1); + assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedBy()).isIn(first, second); + } + + private int attempt(CountDownLatch start, String token, String userId) throws InterruptedException { + start.await(); + try { + invitations.accept(token, userId); + return 1; + } catch (DomainNotFoundException expected) { + return 0; + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java index 149f885d..16012dba 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; @@ -40,6 +41,7 @@ class AdminNamespaceAppServiceTest { private final NamespaceGovernanceService namespaceGovernanceService = mock(NamespaceGovernanceService.class); private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class); private final NamespaceMemberRepository namespaceMemberRepository = mock(NamespaceMemberRepository.class); + private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); private final NamespaceMemberCandidateService namespaceMemberCandidateService = mock(NamespaceMemberCandidateService.class); private final NamespaceAccessPolicy namespaceAccessPolicy = mock(NamespaceAccessPolicy.class); private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); @@ -49,6 +51,7 @@ class AdminNamespaceAppServiceTest { namespaceGovernanceService, namespaceMemberService, namespaceMemberRepository, + namespaceRepository, namespaceMemberCandidateService, namespaceAccessPolicy, userAccountRepository @@ -84,6 +87,8 @@ class AdminNamespaceAppServiceTest { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); NamespaceMember saved = member(10L, 1L, "user-2", NamespaceRole.ADMIN); when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")).thenReturn(Optional.empty()); @@ -100,6 +105,8 @@ class AdminNamespaceAppServiceTest { void updateMemberRoleRejectsDirectOwnerAssignment() { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); @@ -118,6 +125,8 @@ class AdminNamespaceAppServiceTest { NamespaceMember currentOwner = member(10L, 1L, "owner-1", NamespaceRole.OWNER); NamespaceMember newOwner = member(11L, 1L, "admin-1", NamespaceRole.ADMIN); when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndRoleIn(1L, List.of(NamespaceRole.OWNER))) @@ -138,6 +147,8 @@ class AdminNamespaceAppServiceTest { void globalNamespaceCannotBeMutatedFromAdminMemberApi() { Namespace global = namespace(1L, "global", NamespaceStatus.ACTIVE, NamespaceType.GLOBAL); when(namespaceService.getNamespaceBySlug("global")).thenReturn(global); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(global)); + when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L)); when(namespaceAccessPolicy.isImmutable(global)).thenReturn(true); assertThatThrownBy(() -> service.addMember( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java index 1f4ad1ac..b069b1b0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java @@ -80,7 +80,7 @@ class NamespacePortalCommandAppServiceTest { @Test void deleteNamespace_delegatesToDomainService() { Namespace namespace = namespace(7L, "team-alpha"); - when(namespaceService.getNamespaceBySlug("team-alpha")).thenReturn(namespace); + when(namespaceRepository.findIdBySlug("team-alpha")).thenReturn(Optional.of(7L)); MessageResponse response = service.deleteNamespace("team-alpha", "owner-1"); @@ -102,7 +102,7 @@ class NamespacePortalCommandAppServiceTest { ReflectionTestUtils.setField(member, "id", 10L); UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberService.addMember(1L, "user-2", NamespaceRole.ADMIN, "owner-1")) .thenReturn(member); when(userAccountRepository.findById("user-2")) @@ -122,7 +122,7 @@ class NamespacePortalCommandAppServiceTest { NamespaceMember member = new NamespaceMember(1L, "ghost", NamespaceRole.MEMBER); ReflectionTestUtils.setField(member, "id", 20L); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberService.addMember(1L, "ghost", NamespaceRole.MEMBER, "owner-1")) .thenReturn(member); when(userAccountRepository.findById("ghost")) @@ -142,7 +142,7 @@ class NamespacePortalCommandAppServiceTest { ReflectionTestUtils.setField(member, "id", 10L); UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberService.updateMemberRole(1L, "user-2", NamespaceRole.OWNER, "owner-1")) .thenReturn(member); when(userAccountRepository.findById("user-2")) @@ -166,7 +166,7 @@ class NamespacePortalCommandAppServiceTest { NamespaceMember member = new NamespaceMember(1L, "ghost", NamespaceRole.MEMBER); ReflectionTestUtils.setField(member, "id", 20L); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberService.updateMemberRole(1L, "ghost", NamespaceRole.ADMIN, "owner-1")) .thenReturn(member); when(userAccountRepository.findById("ghost")) @@ -186,7 +186,7 @@ class NamespacePortalCommandAppServiceTest { @Test void transferOwnership_success() { Namespace ns = namespace(1L, "team-a"); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); MessageResponse result = service.transferOwnership("team-a", "new-owner", "current-owner"); @@ -197,7 +197,7 @@ class NamespacePortalCommandAppServiceTest { @Test void transferOwnership_nonOwnerFails() { Namespace ns = namespace(1L, "team-a"); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); doThrow(new DomainBadRequestException("error.namespace.owner.current.invalid")) .when(namespaceMemberService).transferOwnership(1L, "non-owner", "new-owner"); @@ -208,7 +208,7 @@ class NamespacePortalCommandAppServiceTest { @Test void transferOwnership_targetNotFoundFails() { Namespace ns = namespace(1L, "team-a"); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); doThrow(new DomainBadRequestException("error.namespace.owner.new.notFound")) .when(namespaceMemberService).transferOwnership(1L, "owner-1", "non-existent"); @@ -220,7 +220,7 @@ class NamespacePortalCommandAppServiceTest { void transferOwnership_frozenNamespaceFails() { Namespace ns = namespace(1L, "team-a"); ns.setStatus(NamespaceStatus.FROZEN); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); doThrow(new DomainBadRequestException("error.namespace.readonly", "team-a")) .when(namespaceMemberService).transferOwnership(1L, "owner-1", "new-owner"); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java index b5de4dbe..ecfd3794 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java @@ -12,6 +12,7 @@ import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -49,6 +50,7 @@ public class AccountMergeService { private final UserRoleBindingRepository userRoleBindingRepository; private final ApiTokenRepository apiTokenRepository; private final NamespaceMemberRepository namespaceMemberRepository; + private final NamespaceRepository namespaceRepository; private final Clock clock; public AccountMergeService(AccountMergeRequestRepository mergeRequestRepository, @@ -58,6 +60,7 @@ public class AccountMergeService { UserRoleBindingRepository userRoleBindingRepository, ApiTokenRepository apiTokenRepository, NamespaceMemberRepository namespaceMemberRepository, + NamespaceRepository namespaceRepository, Clock clock) { this.mergeRequestRepository = mergeRequestRepository; this.userAccountRepository = userAccountRepository; @@ -66,6 +69,7 @@ public class AccountMergeService { this.userRoleBindingRepository = userRoleBindingRepository; this.apiTokenRepository = apiTokenRepository; this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceRepository = namespaceRepository; this.clock = clock; } @@ -287,6 +291,8 @@ public class AccountMergeService { private void migrateNamespaceMemberships(String primaryUserId, String secondaryUserId) { List secondaryMemberships = namespaceMemberRepository.findByUserId(secondaryUserId); + secondaryMemberships.stream().map(NamespaceMember::getNamespaceId).distinct().sorted() + .forEach(namespaceRepository::findLockedById); for (NamespaceMember secondaryMembership : secondaryMemberships) { Optional existingPrimaryMembership = namespaceMemberRepository .findByNamespaceIdAndUserId(secondaryMembership.getNamespaceId(), primaryUserId); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java index 08a6d480..49d678b5 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java @@ -20,6 +20,7 @@ import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -52,6 +53,8 @@ class AccountMergeServiceTest { private ApiTokenRepository apiTokenRepository; @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private NamespaceRepository namespaceRepository; private AccountMergeService service; private Clock clock; @@ -66,6 +69,7 @@ class AccountMergeServiceTest { userRoleBindingRepository, apiTokenRepository, namespaceMemberRepository, + namespaceRepository, clock ); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java index 1ef7d247..5b251a91 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.domain.namespace; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,21 +14,26 @@ public class GlobalNamespaceMembershipService { private final NamespaceRepository namespaceRepository; private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; public GlobalNamespaceMembershipService(NamespaceRepository namespaceRepository, - NamespaceMemberRepository namespaceMemberRepository) { + NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; } @Transactional public void ensureMember(String userId) { - Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE_SLUG) + userAccountRepository.findLockedById(userId); + Long namespaceId = namespaceRepository.findIdBySlug(GLOBAL_NAMESPACE_SLUG) .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); + namespaceRepository.findLockedById(namespaceId); - namespaceMemberRepository.findByNamespaceIdAndUserId(globalNamespace.getId(), userId) + namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) .orElseGet(() -> namespaceMemberRepository.save( - new NamespaceMember(globalNamespace.getId(), userId, NamespaceRole.MEMBER) + new NamespaceMember(namespaceId, userId, NamespaceRole.MEMBER) )); } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java index 984fe4fc..4e0072fd 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.audit.AuditDetail; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,15 +19,18 @@ public class NamespaceGovernanceService { private final NamespaceMemberRepository namespaceMemberRepository; private final NamespaceAccessPolicy namespaceAccessPolicy; private final AuditLogService auditLogService; + private final UserAccountRepository userAccountRepository; public NamespaceGovernanceService(NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, NamespaceAccessPolicy namespaceAccessPolicy, - AuditLogService auditLogService) { + AuditLogService auditLogService, + UserAccountRepository userAccountRepository) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.namespaceAccessPolicy = namespaceAccessPolicy; this.auditLogService = auditLogService; + this.userAccountRepository = userAccountRepository; } @Transactional @@ -36,6 +40,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); NamespaceRole role = requireRole(namespace.getId(), actorUserId); if (namespace.getStatus() != NamespaceStatus.ACTIVE) { @@ -57,6 +62,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); if (namespace.getStatus() != NamespaceStatus.ACTIVE) { throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); @@ -73,6 +79,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); NamespaceRole role = requireRole(namespace.getId(), actorUserId); if (namespace.getStatus() != NamespaceStatus.FROZEN) { @@ -93,6 +100,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); if (namespace.getStatus() != NamespaceStatus.FROZEN) { throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); @@ -110,6 +118,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); NamespaceRole role = requireRole(namespace.getId(), actorUserId); if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { @@ -131,6 +140,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); @@ -147,6 +157,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); NamespaceRole role = requireRole(namespace.getId(), actorUserId); if (namespace.getStatus() != NamespaceStatus.ARCHIVED) { @@ -167,6 +178,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); if (namespace.getStatus() != NamespaceStatus.ARCHIVED) { throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); @@ -178,7 +190,9 @@ public class NamespaceGovernanceService { } private Namespace loadNamespaceBySlug(String slug) { - Namespace namespace = namespaceRepository.findBySlug(slug) + Long namespaceId = namespaceRepository.findIdBySlug(slug) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + Namespace namespace = namespaceRepository.findLockedById(namespaceId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); if (namespaceAccessPolicy.isImmutable(namespace)) { throw new DomainBadRequestException("error.namespace.system.immutable", slug); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java index cf54afb3..36e0f99b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java @@ -5,6 +5,7 @@ import java.util.Optional; public interface NamespaceInvitationRepository { Optional findByNamespaceId(Long namespaceId); Optional findByTokenHash(String tokenHash); + Optional findNamespaceIdByTokenHash(String tokenHash); NamespaceInvitation save(NamespaceInvitation invitation); void deleteByNamespaceId(Long namespaceId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java index 877b94eb..0706f121 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.namespace; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; import org.springframework.stereotype.Service; @@ -42,6 +43,12 @@ public class NamespaceInvitationService { @Transactional public Created create(String slug, String actorId) { + return create(slug, actorId, false); + } + + @Transactional + public Created create(String slug, String actorId, boolean replace) { + requireActiveLockedUser(actorId); Namespace namespace = lock(slug); requireManager(namespace, actorId); requireActiveTeam(namespace); @@ -51,6 +58,10 @@ public class NamespaceInvitationService { Instant now = clock.instant(); NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElse(null); boolean replaced = invitation != null; + if (invitation != null && invitation.getConsumedAt() == null + && invitation.getExpiresAt().isAfter(now) && !replace) { + throw new DomainConflictException("error.namespace.invite.replaceConfirmationRequired"); + } if (invitation == null) { invitation = new NamespaceInvitation(namespace.getId(), hash(token), actorId, now, now.plusSeconds(86400)); } else { @@ -63,6 +74,7 @@ public class NamespaceInvitationService { @Transactional public void revoke(String slug, String actorId) { + requireActiveLockedUser(actorId); Namespace namespace = lock(slug); requireManager(namespace, actorId); requireActiveTeam(namespace); @@ -97,8 +109,11 @@ public class NamespaceInvitationService { @Transactional public Preview accept(String token, String userId) { - NamespaceInvitation located = current(token); - Namespace namespace = namespaces.findLockedById(located.getNamespaceId()).orElseThrow(this::invalid); + // Membership writers and account merge lock users before the Namespace root. + // The FK check on namespace_member would otherwise invert that order. + requireActiveLockedUser(userId); + Long namespaceId = locateNamespaceId(token); + Namespace namespace = namespaces.findLockedById(namespaceId).orElseThrow(this::invalid); NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElseThrow(this::invalid); if (!MessageDigest.isEqual(invitation.getTokenHash().getBytes(StandardCharsets.US_ASCII), hash(token).getBytes(StandardCharsets.US_ASCII))) { @@ -117,8 +132,8 @@ public class NamespaceInvitationService { } private Namespace lock(String slug) { - Namespace found = namespaces.findBySlug(slug).orElseThrow(this::invalid); - return namespaces.findLockedById(found.getId()).orElseThrow(this::invalid); + Long namespaceId = namespaces.findIdBySlug(slug).orElseThrow(this::invalid); + return namespaces.findLockedById(namespaceId).orElseThrow(this::invalid); } private NamespaceInvitation current(String token) { @@ -126,6 +141,11 @@ public class NamespaceInvitationService { return invitations.findByTokenHash(hash(token)).orElseThrow(this::invalid); } + private Long locateNamespaceId(String token) { + if (token == null || !token.matches("[A-Za-z0-9_-]{43}")) throw invalid(); + return invitations.findNamespaceIdByTokenHash(hash(token)).orElseThrow(this::invalid); + } + private void requireManager(Namespace namespace, String userId) { NamespaceRole role = members.findByNamespaceIdAndUserId(namespace.getId(), userId) .map(NamespaceMember::getRole).orElse(null); @@ -137,6 +157,10 @@ public class NamespaceInvitationService { if (users.findById(userId).filter(user -> user.getStatus() == UserStatus.ACTIVE).isEmpty()) throw invalid(); } + private void requireActiveLockedUser(String userId) { + if (users.findLockedById(userId).filter(user -> user.getStatus() == UserStatus.ACTIVE).isEmpty()) throw invalid(); + } + private void requireActiveTeam(Namespace namespace) { if (namespace.getStatus() != NamespaceStatus.ACTIVE || namespace.getType() != NamespaceType.TEAM) throw invalid(); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java index d8e8bc74..040f048b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.domain.namespace; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -18,17 +19,24 @@ public class NamespaceMemberService { private final NamespaceMemberRepository namespaceMemberRepository; private final NamespaceService namespaceService; private final NamespaceAccessPolicy namespaceAccessPolicy; + private final NamespaceRepository namespaceRepository; + private final UserAccountRepository userAccountRepository; public NamespaceMemberService(NamespaceMemberRepository namespaceMemberRepository, NamespaceService namespaceService, - NamespaceAccessPolicy namespaceAccessPolicy) { + NamespaceAccessPolicy namespaceAccessPolicy, + NamespaceRepository namespaceRepository, + UserAccountRepository userAccountRepository) { this.namespaceMemberRepository = namespaceMemberRepository; this.namespaceService = namespaceService; this.namespaceAccessPolicy = namespaceAccessPolicy; + this.namespaceRepository = namespaceRepository; + this.userAccountRepository = userAccountRepository; } @Transactional public NamespaceMember addMember(Long namespaceId, String userId, NamespaceRole role, String operatorUserId) { + lockUserThenNamespace(userId, namespaceId); assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); @@ -46,6 +54,7 @@ public class NamespaceMemberService { @Transactional public void removeMember(Long namespaceId, String userId, String operatorUserId) { + lockUserThenNamespace(userId, namespaceId); assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); @@ -61,6 +70,7 @@ public class NamespaceMemberService { @Transactional public NamespaceMember updateMemberRole(Long namespaceId, String userId, NamespaceRole newRole, String operatorUserId) { + lockUserThenNamespace(userId, namespaceId); assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); @@ -77,6 +87,14 @@ public class NamespaceMemberService { @Transactional public void transferOwnership(Long namespaceId, String currentOwnerId, String newOwnerId) { + if (currentOwnerId.compareTo(newOwnerId) <= 0) { + userAccountRepository.findLockedById(currentOwnerId); + userAccountRepository.findLockedById(newOwnerId); + } else { + userAccountRepository.findLockedById(newOwnerId); + userAccountRepository.findLockedById(currentOwnerId); + } + namespaceRepository.findLockedById(namespaceId); Namespace namespace = namespaceService.getNamespace(namespaceId); if (!namespaceAccessPolicy.canTransferOwnership(namespace)) { throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); @@ -117,4 +135,9 @@ public class NamespaceMemberService { throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); } } + + private void lockUserThenNamespace(String userId, Long namespaceId) { + userAccountRepository.findLockedById(userId); + namespaceRepository.findLockedById(namespaceId); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index d5bbedbc..b235d2f5 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -17,6 +17,7 @@ public interface NamespaceRepository { List findBySlugIn(List slugs); Page findByIdIn(List ids, Pageable pageable); Optional findBySlug(String slug); + Optional findIdBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); Namespace save(Namespace namespace); void delete(Namespace namespace); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java index e54379e0..764f30f3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java @@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,19 +23,22 @@ public class NamespaceService { private final SkillRepository skillRepository; private final ReviewTaskRepository reviewTaskRepository; private final PromotionRequestRepository promotionRequestRepository; + private final UserAccountRepository userAccountRepository; public NamespaceService(NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, NamespaceAccessPolicy namespaceAccessPolicy, SkillRepository skillRepository, ReviewTaskRepository reviewTaskRepository, - PromotionRequestRepository promotionRequestRepository) { + PromotionRequestRepository promotionRequestRepository, + UserAccountRepository userAccountRepository) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.namespaceAccessPolicy = namespaceAccessPolicy; this.skillRepository = skillRepository; this.reviewTaskRepository = reviewTaskRepository; this.promotionRequestRepository = promotionRequestRepository; + this.userAccountRepository = userAccountRepository; } /** @@ -43,6 +47,7 @@ public class NamespaceService { */ @Transactional public Namespace createNamespace(String slug, String displayName, String description, String creatorUserId) { + userAccountRepository.findLockedById(creatorUserId); SlugValidator.validate(slug); if (namespaceRepository.findBySlug(slug).isPresent()) { @@ -119,7 +124,8 @@ public class NamespaceService { */ @Transactional public void deleteNamespace(Long namespaceId, String operatorUserId) { - Namespace namespace = getNamespace(namespaceId); + Namespace namespace = namespaceRepository.findLockedById(namespaceId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); assertNotImmutable(namespace); NamespaceRole role = requireRole(namespaceId, operatorUserId); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java index c4e211bb..95ae6ad1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java @@ -24,12 +24,14 @@ class GlobalNamespaceMembershipServiceTest { @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository; private GlobalNamespaceMembershipService service; @BeforeEach void setUp() { - service = new GlobalNamespaceMembershipService(namespaceRepository, namespaceMemberRepository); + service = new GlobalNamespaceMembershipService(namespaceRepository, namespaceMemberRepository, userAccountRepository); } @Test @@ -37,7 +39,7 @@ class GlobalNamespaceMembershipServiceTest { Namespace global = new Namespace("global", "Global", "system"); setNamespaceId(global, 1L); - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.empty()); service.ensureMember("usr_1"); @@ -55,7 +57,7 @@ class GlobalNamespaceMembershipServiceTest { setNamespaceId(global, 1L); NamespaceMember existing = new NamespaceMember(1L, "usr_1", NamespaceRole.ADMIN); - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.of(existing)); service.ensureMember("usr_1"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java index 65290270..f164cc2e 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java @@ -3,7 +3,9 @@ package com.iflytek.skillhub.domain.namespace; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -30,14 +32,23 @@ class NamespaceGovernanceServiceTest { @Mock private AuditLogService auditLogService; + @Mock + private UserAccountRepository userAccountRepository; @InjectMocks private NamespaceGovernanceService governanceService; + @BeforeEach + void lockResolvedNamespace() { + org.mockito.Mockito.lenient().when(namespaceRepository.findLockedById(org.mockito.ArgumentMatchers.anyLong())) + .thenAnswer(call -> namespaceRepository.findBySlug("team-a")); + } + @Test void freezeNamespace_allowsAdminOnActiveTeamNamespace() { Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) .thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN))); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); @@ -54,6 +65,7 @@ class NamespaceGovernanceServiceTest { void archiveNamespace_rejectsAdminAndAllowsOnlyOwner() { Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) .thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN))); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); @@ -67,6 +79,7 @@ class NamespaceGovernanceServiceTest { void restoreNamespace_movesArchivedNamespaceBackToActive() { Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ARCHIVED); when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner-1")) .thenReturn(Optional.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); @@ -81,7 +94,8 @@ class NamespaceGovernanceServiceTest { @Test void freezeNamespace_rejectsGlobalNamespace() { Namespace namespace = namespace(1L, "global", NamespaceType.GLOBAL, NamespaceStatus.ACTIVE); - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L)); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); assertThrows(DomainBadRequestException.class, @@ -92,6 +106,7 @@ class NamespaceGovernanceServiceTest { void unfreezeNamespace_rejectsIllegalTransition() { Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner-1")) .thenReturn(Optional.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java index a2d78607..9e4895e2 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java @@ -39,6 +39,7 @@ class NamespaceInvitationServiceTest { throw new AssertionError(e); } when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(namespaces.findIdBySlug("team")).thenReturn(Optional.of(7L)); when(namespaces.findLockedById(7L)).thenReturn(Optional.of(namespace)); when(namespaces.findById(7L)).thenReturn(Optional.of(namespace)); when(members.findByNamespaceIdAndUserId(7L, "owner")) @@ -47,6 +48,8 @@ class NamespaceInvitationServiceTest { when(owner.getStatus()).thenReturn(com.iflytek.skillhub.domain.user.UserStatus.ACTIVE); when(users.findById("owner")).thenReturn(Optional.of(owner)); when(users.findById("guest")).thenReturn(Optional.of(owner)); + lenient().when(users.findLockedById("owner")).thenReturn(Optional.of(owner)); + lenient().when(users.findLockedById("guest")).thenReturn(Optional.of(owner)); } @Test @@ -65,4 +68,72 @@ class NamespaceInvitationServiceTest { .isInstanceOf(DomainNotFoundException.class); verify(invitations).findByTokenHash(any()); } + + @Test + void acceptCreatesMemberAndConsumesExactlyTheCurrentInvitation() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation invitation = saved.getValue(); + when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation)); + when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); + + assertThat(service.preview(created.token(), "guest").alreadyMember()).isFalse(); + assertThat(service.accept(created.token(), "guest").alreadyMember()).isFalse(); + + verify(members).save(argThat(member -> member.getRole() == NamespaceRole.MEMBER + && member.getNamespaceId().equals(7L) && member.getUserId().equals("guest"))); + assertThat(invitation.getConsumedBy()).isEqualTo("guest"); + assertThat(invitation.getConsumedAt()).isEqualTo(clock.instant()); + Optional guest = users.findById("guest"); + when(users.findLockedById("another")).thenReturn(guest); + when(users.findById("another")).thenReturn(guest); + assertThatThrownBy(() -> service.accept(created.token(), "another")) + .isInstanceOf(DomainNotFoundException.class); + } + + @Test + void existingMemberCanOpenCurrentConsumedInvitationWithoutReconsuming() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation invitation = saved.getValue(); + invitation.consume("someone", clock.instant()); + when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation)); + when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); + when(members.findByNamespaceIdAndUserId(7L, "guest")) + .thenReturn(Optional.of(new NamespaceMember(7L, "guest", NamespaceRole.MEMBER))); + + assertThat(service.preview(created.token(), "guest").alreadyMember()).isTrue(); + assertThat(service.accept(created.token(), "guest").alreadyMember()).isTrue(); + verify(members, never()).save(any()); + } + + @Test + void replacingCurrentInvitationMakesOldTokenInvalidAfterPreview() { + NamespaceInvitationService.Created first = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation invitation = saved.getValue(); + when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation)); + when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); + service.preview(first.token(), "guest"); + + service.create("team", "owner", true); + assertThatThrownBy(() -> service.accept(first.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(members, never()).save(any()); + } + + @Test + void revokeDeletesCurrentInvitationWithoutRemovingMembers() { + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(new NamespaceInvitation( + 7L, "a".repeat(64), "owner", clock.instant(), clock.instant().plusSeconds(86400)))); + service.revoke("team", "owner"); + verify(invitations).deleteByNamespaceId(7L); + verify(members, never()).deleteByNamespaceId(any()); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java index 84d823c1..5d897a3d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.namespace; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -22,6 +23,10 @@ class NamespaceMemberServiceTest { private NamespaceService namespaceService; @Mock private NamespaceAccessPolicy namespaceAccessPolicy; + @Mock + private NamespaceRepository namespaceRepository; + @Mock + private UserAccountRepository userAccountRepository; @InjectMocks private NamespaceMemberService namespaceMemberService; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java index 12c555f3..1631fa6c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java @@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -38,6 +39,8 @@ class NamespaceServiceTest { @Mock private PromotionRequestRepository promotionRequestRepository; + @Mock + private UserAccountRepository userAccountRepository; @InjectMocks private NamespaceService namespaceService; @@ -214,7 +217,7 @@ class NamespaceServiceTest { Long namespaceId = 1L; String operatorUserId = "owner-1"; Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); - when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findLockedById(namespaceId)).thenReturn(Optional.of(namespace)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) @@ -234,7 +237,7 @@ class NamespaceServiceTest { Long namespaceId = 1L; String operatorUserId = "admin-1"; Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); - when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findLockedById(namespaceId)).thenReturn(Optional.of(namespace)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.ADMIN)).thenReturn(false); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) @@ -252,7 +255,7 @@ class NamespaceServiceTest { Long namespaceId = 1L; String operatorUserId = "owner-1"; Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); - when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findLockedById(namespaceId)).thenReturn(Optional.of(namespace)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java index 074fa035..b305ef6e 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java @@ -3,6 +3,8 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.namespace.NamespaceInvitation; import com.iflytek.skillhub.domain.namespace.NamespaceInvitationRepository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -10,5 +12,7 @@ import java.util.Optional; @Repository public interface NamespaceInvitationJpaRepository extends JpaRepository, NamespaceInvitationRepository { Optional findByTokenHash(String tokenHash); + @Query("select invitation.namespaceId from NamespaceInvitation invitation where invitation.tokenHash = :tokenHash") + Optional findNamespaceIdByTokenHash(@Param("tokenHash") String tokenHash); void deleteByNamespaceId(Long namespaceId); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index 0f909e58..fd112bf3 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -29,5 +29,8 @@ public interface NamespaceJpaRepository List findBySlugIn(List slugs); Page findByIdIn(List ids, Pageable pageable); Optional findBySlug(String slug); + @Override + @Query("select n.id from Namespace n where n.slug = :slug") + Optional findIdBySlug(@Param("slug") String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 09f2fd9d..5b33c645 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -747,6 +747,37 @@ export const labelApi = { } export const namespaceApi = { + async invitationStatus(slug: string): Promise<{ active: boolean }> { + return fetchJson<{ active: boolean }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`) + }, + + async createInvitation(slug: string, replace: boolean): Promise<{ token: string; expiresAt: string }> { + return fetchJson<{ token: string; expiresAt: string }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`, { + method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ replace }), + }) + }, + + async revokeInvitation(slug: string): Promise { + await fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitation`, { + method: 'DELETE', headers: await ensureCsrfHeaders(), + }) + }, + + async previewInvitation(token: string): Promise<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }> { + return fetchJson<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }>(`${WEB_API_PREFIX}/invitations/preview`, { + method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ token }), + }) + }, + + async acceptInvitation(token: string): Promise<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }> { + return fetchJson<{ namespaceSlug: string; namespaceName: string; alreadyMember: boolean }>(`${WEB_API_PREFIX}/invitations/accept`, { + method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ token }), + }) + }, + async create(request: CreateNamespaceRequest): Promise { const namespace = await fetchJson('/api/v1/namespaces', { method: 'POST', diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 54573332..6a706d03 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -7,6 +7,7 @@ import { RouteError } from '@/shared/components/route-error' import { createRedirectAuthenticated, createRequireAuth, isSafeAuthReturnTo } from '@/shared/lib/auth-route' import { clearDynamicImportReloadGuard, recoverFromDynamicImportError } from '@/shared/lib/dynamic-import-recovery' import { normalizeSearchQuery } from '@/shared/lib/search-query' +import { captureInvitationFragment } from '@/shared/lib/namespace-invitation' /** * Central route registry for the SkillHub web app. @@ -16,6 +17,7 @@ import { normalizeSearchQuery } from '@/shared/lib/search-query' */ // Capture original URL before TanStack Router rewrites it const ORIGINAL_URL_SEARCH = typeof window !== 'undefined' ? window.location.search : '' +captureInvitationFragment() // Export for use in cli-auth page export { ORIGINAL_URL_SEARCH } @@ -95,6 +97,7 @@ const SuitesPage = createLazyRouteComponent(() => import('@/pages/suites'), 'Sui const SuiteDetailPage = createLazyRouteComponent(() => import('@/pages/suite-detail'), 'SuiteDetailPage') const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage') const NamespacePage = createLazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage') +const JoinPage = createLazyRouteComponent(() => import('@/pages/join'), 'JoinPage') const SkillDetailPage = createLazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage') const SkillVersionComparePage = createLazyRouteComponent(() => import('@/pages/skill-version-compare'), 'SkillVersionComparePage') const dashboardRouteOptions = { silentFallback: true } satisfies LazyRouteOptions @@ -323,6 +326,13 @@ const namespaceRoute = createRoute({ component: NamespacePage, }) +const joinRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'join', + beforeLoad: requireAuth, + component: JoinPage, +}) + const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/space/$namespace/$slug', @@ -678,6 +688,7 @@ const routeTree = rootRoute.addChildren([ suiteDetailRoute, termsRoute, namespaceRoute, + joinRoute, skillDetailRoute, skillVersionCompareRoute, dashboardRoute, diff --git a/web/src/features/namespace/invitation-controls.tsx b/web/src/features/namespace/invitation-controls.tsx new file mode 100644 index 00000000..b0e4fdc5 --- /dev/null +++ b/web/src/features/namespace/invitation-controls.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { getAppBaseUrl, namespaceApi } from '@/api/client' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { BASE_PATH } from '@/shared/lib/base-path' +import { invitationUrl } from '@/shared/lib/namespace-invitation' +import { resolvePublicRegistryUrl } from '@/shared/lib/registry-url' +import { toast } from '@/shared/lib/toast' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' + +export function InvitationControls({ slug }: { slug: string }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [confirm, setConfirm] = useState<'replace' | 'revoke' | null>(null) + const [link, setLink] = useState(null) + const status = useQuery({ queryKey: ['namespace-invitation-status', slug], queryFn: () => namespaceApi.invitationStatus(slug) }) + const create = useMutation({ + mutationFn: (replace: boolean) => namespaceApi.createInvitation(slug, replace), + onSuccess: async ({ token }) => { + await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }) + const url = invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), token) + setLink(url) + try { + await navigator.clipboard.writeText(url) + toast.success(t('invitation.copied')) + } catch { + toast.info(t('invitation.manualCopy')) + } + }, + }) + const revoke = useMutation({ + mutationFn: () => namespaceApi.revokeInvitation(slug), + onSuccess: async () => { + setLink(null) + await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }) + toast.success(t('invitation.revoked')) + }, + }) + const handleCreate = () => { + if (status.data?.active) setConfirm('replace') + else create.mutate(false) + } + return ( +
+
+ + {status.data?.active ? : null} +
+ {link ? ( +
+ + event.currentTarget.select()} /> +

{t('invitation.linkHint')}

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

{t('invitation.error')}

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

{t('invitation.title')}

+ {invalid ?

{t('invitation.invalid')}

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

{t('invitation.loading')}

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

{t('invitation.networkError')}

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

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

+

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

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

{t('invitation.alreadyMember')}

+ + + ) : ( + <> +

{t('invitation.permissions')}

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

{t('invitation.networkError')}

+ ) : null} + + )} +
+ ) : null} +
+
+ ) +} diff --git a/web/src/shared/lib/namespace-invitation.test.ts b/web/src/shared/lib/namespace-invitation.test.ts new file mode 100644 index 00000000..0b389281 --- /dev/null +++ b/web/src/shared/lib/namespace-invitation.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from 'vitest' +import { captureInvitationFragment, clearInvitationToken, invitationUrl, storedInvitationToken } from './namespace-invitation' + +const token = 'A'.repeat(43) + +describe('namespace invitation URL handoff', () => { + beforeEach(() => { + clearInvitationToken() + window.history.replaceState(null, '', '/') + }) + + it('moves a token from the fragment to tab-scoped storage before login routing', () => { + window.history.replaceState(null, '', `/join?from=mail#${token}`) + captureInvitationFragment() + expect(storedInvitationToken()).toBe(token) + expect(window.location.href).toContain('/join?from=mail') + expect(window.location.hash).toBe('') + }) + + it('rejects malformed fragments and still removes them from the URL', () => { + window.history.replaceState(null, '', '/join#bad-token') + captureInvitationFragment() + expect(storedInvitationToken()).toBeNull() + expect(window.location.hash).toBe('') + }) + + it('uses a resolved public base path in the shared link', () => { + expect(invitationUrl('https://example.com/skillhub', token)).toBe(`https://example.com/skillhub/join#${token}`) + }) +}) diff --git a/web/src/shared/lib/namespace-invitation.ts b/web/src/shared/lib/namespace-invitation.ts new file mode 100644 index 00000000..9a1b9140 --- /dev/null +++ b/web/src/shared/lib/namespace-invitation.ts @@ -0,0 +1,28 @@ +import { toRouterPath } from './base-path' + +const STORAGE_KEY = 'skillhub.namespace.invitation' + +// Run before route guards inspect the location: a login redirect must never copy the fragment. +export function captureInvitationFragment() { + if (typeof window === 'undefined' || !window.location.hash) return + if (toRouterPath(window.location.pathname) !== '/join') return + const token = window.location.hash.slice(1) + try { + if (/^[A-Za-z0-9_-]{43}$/.test(token)) window.sessionStorage.setItem(STORAGE_KEY, token) + } finally { + window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}`) + } +} + +export function storedInvitationToken(): string | null { + if (typeof window === 'undefined') return null + return window.sessionStorage.getItem(STORAGE_KEY) +} + +export function clearInvitationToken() { + if (typeof window !== 'undefined') window.sessionStorage.removeItem(STORAGE_KEY) +} + +export function invitationUrl(publicBase: string, token: string): string { + return `${publicBase}/join#${token}` +}