From dd4700bccd983c38a6d2f5bc185f3aaa074e4fb2 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:32:46 +0800 Subject: [PATCH] fix(web): explain pending scope and linked global skills Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/04-search-architecture.md | 7 +- docs/skill-visibility.md | 8 + .../design.md | 2 + .../proposal.md | 1 + .../specs/skill-visibility/spec.md | 14 ++ .../tasks.md | 9 +- .../skillhub/dto/SkillSummaryResponse.java | 31 ++- .../repository/JpaMySkillQueryRepository.java | 100 ++++++++ .../repository/MySkillQueryRepository.java | 5 + .../skillhub/service/MySkillAppService.java | 8 +- .../SkillVisibilityControllerTest.java | 4 + .../MySkillManagementPostgresTest.java | 236 ++++++++++++++++++ .../service/MySkillAppServiceTest.java | 18 +- web/src/api/generated/schema.d.ts | 9 + web/src/api/types.ts | 1 + web/src/i18n/locales/en.json | 6 + web/src/i18n/locales/ru.json | 6 + web/src/i18n/locales/zh.json | 6 + web/src/pages/dashboard/my-skills.test.ts | 38 ++- web/src/pages/dashboard/my-skills.tsx | 28 ++- 20 files changed, 519 insertions(+), 18 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/MySkillManagementPostgresTest.java diff --git a/docs/04-search-architecture.md b/docs/04-search-architecture.md index 32e6c2618..0090be0b8 100644 --- a/docs/04-search-architecture.md +++ b/docs/04-search-architecture.md @@ -37,8 +37,8 @@ public record SearchQuery( public record SearchVisibilityScope( boolean includeAllPublic, // 是否包含所有 PUBLIC 技能 Set memberNamespaceIds, // 用户是 MEMBER 的 namespace(可见 NAMESPACE_ONLY) - Set adminNamespaceIds, // 用户是 ADMIN 的 namespace(可见 PRIVATE) - String userId // 当前用户 ID(可见自己的 PRIVATE skill),匿名为 null + Set adminNamespaceIds, // 管理上下文,门户搜索不因此展示 PRIVATE + String userId // 当前用户 ID,匿名为 null;PRIVATE 在“我的技能”管理 ) {} ``` @@ -50,9 +50,10 @@ ACL 投影计算规则: ```sql WHERE (visibility = 'PUBLIC') OR (visibility = 'NAMESPACE_ONLY' AND namespace_id IN (:memberNamespaceIds)) - OR (visibility = 'PRIVATE' AND (namespace_id IN (:adminNamespaceIds) OR owner_id = :userId)) ``` +门户市场仅展示公开技能和当前用户所属空间的成员可见技能;即使本人或管理员有权访问 PRIVATE 详情,也不会将其列入市场。私有技能在“我的技能”管理。查询权限使用当前 `skill.visibility`,不信任可能尚未刷新的索引范围。 + 迁移到 ES 时,`SearchVisibilityScope` 可直接映射为 bool query 的 should/filter 子句。 ## 3 搜索文档表 skill_search_document diff --git a/docs/skill-visibility.md b/docs/skill-visibility.md index 4159eb916..556424b3d 100644 --- a/docs/skill-visibility.md +++ b/docs/skill-visibility.md @@ -19,6 +19,14 @@ 私有并不代表平台无法治理:技能所有者、拥有相应空间权限的管理员以及现有超级管理员访问规则仍然适用。 +## 列表中的当前范围和申请进度 + +「我的技能」会同时说明当前生效范围和待审目标,例如「当前范围:私有」以及「申请公开 · 审核中,批准前保持当前范围」。申请提交后刷新页面,仍可看到这项说明;批准、拒绝或撤回后,待审说明消失。申请公开不等于已经公开。「待审核」筛选同时包含待审内容版本和待审范围申请;同一技能只列一次,范围申请结束后退出该筛选(仍有待审内容版本时继续保留)。 + +私有技能不出现在技能市场,包括所有者自己的私有技能;请在「我的技能」管理。 + +已提升的团队技能仍保留,同时存在独立的全局副本。「我的技能」分别标明「团队来源」和「全局副本」,有权查看时提供互相跳转入口。团队来源再次提交时使用「更新全局版本」;两条技能的版本和统计仍各自独立,不会自动同步。 + ## 扩大范围后的审核 - 批准前仍按原范围访问;不提前出现在目标受众的搜索或下载结果中。 diff --git a/openspec/changes/change-published-skill-visibility/design.md b/openspec/changes/change-published-skill-visibility/design.md index cca94e557..c212c9a84 100644 --- a/openspec/changes/change-published-skill-visibility/design.md +++ b/openspec/changes/change-published-skill-visibility/design.md @@ -4,5 +4,7 @@ - 技能行锁协调提交、发布和审核;范围申请不修改 SkillVersion 状态和 latestVersionId。 - 审批 CAS 保留现有冲突机制。成功改变范围后通过专用范围变更事件复用现有搜索重建,避免重复发送内容更新通知;审计区分提交、通过、拒绝、撤回。 - Web 复用管理交互与统一审核详情;展示当前范围和申请范围。 +- 我的技能响应按当前页技能批量投影管理状态,提供待审范围及真实来源/全局关联。不得逐卡拉取审核或提升详情,不从同名推断关联;公共市场、收藏等摘要不附带这些私有管理信息。相关技能无访问权限时不暴露其坐标或跳转。 +- 团队来源与全局发布仍是独立记录,可分别更新;列表只解释现有关系,不引入自动同步或删除联动。市场维持现有收录范围,私有技能在“我的技能”维护。 - 业务约束在服务代码与测试维护,迁移只增加必要列,不增加 SQL CHECK。 - 已发布 PRIVATE 版本没有可复用的安全通过结果时,复用 ScanTask/outbox 对已存 bundle 重新扫描,保留 PUBLISHED 状态。范围申请绑定这一次 taskId;旧结果不能解锁新申请,未完成或不安全结果不能批准。scanner 关闭且需要新扫描时明确拒绝,不改变原范围。 diff --git a/openspec/changes/change-published-skill-visibility/proposal.md b/openspec/changes/change-published-skill-visibility/proposal.md index b418b8755..1e2f88972 100644 --- a/openspec/changes/change-published-skill-visibility/proposal.md +++ b/openspec/changes/change-published-skill-visibility/proposal.md @@ -8,6 +8,7 @@ Issue #951:当前必须重新上传包才能改变可见范围。用户需要 - 缩小范围立即生效;扩大范围进入现有统一技能审核,批准前继续使用原范围。 - 可见范围申请与待审内容版本互斥,支持在现有审核进度中撤回。 - 所有入口继续遵守访问控制、安全扫描、头像受众审核和操作审计。 +- 我的技能列表同时说明当前生效范围与待审目标;提升后通过真实关联区分团队来源和全局发布记录。 ## Impact 后端 ReviewTask、技能生命周期、发布互斥、搜索投影;Web 管理入口和统一审核详情;API 类型及用户文档。 diff --git a/openspec/changes/change-published-skill-visibility/specs/skill-visibility/spec.md b/openspec/changes/change-published-skill-visibility/specs/skill-visibility/spec.md index 14d818939..6fb5db710 100644 --- a/openspec/changes/change-published-skill-visibility/specs/skill-visibility/spec.md +++ b/openspec/changes/change-published-skill-visibility/specs/skill-visibility/spec.md @@ -27,3 +27,17 @@ #### Scenario: 审核期间发布版本改变 - **WHEN** 范围申请绑定的发布版本或原范围与当前技能不符 - **THEN** 审批拒绝过期申请,不能覆盖较新的状态 + +### Requirement: REQ-VIS-05 管理列表 SHALL 区分当前范围与待审目标 +我的技能列表 SHALL 显示当前生效范围;存在待审范围申请时 SHALL 同时说明申请目标与审核中状态,不将目标误作已经生效的范围。刷新、分页后 SHALL 保留正确状态。“待审核”筛选 SHALL 同时包含原内容待审与范围待审,计数与分页使用同一规则,同一技能不重复出现;其余分类沿用原内容状态。审批通过后显示新范围,拒绝或撤回后保留原范围并移除待审提示;不再有待审内容或范围时 SHALL 从待审核筛选退出。管理状态 SHALL 由服务端按当前页批量提供,不逐卡请求详情,不将私有审核元数据暴露到公共摘要。 + +#### Scenario: 列表中的私有转公开申请 +- **WHEN** 所有者提交将 PRIVATE 改为 PUBLIC 的申请并刷新“我的技能” +- **THEN** 卡片说明当前私有及申请公开审核中;公众仍无权访问;审批通过后卡片更新为公开且不再显示待审提示 + +### Requirement: REQ-VIS-06 提升关系 SHALL 可识别且保留独立记录 +我的技能列表 SHALL 基于真实来源关联区分团队来源与全局发布记录,在调用者有权访问关联技能时提供对应入口,不按相同名称推断关联。首次提升与已有全局记录的更新入口 SHALL 使用准确文案。该展示 SHALL 不改变已确认的双记录模型,不自动同步内容、版本、统计或权限。 + +#### Scenario: 同时管理团队来源和全局发布记录 +- **WHEN** 调用者的列表包含一组已提升的团队来源与全局技能 +- **THEN** 列表明确两条记录各自角色及有权查看的关联入口,用户可分别管理;不能查看来源时不通过关联展示泄露来源坐标 diff --git a/openspec/changes/change-published-skill-visibility/tasks.md b/openspec/changes/change-published-skill-visibility/tasks.md index 63b99a62d..5636f670c 100644 --- a/openspec/changes/change-published-skill-visibility/tasks.md +++ b/openspec/changes/change-published-skill-visibility/tasks.md @@ -6,6 +6,11 @@ ## 验证 - [x] 后端成功、拒绝、权限、并发及真实数据库迁移(开发反馈使用 Java 25;正式 Java 21 整合验收另记) - [x] 前端请求、状态、审核和类型检查 -- [ ] 本地构建部署后验证详情、搜索、下载一致性 +- [x] 本地构建部署后验证详情、搜索、下载一致性(上一轮已完成;本轮人工反馈补验见下) -开发检查覆盖扫描失败/旧 task、完整派生 bundle/缺文件拒绝、陈旧搜索索引、提升更新两事务陈旧实体与待审互斥。部署与真实浏览器验收尚未执行,不以单测代替。 +开发及运行验证已覆盖扫描失败/旧 task、完整派生 bundle/缺文件拒绝、陈旧搜索索引、提升更新两事务陈旧实体与待审互斥;实际执行版本、组合继承边界、浏览器及故障演练记录由 PR 验收报告说明。 + +## 人工反馈补充 +- [x] 按当前页批量提供待审范围与真实提升关联,更新实际 OpenAPI 类型 +- [x] 列表展示当前范围/待审目标及来源/全局角色,不改变市场收录或双记录模型 +- [ ] 覆盖分页、无权关联、批准/拒绝/撤回后的投影及实际页面交互 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index 1773bec49..7892a1394 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -33,9 +33,36 @@ public record SkillSummaryResponse( */ @JsonInclude(JsonInclude.Include.NON_NULL) List labels, - String avatarUrl + String avatarUrl, + @JsonInclude(JsonInclude.Include.NON_NULL) + ManagementState managementState ) { + /** Owner workspace projection, never populated by public discovery or social lists. */ + public record ManagementState(String requestedVisibility, Long reviewTaskId, + String promotionRole, String relatedNamespace, String relatedSlug) {} + + public SkillSummaryResponse( + Long id, String slug, String displayName, String summary, String visibility, String status, + Long downloadCount, Integer starCount, BigDecimal ratingAvg, Integer ratingCount, + String namespace, Instant updatedAt, String ownerId, String ownerDisplayName, + boolean canSubmitPromotion, SkillLifecycleVersionResponse headlineVersion, + SkillLifecycleVersionResponse publishedVersion, SkillLifecycleVersionResponse ownerPreviewVersion, + String resolutionMode, ComplianceSnapshotResponse complianceSnapshot, List labels, + String avatarUrl) { + this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg, + ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion, + headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, + labels, avatarUrl, null); + } + + public SkillSummaryResponse withManagementState(ManagementState state) { + return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount, + starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, + canSubmitPromotion, headlineVersion, publishedVersion, ownerPreviewVersion, + resolutionMode, complianceSnapshot, labels, avatarUrl, state); + } + public SkillSummaryResponse( Long id, String slug, String displayName, String summary, String visibility, String status, Long downloadCount, Integer starCount, BigDecimal ratingAvg, Integer ratingCount, @@ -107,6 +134,6 @@ public record SkillSummaryResponse( return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion, headlineVersion, - publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels, avatarUrl); + publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels, avatarUrl, managementState); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java index ce65d0b7a..3d1b4b65e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java @@ -24,6 +24,9 @@ import org.springframework.beans.factory.annotation.Autowired; @Repository public class JpaMySkillQueryRepository implements MySkillQueryRepository { + @jakarta.persistence.PersistenceContext + private jakarta.persistence.EntityManager entityManager; + private final NamespaceRepository namespaceRepository; private final PromotionRequestRepository promotionRequestRepository; private final SkillLifecycleProjectionService skillLifecycleProjectionService; @@ -64,6 +67,103 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository { .toList(); } + /** Page and count share this read-model predicate, including the existing owner-preview recency rule. */ + private static final String PENDING_FROM_WHERE = """ + FROM skill s + LEFT JOIN LATERAL ( + SELECT published.id, published.created_at FROM skill_version published + WHERE published.skill_id = s.id AND published.status = 'PUBLISHED' + ORDER BY CASE WHEN published.id = s.latest_version_id THEN 0 ELSE 1 END, + published.published_at DESC NULLS FIRST, published.created_at DESC, published.id DESC + LIMIT 1 + ) published ON TRUE + LEFT JOIN LATERAL ( + SELECT work.status FROM skill_version work + WHERE work.skill_id = s.id AND work.status NOT IN ('PUBLISHED', 'YANKED') + AND (published.id IS NULL OR (work.created_at, work.id) > (published.created_at, published.id)) + ORDER BY work.created_at DESC, work.id DESC LIMIT 1 + ) preview ON TRUE + WHERE s.owner_id = :ownerId AND s.hidden = FALSE AND s.status <> 'ARCHIVED' + AND (CAST(:namespaceId AS BIGINT) IS NULL OR s.namespace_id = :namespaceId) + AND (:keyword = '' OR POSITION(:keyword IN LOWER(COALESCE(s.display_name, ''))) > 0 + OR POSITION(:keyword IN LOWER(s.slug)) > 0 + OR POSITION(:keyword IN LOWER(COALESCE(s.summary, ''))) > 0) + AND (preview.status = 'PENDING_REVIEW' OR EXISTS ( + SELECT 1 FROM review_task r WHERE r.skill_id = s.id + AND r.requested_visibility IS NOT NULL AND r.status = 'PENDING' + )) + """; + + @Override + @org.springframework.transaction.annotation.Transactional(readOnly = true) + public org.springframework.data.domain.Page findPendingSkills(String ownerId, String keyword, + Long namespaceId, org.springframework.data.domain.Pageable pageable) { + String query = keyword == null ? "" : keyword.trim().toLowerCase(java.util.Locale.ROOT); + // Native PostgreSQL LATERAL mirrors the owner-preview resolver without per-skill lookups. + var rows = entityManager.createNativeQuery("SELECT s.* " + PENDING_FROM_WHERE + + " ORDER BY s.updated_at DESC, s.id DESC", Skill.class); + var count = entityManager.createNativeQuery("SELECT COUNT(*) " + PENDING_FROM_WHERE); + for (var statement : List.of(rows, count)) { + statement.setParameter("ownerId", ownerId).setParameter("keyword", query) + .setParameter("namespaceId", namespaceId); + } + @SuppressWarnings("unchecked") + List items = rows.setFirstResult((int) pageable.getOffset()).setMaxResults(pageable.getPageSize()).getResultList(); + return new org.springframework.data.domain.PageImpl<>(items, pageable, ((Number) count.getSingleResult()).longValue()); + } + + /** + * One page-bounded management read. Authorizing related coordinates by the same owner avoids + * revealing a private source through a public/global card. No per-card visibility requests. + */ + @Override + @org.springframework.transaction.annotation.Transactional(readOnly = true) + public Map getManagementStates(List skills, String ownerId) { + List ids = skills.stream().filter(skill -> ownerId.equals(skill.getOwnerId())) + .map(Skill::getId).toList(); + if (ids.isEmpty()) { + return Map.of(); + } + // App read-model joins stay in this query repository; domain repositories remain unchanged. + @SuppressWarnings("unchecked") + List rows = entityManager.createNativeQuery(""" + SELECT s.id, pending.requested_visibility, pending.id, + CASE WHEN s.source_skill_id IS NOT NULL THEN 'GLOBAL_COPY' + WHEN target_ns.id IS NOT NULL THEN 'TEAM_SOURCE' END, + CASE WHEN s.source_skill_id IS NOT NULL THEN source_ns.slug + WHEN target.visibility <> 'NAMESPACE_ONLY' OR EXISTS ( + SELECT 1 FROM namespace_member m + WHERE m.namespace_id = target.namespace_id AND m.user_id = :ownerId + ) THEN target_ns.slug END, + CASE WHEN s.source_skill_id IS NOT NULL THEN source.slug ELSE target.slug END + FROM skill s + LEFT JOIN LATERAL ( + SELECT r.id, r.requested_visibility FROM review_task r + WHERE r.skill_id = s.id AND r.requested_visibility IS NOT NULL AND r.status = 'PENDING' + ORDER BY r.id DESC LIMIT 1 + ) pending ON TRUE + LEFT JOIN skill source ON source.id = s.source_skill_id AND source.owner_id = :ownerId + AND source.hidden = FALSE AND source.status = 'ACTIVE' + AND (source.visibility <> 'NAMESPACE_ONLY' OR EXISTS ( + SELECT 1 FROM namespace_member m WHERE m.namespace_id = source.namespace_id AND m.user_id = :ownerId + )) + LEFT JOIN namespace source_ns ON source_ns.id = source.namespace_id AND source_ns.status = 'ACTIVE' + LEFT JOIN skill target ON target.source_skill_id = s.id AND target.owner_id = :ownerId + AND target.hidden = FALSE AND target.status = 'ACTIVE' + LEFT JOIN namespace target_ns ON target_ns.id = target.namespace_id + AND target_ns.type = 'GLOBAL' AND target_ns.status = 'ACTIVE' + WHERE s.id IN :ids AND s.owner_id = :ownerId + """) + .setParameter("ids", ids).setParameter("ownerId", ownerId).getResultList(); + Map states = new java.util.HashMap<>(); + for (Object[] row : rows) { + states.put(((Number) row[0]).longValue(), new SkillSummaryResponse.ManagementState( + (String) row[1], row[2] == null ? null : ((Number) row[2]).longValue(), + (String) row[3], (String) row[4], row[4] == null ? null : (String) row[5])); + } + return states; + } + @Override public List getHiddenSkillSummaries(List skills) { if (skills.isEmpty()) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillQueryRepository.java index a45b68217..5cd34aa7d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillQueryRepository.java @@ -11,5 +11,10 @@ import java.util.List; public interface MySkillQueryRepository { List getSkillSummaries(List skills, String currentUserId); + org.springframework.data.domain.Page findPendingSkills(String ownerId, String keyword, + Long namespaceId, org.springframework.data.domain.Pageable pageable); + + java.util.Map getManagementStates(List skills, String ownerId); + List getHiddenSkillSummaries(List skills); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index 7e96572e0..c5c321203 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -83,7 +83,9 @@ public class MySkillAppService { MySkillFilter normalizedFilter = parseFilter(filter); Page skillPage; - if (normalizedFilter == MySkillFilter.HIDDEN) { + if (normalizedFilter == MySkillFilter.PENDING_REVIEW) { + skillPage = mySkillQueryRepository.findPendingSkills(userId, keyword, resolveNamespaceId(namespace), PageRequest.of(page, size)); + } else if (normalizedFilter == MySkillFilter.HIDDEN) { skillPage = listHiddenSkills(page, size, keyword, namespace, platformRoles); } else if (normalizedFilter == MySkillFilter.ALL && (keyword == null || keyword.isBlank()) @@ -97,6 +99,10 @@ public class MySkillAppService { ? mySkillQueryRepository.getHiddenSkillSummaries(skillPage.getContent()) : mySkillQueryRepository.getSkillSummaries(skillPage.getContent(), userId); + if (normalizedFilter != MySkillFilter.HIDDEN) { + var managementStates = mySkillQueryRepository.getManagementStates(skillPage.getContent(), userId); + items = items.stream().map(item -> item.withManagementState(managementStates.get(item.id()))).toList(); + } return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize()); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillVisibilityControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillVisibilityControllerTest.java index ea901c265..e50c32907 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillVisibilityControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillVisibilityControllerTest.java @@ -55,6 +55,10 @@ class SkillVisibilityControllerTest { assertThat(schema.path("paths").has("/api/web/skills/{namespace}/{slug}/visibility")).isTrue(); assertThat(schema.path("components").path("schemas").path("ReviewTaskResponse") .path("properties").has("requestedVisibility")).isTrue(); + assertThat(schema.path("components").path("schemas").path("SkillSummaryResponse") + .path("properties").has("managementState")).isTrue(); + assertThat(schema.path("components").path("schemas").path("ManagementState") + .path("properties").has("requestedVisibility")).isTrue(); String output = System.getProperty("skillhub.openapi.output"); if (output != null && !output.isBlank()) { Files.writeString(Path.of(output), json); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/MySkillManagementPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/MySkillManagementPostgresTest.java new file mode 100644 index 000000000..378fbd54b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/MySkillManagementPostgresTest.java @@ -0,0 +1,236 @@ +package com.iflytek.skillhub.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.iflytek.skillhub.domain.namespace.*; +import com.iflytek.skillhub.domain.review.*; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; +import com.iflytek.skillhub.domain.user.UserAccount; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.hibernate.SessionFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.util.ReflectionTestUtils; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +class MySkillManagementPostgresTest { + @Container static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + @DynamicPropertySource static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + @Autowired EntityManager entities; + JpaMySkillQueryRepository query; + Namespace team; + Namespace global; + @BeforeEach void setup() { + query = new JpaMySkillQueryRepository(mock(NamespaceRepository.class), mock(PromotionRequestRepository.class), + mock(SkillLifecycleProjectionService.class)); + ReflectionTestUtils.setField(query, "entityManager", entities); + entities.persist(new UserAccount("owner", "Owner", "owner@example.test", null)); + entities.persist(new UserAccount("other", "Other", "other@example.test", null)); + team = new Namespace("management-team", "Team", "owner"); + entities.persist(team); + global = new Namespace("management-global", "Global", "owner"); + global.setType(NamespaceType.GLOBAL); + entities.persist(global); + } + Skill skill(Namespace namespace, String slug) { + Skill skill = new Skill(namespace.getId(), slug, "owner", SkillVisibility.PRIVATE); + skill.setDisplayName(slug); + entities.persist(skill); + return skill; + } + @Test void pendingProjectionIsPageBoundedSingleQueryAndClearsForAllFinishedStatuses() { + Skill first = skill(team, "first"); + Skill second = skill(team, "second"); + Skill outside = skill(team, "outside-page"); + ReviewTask task = ReviewTask.forVisibility(first, SkillVisibility.PUBLIC, "owner"); + entities.persist(task); + entities.persist(ReviewTask.forVisibility(outside, SkillVisibility.PUBLIC, "owner")); + entities.flush(); + var statistics = entities.getEntityManagerFactory().unwrap(SessionFactory.class).getStatistics(); + statistics.setStatisticsEnabled(true); + long before = statistics.getPrepareStatementCount(); + var states = query.getManagementStates(List.of(first, second), "owner"); + assertThat(statistics.getPrepareStatementCount() - before).isEqualTo(1); + assertThat(states.keySet()).containsExactlyInAnyOrder(first.getId(), second.getId()); + assertThat(states.get(first.getId()).requestedVisibility()).isEqualTo("PUBLIC"); + assertThat(states.get(first.getId()).reviewTaskId()).isEqualTo(task.getId()); + assertThat(states.get(second.getId()).requestedVisibility()).isNull(); + assertThat(query.getManagementStates(List.of(first), "other")).isEmpty(); + for (ReviewTaskStatus status : List.of(ReviewTaskStatus.APPROVED, ReviewTaskStatus.REJECTED)) { + task.setStatus(status); + entities.flush(); + assertThat(query.getManagementStates(List.of(first), "owner").get(first.getId()).requestedVisibility()).isNull(); + } + entities.remove(task); + entities.flush(); + assertThat(query.getManagementStates(List.of(first), "owner").get(first.getId()).reviewTaskId()).isNull(); + } + @Test void actualSourceIdentityDrivesLinksAndNeverSameSlugOrForeignOwner() { + Skill source = skill(team, "original"); + Skill unrelated = skill(team, "same-name"); + Skill copy = skill(global, "same-name"); + copy.setVisibility(SkillVisibility.PUBLIC); + copy.setSourceSkillId(source.getId()); + entities.flush(); + var states = query.getManagementStates(List.of(source, copy, unrelated), "owner"); + assertThat(states.get(source.getId()).promotionRole()).isEqualTo("TEAM_SOURCE"); + assertThat(states.get(source.getId()).relatedNamespace()).isEqualTo(global.getSlug()); + assertThat(states.get(copy.getId()).promotionRole()).isEqualTo("GLOBAL_COPY"); + assertThat(states.get(copy.getId()).relatedSlug()).isEqualTo(source.getSlug()); + assertThat(states.get(unrelated.getId()).promotionRole()).isNull(); + ReflectionTestUtils.setField(source, "ownerId", "other"); + entities.flush(); + var hidden = query.getManagementStates(List.of(copy), "owner").get(copy.getId()); + assertThat(hidden.promotionRole()).isEqualTo("GLOBAL_COPY"); + assertThat(hidden.relatedNamespace()).isNull(); + assertThat(hidden.relatedSlug()).isNull(); + } + @Test void pendingFilterPagesAndCountsScopeAndContentOnceAndRespectsOwnership() { + Skill scope = skill(team, "scope-only"); + Skill content = skill(team, "content-only"); + Skill both = skill(team, "both-pending"); + Skill foreign = skill(team, "foreign-pending"); + ReflectionTestUtils.setField(foreign, "ownerId", "other"); + Skill settled = skill(team, "settled"); + Namespace elsewhere = new Namespace("elsewhere", "Elsewhere", "owner"); + entities.persist(elsewhere); + Skill outside = skill(elsewhere, "outside-namespace"); + for (Skill skill : List.of(scope, content, both, foreign, settled, outside)) { + ReflectionTestUtils.setField(skill, "updatedAt", java.time.Instant.parse("2026-10-15T00:00:00Z")); + } + var scopeTask = ReviewTask.forVisibility(scope, SkillVisibility.PUBLIC, "owner"); + var bothTask = ReviewTask.forVisibility(both, SkillVisibility.PUBLIC, "owner"); + entities.persist(scopeTask); + entities.persist(bothTask); + entities.persist(ReviewTask.forVisibility(foreign, SkillVisibility.PUBLIC, "other")); + entities.persist(ReviewTask.forVisibility(outside, SkillVisibility.PUBLIC, "owner")); + var contentWork = new SkillVersion(content.getId(), "1.0.0", "owner"); + contentWork.setStatus(SkillVersionStatus.PENDING_REVIEW); + entities.persist(contentWork); + var bothWork = new SkillVersion(both.getId(), "1.0.0", "owner"); + bothWork.setStatus(SkillVersionStatus.PENDING_REVIEW); + entities.persist(bothWork); + entities.flush(); + var first = query.findPendingSkills("owner", null, team.getId(), org.springframework.data.domain.PageRequest.of(0, 2)); + var second = query.findPendingSkills("owner", null, team.getId(), org.springframework.data.domain.PageRequest.of(1, 2)); + assertThat(first.getTotalElements()).isEqualTo(3); + assertThat(second.getTotalElements()).isEqualTo(3); + assertThat(first.getContent()).extracting(Skill::getId).containsExactly(both.getId(), content.getId()); + assertThat(second.getContent()).extracting(Skill::getId).containsExactly(scope.getId()); + assertThat(query.findPendingSkills("owner", "content", team.getId(), org.springframework.data.domain.PageRequest.of(0, 1)).getTotalElements()).isEqualTo(1); + assertThat(query.findPendingSkills("owner", null, -1L, org.springframework.data.domain.PageRequest.of(0, 2)).getTotalElements()).isZero(); + assertThat(query.findPendingSkills("owner", null, null, org.springframework.data.domain.PageRequest.of(0, 10)).getTotalElements()).isEqualTo(4); + for (ReviewTaskStatus status : List.of(ReviewTaskStatus.APPROVED, ReviewTaskStatus.REJECTED)) { + scopeTask.setStatus(status); + bothTask.setStatus(status); + entities.flush(); + var remaining = query.findPendingSkills("owner", null, team.getId(), org.springframework.data.domain.PageRequest.of(0, 10)); + assertThat(remaining.getTotalElements()).isEqualTo(2); + assertThat(remaining.getContent()).extracting(Skill::getId).containsExactly(both.getId(), content.getId()); + } + entities.remove(scopeTask); + entities.remove(bothTask); + entities.flush(); + assertThat(query.findPendingSkills("owner", null, team.getId(), org.springframework.data.domain.PageRequest.of(0, 10)).getTotalElements()).isEqualTo(2); + } + @Test void pendingContentFilterPreservesOwnerPreviewRecencyRules() { + Skill skill = skill(team, "version-recency"); + SkillVersion oldPending = new SkillVersion(skill.getId(), "1.0.0", "owner"); + oldPending.setStatus(SkillVersionStatus.PENDING_REVIEW); + ReflectionTestUtils.setField(oldPending, "createdAt", java.time.Instant.parse("2026-10-01T00:00:00Z")); + entities.persist(oldPending); + SkillVersion published = new SkillVersion(skill.getId(), "2.0.0", "owner"); + published.setStatus(SkillVersionStatus.PUBLISHED); + ReflectionTestUtils.setField(published, "createdAt", java.time.Instant.parse("2026-10-02T00:00:00Z")); + entities.persist(published); + skill.setLatestVersionId(published.getId()); + entities.flush(); + assertThat(query.findPendingSkills("owner", null, null, org.springframework.data.domain.PageRequest.of(0, 10)).getTotalElements()).isZero(); + SkillVersion pending = new SkillVersion(skill.getId(), "3.0.0", "owner"); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + ReflectionTestUtils.setField(pending, "createdAt", java.time.Instant.parse("2026-10-03T00:00:00Z")); + entities.persist(pending); + entities.flush(); + assertThat(query.findPendingSkills("owner", null, null, org.springframework.data.domain.PageRequest.of(0, 10)).getTotalElements()).isEqualTo(1); + SkillVersion newerWork = new SkillVersion(skill.getId(), "4.0.0", "owner"); + newerWork.setStatus(SkillVersionStatus.UPLOADED); + ReflectionTestUtils.setField(newerWork, "createdAt", java.time.Instant.parse("2026-10-04T00:00:00Z")); + entities.persist(newerWork); + entities.flush(); + assertThat(query.findPendingSkills("owner", null, null, org.springframework.data.domain.PageRequest.of(0, 10)).getTotalElements()).isZero(); + } + @Test void normalSummarySerializationDoesNotExposeOwnerManagementState() throws Exception { + var summary = new com.iflytek.skillhub.dto.SkillSummaryResponse(1L, "foo", "Foo", null, + "PUBLIC", "ACTIVE", 0L, 0, null, 0, "global", null, false, + null, null, null, null, null); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + assertThat(mapper.readTree(mapper.writeValueAsString(summary)).has("managementState")).isFalse(); + var managed = summary.withManagementState(new com.iflytek.skillhub.dto.SkillSummaryResponse.ManagementState( + "PUBLIC", 42L, null, null, null)); + assertThat(mapper.readTree(mapper.writeValueAsString(managed)).path("managementState") + .path("requestedVisibility").asText()).isEqualTo("PUBLIC"); + } + @Test void privateGlobalCopyKeepsSourceRelationshipAndNamespaceOnlyCopyRequiresMembership() { + Skill source = skill(team, "source-to-global"); + Skill copy = skill(global, "global-copy"); + copy.setVisibility(SkillVisibility.PRIVATE); + copy.setSourceSkillId(source.getId()); + entities.flush(); + var privateState = query.getManagementStates(List.of(source), "owner").get(source.getId()); + assertThat(privateState.promotionRole()).isEqualTo("TEAM_SOURCE"); + assertThat(privateState.relatedNamespace()).isEqualTo(global.getSlug()); + assertThat(privateState.relatedSlug()).isEqualTo(copy.getSlug()); + copy.setVisibility(SkillVisibility.NAMESPACE_ONLY); + entities.flush(); + var restricted = query.getManagementStates(List.of(source), "owner").get(source.getId()); + assertThat(restricted.promotionRole()).isEqualTo("TEAM_SOURCE"); + assertThat(restricted.relatedNamespace()).isNull(); + assertThat(restricted.relatedSlug()).isNull(); + entities.persist(new NamespaceMember(global.getId(), "owner", NamespaceRole.MEMBER)); + entities.flush(); + var memberState = query.getManagementStates(List.of(source), "owner").get(source.getId()); + assertThat(memberState.promotionRole()).isEqualTo("TEAM_SOURCE"); + assertThat(memberState.relatedSlug()).isEqualTo(copy.getSlug()); + copy.setHidden(true); + entities.flush(); + assertThat(query.getManagementStates(List.of(source), "owner").get(source.getId()).relatedSlug()).isNull(); + } + @Test void namespaceOnlySourceRequiresMembershipAndArchivedSourceNeverLeaksCoordinates() { + Skill source = skill(team, "scoped-source"); + Skill copy = skill(global, "scoped-copy"); + copy.setVisibility(SkillVisibility.PUBLIC); + copy.setSourceSkillId(source.getId()); + source.setVisibility(SkillVisibility.NAMESPACE_ONLY); + entities.flush(); + assertThat(query.getManagementStates(List.of(copy), "owner").get(copy.getId()).relatedSlug()).isNull(); + entities.persist(new NamespaceMember(team.getId(), "owner", NamespaceRole.MEMBER)); + entities.flush(); + assertThat(query.getManagementStates(List.of(copy), "owner").get(copy.getId()).relatedSlug()).isEqualTo(source.getSlug()); + team.setStatus(NamespaceStatus.ARCHIVED); + entities.flush(); + assertThat(query.getManagementStates(List.of(copy), "owner").get(copy.getId()).relatedNamespace()).isNull(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java index 8daa73ad5..b9bd189a4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -68,11 +68,19 @@ class MySkillAppServiceTest { @BeforeEach void setUp() { skillLifecycleProjectionService = new SkillLifecycleProjectionService(skillVersionRepository); - mySkillQueryRepository = new JpaMySkillQueryRepository( + mySkillQueryRepository = org.mockito.Mockito.spy(new JpaMySkillQueryRepository( namespaceRepository, promotionRequestRepository, skillLifecycleProjectionService - ); + )); + var entityManager = org.mockito.Mockito.mock(jakarta.persistence.EntityManager.class); + var managementQuery = org.mockito.Mockito.mock(jakarta.persistence.Query.class); + org.mockito.Mockito.lenient().when(entityManager.createNativeQuery(org.mockito.ArgumentMatchers.anyString())) + .thenReturn(managementQuery); + org.mockito.Mockito.lenient().when(managementQuery.setParameter(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any())) + .thenReturn(managementQuery); + org.mockito.Mockito.lenient().when(managementQuery.getResultList()).thenReturn(List.of()); + ReflectionTestUtils.setField(mySkillQueryRepository, "entityManager", entityManager); service = new MySkillAppService( skillRepository, skillVersionRepository, @@ -219,12 +227,10 @@ class MySkillAppServiceTest { void listMySkills_filtersPendingReviewForRegularUsers() { Skill pendingSkill = createSkill(1L, 101L, "pending-skill", "user-1"); SkillVersion pendingVersion = createVersion(1L, 11L, "1.0.0", SkillVersionStatus.PENDING_REVIEW, "2026-03-15T09:30:00Z"); - Skill publishedSkill = createSkill(2L, 101L, "published-skill", "user-1"); - SkillVersion publishedVersion = createVersion(2L, 22L, "1.2.0", SkillVersionStatus.PUBLISHED, "2026-03-15T10:30:00Z"); - given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(pendingSkill, publishedSkill)); + org.mockito.Mockito.doReturn(new PageImpl<>(List.of(pendingSkill), PageRequest.of(0, 10), 1)) + .when(mySkillQueryRepository).findPendingSkills("user-1", null, null, PageRequest.of(0, 10)); given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(pendingVersion)); - given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(publishedVersion)); given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); var result = service.listMySkills("user-1", 0, 10, "PENDING_REVIEW", Set.of("USER")); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 6feaaaaf9..482ac42bd 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -7735,6 +7735,14 @@ export interface components { items?: components["schemas"]["ComplianceMappingResponse"][]; digest?: string; }; + ManagementState: { + requestedVisibility?: string; + /** Format: int64 */ + reviewTaskId?: number; + promotionRole?: string; + relatedNamespace?: string; + relatedSlug?: string; + }; SearchResponse: { items?: components["schemas"]["SkillSummaryResponse"][]; /** Format: int64 */ @@ -7780,6 +7788,7 @@ export interface components { complianceSnapshot?: components["schemas"]["ComplianceSnapshotResponse"]; labels?: components["schemas"]["SkillLabelDto"][]; avatarUrl?: string; + managementState?: components["schemas"]["ManagementState"]; }; ApiResponseBoolean: { /** Format: int32 */ diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 3c5a2a7d8..91db3afaa 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -253,6 +253,7 @@ export interface BatchMemberResponse { // Skill types export interface SkillSummary { + managementState?: components['schemas']['ManagementState'] | null id: number slug: string displayName: string diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index d0796c93c..793b47936 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -518,6 +518,10 @@ "securityDesc": "Change password and account security options" }, "mySkills": { + "updateGlobalVersion": "Update global version", + "viewGlobalCopy": "View global copy", + "viewTeamSource": "View team source", + "promotionRoles": {"TEAM_SOURCE": "Team source · promoted to global", "GLOBAL_COPY": "Global copy · updated independently"}, "versionDifference": "This update v{{version}} · Public v{{published}}", "statusUpdatePendingReview": "Update awaiting review", "title": "My Skills", @@ -570,6 +574,7 @@ "withdrawSuccessDescription": "The pending version for \"{{skill}}\" has been withdrawn.", "withdrawErrorTitle": "Failed to withdraw upload", "promoteToGlobal": "Promote to Global", + "submitVersionToGlobal": "Submit version to global", "promotionConfirmTitle": "Submit promotion request", "promotionConfirmDescription": "Submit v{{version}} of \"{{skill}}\" for promotion into the global namespace?", "promotionSuccessTitle": "Promotion request submitted", @@ -2768,6 +2773,7 @@ "deleteHint": "Permanently delete the space. Handle its skills and members first." }, "skillVisibility": { + "pendingList": "Requested {{visibility}} · review pending. Current access remains in effect.", "title": "Visibility", "description": "Change access without changing the skill identity, package version, or statistics.", "current": "Current scope: {{visibility}}", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 5880b1dd2..5fecf068b 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -518,6 +518,10 @@ "securityDesc": "Изменение пароля и параметров безопасности" }, "mySkills": { + "updateGlobalVersion": "Обновить глобальную версию", + "viewGlobalCopy": "Открыть глобальную копию", + "viewTeamSource": "Открыть командный источник", + "promotionRoles": {"TEAM_SOURCE": "Командный источник · опубликован глобально", "GLOBAL_COPY": "Глобальная копия · обновляется отдельно"}, "versionDifference": "Обновление v{{version}} · Публичная v{{published}}", "statusUpdatePendingReview": "Обновление на проверке", "title": "Мои скиллы", @@ -570,6 +574,7 @@ "withdrawSuccessDescription": "Ожидающая версия для «{{skill}}» отозвана.", "withdrawErrorTitle": "Не удалось отозвать загрузку", "promoteToGlobal": "Продвинуть в Global", + "submitVersionToGlobal": "Отправить версию в глобальный каталог", "promotionConfirmTitle": "Отправить запрос на продвижение", "promotionConfirmDescription": "Отправить v{{version}} скилла «{{skill}}» на продвижение в глобальное пространство имён?", "promotionSuccessTitle": "Запрос на продвижение отправлен", @@ -2770,6 +2775,7 @@ "deleteHint": "Удалить пространство навсегда. Сначала обработайте его навыки и участников." }, "skillVisibility": { + "pendingList": "Запрошено: {{visibility}} · на рассмотрении. Текущий доступ сохраняется.", "title": "Видимость", "description": "Изменение доступа без изменения идентификатора навыка, версии пакета и статистики.", "current": "Текущий доступ: {{visibility}}", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 3328cc1c3..c6cf1bb13 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -518,6 +518,10 @@ "securityDesc": "修改密码和账户安全选项" }, "mySkills": { + "updateGlobalVersion": "更新全局版本", + "viewGlobalCopy": "查看全局副本", + "viewTeamSource": "查看团队来源", + "promotionRoles": {"TEAM_SOURCE": "团队来源 · 已提升到全局", "GLOBAL_COPY": "全局副本 · 与团队来源独立更新"}, "versionDifference": "本次 v{{version}} · 当前公开 v{{published}}", "statusUpdatePendingReview": "更新待审核", "title": "我的技能", @@ -570,6 +574,7 @@ "withdrawSuccessDescription": "“{{skill}}”的待审核版本已撤销。", "withdrawErrorTitle": "撤销上传失败", "promoteToGlobal": "申请提升到全局", + "submitVersionToGlobal": "提交版本到全局", "promotionConfirmTitle": "确认提交提升申请", "promotionConfirmDescription": "确认将“{{skill}}”的 v{{version}} 提交为提升到全局空间的申请吗?", "promotionSuccessTitle": "提升申请已提交", @@ -2767,6 +2772,7 @@ "deleteHint": "永久删除空间,请先处理其中的技能和成员。" }, "skillVisibility": { + "pendingList": "申请{{visibility}} · 审核中,批准前保持当前范围。", "title": "可见范围", "description": "仅调整访问范围,保留技能标识、内容版本和统计。", "current": "当前范围:{{visibility}}", diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts index 946f0078a..f23a80cb1 100644 --- a/web/src/pages/dashboard/my-skills.test.ts +++ b/web/src/pages/dashboard/my-skills.test.ts @@ -1,12 +1,16 @@ import { createElement, type ReactNode } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createInstance, type i18n } from 'i18next' +import zh from '@/i18n/locales/zh.json' +import en from '@/i18n/locales/en.json' const navigateMock = vi.fn() const buttonRecords: Array<{ label: string; onClick?: ((event?: { stopPropagation: () => void }) => void) | undefined }> = [] const useMySkillsMock = vi.fn() const searchMock: { filter?: string } = {} let isSuperAdmin = false +let renderI18n: i18n | undefined vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, @@ -19,7 +23,7 @@ vi.mock('react-i18next', async () => { return { ...actual, useTranslation: () => ({ - t: (key: string) => key, + t: (key: string, options?: Record) => renderI18n ? String(renderI18n.t(key, options)) : key, }), } }) @@ -126,6 +130,7 @@ function findButton(label: string) { describe('MySkillsPage', () => { beforeEach(() => { + renderI18n = undefined navigateMock.mockReset() buttonRecords.length = 0 delete searchMock.filter @@ -311,6 +316,37 @@ describe('MySkillsPage', () => { expect(html).not.toContain('mySkills.statusPublished') }) + it.each(['zh', 'en'])('shows effective private access and the pending public request after refresh in %s', async language => { + renderI18n = createInstance() + await renderI18n.init({ lng: language, fallbackLng: false, resources: { zh: { translation: zh }, en: { translation: en } }, interpolation: { escapeValue: false } }) + const item = { id: 1, displayName: 'Owned skill', namespace: 'team-ai', slug: 'owned', status: 'ACTIVE', + visibility: 'PRIVATE', downloadCount: 0, managementState: { requestedVisibility: 'PUBLIC', reviewTaskId: 42 } } + useMySkillsMock.mockReturnValue({ data: { items: [item], total: 1, page: 0, size: 10 }, isLoading: false }) + const html = renderToStaticMarkup(createElement(MySkillsPage)) + expect(html).toContain(renderI18n.t('skillVisibility.current', { visibility: renderI18n.t('skillVisibility.PRIVATE') })) + expect(html).toContain(renderI18n.t('skillVisibility.pendingList', { visibility: renderI18n.t('skillVisibility.PUBLIC') })) + expect(html.match(/class="status-pill[^"]*"/g)).toHaveLength(1) + item.managementState = { requestedVisibility: '', reviewTaskId: 0 } + const afterReview = renderToStaticMarkup(createElement(MySkillsPage)) + expect(afterReview).not.toContain('role="status"') + }) + + it('distinguishes actual promotion links and update action without merging the two skills', () => { + useMySkillsMock.mockReturnValue({ data: { items: [ + { id: 1, displayName: 'Team', namespace: 'team-ai', slug: 'one', status: 'ACTIVE', downloadCount: 0, + canSubmitPromotion: true, managementState: { promotionRole: 'TEAM_SOURCE', relatedNamespace: 'global', relatedSlug: 'copy' } }, + { id: 2, displayName: 'Global', namespace: 'global', slug: 'copy', status: 'ACTIVE', downloadCount: 0, + managementState: { promotionRole: 'GLOBAL_COPY' } }, + ], total: 2, page: 0, size: 10 }, isLoading: false }) + const html = renderToStaticMarkup(createElement(MySkillsPage)) + expect(html).toContain('mySkills.promotionRoles.TEAM_SOURCE') + expect(html).toContain('mySkills.promotionRoles.GLOBAL_COPY') + expect(html).toContain('mySkills.viewGlobalCopy') + expect(html).not.toContain('mySkills.viewTeamSource') + expect(buttonRecords.some(button => button.label === 'mySkills.updateGlobalVersion')).toBe(true) + expect(buttonRecords.some(button => button.label === 'mySkills.promoteToGlobal')).toBe(false) + }) + it('exports a named component function', () => { expect(typeof MySkillsPage).toBe('function') }) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index ae0b6d800..aaf10f9c9 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -371,6 +371,8 @@ export function MySkillsPage() {
{skills.map((skill, idx) => ( (() => { + const relatedNamespace = skill.managementState?.relatedNamespace + const relatedSlug = skill.managementState?.relatedSlug const headlineVersion = getHeadlineVersion(skill) const publishedVersion = getPublishedVersion(skill) const ownerPreviewVersion = getOwnerPreviewVersion(skill) @@ -397,12 +399,27 @@ export function MySkillsPage() { {displayName}
+ {skill.managementState?.promotionRole && ( +
+ {t(`mySkills.promotionRoles.${skill.managementState.promotionRole}`)} + {relatedNamespace && relatedSlug && ( + + )} +
+ )} {skill.status === 'ACTIVE' && filter !== 'HIDDEN' && } {summary && (

{summary}

)}
- @{skill.namespace} + @{skill.namespace} + {t('skillVisibility.current', { visibility: t(`skillVisibility.${skill.visibility ?? 'PUBLIC'}`) })} {preview && publishedVersion ? ( {t('mySkills.versionDifference', { version: preview.version, published: publishedVersion.version })} ) : headlineVersion ? ( @@ -421,6 +438,11 @@ export function MySkillsPage() { )}
+ {skill.managementState?.requestedVisibility && ( +

+ {t('skillVisibility.pendingList', { visibility: t(`skillVisibility.${skill.managementState.requestedVisibility}`) })} +

+ )}
{canRestoreHidden ? ( @@ -480,7 +502,7 @@ export function MySkillsPage() { }) }} > - {t('mySkills.promoteToGlobal')} + {t(skill.managementState?.promotionRole === 'TEAM_SOURCE' ? 'mySkills.updateGlobalVersion' : 'mySkills.promoteToGlobal')} ) : skill.status === 'ARCHIVED' ? (