mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
fix(web): explain pending scope and linked global skills
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
5560976c2c
commit
dd4700bccd
20 changed files with 519 additions and 18 deletions
|
|
@ -37,8 +37,8 @@ public record SearchQuery(
|
|||
public record SearchVisibilityScope(
|
||||
boolean includeAllPublic, // 是否包含所有 PUBLIC 技能
|
||||
Set<Long> memberNamespaceIds, // 用户是 MEMBER 的 namespace(可见 NAMESPACE_ONLY)
|
||||
Set<Long> adminNamespaceIds, // 用户是 ADMIN 的 namespace(可见 PRIVATE)
|
||||
String userId // 当前用户 ID(可见自己的 PRIVATE skill),匿名为 null
|
||||
Set<Long> 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
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@
|
|||
|
||||
私有并不代表平台无法治理:技能所有者、拥有相应空间权限的管理员以及现有超级管理员访问规则仍然适用。
|
||||
|
||||
## 列表中的当前范围和申请进度
|
||||
|
||||
「我的技能」会同时说明当前生效范围和待审目标,例如「当前范围:私有」以及「申请公开 · 审核中,批准前保持当前范围」。申请提交后刷新页面,仍可看到这项说明;批准、拒绝或撤回后,待审说明消失。申请公开不等于已经公开。「待审核」筛选同时包含待审内容版本和待审范围申请;同一技能只列一次,范围申请结束后退出该筛选(仍有待审内容版本时继续保留)。
|
||||
|
||||
私有技能不出现在技能市场,包括所有者自己的私有技能;请在「我的技能」管理。
|
||||
|
||||
已提升的团队技能仍保留,同时存在独立的全局副本。「我的技能」分别标明「团队来源」和「全局副本」,有权查看时提供互相跳转入口。团队来源再次提交时使用「更新全局版本」;两条技能的版本和统计仍各自独立,不会自动同步。
|
||||
|
||||
## 扩大范围后的审核
|
||||
|
||||
- 批准前仍按原范围访问;不提前出现在目标受众的搜索或下载结果中。
|
||||
|
|
|
|||
|
|
@ -4,5 +4,7 @@
|
|||
- 技能行锁协调提交、发布和审核;范围申请不修改 SkillVersion 状态和 latestVersionId。
|
||||
- 审批 CAS 保留现有冲突机制。成功改变范围后通过专用范围变更事件复用现有搜索重建,避免重复发送内容更新通知;审计区分提交、通过、拒绝、撤回。
|
||||
- Web 复用管理交互与统一审核详情;展示当前范围和申请范围。
|
||||
- 我的技能响应按当前页技能批量投影管理状态,提供待审范围及真实来源/全局关联。不得逐卡拉取审核或提升详情,不从同名推断关联;公共市场、收藏等摘要不附带这些私有管理信息。相关技能无访问权限时不暴露其坐标或跳转。
|
||||
- 团队来源与全局发布仍是独立记录,可分别更新;列表只解释现有关系,不引入自动同步或删除联动。市场维持现有收录范围,私有技能在“我的技能”维护。
|
||||
- 业务约束在服务代码与测试维护,迁移只增加必要列,不增加 SQL CHECK。
|
||||
- 已发布 PRIVATE 版本没有可复用的安全通过结果时,复用 ScanTask/outbox 对已存 bundle 重新扫描,保留 PUBLISHED 状态。范围申请绑定这一次 taskId;旧结果不能解锁新申请,未完成或不安全结果不能批准。scanner 关闭且需要新扫描时明确拒绝,不改变原范围。
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Issue #951:当前必须重新上传包才能改变可见范围。用户需要
|
|||
- 缩小范围立即生效;扩大范围进入现有统一技能审核,批准前继续使用原范围。
|
||||
- 可见范围申请与待审内容版本互斥,支持在现有审核进度中撤回。
|
||||
- 所有入口继续遵守访问控制、安全扫描、头像受众审核和操作审计。
|
||||
- 我的技能列表同时说明当前生效范围与待审目标;提升后通过真实关联区分团队来源和全局发布记录。
|
||||
|
||||
## Impact
|
||||
后端 ReviewTask、技能生命周期、发布互斥、搜索投影;Web 管理入口和统一审核详情;API 类型及用户文档。
|
||||
|
|
|
|||
|
|
@ -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** 列表明确两条记录各自角色及有权查看的关联入口,用户可分别管理;不能查看来源时不通过关联展示泄露来源坐标
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@
|
|||
## 验证
|
||||
- [x] 后端成功、拒绝、权限、并发及真实数据库迁移(开发反馈使用 Java 25;正式 Java 21 整合验收另记)
|
||||
- [x] 前端请求、状态、审核和类型检查
|
||||
- [ ] 本地构建部署后验证详情、搜索、下载一致性
|
||||
- [x] 本地构建部署后验证详情、搜索、下载一致性(上一轮已完成;本轮人工反馈补验见下)
|
||||
|
||||
开发检查覆盖扫描失败/旧 task、完整派生 bundle/缺文件拒绝、陈旧搜索索引、提升更新两事务陈旧实体与待审互斥。部署与真实浏览器验收尚未执行,不以单测代替。
|
||||
开发及运行验证已覆盖扫描失败/旧 task、完整派生 bundle/缺文件拒绝、陈旧搜索索引、提升更新两事务陈旧实体与待审互斥;实际执行版本、组合继承边界、浏览器及故障演练记录由 PR 验收报告说明。
|
||||
|
||||
## 人工反馈补充
|
||||
- [x] 按当前页批量提供待审范围与真实提升关联,更新实际 OpenAPI 类型
|
||||
- [x] 列表展示当前范围/待审目标及来源/全局角色,不改变市场收录或双记录模型
|
||||
- [ ] 覆盖分页、无权关联、批准/拒绝/撤回后的投影及实际页面交互
|
||||
|
|
|
|||
|
|
@ -33,9 +33,36 @@ public record SkillSummaryResponse(
|
|||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
List<SkillLabelDto> 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<SkillLabelDto> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Skill> 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<Skill> 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<Long, SkillSummaryResponse.ManagementState> getManagementStates(List<Skill> skills, String ownerId) {
|
||||
List<Long> 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<Object[]> 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<Long, SkillSummaryResponse.ManagementState> 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<SkillSummaryResponse> getHiddenSkillSummaries(List<Skill> skills) {
|
||||
if (skills.isEmpty()) {
|
||||
|
|
|
|||
|
|
@ -11,5 +11,10 @@ import java.util.List;
|
|||
public interface MySkillQueryRepository {
|
||||
List<SkillSummaryResponse> getSkillSummaries(List<Skill> skills, String currentUserId);
|
||||
|
||||
org.springframework.data.domain.Page<Skill> findPendingSkills(String ownerId, String keyword,
|
||||
Long namespaceId, org.springframework.data.domain.Pageable pageable);
|
||||
|
||||
java.util.Map<Long, SkillSummaryResponse.ManagementState> getManagementStates(List<Skill> skills, String ownerId);
|
||||
|
||||
List<SkillSummaryResponse> getHiddenSkillSummaries(List<Skill> skills);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,9 @@ public class MySkillAppService {
|
|||
MySkillFilter normalizedFilter = parseFilter(filter);
|
||||
|
||||
Page<Skill> 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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
|
|
|
|||
9
web/src/api/generated/schema.d.ts
vendored
9
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -253,6 +253,7 @@ export interface BatchMemberResponse {
|
|||
|
||||
// Skill types
|
||||
export interface SkillSummary {
|
||||
managementState?: components['schemas']['ManagementState'] | null
|
||||
id: number
|
||||
slug: string
|
||||
displayName: string
|
||||
|
|
|
|||
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => 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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -371,6 +371,8 @@ export function MySkillsPage() {
|
|||
<div className="grid grid-cols-1 gap-4">
|
||||
{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}
|
||||
</h3>
|
||||
</div>
|
||||
{skill.managementState?.promotionRole && (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t(`mySkills.promotionRoles.${skill.managementState.promotionRole}`)}</span>
|
||||
{relatedNamespace && relatedSlug && (
|
||||
<button type="button" className="text-primary underline underline-offset-4"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
handleSkillClick(relatedNamespace, relatedSlug)
|
||||
}}>
|
||||
{t(skill.managementState.promotionRole === 'TEAM_SOURCE' ? 'mySkills.viewGlobalCopy' : 'mySkills.viewTeamSource')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{skill.status === 'ACTIVE' && filter !== 'HIDDEN' && <SkillAvatarManager namespace={skill.namespace} slug={skill.slug} previewOnly />}
|
||||
{summary && (
|
||||
<p className="text-sm text-muted-foreground mb-3 leading-relaxed">{summary}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="handle-tag">@{skill.namespace}</span>
|
||||
<span className="handle-tag max-w-full break-all">@{skill.namespace}</span>
|
||||
<span className="text-xs">{t('skillVisibility.current', { visibility: t(`skillVisibility.${skill.visibility ?? 'PUBLIC'}`) })}</span>
|
||||
{preview && publishedVersion ? (
|
||||
<span className="text-xs">{t('mySkills.versionDifference', { version: preview.version, published: publishedVersion.version })}</span>
|
||||
) : headlineVersion ? (
|
||||
|
|
@ -421,6 +438,11 @@ export function MySkillsPage() {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{skill.managementState?.requestedVisibility && (
|
||||
<p role="status" className="mt-3 text-sm text-muted-foreground">
|
||||
{t('skillVisibility.pendingList', { visibility: t(`skillVisibility.${skill.managementState.requestedVisibility}`) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 sm:max-w-[45%] sm:justify-end">
|
||||
{canRestoreHidden ? (
|
||||
|
|
@ -480,7 +502,7 @@ export function MySkillsPage() {
|
|||
})
|
||||
}}
|
||||
>
|
||||
{t('mySkills.promoteToGlobal')}
|
||||
{t(skill.managementState?.promotionRole === 'TEAM_SOURCE' ? 'mySkills.updateGlobalVersion' : 'mySkills.promoteToGlobal')}
|
||||
</Button>
|
||||
) : skill.status === 'ARCHIVED' ? (
|
||||
<Button
|
||||
|
|
@ -566,7 +588,7 @@ export function MySkillsPage() {
|
|||
}}
|
||||
title={t('mySkills.promotionConfirmTitle')}
|
||||
description={promotionTarget ? t('mySkills.promotionConfirmDescription', { skill: promotionTarget.name, version: promotionTarget.version }) : ''}
|
||||
confirmText={t('mySkills.promoteToGlobal')}
|
||||
confirmText={t('mySkills.submitVersionToGlobal')}
|
||||
onConfirm={handleSubmitPromotion}
|
||||
/>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue