diff --git a/docs/02-domain-model.md b/docs/02-domain-model.md index f076addd..45efbc0b 100644 --- a/docs/02-domain-model.md +++ b/docs/02-domain-model.md @@ -75,7 +75,7 @@ | updated_by | varchar(128) | | | updated_at | datetime | | -- 唯一约束:`(namespace_id, slug)` +- 唯一约束:`(namespace_id, slug, owner_id)`;面向公众的同名冲突还须按已发布记录检查不同 owner,避免地址解析歧义 - `status` 表示 skill 容器生命周期,不再承载“隐藏”语义。隐藏是独立的治理覆盖层,由 `hidden` / `hidden_at` / `hidden_by` 表达 - 当前代码下的实际可见性判定以 `VisibilityChecker` 为准,规则如下: - 若 `hidden=true`:仅 skill owner 或该 namespace 的 `ADMIN` / `OWNER` 可读 @@ -90,7 +90,7 @@ - `rating_avg` / `rating_count` 冗余字段,避免每次查询聚合 - `slug`:面向用户的 URL 标识,来自 SKILL.md 的 `name` 字段,首次发布后不可变更。slug 格式校验规则与 namespace slug 相同:`[a-z0-9]([a-z0-9-]*[a-z0-9])?`,同样适用保留词限制,且不得包含连续两个以上的连字符 `--`(为兼容层坐标映射保留)。全局空间(`@global`)下的 skill slug 额外禁止包含 `--`,以避免与兼容层 canonical slug 产生歧义 - `source_skill_id`:仅在"团队技能提升到全局"场景下填充,记录原始团队空间的 skill ID,用于追溯来源 -- 提升关系的唯一事实来源是 `promotion_request` 表,UI 查询"是否已提升"通过 `SELECT ... FROM promotion_request WHERE source_skill_id=? AND status='APPROVED'` 判定 +- 提升关系的唯一事实来源是 `promotion_request` 表。当前有效全局目标由 `request_kind='INITIAL' AND status='APPROVED' AND target_skill_id IS NOT NULL` 的首次提升记录确定;后续更新记录指向同一目标,撤销时解除目标引用并保留快照。 ### skill_version @@ -98,7 +98,7 @@ |------|------|------| | id | bigint | | | skill_id | bigint | | -| version | varchar(32) | semver | +| version | varchar(32) | 版本标识;可来自 SKILL.md 或系统生成,不按字符串大小判断“最新” | | version_sort | bigint | 排序用数值 | | changelog | text | | | manifest_json | json | 文件清单 | @@ -197,6 +197,11 @@ | source_version_id | bigint | 申请提升的版本 | | target_namespace_id | bigint | 目标全局 namespace | | target_skill_id | bigint | 审批通过后生成的全局 skill ID,nullable | +| request_kind | varchar(16) | `INITIAL` / `UPDATE`;存量记录迁移为 `INITIAL` | +| target_version_id | bigint | 审批形成的全局版本 ID 快照,允许目标撤销后保留 | +| target_skill_id_snapshot | bigint | 撤销后仍保留原全局 skill ID | +| target_version_id_snapshot | bigint | 撤销后保留结果版本 ID | +| revoked_at / revoked_by | datetime / varchar(128) | 撤销生效时间和操作者 | | status | enum | `PENDING` / `APPROVED` / `REJECTED` | | version | int | 乐观锁版本号,默认 1 | | submitted_by | varchar(128) | 提交人 | @@ -205,11 +210,18 @@ | submitted_at | datetime | | | reviewed_at | datetime | | -- 完整表达"哪个团队 skill 的哪一版被申请提升到哪个全局空间" -- 审批通过后填充 `target_skill_id`,指向全局空间新创建的 skill +- 完整表达"哪个团队 skill 的哪一版被申请提升到哪个全局空间"。首次提升新建目标 skill;后续 UPDATE 在同一目标下增加不可变版本,两次发布审核分开进行。 +- 审批通过后填充 `target_skill_id`,指向当前关联的全局 skill;撤销后清空该外键并保留快照和提升历史。 - `promotion_request` 是提升关系的唯一事实来源,skill 表不再冗余 `promoted_to_skill_id` -- 业务约束:同一 `source_version_id` 在 `status=PENDING` 时只能存在一条记录,重复提交返回 409 Conflict -- PostgreSQL 并发约束落地:与 `review_task` 类似,通过唯一索引防止并发重复提交。推荐使用 partial unique index:`CREATE UNIQUE INDEX ON promotion_request (source_version_id) WHERE status = 'PENDING'`,或增加 `deleted` 字段 + `(source_version_id, deleted)` 唯一约束,或采用物理删除 + `(source_version_id)` 唯一约束方案 +- 业务约束:同一来源 skill 同时最多有一条 `PENDING` 提升申请;同一来源同时最多有一条有效 `INITIAL` 提升关系。全局目标有任何同号版本时,不允许 UPDATE 审批覆盖;异号审批按实际发布时间更新 latest。 +- PostgreSQL 以 `source_skill_id WHERE status='PENDING'` 和 `source_skill_id WHERE request_kind='INITIAL' AND status='APPROVED' AND target_skill_id IS NOT NULL` 两个部分唯一索引防止并发重复。 + +### promotion_revocation_request + +- 来源技能所有者或团队管理员可提交撤销,平台管理员审核;平台管理员直接撤销也留存申请和审计。 +- 审核通过时删除全局派生 skill 及其版本、统计和公开入口,不把数据迁回团队 skill;团队原件和共用的文件对象保留。 +- 记录保留原始来源/目标 ID 与坐标、提交人、审核人、理由、状态及时间。目标全局 skill 物理删除后,这些字段仍供管理员审计。 +- 撤销释放旧全局坐标;重新提升须重新申请、审核,并再次检查同名冲突。此前已下载的客户端文件无法收回。 ### skill_star diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index 74b206a6..f809c531 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -182,7 +182,16 @@ Web 端与 CLI 保持同一发布语义,只是在交互上可提供更明确 后续版本更新: - 全局空间的新 skill 由其 owner 独立管理版本 - 原团队 skill 可继续独立迭代 -- 两者版本不自动同步,如需同步由 owner 手动操作 +- 两者版本不自动同步。来源 owner 或 namespace 管理员可在团队详情选择已发布版本,再次提交平台提升审核;审核通过后在原全局 skill 上新增版本,不覆盖原有版本 +- 再次提升通过后,全局 skill 的展示名和摘要也更新为审核时来源 skill 的展示信息,使详情页与最新发布内容保持一致 +- 申请和审批时均检查目标身份、来源版本状态及全局同号冲突。全局独立上传可以同时待审;不同号版本分别发布,后通过者成为 latest,同号后通过的申请失败 +- 版本号不比较数值高低;例如全局当前 v1.3、团队提交尚未占用的 v1.1,确认提示后仍可提交,审批通过后 v1.1 成为 latest,v1.3 保留在历史 + +撤销提升: +- 来源 owner 或 namespace 管理员提交撤销申请,平台管理员审核;平台管理员也可直接执行并留审计 +- 撤销的是关联的整条全局派生 skill,不移动原团队 skill。通过后公众无法访问全局详情、搜索结果和下载,旧全局坐标释放;后台保留提升、撤销及操作审计 +- 共用的对象存储文件继续供团队原技能使用。全局独立发布形成的版本也随该全局 skill 删除;已下载到客户端的文件无法收回 +- 重新提升须重新申请、审核并通过同名冲突检查;原地址可能已被其他技能占用 提升流程当前严格绑定已发布版本: diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 1dbd906c..a8efd2f9 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -312,8 +312,16 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: |------|------|------| | GET | `/api/v1/promotions` | 待审核提升申请列表(需 `SKILL_ADMIN` / `SUPER_ADMIN`;路由不在 `/admin/*` 下) | | GET | `/api/v1/promotions/{id}` | 提升申请详情(提交人本人或 `SKILL_ADMIN` / `SUPER_ADMIN` 可读) | +| GET | `/api/v1/promotions/source/{sourceSkillId}/state` | 来源团队技能的提升状态和关联全局版本(服务端校验来源管理权限) | | POST | `/api/v1/promotions/{id}/approve` | 通过提升申请(需 `SKILL_ADMIN` / `SUPER_ADMIN`) | | POST | `/api/v1/promotions/{id}/reject` | 拒绝提升申请(需 `SKILL_ADMIN` / `SUPER_ADMIN`) | +| GET | `/api/v1/promotion-revocations/pending` | 待审撤销申请(需平台技能管理员) | +| GET | `/api/v1/promotion-revocations/history?page=0&size=20` | 已审核撤销申请历史,服务端分页(需平台技能管理员) | +| GET | `/api/v1/promotion-revocations/{id}` | 撤销申请详情(提交人或平台技能管理员) | +| GET | `/api/v1/promotion-revocations/source/{sourceSkillId}/history` | 来源技能的撤销申请历史(按来源权限读取) | +| POST | `/api/v1/promotion-revocations/{id}/approve` | 审核通过撤销并删除全局派生技能 | +| POST | `/api/v1/promotion-revocations/{id}/reject` | 拒绝撤销申请 | +| POST | `/api/v1/promotion-revocations/source/{sourceSkillId}/direct` | 平台技能管理员直接撤销,仍写申请及审计 | | POST | `/api/v1/admin/skills/{id}/hide` | 隐藏技能(仅 `SUPER_ADMIN`) | | POST | `/api/v1/admin/skills/{id}/unhide` | 恢复技能(仅 `SUPER_ADMIN`) | | POST | `/api/v1/admin/skills/versions/{versionId}/yank` | 撤回已发布版本(`SKILL_ADMIN` / `SUPER_ADMIN`) | @@ -350,7 +358,8 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: | GET | `/api/v1/reviews?namespaceId={id}` | 该空间待审核列表 | | POST | `/api/v1/reviews/{id}/approve` | 空间管理员审核通过 | | POST | `/api/v1/reviews/{id}/reject` | 空间管理员审核拒绝 | -| POST | `/api/v1/promotions` | 申请提升到全局 | +| POST | `/api/v1/promotions` | 首次提升或再次提交已发布团队版本;后者自动关联原全局技能,均需平台审核 | +| POST | `/api/v1/promotion-revocations` | 来源 owner 或 namespace 管理员申请撤销提升 | ## 7.8 `latest` 语义说明 diff --git a/docs/08-frontend-architecture.md b/docs/08-frontend-architecture.md index 972f45a4..2a018e69 100644 --- a/docs/08-frontend-architecture.md +++ b/docs/08-frontend-architecture.md @@ -59,7 +59,7 @@ | 页面 | 路径 | 所需角色 | 说明 | |------|------|---------|------| | 审核中心 | `/admin/reviews` | SKILL_ADMIN | 全局待审核列表 | -| 提升审核 | `/admin/promotions` | SKILL_ADMIN | 提升到全局的申请列表 | +| 提升审核 | `/admin/promotions` | SKILL_ADMIN | 首次提升、再次更新和撤销申请的审核与历史 | | 技能管理 | `/admin/skills` | SKILL_ADMIN | 隐藏/恢复技能、撤回已发布版本 | | 用户管理 | `/admin/users` | USER_ADMIN | 用户列表、角色分配、准入审批、封禁/解封 | | 审计日志 | `/admin/audit-logs` | AUDITOR | 操作日志查询 | diff --git a/docs/14-skill-lifecycle.md b/docs/14-skill-lifecycle.md index 197f5558..2237a368 100644 --- a/docs/14-skill-lifecycle.md +++ b/docs/14-skill-lifecycle.md @@ -141,6 +141,12 @@ Status: current code-aligned reference - 不再新增旧兼容字段依赖 - `hidden` 仅作为治理标记展示,不参与版本状态拼装 +### 5.3 提升更新与撤销 + +- 提升先要求团队来源版本 `PUBLISHED`,再单独进入平台提升审核;后续更新同一全局 Skill 时为它新增不可变的已发布版本。 +- 全局 Skill 也可独立上传并审核。提升更新与独立上传使用相同版本号时不能互相覆盖;不同号版本分别发布,latest 指向最后通过审核的版本,不按版本字符串大小排序。 +- 撤销提升不同于归档或隐藏:平台审核通过后移除全局派生 Skill 的公众入口及记录,保留来源团队 Skill、提升和撤销审计,并保护共用对象存储文件。重新提升形成新的全局 Skill ID。 + ## 6. 权限边界 - `withdraw-review`:仅提交人本人 diff --git a/openspec/changes/support-promotion-updates-and-revocation/design.md b/openspec/changes/support-promotion-updates-and-revocation/design.md new file mode 100644 index 00000000..c2738bf5 --- /dev/null +++ b/openspec/changes/support-promotion-updates-and-revocation/design.md @@ -0,0 +1,43 @@ +## Context + +现有提升使用独立 `promotion_request` 审核,首次通过后创建一条新的全局 `Skill`,团队原技能仍在。全局和团队可分别发布版本;提升后的文件记录复用团队存储 key。当前代码以“一个来源只有一条 APPROVED 请求”为假设,再次提升和撤销都需要调整该假设。`SkillHardDeleteService` 会删除关联的提升记录和全部文件 key,不能原样用于撤销。 + +## Decision 1: 保留双记录和独立审核 + +团队技能详情提供首次提升与再次提交入口。来源团队版本须在提交时和平台审核时均为 `PUBLISHED`。首次提升创建目标 Skill;后续更新必须通过当前有效的首次提升关系找到目标 Skill ID,并核验其 `sourceSkillId`、namespace、owner、状态和未隐藏标记。不得根据同名 slug 猜测或由请求体指定目标 Skill ID。 + +每次审批均以当时的数据库状态校验:若目标已有同号版本(包括待审),拒绝该次审批并保持既有全局内容;异号的独立上传与提升申请可以并行,后发布者更新 latest 指针。版本号不做大小比较,来源 v1.1 可以在全局 v1.3 后发布,但提交确认与审核卡片明确提示。首次提升审批还须检查不同 owner 的已发布同 slug 全局技能,避免地址歧义。 + +## Decision 2: 显式区分首次、更新和有效关系 + +扩展 `promotion_request` 的申请类型(`INITIAL`/`UPDATE`),更新申请记录目标 Skill ID;获批后记录目标版本 ID 与来源版本 ID 的精确映射。现有数据迁移为 `INITIAL`。有效关系只由一条获批且未撤销的首次申请定义;所有依赖 `findBySourceSkillIdAndStatus(APPROVED)` 的单条查询改用有效首次申请或明确的历史列表。数据库约束应防止同一来源有多个待审申请或多个有效首次关系。 + +审批状态更新不得把更新申请已绑定的目标 Skill ID 清空。审核并发用数据库唯一约束、乐观锁和审批事务重检;冲突映射为可读错误。来源版本和目标版本的内容在发布后均不可变。 + +## Decision 3: 撤销是专用的已审核删除操作 + +增设撤销申请记录,包含来源/目标 Skill ID、坐标快照、提交人与审核人、状态、理由和时间;目标 ID 作为历史快照保存,不依赖即将删除的 Skill FK。来源 owner 或 namespace 管理员可提交;平台 `SKILL_ADMIN`/`SUPER_ADMIN` 审核,沿用现有防自审规则;平台管理员直接发起并执行也写同一类审计。待审撤销对同一目标唯一。 + +撤销审批事务锁定并核验当前有效关系及目标 ID,关闭该目标的待审提升更新,保留所有历史提升请求但解除 `target_skill_id` FK,并在历史中记录撤销时间、操作者及原目标 ID 快照。随后删除全局目标的版本、评论/星标/统计/搜索文档等依赖行和 Skill 容器,释放其坐标。团队原技能及其数据不迁移、不合并。 + +安全删除重用可验证的清理步骤,但对每个文件存储 key 检查是否仍被其他 `skill_file` 记录引用;共享对象不得删除。仅目标独有的对象和包在数据库提交后删除,失败时走现有补偿记录。搜索文档须随数据库删除事务一起移除,避免回滚后索引缺失或提交后泄露。 + +再次提升视为新的首次申请,重新经过平台审核并创建新的目标 Skill ID。地址可能已被无关技能取得,因此提交及审批时均执行全局同名冲突检查。公众旧链接可能在未来解析到另一条 Skill;撤销确认文案明确提示,现有 CLI 安装记录及本地文件不自动迁移。 + +## Contracts and UI + +- 保持现有 `POST /api/v1/promotions` 的首次提升请求兼容;增加明确的更新提交方式,不接受客户端自由指定目标 ID。 +- 扩展提升响应,包含申请类型、当前/结果目标版本和有效关系状态;提供团队详情所需的提升状态读模型。 +- 增加撤销申请、审核、历史 API;所有服务端动作重新校验权限,不依赖按钮可见性。 +- 团队详情复用现有提升区域,新增版本关系、提交确认、待审/拒绝状态与撤销申请入口。管理员提升审核页面区分首次/更新,并提供撤销审核区域;不新增独立管理路由。 +- 所有新增文案进入现有多语言目录,API 变更后生成 OpenAPI 类型。 + +## Compatibility and rollout + +数据库迁移为既有获批提升回填申请类型和目标 ID 快照;不得在上线时删除或改写现有技能内容。API 保持旧客户端首次提升行为。部署前后需用数据库集成测试验证 FK 删除顺序、存储引用和搜索索引。回滚前若已有新类型申请或撤销,不应直接回退迁移丢失审计;代码回退须保留新列与记录。 + +## Risks + +- 当前版本字符串没有统一顺序语义,UI 只能解释“最后通过审核的是最新”,不能承诺数字递增。 +- 全局地址释放后可能被别人复用;显式升级旧安装有接管风险,本次通过确认提示和身份校验减轻,但不改 CLI 身份模型。 +- 全局其他版本可能由独立上传形成,撤销整条全局派生技能时一并删除;确认窗口须列出删除范围。 diff --git a/openspec/changes/support-promotion-updates-and-revocation/proposal.md b/openspec/changes/support-promotion-updates-and-revocation/proposal.md new file mode 100644 index 00000000..65ba7ae7 --- /dev/null +++ b/openspec/changes/support-promotion-updates-and-revocation/proposal.md @@ -0,0 +1,26 @@ +## Why + +现有首次提升把团队版本复制为独立全局技能。之后团队技能发布新版本时,提升接口因已有通过记录而拒绝再次申请(#928)。提升通过后也没有撤销路径;归档、隐藏和普通硬删除都无法同时满足公众删除、保留审计和保留团队原件(#924)。 + +## What Changes + +- 来源技能所有者或团队管理员可选择已发布团队版本,对关联的原全局技能提交更新申请;平台审核通过后为同一全局技能增加版本,历史版本不可变。 +- 首次提升与后续更新共用管理后台提升审核入口,但明确标识申请类型及来源、目标版本;团队技能详情显示提交入口、全局当前版和审核状态。 +- 来源技能所有者或团队管理员可申请撤销全局提升,由平台管理员审核;平台管理员也可直接发起并执行。通过后删除公众侧全局派生技能,保留团队源技能、提升和撤销审计,释放全局坐标。 +- 撤销使用专用安全删除逻辑,不能删除团队与全局共享的对象存储文件;重新提升走新申请和审核,同名全局技能冲突要显式拒绝。 + +## Product decisions + +- 保持团队与全局两条 Skill 记录,不迁移命名空间;团队与全局继续分别审核,不自动同步。 +- 用户主动选择团队已发布版本提交全局,平台审核后才对公众可见。 +- 撤销是公众侧删除及地址释放;后台只保留申请与审计,原团队技能继续存在。不会收回用户已下载文件。 +- 全局历史版本不可原位覆盖;再次提升沿用团队版本号,只拒绝全局同号,按实际审批时间更新 latest。页面提示版本号看似回退的情况。 + +## Related issues + +- [#928](https://github.com/iflytek/skillhub/issues/928) +- [#924](https://github.com/iflytek/skillhub/issues/924) + +## Out of scope + +自动同步、合并团队与全局统计、CLI 本地已安装文件回收、同一 Skill 跨命名空间迁移。 diff --git a/openspec/changes/support-promotion-updates-and-revocation/specs/promotion-revocation/spec.md b/openspec/changes/support-promotion-updates-and-revocation/specs/promotion-revocation/spec.md new file mode 100644 index 00000000..ca0cabec --- /dev/null +++ b/openspec/changes/support-promotion-updates-and-revocation/specs/promotion-revocation/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Authorized revocation requires platform review + +The source skill owner or source namespace administrator SHALL be able to request revocation of the active global promotion. A platform reviewer SHALL approve or reject the request. A platform administrator MAY submit and directly execute the same audited action. The target SHALL be resolved by active relation and immutable Skill ID, never by slug alone. + +#### Scenario: Unauthorized or unrelated target + +- **WHEN** a user lacks permission on the source, or the supplied target is not the active derived global Skill +- **THEN** the system rejects the request without touching any skill + +### Requirement: Approved revocation removes the global derivative and retains evidence + +After approval, public discovery, detail, version resolution and download SHALL no longer expose the revoked global Skill. Its namespace/slug coordinate SHALL be available for a later normal application, subject to current collision checks. The team Skill and all of its versions/files SHALL remain usable. Promotion and revocation request history and audit records SHALL remain available to authorized administrators. + +#### Scenario: Shared source and target storage + +- **GIVEN** promoted global file records reference object keys also used by the team source +- **WHEN** revocation is approved +- **THEN** the global records are removed and the shared objects remain readable by the team source + +#### Scenario: Re-promotion after revocation + +- **WHEN** the original owner submits a new first-promotion request +- **THEN** the request follows ordinary platform review and creates a new global Skill identity if the coordinate is free +- **AND** an unrelated existing global Skill at the same coordinate cannot be overwritten + +#### Scenario: Previously installed copies + +- **WHEN** revocation is requested +- **THEN** the UI warns that already downloaded files cannot be recalled and an old coordinate may be claimed by a future Skill + +### Requirement: Revocation and pending updates are consistent + +Approval of revocation SHALL atomically close or invalidate pending updates against the removed global Skill, so a stale reviewer action cannot recreate or modify that Skill. Repeated approval SHALL not delete another Skill that later reuses the coordinate. + +#### Scenario: Stale update approval after revocation + +- **GIVEN** an update request is pending for a global Skill being revoked +- **WHEN** the revocation is approved and a reviewer later attempts to approve the old update +- **THEN** the old update cannot publish or recreate the removed global Skill diff --git a/openspec/changes/support-promotion-updates-and-revocation/specs/promotion-update/spec.md b/openspec/changes/support-promotion-updates-and-revocation/specs/promotion-update/spec.md new file mode 100644 index 00000000..32c026d6 --- /dev/null +++ b/openspec/changes/support-promotion-updates-and-revocation/specs/promotion-update/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: Published team versions can update the linked global skill + +An authorized source owner or source namespace administrator SHALL be able to submit a published team version for platform review against the currently linked global skill. The system SHALL derive the target from the approved promotion relation and SHALL NOT accept a client-selected unrelated target. Each submission SHALL require platform review. + +#### Scenario: Review approves a subsequent promotion + +- **GIVEN** a published team version and an active linked global skill +- **WHEN** an authorized user submits the version and a platform reviewer approves it +- **THEN** the same global Skill ID gets a new published version with the team's version string and source-version provenance +- **AND** the previous global versions remain unchanged +- **AND** the global skill's display name and summary reflect the source skill at approval time + +#### Scenario: Team version is not published + +- **WHEN** the source version is pending review, rejected, or yanked +- **THEN** submission or approval is rejected and the global skill is unchanged + +### Requirement: Concurrent updates never overwrite a version + +The system SHALL reject an update when the global skill already has the same version string, including a draft or pending version. Version strings SHALL NOT be ordered to decide eligibility; the most recently approved release becomes latest. The system SHALL recheck permissions, source/target status and version uniqueness at approval time. + +#### Scenario: Global independent upload races with a promotion update + +- **GIVEN** an independent global upload and a team promotion update are both pending +- **WHEN** they use different version strings and both are approved +- **THEN** both versions remain, and the later approval becomes latest +- **WHEN** they use the same version string +- **THEN** only one can publish and the other receives a conflict, without replacing bytes + +#### Scenario: Version labels appear to move backward + +- **GIVEN** the current global latest is v1.3 and the team candidate is v1.1, not already present globally +- **WHEN** the source user sees the version difference and confirms submission, and the reviewer approves +- **THEN** global v1.1 becomes latest by publish time and v1.3 remains in history + +### Requirement: Submission and review are visible in existing product surfaces + +The team skill detail SHALL show first-promotion or subsequent-update action, team and global current published versions, and pending/rejected/approved feedback. The existing admin promotion review surface SHALL distinguish initial and update requests and show the source version and current global version before review. + +#### Scenario: Reviewer examines an update request + +- **GIVEN** a submitted update request +- **WHEN** a platform reviewer opens the existing promotion review surface +- **THEN** the request is labeled as an update and shows the source version and current target version before approval diff --git a/openspec/changes/support-promotion-updates-and-revocation/tasks.md b/openspec/changes/support-promotion-updates-and-revocation/tasks.md new file mode 100644 index 00000000..905889ee --- /dev/null +++ b/openspec/changes/support-promotion-updates-and-revocation/tasks.md @@ -0,0 +1,14 @@ +## Implementation + +- [x] 扩展提升关系和申请持久化,兼容既有首次提升记录。 +- [x] 实现再次提升提交、审核和原全局技能新增不可变版本;处理并发、权限、版本冲突及审核期间状态变化。 +- [x] 实现撤销申请、审核、审计与全局派生技能安全删除,保护来源文件和历史。 +- [x] 更新详情页提升区域和管理员审核区域,补齐中英文等现有语言文案与可访问状态。 +- [x] 更新 API 类型、产品/域模型文档和提升烟测。 + +## Verification + +- [x] 后端单元测试及本地 PostgreSQL 烟测覆盖首次、再次提升、撤销、重新提升和权限;独立全局版本较高时的发布顺序、版本冲突与并发保护由领域测试和数据库约束覆盖。 +- [x] 前端类型检查、lint、相关组件测试与构建通过。 +- [x] OpenAPI 类型由本地运行中的后端重新生成,类型检查通过。 +- [x] 本地端到端烟测完成团队发布、全局审核、撤销、再次申请及公众不可见验收。 diff --git a/scripts/promotion-smoke-test.sh b/scripts/promotion-smoke-test.sh index 0a45ce38..9d54c6dd 100755 --- a/scripts/promotion-smoke-test.sh +++ b/scripts/promotion-smoke-test.sh @@ -366,6 +366,229 @@ else fail "Promoted global bundle should contain the source SKILL.md" fi +# A published team update goes through a second, separate global review. +cat > "$WORK_DIR/SKILL.md" < getPromotionSourceState( + @PathVariable Long sourceSkillId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return ok("response.success.read", governanceWorkflowAppService.getPromotionSourceState( + sourceSkillId, userId, userNsRoles)); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionRevocationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionRevocationController.java new file mode 100644 index 00000000..3c17379e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionRevocationController.java @@ -0,0 +1,95 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PromotionRevocationActionRequest; +import com.iflytek.skillhub.dto.PromotionRevocationResponse; +import com.iflytek.skillhub.dto.PromotionRevocationSubmitRequest; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.PromotionRevocationPortalAppService; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping({"/api/v1/promotion-revocations", "/api/web/promotion-revocations"}) +public class PromotionRevocationController extends BaseApiController { + private final PromotionRevocationPortalAppService appService; + + public PromotionRevocationController(PromotionRevocationPortalAppService appService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.appService = appService; + } + + @PostMapping + public ApiResponse submit(@RequestBody PromotionRevocationSubmitRequest body, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + HttpServletRequest request) { + return ok("response.success.created", appService.submit(body.sourceSkillId(), userId, + roles, body.reason(), AuditRequestContext.from(request))); + } + + @PostMapping("/source/{sourceSkillId}/direct") + public ApiResponse revokeDirect(@PathVariable Long sourceSkillId, + @RequestBody(required = false) PromotionRevocationSubmitRequest body, + @RequestAttribute("userId") String userId, HttpServletRequest request) { + return ok("response.success.updated", appService.revokeDirect(sourceSkillId, userId, + body != null ? body.reason() : null, AuditRequestContext.from(request))); + } + + @PostMapping("/{id}/approve") + public ApiResponse approve(@PathVariable Long id, + @RequestBody(required = false) PromotionRevocationActionRequest body, + @RequestAttribute("userId") String userId, HttpServletRequest request) { + return ok("response.success.updated", appService.approve(id, userId, + body != null ? body.comment() : null, AuditRequestContext.from(request))); + } + + @PostMapping("/{id}/reject") + public ApiResponse reject(@PathVariable Long id, + @RequestBody(required = false) PromotionRevocationActionRequest body, + @RequestAttribute("userId") String userId, HttpServletRequest request) { + return ok("response.success.updated", appService.reject(id, userId, + body != null ? body.comment() : null, AuditRequestContext.from(request))); + } + + @GetMapping("/pending") + public ApiResponse> pending(@RequestAttribute("userId") String userId) { + return ok("response.success.read", appService.pending(userId)); + } + + @GetMapping("/history") + public ApiResponse> reviewedHistory( + @RequestAttribute("userId") String userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ok("response.success.read", appService.reviewedHistory(userId, page, size)); + } + + @GetMapping("/source/{sourceSkillId}/history") + public ApiResponse> history(@PathVariable Long sourceSkillId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles) { + return ok("response.success.read", appService.history(sourceSkillId, userId, roles)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles) { + return ok("response.success.read", appService.get(id, userId, roles)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java index 62c0d535..f3ed0278 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionResponseDto.java @@ -23,5 +23,22 @@ public record PromotionResponseDto( String reviewedByName, String reviewComment, Instant submittedAt, - Instant reviewedAt -) {} + Instant reviewedAt, + String requestKind, + String targetCurrentVersion, + Long targetVersionId +) { + public PromotionResponseDto(Long id, Long sourceSkillId, String sourceSkillDisplayName, + String sourceSkillSummary, String sourceNamespace, String sourceSkillSlug, + String sourceVersion, Integer sourceVersionFileCount, Long sourceVersionTotalSize, + Long sourceSkillDownloadCount, Integer sourceSkillStarCount, + String targetNamespace, Long targetSkillId, String status, String submittedBy, + String submittedByName, String reviewedBy, String reviewedByName, + String reviewComment, Instant submittedAt, Instant reviewedAt) { + this(id, sourceSkillId, sourceSkillDisplayName, sourceSkillSummary, sourceNamespace, + sourceSkillSlug, sourceVersion, sourceVersionFileCount, sourceVersionTotalSize, + sourceSkillDownloadCount, sourceSkillStarCount, targetNamespace, targetSkillId, + status, submittedBy, submittedByName, reviewedBy, reviewedByName, + reviewComment, submittedAt, reviewedAt, "INITIAL", null, null); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionRevocationActionRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionRevocationActionRequest.java new file mode 100644 index 00000000..55219ac7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionRevocationActionRequest.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.dto; + +public record PromotionRevocationActionRequest(String comment) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionRevocationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionRevocationResponse.java new file mode 100644 index 00000000..d452e6ec --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionRevocationResponse.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.review.PromotionRevocationRequest; +import java.time.Instant; + +public record PromotionRevocationResponse( + Long id, Long initialPromotionRequestId, Long sourceSkillId, Long targetSkillId, + Long sourceNamespaceId, Long targetNamespaceId, String skillSlug, + String status, String reason, String submittedBy, String reviewedBy, + String reviewComment, Instant submittedAt, Instant reviewedAt) { + public static PromotionRevocationResponse from(PromotionRevocationRequest request) { + return new PromotionRevocationResponse(request.getId(), request.getInitialPromotionRequestId(), + request.getSourceSkillId(), request.getTargetSkillId(), request.getSourceNamespaceId(), + request.getTargetNamespaceId(), request.getSkillSlug(), request.getStatus().name(), + request.getReason(), request.getSubmittedBy(), request.getReviewedBy(), + request.getReviewComment(), request.getSubmittedAt(), request.getReviewedAt()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionRevocationSubmitRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionRevocationSubmitRequest.java new file mode 100644 index 00000000..06de3d31 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PromotionRevocationSubmitRequest.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.dto; + +public record PromotionRevocationSubmitRequest(Long sourceSkillId, String reason) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java index 43043c74..aaeaca1f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java @@ -161,14 +161,23 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { private PromotionReadBundle loadPromotionBundle(List requests) { List sourceSkillIds = distinct(requests.stream().map(PromotionRequest::getSourceSkillId).toList()); - Map skillsById = sourceSkillIds.isEmpty() + List targetSkillIds = distinct(requests.stream().map(PromotionRequest::getTargetSkillId) + .filter(Objects::nonNull).toList()); + List allSkillIds = distinct(java.util.stream.Stream.concat( + sourceSkillIds.stream(), targetSkillIds.stream()).toList()); + Map skillsById = allSkillIds.isEmpty() ? Map.of() - : skillRepository.findByIdIn(sourceSkillIds).stream() + : skillRepository.findByIdIn(allSkillIds).stream() .collect(Collectors.toMap(Skill::getId, Function.identity())); List sourceVersionIds = distinct(requests.stream().map(PromotionRequest::getSourceVersionId).toList()); - Map versionsById = sourceVersionIds.isEmpty() + List latestVersionIds = distinct(targetSkillIds.stream() + .map(skillsById::get).filter(Objects::nonNull) + .map(Skill::getLatestVersionId).filter(Objects::nonNull).toList()); + List allVersionIds = distinct(java.util.stream.Stream.concat( + sourceVersionIds.stream(), latestVersionIds.stream()).toList()); + Map versionsById = allVersionIds.isEmpty() ? Map.of() - : skillVersionRepository.findByIdIn(sourceVersionIds).stream() + : skillVersionRepository.findByIdIn(allVersionIds).stream() .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); Set namespaceIds = new LinkedHashSet<>(distinct(requests.stream().map(PromotionRequest::getTargetNamespaceId).toList())); namespaceIds.addAll(skillsById.values().stream().map(Skill::getNamespaceId).toList()); @@ -274,10 +283,22 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { reviewedBy != null ? reviewedBy.getDisplayName() : null, request.getReviewComment(), request.getSubmittedAt(), - request.getReviewedAt() + request.getReviewedAt(), + request.getRequestKind().name(), + targetCurrentVersion(request, bundle), + request.getTargetVersionId() ); } + private String targetCurrentVersion(PromotionRequest request, PromotionReadBundle bundle) { + Skill target = bundle.skillsById().get(request.getTargetSkillId()); + if (target == null || target.getLatestVersionId() == null) { + return null; + } + SkillVersion version = bundle.versionsById().get(target.getLatestVersionId()); + return version != null ? version.getVersion() : null; + } + private GovernanceInboxItemResponse toReviewInboxItem(ReviewTask task, ReviewReadBundle bundle) { if (isSuiteReview(task)) { SkillSuite suite = bundle.suitesById().get(task.getSubjectId()); 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 b60f01fd..e7a3acd7 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 @@ -164,9 +164,6 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository { if (promotionRequestRepository.findBySourceSkillIdAndStatus(skill.getId(), ReviewTaskStatus.PENDING).isPresent()) { return false; } - if (promotionRequestRepository.findBySourceSkillIdAndStatus(skill.getId(), ReviewTaskStatus.APPROVED).isPresent()) { - return false; - } return publishedVersion != null && "PUBLISHED".equals(publishedVersion.status()); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index 3a29d9f0..f4a0d6ae 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -208,6 +208,11 @@ public class GovernanceWorkflowAppService { return promotionPortalAppService.getPromotionDetail(promotionId, userId); } + public com.iflytek.skillhub.domain.review.PromotionState getPromotionSourceState( + Long sourceSkillId, String userId, Map userNsRoles) { + return promotionPortalAppService.getSourceState(sourceSkillId, userId, userNsRoles); + } + public SkillLifecycleMutationResponse archiveSkill(String namespace, String slug, AdminSkillActionRequest request, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java index 4bf92feb..f4ac1f3c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.review.PromotionRequest; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.PromotionService; +import com.iflytek.skillhub.domain.review.PromotionState; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; @@ -70,7 +71,12 @@ public class PromotionPortalAppService { userId, promotion.getId(), auditContext, - AuditDetail.of("sourceSkillId", sourceSkillId, "sourceVersionId", sourceVersionId) + AuditDetail.builder() + .put("sourceSkillId", sourceSkillId) + .put("sourceVersionId", sourceVersionId) + .put("requestKind", promotion.getRequestKind().name()) + .put("targetSkillId", promotion.getTargetSkillId()) + .build() ); return governanceQueryRepository.getPromotionResponse(promotion); } @@ -80,14 +86,25 @@ public class PromotionPortalAppService { String comment, String userId, AuditRequestContext auditContext) { + PromotionRequest pending = promotionRequestRepository.findById(promotionId) + .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", promotionId)); PromotionRequest promotion = promotionService.approvePromotion( promotionId, userId, comment, - platformRoles(userId) + platformRoles(userId), + platformRoles(pending.getSubmittedBy()) ); recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), auditContext, - detailWithComment(comment, promotion.getSubmittedBy().equals(userId))); + promotion.getRequestKind() == com.iflytek.skillhub.domain.review.PromotionRequestKind.INITIAL + ? detailWithComment(comment, promotion.getSubmittedBy().equals(userId)) + : AuditDetail.builder() + .put("requestKind", promotion.getRequestKind().name()) + .put("targetSkillId", promotion.getTargetSkillId()) + .put("targetVersionId", promotion.getTargetVersionId()) + .put("comment", comment) + .put("selfReview", promotion.getSubmittedBy().equals(userId) ? Boolean.TRUE : null) + .build()); return governanceQueryRepository.getPromotionResponse(promotion); } @@ -152,6 +169,12 @@ public class PromotionPortalAppService { return governanceQueryRepository.getPromotionResponse(promotion); } + public PromotionState getSourceState(Long sourceSkillId, String userId, + Map userNsRoles) { + return promotionService.getSourceState(sourceSkillId, userId, normalizeRoles(userNsRoles), + platformRoles(userId)); + } + private ReviewTaskStatus parsePromotionStatus(String status) { if (status == null) { return ReviewTaskStatus.PENDING; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionRevocationPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionRevocationPortalAppService.java new file mode 100644 index 00000000..b9893732 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionRevocationPortalAppService.java @@ -0,0 +1,180 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditDetail; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.review.PromotionRevocationRequest; +import com.iflytek.skillhub.domain.review.PromotionRevocationRequestRepository; +import com.iflytek.skillhub.domain.review.PromotionRevocationService; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.dto.PromotionRevocationResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.search.SearchIndexService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; + +/** API-facing revocation workflow, including transactionally removing the search document. */ +@Service +public class PromotionRevocationPortalAppService { + private final PromotionRevocationService revocationService; + private final PromotionRevocationRequestRepository revocationRepository; + private final PromotionRequestRepository promotionRepository; + private final SkillRepository skillRepository; + private final RbacService rbacService; + private final SearchIndexService searchIndexService; + private final AuditLogService auditLogService; + private final RequestIdAccessor requestIdAccessor; + + public PromotionRevocationPortalAppService(PromotionRevocationService revocationService, + PromotionRevocationRequestRepository revocationRepository, + PromotionRequestRepository promotionRepository, + SkillRepository skillRepository, RbacService rbacService, + SearchIndexService searchIndexService, + AuditLogService auditLogService, + RequestIdAccessor requestIdAccessor) { + this.revocationService = revocationService; + this.revocationRepository = revocationRepository; + this.promotionRepository = promotionRepository; + this.skillRepository = skillRepository; + this.rbacService = rbacService; + this.searchIndexService = searchIndexService; + this.auditLogService = auditLogService; + this.requestIdAccessor = requestIdAccessor; + } + + @Transactional + public PromotionRevocationResponse submit(Long sourceSkillId, String userId, + Map namespaceRoles, + String reason, AuditRequestContext context) { + PromotionRevocationRequest request = revocationService.submit(sourceSkillId, userId, + roles(namespaceRoles), platformRoles(userId), reason); + audit("PROMOTION_REVOCATION_SUBMIT", request, userId, context); + return PromotionRevocationResponse.from(request); + } + + @Transactional + public PromotionRevocationResponse approve(Long requestId, String userId, + String comment, AuditRequestContext context) { + revocationRepository.findById(requestId) + .ifPresent(pending -> searchIndexService.remove(pending.getTargetSkillId())); + PromotionRevocationRequest request = revocationService.approve(requestId, userId, + platformRoles(userId), comment, clientIp(context), userAgent(context)); + audit("PROMOTION_REVOCATION_APPROVE", request, userId, context); + return PromotionRevocationResponse.from(request); + } + + @Transactional + public PromotionRevocationResponse reject(Long requestId, String userId, + String comment, AuditRequestContext context) { + PromotionRevocationRequest request = revocationService.reject(requestId, userId, + platformRoles(userId), comment); + audit("PROMOTION_REVOCATION_REJECT", request, userId, context); + return PromotionRevocationResponse.from(request); + } + + @Transactional + public PromotionRevocationResponse revokeDirect(Long sourceSkillId, String userId, + String reason, AuditRequestContext context) { + promotionRepository.findActiveInitialBySourceSkillId(sourceSkillId) + .ifPresent(initial -> searchIndexService.remove(initial.getTargetSkillId())); + PromotionRevocationRequest request = revocationService.revokeDirect(sourceSkillId, userId, + platformRoles(userId), reason, clientIp(context), userAgent(context)); + audit("PROMOTION_REVOCATION_DIRECT", request, userId, context); + return PromotionRevocationResponse.from(request); + } + + public PromotionRevocationResponse get(Long requestId, String userId, + Map namespaceRoles) { + PromotionRevocationRequest request = revocationRepository.findById(requestId) + .orElseThrow(() -> new DomainNotFoundException("promotion.revocation.not_found", requestId)); + requireRead(request, userId, namespaceRoles); + return PromotionRevocationResponse.from(request); + } + + public List history(Long sourceSkillId, String userId, + Map namespaceRoles) { + requireReadSource(sourceSkillId, userId, namespaceRoles); + return revocationRepository.findBySourceSkillIdOrderBySubmittedAtDesc(sourceSkillId).stream() + .map(PromotionRevocationResponse::from).toList(); + } + + public List pending(String userId) { + requirePlatformAdmin(userId); + return revocationRepository.findByStatusOrderBySubmittedAtAsc(ReviewTaskStatus.PENDING).stream() + .map(PromotionRevocationResponse::from).toList(); + } + + public PageResponse reviewedHistory(String userId, int page, int size) { + requirePlatformAdmin(userId); + if (page < 0 || size < 1 || size > 100) { + throw new DomainBadRequestException("promotion.revocation.page_invalid"); + } + return PageResponse.from(revocationRepository.findByStatusIn( + List.of(ReviewTaskStatus.APPROVED, ReviewTaskStatus.REJECTED), + PageRequest.of(page, size, Sort.by(Sort.Order.desc("reviewedAt"), Sort.Order.desc("id"))) + ).map(PromotionRevocationResponse::from)); + } + + private void requireRead(PromotionRevocationRequest request, String userId, + Map namespaceRoles) { + if (request.getSubmittedBy().equals(userId) || isPlatformAdmin(platformRoles(userId))) { + return; + } + NamespaceRole role = roles(namespaceRoles).get(request.getSourceNamespaceId()); + if (role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) { + throw new DomainForbiddenException("promotion.revocation.read_no_permission"); + } + } + + private void requireReadSource(Long sourceSkillId, String userId, + Map namespaceRoles) { + if (isPlatformAdmin(platformRoles(userId))) { + return; + } + Skill skill = skillRepository.findById(sourceSkillId) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId)); + NamespaceRole role = roles(namespaceRoles).get(skill.getNamespaceId()); + if (!userId.equals(skill.getOwnerId()) && role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) { + throw new DomainForbiddenException("promotion.revocation.read_no_permission"); + } + } + + private void requirePlatformAdmin(String userId) { + if (!isPlatformAdmin(platformRoles(userId))) { + throw new DomainForbiddenException("promotion.revocation.review_no_permission"); + } + } + + private boolean isPlatformAdmin(Set roles) { + return roles.contains("SKILL_ADMIN") || roles.contains("SUPER_ADMIN"); + } + + private Set platformRoles(String userId) { return rbacService.getUserRoleCodes(userId); } + private Map roles(Map roles) { + return roles != null ? roles : Map.of(); + } + private String clientIp(AuditRequestContext context) { return context != null ? context.clientIp() : null; } + private String userAgent(AuditRequestContext context) { return context != null ? context.userAgent() : null; } + + private void audit(String action, PromotionRevocationRequest request, String actorId, + AuditRequestContext context) { + auditLogService.record(actorId, action, "PROMOTION_REVOCATION_REQUEST", request.getId(), + requestIdAccessor.current(), clientIp(context), userAgent(context), + AuditDetail.builder().put("sourceSkillId", request.getSourceSkillId()) + .put("targetSkillId", request.getTargetSkillId()) + .put("status", request.getStatus().name()).build()); + } +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V66__promotion_update.sql b/server/skillhub-app/src/main/resources/db/migration/V66__promotion_update.sql new file mode 100644 index 00000000..bde6b04d --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V66__promotion_update.sql @@ -0,0 +1,19 @@ +ALTER TABLE promotion_request + ADD COLUMN request_kind VARCHAR(16) NOT NULL DEFAULT 'INITIAL', + ADD COLUMN target_version_id BIGINT; + +UPDATE promotion_request p +SET target_version_id = target_version.id +FROM skill_version source_version, skill_version target_version +WHERE p.status = 'APPROVED' + AND p.source_version_id = source_version.id + AND p.target_skill_id = target_version.skill_id + AND source_version.version = target_version.version + AND p.request_kind = 'INITIAL'; + +CREATE UNIQUE INDEX uq_promotion_source_pending + ON promotion_request(source_skill_id) WHERE status = 'PENDING'; + +CREATE UNIQUE INDEX uq_promotion_active_initial + ON promotion_request(source_skill_id) + WHERE request_kind = 'INITIAL' AND status = 'APPROVED' AND target_skill_id IS NOT NULL; diff --git a/server/skillhub-app/src/main/resources/db/migration/V67__promotion_revocation_request.sql b/server/skillhub-app/src/main/resources/db/migration/V67__promotion_revocation_request.sql new file mode 100644 index 00000000..b47ced2d --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V67__promotion_revocation_request.sql @@ -0,0 +1,27 @@ +ALTER TABLE promotion_request + ADD COLUMN revoked_at TIMESTAMPTZ, + ADD COLUMN revoked_by VARCHAR(128), + ADD COLUMN target_skill_id_snapshot BIGINT, + ADD COLUMN target_version_id_snapshot BIGINT; + +CREATE TABLE promotion_revocation_request ( + id BIGSERIAL PRIMARY KEY, + initial_promotion_request_id BIGINT NOT NULL, + source_skill_id BIGINT NOT NULL, + target_skill_id BIGINT NOT NULL, + source_namespace_id BIGINT NOT NULL, + target_namespace_id BIGINT NOT NULL, + skill_slug VARCHAR(100) NOT NULL, + submitted_by VARCHAR(128) NOT NULL REFERENCES user_account(id), + reviewed_by VARCHAR(128) REFERENCES user_account(id), + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + version INT NOT NULL DEFAULT 1, + reason TEXT, + review_comment TEXT, + submitted_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + reviewed_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX uq_promotion_revocation_pending_target + ON promotion_revocation_request(target_skill_id) WHERE status = 'PENDING'; +CREATE INDEX idx_promotion_revocation_source ON promotion_revocation_request(source_skill_id, submitted_at DESC); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index b71fda79..337efb13 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -192,6 +192,9 @@ validation.auth.password.reset.code.notBlank=Verification code cannot be blank validation.auth.password.reset.code.invalid=Verification code must be 6 digits validation.auth.password.reset.newPassword.notBlank=New password cannot be blank promotion.target_skill_conflict=The target global skill "{0}" already exists +promotion.target_version_conflict=Global skill version "{0}" already exists or is under review +promotion.target_inactive=The linked global skill for source skill {0} is no longer active +promotion.source_inactive=Source skill {0} is no longer active promotion.status.invalid=Unsupported promotion status: {0} promotion.sort.field.invalid=Unsupported promotion sort field: {0} promotion.sort.direction.invalid=Unsupported promotion sort direction: {0} @@ -260,3 +263,13 @@ error.suite.bundle.operation.cancel.notAllowed=This Suite Bundle operation can n error.suite.bundle.operation.retry.notAllowed=Only a retryable blocked Suite Bundle operation can be retried error.suite.bundle.member.stateChanged=The bound Skill or version state changed; create a new Suite Bundle preview error.suite.bundle.actor.inactive=The Suite Bundle actor is no longer active +promotion.revocation.submit_no_permission=You cannot request revocation for this skill +promotion.revocation.review_no_permission=You cannot review this revocation request +promotion.revocation.read_no_permission=You cannot view this revocation request +promotion.revocation.not_active=This skill has no active global promotion +promotion.revocation.target_changed=The global target changed; submit a new revocation request +promotion.revocation.duplicate_pending=A revocation request is already pending for this global skill +promotion.revocation.not_found=Revocation request {0} was not found +promotion.revocation.not_pending=Revocation request {0} is no longer pending +promotion.revocation.required=Use the reviewed revocation workflow to remove a promoted skill +promotion.revocation.page_invalid=Page must be nonnegative and size must be between 1 and 100 diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 2923cf4f..3a6f22a8 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -192,6 +192,9 @@ validation.auth.password.reset.code.notBlank=验证码不能为空 validation.auth.password.reset.code.invalid=验证码必须为 6 位数字 validation.auth.password.reset.newPassword.notBlank=新密码不能为空 promotion.target_skill_conflict=目标全局技能“{0}”已存在 +promotion.target_version_conflict=全局技能版本“{0}”已存在或正在审核 +promotion.target_inactive=来源技能 {0} 关联的全局技能已不可用 +promotion.source_inactive=来源技能 {0} 已不可用 promotion.status.invalid=不支持的提升审核状态:{0} promotion.sort.field.invalid=不支持的提升审核排序字段:{0} promotion.sort.direction.invalid=不支持的提升审核排序方向:{0} @@ -260,3 +263,13 @@ error.suite.bundle.operation.cancel.notAllowed=该技能套件 Bundle 操作已 error.suite.bundle.operation.retry.notAllowed=只有处于可重试阻塞状态的技能套件 Bundle 操作才能重试 error.suite.bundle.member.stateChanged=绑定的技能或版本状态已经变化,请重新创建技能套件 Bundle 预览 error.suite.bundle.actor.inactive=技能套件 Bundle 的操作者已不再处于可用状态 +promotion.revocation.submit_no_permission=你没有权限申请撤销该技能的全局提升 +promotion.revocation.review_no_permission=你没有权限审核该撤销申请 +promotion.revocation.read_no_permission=你没有权限查看该撤销申请 +promotion.revocation.not_active=该技能没有有效的全局提升记录 +promotion.revocation.target_changed=全局目标已经变化,请重新提交撤销申请 +promotion.revocation.duplicate_pending=该全局技能已有待审核的撤销申请 +promotion.revocation.not_found=撤销申请 {0} 不存在 +promotion.revocation.not_pending=撤销申请 {0} 已不在待审核状态 +promotion.revocation.required=已提升的技能须通过审核撤销流程删除 +promotion.revocation.page_invalid=页码不能为负数,每页数量须在 1 到 100 之间 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionRevocationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionRevocationControllerTest.java new file mode 100644 index 00000000..2645ac28 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionRevocationControllerTest.java @@ -0,0 +1,49 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PromotionRevocationActionRequest; +import com.iflytek.skillhub.dto.PromotionRevocationSubmitRequest; +import com.iflytek.skillhub.service.PromotionRevocationPortalAppService; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PromotionRevocationControllerTest { + @Mock PromotionRevocationPortalAppService appService; + @Mock ApiResponseFactory responseFactory; + @Mock HttpServletRequest request; + + @Test + void submitForwardsSourceIdAndAuthenticatedUser() { + PromotionRevocationController controller = new PromotionRevocationController(appService, responseFactory); + + controller.submit(new PromotionRevocationSubmitRequest(10L, "withdraw"), "owner-1", null, request); + + verify(appService).submit(eq(10L), eq("owner-1"), eq(null), eq("withdraw"), any()); + } + + @Test + void approveUsesReviewerIdentityAndRequestId() { + PromotionRevocationController controller = new PromotionRevocationController(appService, responseFactory); + + controller.approve(50L, new PromotionRevocationActionRequest("approved"), "admin-1", request); + + verify(appService).approve(eq(50L), eq("admin-1"), eq("approved"), any()); + } + + @Test + void reviewedHistoryForwardsPaginationAndAuthenticatedUser() { + PromotionRevocationController controller = new PromotionRevocationController(appService, responseFactory); + + controller.reviewedHistory("admin-1", 1, 25); + + verify(appService).reviewedHistory("admin-1", 1, 25); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java index bdaa0f82..970114a0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java @@ -170,7 +170,9 @@ class JpaGovernanceQueryRepositoryTest { UserAccount submitter = new UserAccount("submitter", "Submitter", "submitter@example.com", null); UserAccount reviewer = new UserAccount("reviewer", "Reviewer", "reviewer@example.com", null); - given(skillRepository.findByIdIn(List.of(201L))).willReturn(List.of(skill)); + Skill targetSkill = new Skill(12L, "skill-a", "submitter", SkillVisibility.PUBLIC); + setField(targetSkill, "id", 301L); + given(skillRepository.findByIdIn(List.of(201L, 301L))).willReturn(List.of(skill, targetSkill)); given(skillVersionRepository.findByIdIn(List.of(101L))).willReturn(List.of(version)); given(namespaceRepository.findByIdIn(List.of(12L, 11L))).willReturn(List.of(targetNamespace, sourceNamespace)); given(userAccountRepository.findByIdIn(List.of("submitter", "reviewer"))) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepositoryTest.java index c739f5b4..f8ac810d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepositoryTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepositoryTest.java @@ -74,7 +74,6 @@ class JpaMySkillQueryRepositoryTest { given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion)); given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(publishedVersion, rejectedVersion)); given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.PENDING)).willReturn(Optional.empty()); - given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.APPROVED)).willReturn(Optional.empty()); var responses = repository.getSkillSummaries(List.of(skill), "user-1"); @@ -103,7 +102,6 @@ class JpaMySkillQueryRepositoryTest { given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace)); given(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion)); given(promotionRequestRepository.findBySourceSkillIdAndStatus(3L, ReviewTaskStatus.PENDING)).willReturn(Optional.empty()); - given(promotionRequestRepository.findBySourceSkillIdAndStatus(3L, ReviewTaskStatus.APPROVED)).willReturn(Optional.empty()); var responses = repository.getSkillSummaries(List.of(skill), "viewer-1"); 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 44acf402..8daa73ad 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 @@ -176,7 +176,6 @@ class MySkillAppServiceTest { given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(publishedVersion)); given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace)); given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.PENDING)).willReturn(Optional.empty()); - given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.APPROVED)).willReturn(Optional.empty()); var skills = service.listMySkills("user-1", 0, 10); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java index b19ce879..75b8c841 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java @@ -9,6 +9,7 @@ import com.iflytek.skillhub.dto.PromotionResponseDto; import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.repository.GovernanceQueryRepository; import java.lang.reflect.Field; +import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -56,8 +57,9 @@ class PromotionPortalAppServiceTest { @Test void approvePromotion_recordsSelfReviewAuditDetailForSuperAdminSelfApproval() { PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUPER_ADMIN_ID); + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(promotion)); when(rbacService.getUserRoleCodes(SUPER_ADMIN_ID)).thenReturn(Set.of("SUPER_ADMIN")); - when(promotionService.approvePromotion(PROMOTION_ID, SUPER_ADMIN_ID, "ship", Set.of("SUPER_ADMIN"))) + when(promotionService.approvePromotion(PROMOTION_ID, SUPER_ADMIN_ID, "ship", Set.of("SUPER_ADMIN"), Set.of("SUPER_ADMIN"))) .thenReturn(promotion); when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion)); @@ -110,8 +112,10 @@ class PromotionPortalAppServiceTest { @Test void approvePromotion_keepsExistingAuditDetailForReviewerApprovingOthersPromotion() { PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUBMITTER_ID); + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(promotion)); when(rbacService.getUserRoleCodes(REVIEWER_ID)).thenReturn(Set.of("SKILL_ADMIN")); - when(promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ship", Set.of("SKILL_ADMIN"))) + when(rbacService.getUserRoleCodes(SUBMITTER_ID)).thenReturn(Set.of()); + when(promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ship", Set.of("SKILL_ADMIN"), Set.of())) .thenReturn(promotion); when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion)); @@ -141,8 +145,10 @@ class PromotionPortalAppServiceTest { // failed after the promotion had already been approved. String comment = "looks good\nbut rename it \"foo\"\tfirst\\done"; PromotionRequest promotion = promotionRequest(PROMOTION_ID, SUBMITTER_ID); + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(promotion)); when(rbacService.getUserRoleCodes(REVIEWER_ID)).thenReturn(Set.of("SKILL_ADMIN")); - when(promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, comment, Set.of("SKILL_ADMIN"))) + when(rbacService.getUserRoleCodes(SUBMITTER_ID)).thenReturn(Set.of()); + when(promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, comment, Set.of("SKILL_ADMIN"), Set.of())) .thenReturn(promotion); when(governanceQueryRepository.getPromotionResponse(promotion)).thenReturn(response(promotion)); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionRevocationPortalAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionRevocationPortalAppServiceTest.java new file mode 100644 index 00000000..f08b6e3e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionRevocationPortalAppServiceTest.java @@ -0,0 +1,130 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.review.PromotionRevocationRequest; +import com.iflytek.skillhub.domain.review.PromotionRevocationRequestRepository; +import com.iflytek.skillhub.domain.review.PromotionRevocationService; +import com.iflytek.skillhub.domain.review.PromotionRequest; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.search.SearchIndexService; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PromotionRevocationPortalAppServiceTest { + @Mock PromotionRevocationService domain; + @Mock PromotionRevocationRequestRepository repository; + @Mock PromotionRequestRepository promotionRepository; + @Mock SkillRepository skillRepository; + @Mock RbacService rbacService; + @Mock SearchIndexService searchIndexService; + @Mock AuditLogService auditLogService; + private PromotionRevocationPortalAppService service; + + @BeforeEach + void setUp() { + service = new PromotionRevocationPortalAppService(domain, repository, promotionRepository, skillRepository, + rbacService, searchIndexService, auditLogService, new RequestIdAccessor()); + } + + @Test + void approvalRemovesOnlyApprovedTargetSearchDocument() { + PromotionRevocationRequest request = new PromotionRevocationRequest( + 4L, 10L, 30L, 2L, 1L, "foo", "owner-1", "withdraw"); + when(rbacService.getUserRoleCodes("admin-1")).thenReturn(Set.of("SKILL_ADMIN")); + when(repository.findById(5L)).thenReturn(Optional.of(request)); + when(domain.approve(5L, "admin-1", Set.of("SKILL_ADMIN"), "ok", null, null)) + .thenReturn(request); + + service.approve(5L, "admin-1", "ok", null); + + InOrder order = inOrder(domain, searchIndexService); + order.verify(searchIndexService).remove(30L); + order.verify(domain).approve(5L, "admin-1", Set.of("SKILL_ADMIN"), "ok", null, null); + } + + @Test + void directRevocationRemovesSearchDocumentBeforeDeletingTarget() { + PromotionRequest promotion = new PromotionRequest(10L, 20L, 1L, "owner-1"); + promotion.setTargetSkillId(30L); + PromotionRevocationRequest request = new PromotionRevocationRequest( + 4L, 10L, 30L, 2L, 1L, "foo", "admin-1", "withdraw"); + when(promotionRepository.findActiveInitialBySourceSkillId(10L)) + .thenReturn(Optional.of(promotion)); + when(rbacService.getUserRoleCodes("admin-1")).thenReturn(Set.of("SKILL_ADMIN")); + when(domain.revokeDirect(10L, "admin-1", Set.of("SKILL_ADMIN"), "withdraw", null, null)) + .thenReturn(request); + + service.revokeDirect(10L, "admin-1", "withdraw", null); + + InOrder order = inOrder(searchIndexService, domain); + order.verify(searchIndexService).remove(30L); + order.verify(domain).revokeDirect(10L, "admin-1", Set.of("SKILL_ADMIN"), "withdraw", null, null); + } + + @Test + void sourceHistoryRejectsUnrelatedReader() { + Skill source = new Skill(2L, "foo", "owner-1", SkillVisibility.PUBLIC); + when(rbacService.getUserRoleCodes("other")).thenReturn(Set.of()); + when(skillRepository.findById(10L)).thenReturn(Optional.of(source)); + + assertThatThrownBy(() -> service.history(10L, "other", Map.of())) + .isInstanceOf(DomainForbiddenException.class); + } + + @Test + void teamAdministratorCanReadSourceHistory() { + Skill source = new Skill(2L, "foo", "owner-1", SkillVisibility.PUBLIC); + when(rbacService.getUserRoleCodes("team-admin")).thenReturn(Set.of()); + when(skillRepository.findById(10L)).thenReturn(Optional.of(source)); + when(repository.findBySourceSkillIdOrderBySubmittedAtDesc(10L)).thenReturn(List.of()); + + assertThat(service.history(10L, "team-admin", Map.of(2L, NamespaceRole.ADMIN))).isEmpty(); + verify(repository).findBySourceSkillIdOrderBySubmittedAtDesc(10L); + } + + @Test + void reviewedHistoryIsPagedAndPlatformAdminOnly() { + PromotionRevocationRequest reviewed = new PromotionRevocationRequest( + 4L, 10L, 30L, 2L, 1L, "foo", "owner-1", "withdraw"); + reviewed.review(com.iflytek.skillhub.domain.review.ReviewTaskStatus.APPROVED, + "admin-1", "ok", java.time.Instant.parse("2026-10-09T00:00:00Z")); + when(rbacService.getUserRoleCodes("admin-1")).thenReturn(Set.of("SKILL_ADMIN")); + var pageable = PageRequest.of(0, 20, + Sort.by(Sort.Order.desc("reviewedAt"), Sort.Order.desc("id"))); + when(repository.findByStatusIn(List.of( + com.iflytek.skillhub.domain.review.ReviewTaskStatus.APPROVED, + com.iflytek.skillhub.domain.review.ReviewTaskStatus.REJECTED), pageable)) + .thenReturn(new PageImpl<>(List.of(reviewed), pageable, 1)); + + var result = service.reviewedHistory("admin-1", 0, 20); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).status()).isEqualTo("APPROVED"); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequest.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequest.java index 9b4b0424..e44936b2 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequest.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequest.java @@ -23,6 +23,13 @@ public class PromotionRequest { @Column(name = "target_skill_id") private Long targetSkillId; + @Enumerated(EnumType.STRING) + @Column(name = "request_kind", nullable = false) + private PromotionRequestKind requestKind = PromotionRequestKind.INITIAL; + + @Column(name = "target_version_id") + private Long targetVersionId; + @Enumerated(EnumType.STRING) @Column(nullable = false) private ReviewTaskStatus status = ReviewTaskStatus.PENDING; @@ -66,6 +73,14 @@ public class PromotionRequest { public Long getTargetSkillId() { return targetSkillId; } + public PromotionRequestKind getRequestKind() { return requestKind; } + + public void setRequestKind(PromotionRequestKind requestKind) { this.requestKind = requestKind; } + + public Long getTargetVersionId() { return targetVersionId; } + + public void setTargetVersionId(Long targetVersionId) { this.targetVersionId = targetVersionId; } + public void setTargetSkillId(Long targetSkillId) { this.targetSkillId = targetSkillId; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestKind.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestKind.java new file mode 100644 index 00000000..2353aab6 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestKind.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.domain.review; + +public enum PromotionRequestKind { + INITIAL, + UPDATE +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java index 8bfdf245..3d2f46ee 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java @@ -13,6 +13,7 @@ public interface PromotionRequestRepository { Optional findById(Long id); Optional findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status); Optional findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status); + Optional findActiveInitialBySourceSkillId(Long sourceSkillId); Page findByStatus(ReviewTaskStatus status, Pageable pageable); Page findHistoryByStatusOrderByReviewedAtAsc(ReviewTaskStatus status, Pageable pageable); Page findHistoryByStatusOrderByReviewedAtDesc(ReviewTaskStatus status, Pageable pageable); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRevocationHistoryRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRevocationHistoryRepository.java new file mode 100644 index 00000000..386ddd6e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRevocationHistoryRepository.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.domain.review; + +import java.time.Instant; + +/** Detaches target foreign keys while preserving approved promotion records. */ +public interface PromotionRevocationHistoryRepository { + boolean lockTarget(Long targetSkillId); + int detachTarget(Long targetSkillId, String reviewerId, Instant revokedAt); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRevocationRequest.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRevocationRequest.java new file mode 100644 index 00000000..6ff586cc --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRevocationRequest.java @@ -0,0 +1,96 @@ +package com.iflytek.skillhub.domain.review; + +import jakarta.persistence.*; +import java.time.Instant; + +/** Immutable skill coordinates are retained even after the global target is deleted. */ +@Entity +@Table(name = "promotion_revocation_request") +public class PromotionRevocationRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "initial_promotion_request_id", nullable = false) + private Long initialPromotionRequestId; + + @Column(name = "source_skill_id", nullable = false) + private Long sourceSkillId; + + @Column(name = "target_skill_id", nullable = false) + private Long targetSkillId; + + @Column(name = "source_namespace_id", nullable = false) + private Long sourceNamespaceId; + + @Column(name = "target_namespace_id", nullable = false) + private Long targetNamespaceId; + + @Column(name = "skill_slug", nullable = false) + private String skillSlug; + + @Column(name = "submitted_by", nullable = false) + private String submittedBy; + + @Column(name = "reviewed_by") + private String reviewedBy; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReviewTaskStatus status = ReviewTaskStatus.PENDING; + + @Version + @Column(nullable = false) + private Integer version = 1; + + @Column(columnDefinition = "TEXT") + private String reason; + + @Column(name = "review_comment", columnDefinition = "TEXT") + private String reviewComment; + + @Column(name = "submitted_at", nullable = false) + private Instant submittedAt = Instant.now(); + + @Column(name = "reviewed_at") + private Instant reviewedAt; + + protected PromotionRevocationRequest() {} + + public PromotionRevocationRequest(Long initialPromotionRequestId, Long sourceSkillId, + Long targetSkillId, Long sourceNamespaceId, + Long targetNamespaceId, String skillSlug, + String submittedBy, String reason) { + this.initialPromotionRequestId = initialPromotionRequestId; + this.sourceSkillId = sourceSkillId; + this.targetSkillId = targetSkillId; + this.sourceNamespaceId = sourceNamespaceId; + this.targetNamespaceId = targetNamespaceId; + this.skillSlug = skillSlug; + this.submittedBy = submittedBy; + this.reason = reason; + } + + public Long getId() { return id; } + public Long getInitialPromotionRequestId() { return initialPromotionRequestId; } + public Long getSourceSkillId() { return sourceSkillId; } + public Long getTargetSkillId() { return targetSkillId; } + public Long getSourceNamespaceId() { return sourceNamespaceId; } + public Long getTargetNamespaceId() { return targetNamespaceId; } + public String getSkillSlug() { return skillSlug; } + public String getSubmittedBy() { return submittedBy; } + public String getReviewedBy() { return reviewedBy; } + public ReviewTaskStatus getStatus() { return status; } + public String getReason() { return reason; } + public String getReviewComment() { return reviewComment; } + public Instant getSubmittedAt() { return submittedAt; } + public Instant getReviewedAt() { return reviewedAt; } + + public void review(ReviewTaskStatus newStatus, String reviewerId, String comment, Instant now) { + status = newStatus; + reviewedBy = reviewerId; + reviewComment = comment; + reviewedAt = now; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRevocationRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRevocationRequestRepository.java new file mode 100644 index 00000000..3ad37828 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRevocationRequestRepository.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.domain.review; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface PromotionRevocationRequestRepository { + PromotionRevocationRequest save(PromotionRevocationRequest request); + Optional findById(Long id); + boolean existsByTargetSkillIdAndStatus(Long targetSkillId, ReviewTaskStatus status); + Optional findByTargetSkillIdAndStatus(Long targetSkillId, ReviewTaskStatus status); + List findBySourceSkillIdOrderBySubmittedAtDesc(Long sourceSkillId); + List findByStatusOrderBySubmittedAtAsc(ReviewTaskStatus status); + Page findByStatusIn(List statuses, Pageable pageable); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRevocationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRevocationService.java new file mode 100644 index 00000000..27ecc0fa --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRevocationService.java @@ -0,0 +1,198 @@ +package com.iflytek.skillhub.domain.review; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.service.SkillHardDeleteService; +import java.time.Clock; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Reviews a request to remove a specific derived global skill, preserving its promotion history. */ +@Service +public class PromotionRevocationService { + private final PromotionRequestRepository promotionRepository; + private final PromotionRevocationRequestRepository revocationRepository; + private final PromotionRevocationHistoryRepository historyRepository; + private final SkillRepository skillRepository; + private final NamespaceRepository namespaceRepository; + private final SkillHardDeleteService hardDeleteService; + private final Clock clock; + + public PromotionRevocationService(PromotionRequestRepository promotionRepository, + PromotionRevocationRequestRepository revocationRepository, + PromotionRevocationHistoryRepository historyRepository, + SkillRepository skillRepository, + NamespaceRepository namespaceRepository, + SkillHardDeleteService hardDeleteService, + Clock clock) { + this.promotionRepository = promotionRepository; + this.revocationRepository = revocationRepository; + this.historyRepository = historyRepository; + this.skillRepository = skillRepository; + this.namespaceRepository = namespaceRepository; + this.hardDeleteService = hardDeleteService; + this.clock = clock; + } + + @Transactional + public PromotionRevocationRequest submit(Long sourceSkillId, String actorId, + Map namespaceRoles, + Set platformRoles, String reason) { + Skill source = skillRepository.findById(sourceSkillId) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId)); + if (!canSubmit(source, actorId, namespaceRoles, platformRoles)) { + throw new DomainForbiddenException("promotion.revocation.submit_no_permission"); + } + PromotionRequest initial = promotionRepository.findActiveInitialBySourceSkillId(sourceSkillId) + .orElseThrow(() -> new DomainBadRequestException("promotion.revocation.not_active")); + Skill target = validatedTarget(initial, source); + if (revocationRepository.existsByTargetSkillIdAndStatus(target.getId(), ReviewTaskStatus.PENDING)) { + throw new DomainBadRequestException("promotion.revocation.duplicate_pending"); + } + PromotionRevocationRequest request = new PromotionRevocationRequest( + initial.getId(), source.getId(), target.getId(), source.getNamespaceId(), + target.getNamespaceId(), target.getSlug(), actorId, reason); + try { + return revocationRepository.save(request); + } catch (DataIntegrityViolationException ex) { + DomainBadRequestException conflict = new DomainBadRequestException("promotion.revocation.duplicate_pending"); + conflict.initCause(ex); + throw conflict; + } + } + + @Transactional + public PromotionRevocationRequest approve(Long requestId, String reviewerId, + Set platformRoles, + String comment, String clientIp, String userAgent) { + PromotionRevocationRequest request = pendingRequest(requestId); + assertReviewer(request, reviewerId, platformRoles, false); + executeApproval(request, reviewerId, comment, clientIp, userAgent); + return request; + } + + @Transactional + public PromotionRevocationRequest reject(Long requestId, String reviewerId, + Set platformRoles, String comment) { + PromotionRevocationRequest request = pendingRequest(requestId); + assertReviewer(request, reviewerId, platformRoles, false); + request.review(ReviewTaskStatus.REJECTED, reviewerId, comment, Instant.now(clock)); + return revocationRepository.save(request); + } + + @Transactional + public PromotionRevocationRequest revokeDirect(Long sourceSkillId, String administratorId, + Set platformRoles, String reason, + String clientIp, String userAgent) { + if (!isPlatformAdmin(platformRoles)) { + throw new DomainForbiddenException("promotion.revocation.review_no_permission"); + } + Skill source = skillRepository.findById(sourceSkillId) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId)); + PromotionRequest initial = promotionRepository.findActiveInitialBySourceSkillId(sourceSkillId) + .orElseThrow(() -> new DomainBadRequestException("promotion.revocation.not_active")); + Skill target = validatedTarget(initial, source); + var pending = revocationRepository.findByTargetSkillIdAndStatus(target.getId(), ReviewTaskStatus.PENDING); + if (pending.isPresent()) { + PromotionRevocationRequest request = pending.get(); + executeApproval(request, administratorId, reason, clientIp, userAgent); + return request; + } + PromotionRevocationRequest request = submit(sourceSkillId, administratorId, + Map.of(), platformRoles, reason); + executeApproval(request, administratorId, reason, clientIp, userAgent); + return request; + } + + private void executeApproval(PromotionRevocationRequest request, String reviewerId, + String comment, String clientIp, String userAgent) { + Long targetId = request.getTargetSkillId(); + if (!historyRepository.lockTarget(targetId)) { + throw new DomainBadRequestException("promotion.revocation.not_active"); + } + Skill source = skillRepository.findById(request.getSourceSkillId()) + .orElseThrow(() -> new DomainBadRequestException("promotion.revocation.not_active")); + PromotionRequest initial = promotionRepository.findActiveInitialBySourceSkillId(source.getId()) + .orElseThrow(() -> new DomainBadRequestException("promotion.revocation.not_active")); + if (!initial.getId().equals(request.getInitialPromotionRequestId()) + || !targetId.equals(initial.getTargetSkillId())) { + throw new DomainBadRequestException("promotion.revocation.target_changed"); + } + Skill target = validatedTarget(initial, source); + if (!request.getTargetNamespaceId().equals(target.getNamespaceId()) + || !request.getSkillSlug().equals(target.getSlug())) { + throw new DomainBadRequestException("promotion.revocation.target_changed"); + } + Namespace namespace = namespaceRepository.findById(target.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", target.getNamespaceId())); + if (namespace.getType() != NamespaceType.GLOBAL) { + throw new DomainBadRequestException("promotion.revocation.target_changed"); + } + + Instant now = Instant.now(clock); + int detached = historyRepository.detachTarget(targetId, reviewerId, now); + if (detached < 1) { + throw new DomainBadRequestException("promotion.revocation.target_changed"); + } + hardDeleteService.hardDeleteRevokedPromotionTarget(target, namespace.getSlug(), + reviewerId, clientIp, userAgent); + request.review(ReviewTaskStatus.APPROVED, reviewerId, comment, now); + revocationRepository.save(request); + } + + private Skill validatedTarget(PromotionRequest initial, Skill source) { + Long targetId = initial.getTargetSkillId(); + if (targetId == null) { + throw new DomainBadRequestException("promotion.revocation.not_active"); + } + Skill target = skillRepository.findById(targetId) + .orElseThrow(() -> new DomainBadRequestException("promotion.revocation.not_active")); + if (!source.getId().equals(target.getSourceSkillId()) + || !initial.getTargetNamespaceId().equals(target.getNamespaceId()) + || !source.getOwnerId().equals(target.getOwnerId()) + || !source.getSlug().equals(target.getSlug())) { + throw new DomainBadRequestException("promotion.revocation.target_changed"); + } + return target; + } + + private PromotionRevocationRequest pendingRequest(Long id) { + PromotionRevocationRequest request = revocationRepository.findById(id) + .orElseThrow(() -> new DomainNotFoundException("promotion.revocation.not_found", id)); + if (request.getStatus() != ReviewTaskStatus.PENDING) { + throw new DomainBadRequestException("promotion.revocation.not_pending", id); + } + return request; + } + + private boolean canSubmit(Skill source, String actorId, Map namespaceRoles, + Set platformRoles) { + NamespaceRole role = namespaceRoles.get(source.getNamespaceId()); + return actorId.equals(source.getOwnerId()) || role == NamespaceRole.OWNER + || role == NamespaceRole.ADMIN || isPlatformAdmin(platformRoles); + } + + private void assertReviewer(PromotionRevocationRequest request, String reviewerId, + Set platformRoles, boolean direct) { + if (!isPlatformAdmin(platformRoles) || + (!direct && request.getSubmittedBy().equals(reviewerId) + && !platformRoles.contains("SUPER_ADMIN"))) { + throw new DomainForbiddenException("promotion.revocation.review_no_permission"); + } + } + + private boolean isPlatformAdmin(Set roles) { + return roles.contains("SKILL_ADMIN") || roles.contains("SUPER_ADMIN"); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index 97751186..697548f7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -7,6 +7,8 @@ import com.iflytek.skillhub.domain.event.PromotionSubmittedEvent; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; @@ -43,6 +45,7 @@ public class PromotionService { private final SkillVersionRepository skillVersionRepository; private final SkillFileRepository skillFileRepository; private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; private final ReviewPermissionChecker permissionChecker; private final ApplicationEventPublisher eventPublisher; private final GovernanceNotificationService governanceNotificationService; @@ -54,6 +57,7 @@ public class PromotionService { SkillVersionRepository skillVersionRepository, SkillFileRepository skillFileRepository, NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, ReviewPermissionChecker permissionChecker, ApplicationEventPublisher eventPublisher, GovernanceNotificationService governanceNotificationService, @@ -64,6 +68,7 @@ public class PromotionService { this.skillVersionRepository = skillVersionRepository; this.skillFileRepository = skillFileRepository; this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; this.permissionChecker = permissionChecker; this.eventPublisher = eventPublisher; this.governanceNotificationService = governanceNotificationService; @@ -80,6 +85,14 @@ public class PromotionService { Long targetNamespaceId, String userId, Map userNamespaceRoles, Set platformRoles) { + return submitPromotionInternal(sourceSkillId, sourceVersionId, targetNamespaceId, + userId, userNamespaceRoles, platformRoles, false); + } + + private PromotionRequest submitPromotionInternal(Long sourceSkillId, Long sourceVersionId, + Long targetNamespaceId, String userId, + Map userNamespaceRoles, + Set platformRoles, boolean legacyAuth) { Skill sourceSkill = skillRepository.findById(sourceSkillId) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId)); @@ -94,11 +107,16 @@ public class PromotionService { throw new DomainBadRequestException("promotion.version_not_published", sourceVersionId); } + assertSkillActive(sourceSkill); + Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId()) .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); assertNamespaceActive(sourceNamespace); - if (!permissionChecker.canSubmitPromotion(sourceSkill, userId, userNamespaceRoles, platformRoles)) { + boolean permitted = legacyAuth + ? permissionChecker.canSubmitPromotion(sourceSkill, userId, userNamespaceRoles) + : permissionChecker.canSubmitPromotion(sourceSkill, userId, userNamespaceRoles, platformRoles); + if (!permitted) { throw new DomainForbiddenException("promotion.submit.no_permission"); } @@ -113,12 +131,14 @@ public class PromotionService { .ifPresent(existing -> { throw new DomainBadRequestException("promotion.duplicate_pending", sourceVersionId); }); - promotionRequestRepository.findBySourceSkillIdAndStatus(sourceSkillId, ReviewTaskStatus.APPROVED) - .ifPresent(existing -> { - throw new DomainBadRequestException("promotion.already_promoted", sourceSkillId); - }); - PromotionRequest request = new PromotionRequest(sourceSkillId, sourceVersionId, targetNamespaceId, userId); + promotionRequestRepository.findActiveInitialBySourceSkillId(sourceSkillId) + .ifPresent(initial -> { + Skill target = requireActiveTarget(initial, sourceSkill, targetNamespaceId); + assertTargetVersionAvailable(target.getId(), sourceVersion.getVersion()); + request.setRequestKind(PromotionRequestKind.UPDATE); + request.setTargetSkillId(target.getId()); + }); PromotionRequest saved = promotionRequestRepository.save(request); eventPublisher.publishEvent(new PromotionSubmittedEvent( saved.getId(), saved.getSourceSkillId(), saved.getSourceVersionId(), @@ -130,50 +150,8 @@ public class PromotionService { public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId, Long targetNamespaceId, String userId, Map userNamespaceRoles) { - Skill sourceSkill = skillRepository.findById(sourceSkillId) - .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId)); - - SkillVersion sourceVersion = skillVersionRepository.findById(sourceVersionId) - .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", sourceVersionId)); - - if (!sourceVersion.getSkillId().equals(sourceSkillId)) { - throw new DomainBadRequestException("promotion.version_skill_mismatch", sourceVersionId, sourceSkillId); - } - - if (sourceVersion.getStatus() != SkillVersionStatus.PUBLISHED) { - throw new DomainBadRequestException("promotion.version_not_published", sourceVersionId); - } - - Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId()) - .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); - assertNamespaceActive(sourceNamespace); - - if (!permissionChecker.canSubmitPromotion(sourceSkill, userId, userNamespaceRoles)) { - throw new DomainForbiddenException("promotion.submit.no_permission"); - } - - Namespace targetNamespace = namespaceRepository.findById(targetNamespaceId) - .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", targetNamespaceId)); - - if (targetNamespace.getType() != NamespaceType.GLOBAL) { - throw new DomainBadRequestException("promotion.target_not_global", targetNamespaceId); - } - - promotionRequestRepository.findBySourceSkillIdAndStatus(sourceSkillId, ReviewTaskStatus.PENDING) - .ifPresent(existing -> { - throw new DomainBadRequestException("promotion.duplicate_pending", sourceVersionId); - }); - promotionRequestRepository.findBySourceSkillIdAndStatus(sourceSkillId, ReviewTaskStatus.APPROVED) - .ifPresent(existing -> { - throw new DomainBadRequestException("promotion.already_promoted", sourceSkillId); - }); - - PromotionRequest request = new PromotionRequest(sourceSkillId, sourceVersionId, targetNamespaceId, userId); - PromotionRequest saved = promotionRequestRepository.save(request); - eventPublisher.publishEvent(new PromotionSubmittedEvent( - saved.getId(), saved.getSourceSkillId(), saved.getSourceVersionId(), - saved.getSubmittedBy())); - return saved; + return submitPromotionInternal(sourceSkillId, sourceVersionId, targetNamespaceId, userId, + userNamespaceRoles, Set.of(), true); } /** @@ -182,7 +160,8 @@ public class PromotionService { */ @Transactional public PromotionRequest approvePromotion(Long promotionId, String reviewerId, - String comment, Set platformRoles) { + String comment, Set platformRoles, + Set submitterPlatformRoles) { PromotionRequest request = promotionRequestRepository.findById(promotionId) .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", promotionId)); @@ -194,38 +173,73 @@ public class PromotionService { throw new DomainForbiddenException("promotion.no_permission"); } + Skill sourceSkill = skillRepository.findById(request.getSourceSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId())); + Map submitterNamespaceRoles = namespaceMemberRepository + .findByNamespaceIdAndUserId(sourceSkill.getNamespaceId(), request.getSubmittedBy()) + .map(NamespaceMember::getRole) + .map(role -> Map.of(sourceSkill.getNamespaceId(), role)) + .orElseGet(Map::of); + if (!permissionChecker.canSubmitPromotion(sourceSkill, request.getSubmittedBy(), + submitterNamespaceRoles, submitterPlatformRoles)) { + throw new DomainForbiddenException("promotion.submit.no_permission"); + } + int updated = promotionRequestRepository.updateStatusWithVersion( - promotionId, ReviewTaskStatus.APPROVED, reviewerId, comment, null, request.getVersion()); + promotionId, ReviewTaskStatus.APPROVED, reviewerId, comment, + request.getTargetSkillId(), request.getVersion()); if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } PromotionRequest approvedRequest = promotionRequestRepository.findById(promotionId) .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", promotionId)); - Skill sourceSkill = skillRepository.findById(approvedRequest.getSourceSkillId()) - .orElseThrow(() -> new DomainNotFoundException("skill.not_found", approvedRequest.getSourceSkillId())); - SkillVersion sourceVersion = skillVersionRepository.findById(approvedRequest.getSourceVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", approvedRequest.getSourceVersionId())); - assertTargetSkillNotExists(approvedRequest, sourceSkill); + if (!sourceVersion.getSkillId().equals(sourceSkill.getId()) + || sourceVersion.getStatus() != SkillVersionStatus.PUBLISHED) { + throw new DomainBadRequestException("promotion.version_not_published", sourceVersion.getId()); + } + assertSkillActive(sourceSkill); + Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); + assertNamespaceActive(sourceNamespace); - // Create new skill in global namespace - Skill newSkill = new Skill(approvedRequest.getTargetNamespaceId(), sourceSkill.getSlug(), - sourceSkill.getOwnerId(), SkillVisibility.PUBLIC); - newSkill.setDisplayName(sourceSkill.getDisplayName()); - newSkill.setSummary(sourceSkill.getSummary()); - newSkill.setSourceSkillId(sourceSkill.getId()); - newSkill.setCreatedBy(reviewerId); - newSkill.setUpdatedBy(reviewerId); - try { - newSkill = skillRepository.save(newSkill); - } catch (DataIntegrityViolationException ex) { - throw duplicateTargetSkillConflict(sourceSkill.getSlug(), ex); + Skill targetSkill; + if (approvedRequest.getRequestKind() == PromotionRequestKind.UPDATE) { + PromotionRequest initial = promotionRequestRepository.findActiveInitialBySourceSkillId(sourceSkill.getId()) + .orElseThrow(() -> new DomainBadRequestException("promotion.target_inactive", sourceSkill.getId())); + targetSkill = requireActiveTarget(initial, sourceSkill, approvedRequest.getTargetNamespaceId()); + if (!targetSkill.getId().equals(approvedRequest.getTargetSkillId())) { + throw new DomainBadRequestException("promotion.target_inactive", sourceSkill.getId()); + } + // Serialize approval with ordinary uploads that lock target Skill before publishing. + entityManager.lock(targetSkill, jakarta.persistence.LockModeType.PESSIMISTIC_WRITE); + assertTargetVersionAvailable(targetSkill.getId(), sourceVersion.getVersion()); + targetSkill.setDisplayName(sourceSkill.getDisplayName()); + targetSkill.setSummary(sourceSkill.getSummary()); + } else { + if (promotionRequestRepository.findActiveInitialBySourceSkillId(sourceSkill.getId()).isPresent()) { + throw new DomainBadRequestException("promotion.already_promoted", sourceSkill.getId()); + } + assertTargetSkillNotExists(approvedRequest, sourceSkill); + targetSkill = new Skill(approvedRequest.getTargetNamespaceId(), sourceSkill.getSlug(), + sourceSkill.getOwnerId(), SkillVisibility.PUBLIC); + targetSkill.setDisplayName(sourceSkill.getDisplayName()); + targetSkill.setSummary(sourceSkill.getSummary()); + targetSkill.setSourceSkillId(sourceSkill.getId()); + targetSkill.setCreatedBy(reviewerId); + targetSkill.setUpdatedBy(reviewerId); + try { + targetSkill = skillRepository.save(targetSkill); + } catch (DataIntegrityViolationException ex) { + throw duplicateTargetSkillConflict(sourceSkill.getSlug(), ex); + } } // Create new version copying metadata from source - SkillVersion newVersion = new SkillVersion(newSkill.getId(), sourceVersion.getVersion(), + SkillVersion newVersion = new SkillVersion(targetSkill.getId(), sourceVersion.getVersion(), sourceVersion.getCreatedBy()); newVersion.setStatus(SkillVersionStatus.PUBLISHED); newVersion.setPublishedAt(currentTime()); @@ -237,11 +251,16 @@ public class PromotionService { newVersion.setTotalSize(sourceVersion.getTotalSize()); newVersion.setBundleReady(sourceVersion.isBundleReady()); newVersion.setDownloadReady(sourceVersion.isDownloadReady()); - newVersion = skillVersionRepository.save(newVersion); + try { + newVersion = skillVersionRepository.save(newVersion); + skillVersionRepository.flush(); + } catch (DataIntegrityViolationException ex) { + throw new DomainBadRequestException("promotion.target_version_conflict", sourceVersion.getVersion()); + } // Update skill's latest version - newSkill.setLatestVersionId(newVersion.getId()); - skillRepository.save(newSkill); + targetSkill.setLatestVersionId(newVersion.getId()); + skillRepository.save(targetSkill); // Copy file records (reuse storageKey) List sourceFiles = skillFileRepository.findByVersionId(approvedRequest.getSourceVersionId()); @@ -253,11 +272,12 @@ public class PromotionService { skillFileRepository.saveAll(copiedFiles); // Update promotion request with target skill id - approvedRequest.setTargetSkillId(newSkill.getId()); + approvedRequest.setTargetSkillId(targetSkill.getId()); + approvedRequest.setTargetVersionId(newVersion.getId()); PromotionRequest savedRequest = promotionRequestRepository.save(approvedRequest); eventPublisher.publishEvent(new SkillPublishedEvent( - newSkill.getId(), newVersion.getId(), reviewerId)); + targetSkill.getId(), newVersion.getId(), reviewerId)); eventPublisher.publishEvent(new PromotionApprovedEvent( approvedRequest.getId(), approvedRequest.getSourceSkillId(), reviewerId, approvedRequest.getSubmittedBy())); @@ -274,13 +294,40 @@ public class PromotionService { } private void assertTargetSkillNotExists(PromotionRequest approvedRequest, Skill sourceSkill) { - skillRepository.findByNamespaceIdAndSlugAndOwnerId( - approvedRequest.getTargetNamespaceId(), - sourceSkill.getSlug(), - sourceSkill.getOwnerId() - ).ifPresent(existing -> { - throw duplicateTargetSkillConflict(sourceSkill.getSlug(), null); - }); + for (Skill existing : skillRepository.findByNamespaceIdAndSlug( + approvedRequest.getTargetNamespaceId(), sourceSkill.getSlug())) { + if (!skillVersionRepository.findBySkillIdAndStatus( + existing.getId(), SkillVersionStatus.PUBLISHED).isEmpty()) { + throw duplicateTargetSkillConflict(sourceSkill.getSlug(), null); + } + if (existing.getOwnerId().equals(sourceSkill.getOwnerId())) { + throw duplicateTargetSkillConflict(sourceSkill.getSlug(), null); + } + } + } + + private Skill requireActiveTarget(PromotionRequest initial, Skill sourceSkill, Long targetNamespaceId) { + Skill target = skillRepository.findById(initial.getTargetSkillId()) + .orElseThrow(() -> new DomainBadRequestException("promotion.target_inactive", sourceSkill.getId())); + if (!target.getNamespaceId().equals(targetNamespaceId) + || !sourceSkill.getId().equals(target.getSourceSkillId()) + || !sourceSkill.getOwnerId().equals(target.getOwnerId()) + || target.getStatus() != SkillStatus.ACTIVE || target.isHidden()) { + throw new DomainBadRequestException("promotion.target_inactive", sourceSkill.getId()); + } + return target; + } + + private void assertTargetVersionAvailable(Long targetSkillId, String version) { + if (skillVersionRepository.findBySkillIdAndVersion(targetSkillId, version).isPresent()) { + throw new DomainBadRequestException("promotion.target_version_conflict", version); + } + } + + private void assertSkillActive(Skill skill) { + if (skill.getStatus() != SkillStatus.ACTIVE || skill.isHidden()) { + throw new DomainBadRequestException("promotion.source_inactive", skill.getId()); + } } private DomainBadRequestException duplicateTargetSkillConflict(String slug, Exception cause) { @@ -309,7 +356,8 @@ public class PromotionService { } int updated = promotionRequestRepository.updateStatusWithVersion( - promotionId, ReviewTaskStatus.REJECTED, reviewerId, comment, null, request.getVersion()); + promotionId, ReviewTaskStatus.REJECTED, reviewerId, comment, + request.getTargetSkillId(), request.getVersion()); if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } @@ -334,6 +382,36 @@ public class PromotionService { return permissionChecker.canViewPromotion(request, userId, platformRoles); } + @Transactional(readOnly = true) + public PromotionState getSourceState(Long sourceSkillId, String userId, + Map userNamespaceRoles, + Set platformRoles) { + Skill source = skillRepository.findById(sourceSkillId) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId)); + if (!permissionChecker.canSubmitPromotion(source, userId, userNamespaceRoles, platformRoles)) { + throw new DomainForbiddenException("promotion.submit.no_permission"); + } + PromotionRequest pending = promotionRequestRepository + .findBySourceSkillIdAndStatus(sourceSkillId, ReviewTaskStatus.PENDING).orElse(null); + PromotionRequest initial = promotionRequestRepository.findActiveInitialBySourceSkillId(sourceSkillId) + .orElse(null); + if (initial == null) { + return new PromotionState(pending != null ? "PENDING" : "INITIAL", null, null, + pending != null ? pending.getId() : null); + } + Skill target = skillRepository.findById(initial.getTargetSkillId()) + .orElseThrow(() -> new DomainBadRequestException("promotion.target_inactive", sourceSkillId)); + if (!source.getId().equals(target.getSourceSkillId()) + || !source.getOwnerId().equals(target.getOwnerId()) + || !initial.getTargetNamespaceId().equals(target.getNamespaceId())) { + throw new DomainBadRequestException("promotion.target_inactive", sourceSkillId); + } + String currentVersion = target.getLatestVersionId() == null ? null : skillVersionRepository + .findById(target.getLatestVersionId()).map(SkillVersion::getVersion).orElse(null); + return new PromotionState(pending != null ? "PENDING" : "UPDATE", target.getId(), + currentVersion, pending != null ? pending.getId() : null); + } + private void assertNamespaceActive(Namespace namespace) { if (namespace.getStatus() == NamespaceStatus.FROZEN) { throw new DomainBadRequestException("error.namespace.frozen", namespace.getSlug()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionState.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionState.java new file mode 100644 index 00000000..f35b6ed3 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionState.java @@ -0,0 +1,4 @@ +package com.iflytek.skillhub.domain.review; + +public record PromotionState(String requestKind, Long targetSkillId, + String targetCurrentVersion, Long pendingPromotionId) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java index a110762e..a704944f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java @@ -11,4 +11,5 @@ public interface SkillFileRepository { SkillFile save(SkillFile file); List saveAll(Iterable files); void deleteByVersionId(Long versionId); + boolean existsByStorageKeyAndVersionIdNotIn(String storageKey, List versionIds); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java index e548a608..6308f753 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.report.SkillReportRepository; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.security.SecurityScanService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillFile; import com.iflytek.skillhub.domain.skill.SkillFileRepository; @@ -88,15 +89,31 @@ public class SkillHardDeleteService { @Transactional public void hardDeleteSkill(Skill skill, String namespaceSlug, String actorUserId, String clientIp, String userAgent) { + if (skill.getSourceSkillId() != null + || promotionRequestRepository.findActiveInitialBySourceSkillId(skill.getId()).isPresent()) { + throw new DomainBadRequestException("promotion.revocation.required"); + } + deleteSkill(skill, namespaceSlug, actorUserId, clientIp, userAgent, false); + } + + /** Deletes a revoked global derivative while retaining its promotion and revocation history. */ + @Transactional + public void hardDeleteRevokedPromotionTarget(Skill skill, String namespaceSlug, + String actorUserId, String clientIp, String userAgent) { + deleteSkill(skill, namespaceSlug, actorUserId, clientIp, userAgent, true); + } + + private void deleteSkill(Skill skill, String namespaceSlug, String actorUserId, + String clientIp, String userAgent, boolean preservePromotionHistory) { List versions = skillVersionRepository.findBySkillId(skill.getId()); List versionIds = versions.stream().map(SkillVersion::getId).toList(); List storageKeys = new ArrayList<>(); for (SkillVersion version : versions) { List files = skillFileRepository.findByVersionId(version.getId()); - files.stream() - .map(SkillFile::getStorageKey) + files.stream().map(SkillFile::getStorageKey) .filter(key -> key != null && !key.isBlank()) + .filter(key -> !skillFileRepository.existsByStorageKeyAndVersionIdNotIn(key, versionIds)) .forEach(storageKeys::add); storageKeys.add(buildBundleStorageKey(skill.getId(), version.getId())); } @@ -109,7 +126,9 @@ public class SkillHardDeleteService { // Also removes detached historical attempts whose replaced skill version no longer exists. reviewTaskRepository.deleteBySkillId(skill.getId()); - promotionRequestRepository.deleteBySourceSkillIdOrTargetSkillId(skill.getId(), skill.getId()); + if (!preservePromotionHistory) { + promotionRequestRepository.deleteBySourceSkillIdOrTargetSkillId(skill.getId(), skill.getId()); + } skillTagRepository.deleteBySkillId(skill.getId()); skillStarRepository.deleteBySkillId(skill.getId()); skillRatingRepository.deleteBySkillId(skill.getId()); @@ -125,7 +144,7 @@ public class SkillHardDeleteService { auditLogService.record( actorUserId, - "DELETE_SKILL_HARD", + preservePromotionHistory ? "REVOKE_PROMOTION_TARGET" : "DELETE_SKILL_HARD", "SKILL", skill.getId(), null, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 61565ae4..ddea24d0 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -933,9 +933,6 @@ public class SkillQueryService { if (promotionRequestRepository.findBySourceSkillIdAndStatus(skill.getId(), ReviewTaskStatus.PENDING).isPresent()) { return false; } - if (promotionRequestRepository.findBySourceSkillIdAndStatus(skill.getId(), ReviewTaskStatus.APPROVED).isPresent()) { - return false; - } return canManageRestrictedSkill(skill, currentUserId, userNsRoles); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionRevocationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionRevocationServiceTest.java new file mode 100644 index 00000000..f91d1d7c --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionRevocationServiceTest.java @@ -0,0 +1,130 @@ +package com.iflytek.skillhub.domain.review; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillHardDeleteService; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PromotionRevocationServiceTest { + @Mock PromotionRequestRepository promotionRepository; + @Mock PromotionRevocationRequestRepository revocationRepository; + @Mock PromotionRevocationHistoryRepository historyRepository; + @Mock SkillRepository skillRepository; + @Mock NamespaceRepository namespaceRepository; + @Mock SkillHardDeleteService hardDeleteService; + + private PromotionRevocationService service; + private Skill source; + private Skill target; + private PromotionRequest initial; + + @BeforeEach + void setUp() { + service = new PromotionRevocationService(promotionRepository, revocationRepository, + historyRepository, skillRepository, namespaceRepository, hardDeleteService, + Clock.fixed(Instant.parse("2026-10-09T00:00:00Z"), ZoneOffset.UTC)); + source = new Skill(2L, "foo", "owner-1", SkillVisibility.PUBLIC); + target = new Skill(1L, "foo", "owner-1", SkillVisibility.PUBLIC); + initial = new PromotionRequest(10L, 20L, 1L, "owner-1"); + setId(source, 10L); + setId(target, 30L); + target.setSourceSkillId(10L); + setId(initial, 40L); + initial.setTargetSkillId(30L); + initial.setStatus(ReviewTaskStatus.APPROVED); + } + + @Test + void submitRejectsUnrelatedActorBeforeFindingTarget() { + when(skillRepository.findById(10L)).thenReturn(Optional.of(source)); + + assertThatThrownBy(() -> service.submit(10L, "other", Map.of(), Set.of(), "reason")) + .isInstanceOf(DomainForbiddenException.class); + } + + @Test + void approvalDetachesHistoryBeforeDeletingExactTarget() { + PromotionRevocationRequest request = new PromotionRevocationRequest( + 40L, 10L, 30L, 2L, 1L, "foo", "owner-1", "withdraw"); + setId(request, 50L); + when(revocationRepository.findById(50L)).thenReturn(Optional.of(request)); + when(historyRepository.lockTarget(30L)).thenReturn(true); + when(skillRepository.findById(10L)).thenReturn(Optional.of(source)); + when(skillRepository.findById(30L)).thenReturn(Optional.of(target)); + when(promotionRepository.findActiveInitialBySourceSkillId(10L)).thenReturn(Optional.of(initial)); + Namespace global = new Namespace("global", "Global", "admin-1"); + global.setType(NamespaceType.GLOBAL); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(global)); + when(historyRepository.detachTarget(30L, "admin-1", Instant.parse("2026-10-09T00:00:00Z"))) + .thenReturn(1); + + PromotionRevocationRequest approved = service.approve(50L, "admin-1", + Set.of("SKILL_ADMIN"), "ok", null, null); + + assertThat(approved.getStatus()).isEqualTo(ReviewTaskStatus.APPROVED); + InOrder order = inOrder(historyRepository, hardDeleteService); + order.verify(historyRepository).lockTarget(30L); + order.verify(historyRepository).detachTarget(30L, "admin-1", Instant.parse("2026-10-09T00:00:00Z")); + order.verify(hardDeleteService).hardDeleteRevokedPromotionTarget(target, "global", "admin-1", null, null); + verify(revocationRepository).save(request); + } + + @Test + void directRevocationApprovesExistingPendingRequestInsteadOfCreatingAnother() { + PromotionRevocationRequest pending = new PromotionRevocationRequest( + 40L, 10L, 30L, 2L, 1L, "foo", "owner-1", "withdraw"); + setId(pending, 50L); + when(skillRepository.findById(10L)).thenReturn(Optional.of(source)); + when(skillRepository.findById(30L)).thenReturn(Optional.of(target)); + when(promotionRepository.findActiveInitialBySourceSkillId(10L)).thenReturn(Optional.of(initial)); + when(revocationRepository.findByTargetSkillIdAndStatus(30L, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.of(pending)); + when(historyRepository.lockTarget(30L)).thenReturn(true); + Namespace global = new Namespace("global", "Global", "admin-1"); + global.setType(NamespaceType.GLOBAL); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(global)); + when(historyRepository.detachTarget(30L, "admin-1", Instant.parse("2026-10-09T00:00:00Z"))) + .thenReturn(1); + + PromotionRevocationRequest result = service.revokeDirect(10L, "admin-1", + Set.of("SKILL_ADMIN"), "urgent", null, null); + + assertThat(result.getId()).isEqualTo(50L); + assertThat(result.getStatus()).isEqualTo(ReviewTaskStatus.APPROVED); + verify(hardDeleteService).hardDeleteRevokedPromotionTarget(target, "global", "admin-1", null, null); + } + + private static void setId(Object object, Long id) { + try { + var field = object.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(object, id); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index 1b10f394..37b040b8 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.review; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; @@ -41,6 +42,7 @@ class PromotionServiceTest { @Mock private SkillVersionRepository skillVersionRepository; @Mock private SkillFileRepository skillFileRepository; @Mock private NamespaceRepository namespaceRepository; + @Mock private NamespaceMemberRepository namespaceMemberRepository; @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; @Mock private GovernanceNotificationService governanceNotificationService; @@ -61,7 +63,9 @@ class PromotionServiceTest { void setUp() { promotionService = new PromotionService( promotionRequestRepository, skillRepository, skillVersionRepository, - skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, governanceNotificationService, entityManager, CLOCK); + skillFileRepository, namespaceRepository, namespaceMemberRepository, permissionChecker, + eventPublisher, governanceNotificationService, entityManager, CLOCK); + lenient().when(namespaceRepository.findById(5L)).thenReturn(Optional.of(createSourceNamespace())); } private static void setField(Object target, String fieldName, Object value) { @@ -122,6 +126,11 @@ class PromotionServiceTest { return pr; } + private void allowCurrentSubmitter() { + lenient().when(permissionChecker.canSubmitPromotion(any(Skill.class), eq(USER_ID), anyMap(), anySet())) + .thenReturn(true); + } + private PromotionRequest approvedPromotion(PromotionRequest original, String comment) { PromotionRequest approved = createPendingPromotion(); approved.setStatus(ReviewTaskStatus.APPROVED); @@ -155,8 +164,6 @@ class PromotionServiceTest { when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(globalNs)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.empty()); - when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.APPROVED)) - .thenReturn(Optional.empty()); when(promotionRequestRepository.save(any(PromotionRequest.class))) .thenAnswer(inv -> { PromotionRequest pr = inv.getArgument(0); @@ -209,13 +216,17 @@ class PromotionServiceTest { void shouldThrowWhenVersionNotPublished() { Skill sourceSkill = createSourceSkill(); SkillVersion sv = createPublishedVersion(); - sv.setStatus(SkillVersionStatus.DRAFT); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sv)); - assertThrows(DomainBadRequestException.class, - () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of())); + for (SkillVersionStatus status : List.of(SkillVersionStatus.DRAFT, + SkillVersionStatus.PENDING_REVIEW, SkillVersionStatus.REJECTED, SkillVersionStatus.YANKED)) { + sv.setStatus(status); + assertThrows(DomainBadRequestException.class, + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, + TARGET_NAMESPACE_ID, USER_ID, Map.of())); + } } @Test @@ -272,8 +283,15 @@ class PromotionServiceTest { when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createGlobalNamespace())); when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.empty()); - when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.APPROVED)) + approvedPromotion.setTargetSkillId(NEW_SKILL_ID); + when(promotionRequestRepository.findActiveInitialBySourceSkillId(SOURCE_SKILL_ID)) .thenReturn(Optional.of(approvedPromotion)); + Skill target = new Skill(TARGET_NAMESPACE_ID, sourceSkill.getSlug(), USER_ID, SkillVisibility.PUBLIC); + setField(target, "id", NEW_SKILL_ID); + target.setSourceSkillId(SOURCE_SKILL_ID); + when(skillRepository.findById(NEW_SKILL_ID)).thenReturn(Optional.of(target)); + when(skillVersionRepository.findBySkillIdAndVersion(NEW_SKILL_ID, "1.0.0")) + .thenReturn(Optional.of(createPublishedVersion())); assertThrows(DomainBadRequestException.class, () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of())); @@ -321,8 +339,6 @@ class PromotionServiceTest { when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(globalNs)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.empty()); - when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.APPROVED)) - .thenReturn(Optional.empty()); when(promotionRequestRepository.save(any(PromotionRequest.class))) .thenAnswer(inv -> inv.getArgument(0)); @@ -357,6 +373,11 @@ class PromotionServiceTest { @Nested class ReviewPromotion { + @BeforeEach + void allowSubmitter() { + allowCurrentSubmitter(); + } + @Test void shouldNotifySubmitterWhenPromotionApproved() { PromotionRequest request = createPendingPromotion(); @@ -381,7 +402,7 @@ class PromotionServiceTest { when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(List.of()); when(promotionRequestRepository.save(approvedRequest)).thenReturn(approvedRequest); - promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN")); + promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN"), Set.of()); verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion approved"), any()); } @@ -427,7 +448,7 @@ class PromotionServiceTest { when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(List.of()); when(promotionRequestRepository.save(approvedRequest)).thenReturn(approvedRequest); - promotionService.approvePromotion(PROMOTION_ID, USER_ID, "self approve", Set.of("SUPER_ADMIN")); + promotionService.approvePromotion(PROMOTION_ID, USER_ID, "self approve", Set.of("SUPER_ADMIN"), Set.of()); verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion approved"), any()); } @@ -451,6 +472,39 @@ class PromotionServiceTest { @Nested class ApprovePromotion { + @BeforeEach + void allowSubmitter() { + allowCurrentSubmitter(); + } + + private void assertRevokedSubmitterCannotBeApproved(PromotionRequestKind kind) { + PromotionRequest pending = createPendingPromotion(); + pending.setRequestKind(kind); + Skill source = new Skill(5L, "my-skill", "other-owner", SkillVisibility.NAMESPACE_ONLY); + setField(source, "id", SOURCE_SKILL_ID); + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pending)); + when(permissionChecker.canReviewPromotion(pending, REVIEWER_ID, Set.of("SKILL_ADMIN"))) + .thenReturn(true); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(source)); + when(permissionChecker.canSubmitPromotion(eq(source), eq(USER_ID), eq(Map.of()), eq(Set.of()))) + .thenReturn(false); + + assertThrows(DomainForbiddenException.class, () -> promotionService.approvePromotion( + PROMOTION_ID, REVIEWER_ID, "approved", Set.of("SKILL_ADMIN"), Set.of())); + verify(promotionRequestRepository, never()).updateStatusWithVersion( + anyLong(), any(), anyString(), any(), any(), any()); + } + + @Test + void rejectsInitialApprovalAfterTeamAdminWasRemoved() { + assertRevokedSubmitterCannotBeApproved(PromotionRequestKind.INITIAL); + } + + @Test + void rejectsUpdateApprovalAfterTeamAdminWasRemoved() { + assertRevokedSubmitterCannotBeApproved(PromotionRequestKind.UPDATE); + } + @Test void shouldApprovePromotionSuccessfully() { PromotionRequest pr = createPendingPromotion(); @@ -482,7 +536,7 @@ class PromotionServiceTest { when(promotionRequestRepository.save(approvedRequest)).thenReturn(approvedRequest); PromotionRequest result = promotionService.approvePromotion( - PROMOTION_ID, REVIEWER_ID, "LGTM", Set.of("SKILL_ADMIN")); + PROMOTION_ID, REVIEWER_ID, "LGTM", Set.of("SKILL_ADMIN"), Set.of()); assertNotNull(result); assertEquals(ReviewTaskStatus.APPROVED, result.getStatus()); @@ -541,7 +595,7 @@ class PromotionServiceTest { when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.empty()); assertThrows(DomainNotFoundException.class, - () -> promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN"))); + () -> promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN"), Set.of())); } @Test @@ -551,7 +605,7 @@ class PromotionServiceTest { when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); assertThrows(DomainBadRequestException.class, - () -> promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN"))); + () -> promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN"), Set.of())); } @Test @@ -561,7 +615,7 @@ class PromotionServiceTest { when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of())).thenReturn(false); assertThrows(DomainForbiddenException.class, - () -> promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of())); + () -> promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of(), Set.of())); } @Test @@ -569,11 +623,12 @@ class PromotionServiceTest { PromotionRequest pr = createPendingPromotion(); when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); when(promotionRequestRepository.updateStatusWithVersion( any(), any(), any(), any(), any(), any())).thenReturn(0); assertThrows(ConcurrentModificationException.class, - () -> promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN"))); + () -> promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN"), Set.of())); } @Test @@ -595,7 +650,7 @@ class PromotionServiceTest { .thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint")); DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, - () -> promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN"))); + () -> promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN"), Set.of())); assertEquals("promotion.target_skill_conflict", ex.messageCode()); } @@ -626,7 +681,7 @@ class PromotionServiceTest { when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(List.of()); when(skillFileRepository.saveAll(anyList())).thenReturn(List.of()); - promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN")); + promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN"), Set.of()); ArgumentCaptor skillCaptor = ArgumentCaptor.forClass(Skill.class); verify(skillRepository, times(2)).save(skillCaptor.capture()); @@ -637,6 +692,210 @@ class PromotionServiceTest { } + @Nested + class UpdatePromotion { + + @BeforeEach + void allowSubmitter() { + allowCurrentSubmitter(); + } + private Skill linkedTarget() { + Skill target = new Skill(TARGET_NAMESPACE_ID, "my-skill", USER_ID, SkillVisibility.PUBLIC); + setField(target, "id", NEW_SKILL_ID); + target.setSourceSkillId(SOURCE_SKILL_ID); + return target; + } + + private PromotionRequest initialLink() { + PromotionRequest initial = approvedPromotion(createPendingPromotion(), "approved"); + initial.setTargetSkillId(NEW_SKILL_ID); + return initial; + } + + @Test + void submitsUpdateToExistingGlobalSkill() { + Skill source = createSourceSkill(); + SkillVersion version = createPublishedVersion(); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(source)); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(version)); + when(permissionChecker.canSubmitPromotion(source, USER_ID, Map.of())).thenReturn(true); + when(namespaceRepository.findById(TARGET_NAMESPACE_ID)) + .thenReturn(Optional.of(createGlobalNamespace())); + when(promotionRequestRepository.findActiveInitialBySourceSkillId(SOURCE_SKILL_ID)) + .thenReturn(Optional.of(initialLink())); + when(skillRepository.findById(NEW_SKILL_ID)).thenReturn(Optional.of(linkedTarget())); + when(promotionRequestRepository.save(any(PromotionRequest.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + PromotionRequest result = promotionService.submitPromotion( + SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of()); + + assertEquals(PromotionRequestKind.UPDATE, result.getRequestKind()); + assertEquals(NEW_SKILL_ID, result.getTargetSkillId()); + } + + @Test + void rejectsUpdateWhenGlobalAlreadyHasTheSourceVersionNumber() { + Skill source = createSourceSkill(); + SkillVersion version = createPublishedVersion(); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(source)); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(version)); + when(permissionChecker.canSubmitPromotion(source, USER_ID, Map.of())).thenReturn(true); + when(namespaceRepository.findById(TARGET_NAMESPACE_ID)) + .thenReturn(Optional.of(createGlobalNamespace())); + when(promotionRequestRepository.findActiveInitialBySourceSkillId(SOURCE_SKILL_ID)) + .thenReturn(Optional.of(initialLink())); + when(skillRepository.findById(NEW_SKILL_ID)).thenReturn(Optional.of(linkedTarget())); + SkillVersion occupied = new SkillVersion(NEW_SKILL_ID, version.getVersion(), USER_ID); + when(skillVersionRepository.findBySkillIdAndVersion(NEW_SKILL_ID, version.getVersion())) + .thenReturn(Optional.of(occupied)); + + for (SkillVersionStatus status : List.of(SkillVersionStatus.DRAFT, + SkillVersionStatus.PENDING_REVIEW, SkillVersionStatus.PUBLISHED)) { + occupied.setStatus(status); + assertThrows(DomainBadRequestException.class, () -> promotionService.submitPromotion( + SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of())); + } + verify(promotionRequestRepository, never()).save(any(PromotionRequest.class)); + } + + @Test + void sourceStateKeepsLinkedTargetVisibleWhenArchivedAndHidden() { + Skill source = createSourceSkill(); + Skill target = linkedTarget(); + target.setStatus(SkillStatus.ARCHIVED); + target.setHidden(true); + target.setLatestVersionId(NEW_VERSION_ID); + SkillVersion latest = new SkillVersion(NEW_SKILL_ID, "2.0.0", USER_ID); + setField(latest, "id", NEW_VERSION_ID); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(source)); + when(permissionChecker.canSubmitPromotion(source, USER_ID, Map.of(), Set.of())) + .thenReturn(true); + when(promotionRequestRepository.findActiveInitialBySourceSkillId(SOURCE_SKILL_ID)) + .thenReturn(Optional.of(initialLink())); + when(skillRepository.findById(NEW_SKILL_ID)).thenReturn(Optional.of(target)); + when(skillVersionRepository.findById(NEW_VERSION_ID)).thenReturn(Optional.of(latest)); + + PromotionState state = promotionService.getSourceState(SOURCE_SKILL_ID, USER_ID, + Map.of(), Set.of()); + + assertEquals("UPDATE", state.requestKind()); + assertEquals(NEW_SKILL_ID, state.targetSkillId()); + assertEquals("2.0.0", state.targetCurrentVersion()); + } + + @Test + void approvesUpdateWithoutCreatingAnotherGlobalSkill() { + PromotionRequest pending = createPendingPromotion(); + pending.setRequestKind(PromotionRequestKind.UPDATE); + pending.setTargetSkillId(NEW_SKILL_ID); + PromotionRequest approved = approvedPromotion(pending, "approved"); + approved.setRequestKind(PromotionRequestKind.UPDATE); + approved.setTargetSkillId(NEW_SKILL_ID); + Skill target = linkedTarget(); + target.setDisplayName("Independently updated global name"); + target.setSummary("Independently updated global summary"); + // The global skill may have advanced independently. Publication order, not + // numeric version order, determines the latest version after approval. + target.setLatestVersionId(999L); + SkillVersion created = new SkillVersion(NEW_SKILL_ID, "1.0.0", USER_ID); + setField(created, "id", NEW_VERSION_ID); + when(promotionRequestRepository.findById(PROMOTION_ID)) + .thenReturn(Optional.of(pending), Optional.of(approved)); + when(permissionChecker.canReviewPromotion(pending, REVIEWER_ID, Set.of("SKILL_ADMIN"))) + .thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "approved", + NEW_SKILL_ID, pending.getVersion())) + .thenReturn(1); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(promotionRequestRepository.findActiveInitialBySourceSkillId(SOURCE_SKILL_ID)) + .thenReturn(Optional.of(initialLink())); + when(skillRepository.findById(NEW_SKILL_ID)).thenReturn(Optional.of(target)); + when(skillVersionRepository.save(any(SkillVersion.class))).thenReturn(created); + when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(List.of()); + when(promotionRequestRepository.save(approved)).thenReturn(approved); + + PromotionRequest result = promotionService.approvePromotion( + PROMOTION_ID, REVIEWER_ID, "approved", Set.of("SKILL_ADMIN"), Set.of()); + + assertEquals(NEW_SKILL_ID, result.getTargetSkillId()); + assertEquals(NEW_VERSION_ID, result.getTargetVersionId()); + assertEquals(NEW_VERSION_ID, target.getLatestVersionId()); + assertEquals("My Skill", target.getDisplayName()); + assertEquals("A test skill", target.getSummary()); + verify(promotionRequestRepository).updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "approved", + NEW_SKILL_ID, pending.getVersion()); + verify(skillRepository, times(1)).save(target); + verify(entityManager).lock(target, jakarta.persistence.LockModeType.PESSIMISTIC_WRITE); + } + + @Test + void rejectsUpdateApprovalWhenGlobalVersionWasOccupiedAfterSubmission() { + PromotionRequest pending = createPendingPromotion(); + pending.setRequestKind(PromotionRequestKind.UPDATE); + pending.setTargetSkillId(NEW_SKILL_ID); + PromotionRequest approved = approvedPromotion(pending, "approved"); + approved.setRequestKind(PromotionRequestKind.UPDATE); + approved.setTargetSkillId(NEW_SKILL_ID); + Skill target = linkedTarget(); + SkillVersion sourceVersion = createPublishedVersion(); + when(promotionRequestRepository.findById(PROMOTION_ID)) + .thenReturn(Optional.of(pending), Optional.of(approved)); + when(permissionChecker.canReviewPromotion(pending, REVIEWER_ID, Set.of("SKILL_ADMIN"))) + .thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "approved", + NEW_SKILL_ID, pending.getVersion())).thenReturn(1); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(promotionRequestRepository.findActiveInitialBySourceSkillId(SOURCE_SKILL_ID)) + .thenReturn(Optional.of(initialLink())); + when(skillRepository.findById(NEW_SKILL_ID)).thenReturn(Optional.of(target)); + when(skillVersionRepository.findBySkillIdAndVersion(NEW_SKILL_ID, sourceVersion.getVersion())) + .thenReturn(Optional.of(new SkillVersion(NEW_SKILL_ID, sourceVersion.getVersion(), USER_ID))); + + DomainBadRequestException error = assertThrows(DomainBadRequestException.class, + () -> promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "approved", + Set.of("SKILL_ADMIN"), Set.of())); + assertEquals("promotion.target_version_conflict", error.messageCode()); + verify(entityManager).lock(target, jakarta.persistence.LockModeType.PESSIMISTIC_WRITE); + verify(skillVersionRepository, never()).save(any(SkillVersion.class)); + } + } + + @Test + void initialApprovalRejectsArchivedPublishedSlugOwnedByAnotherUser() { + allowCurrentSubmitter(); + PromotionRequest pending = createPendingPromotion(); + PromotionRequest approved = approvedPromotion(pending, "approved"); + Skill occupied = new Skill(TARGET_NAMESPACE_ID, "my-skill", "another-owner", SkillVisibility.PUBLIC); + setField(occupied, "id", 99L); + occupied.setStatus(SkillStatus.ARCHIVED); + occupied.setHidden(true); + when(promotionRequestRepository.findById(PROMOTION_ID)) + .thenReturn(Optional.of(pending), Optional.of(approved)); + when(permissionChecker.canReviewPromotion(pending, REVIEWER_ID, Set.of("SKILL_ADMIN"))) + .thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "approved", null, pending.getVersion())) + .thenReturn(1); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(skillRepository.findByNamespaceIdAndSlug(TARGET_NAMESPACE_ID, "my-skill")) + .thenReturn(List.of(occupied)); + when(skillVersionRepository.findBySkillIdAndStatus(99L, SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(new SkillVersion(99L, "1.0.0", "another-owner"))); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, + () -> promotionService.approvePromotion( + PROMOTION_ID, REVIEWER_ID, "approved", Set.of("SKILL_ADMIN"), Set.of())); + assertEquals("promotion.target_skill_conflict", ex.messageCode()); + verify(skillRepository, never()).save(any(Skill.class)); + } + @Nested class RejectPromotion { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java index faf94008..9567385b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java @@ -201,6 +201,13 @@ class ReviewPermissionCheckerTest { Map.of(10L, NamespaceRole.ADMIN))); } + @Test + void removedTeamAdminCannotSubmitPromotionForForeignSkill() { + Skill sourceSkill = new Skill(10L, "skill-a", "user-2", SkillVisibility.PUBLIC); + assertFalse(checker.canSubmitPromotion(sourceSkill, "user-1", Map.of(), Set.of())); + assertTrue(checker.canSubmitPromotion(sourceSkill, "user-1", Map.of(), Set.of("SKILL_ADMIN"))); + } + @Test void submitterCanReadOwnPromotion() { PromotionRequest req = new PromotionRequest(1L, 1L, 1L, "user-1"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java index d50c40b0..c500570e 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.report.SkillReportRepository; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.security.SecurityScanService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillFile; import com.iflytek.skillhub.domain.skill.SkillFileRepository; @@ -31,11 +32,13 @@ import org.springframework.transaction.support.TransactionSynchronizationManager import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @ExtendWith(MockitoExtension.class) class SkillHardDeleteServiceTest { @@ -213,6 +216,43 @@ class SkillHardDeleteServiceTest { ); } + @Test + void revokedTargetKeepsSharedSourceObjectAndPromotionHistory() { + Skill target = new Skill(9L, "demo-skill", "owner-1", SkillVisibility.PUBLIC); + setField(target, "id", 7L); + SkillVersion version = new SkillVersion(7L, "1.0.0", "owner-1"); + setField(version, "id", 22L); + given(skillVersionRepository.findBySkillId(7L)).willReturn(List.of(version)); + given(skillFileRepository.findByVersionId(22L)).willReturn(List.of( + new SkillFile(22L, "SKILL.md", 12L, "text/markdown", "sha1", "source/shared/SKILL.md"), + new SkillFile(22L, "README.md", 13L, "text/markdown", "sha2", "target/unique/README.md") + )); + given(skillFileRepository.existsByStorageKeyAndVersionIdNotIn(eq("source/shared/SKILL.md"), eq(List.of(22L)))) + .willReturn(true); + + service.hardDeleteRevokedPromotionTarget(target, "global", "admin-1", null, null); + + verify(objectStorageService).deleteObjects(argThat(keys -> + !keys.contains("source/shared/SKILL.md") + && keys.contains("target/unique/README.md") + && keys.contains("packages/7/22/bundle.zip"))); + verify(promotionRequestRepository, never()).deleteBySourceSkillIdOrTargetSkillId(7L, 7L); + verify(auditLogService).record(eq("admin-1"), eq("REVOKE_PROMOTION_TARGET"), + eq("SKILL"), eq(7L), eq(null), eq(null), eq(null), + eq("{\"namespaceId\":9,\"slug\":\"demo-skill\"}")); + } + + @Test + void ordinaryHardDeleteCannotBypassRevocationReviewForDerivedTarget() { + Skill target = new Skill(9L, "demo-skill", "owner-1", SkillVisibility.PUBLIC); + setField(target, "id", 7L); + target.setSourceSkillId(3L); + + assertThatThrownBy(() -> service.hardDeleteSkill(target, "global", "owner-1", null, null)) + .isInstanceOf(DomainBadRequestException.class); + verify(skillRepository, never()).delete(target); + } + private void setField(Object target, String fieldName, Object value) { try { java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 3a8a8ed9..780a613b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -1051,7 +1051,6 @@ class SkillQueryServiceTest { when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty()); - when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)).thenReturn(Optional.empty()); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); @@ -1090,7 +1089,7 @@ class SkillQueryServiceTest { } @Test - void testGetSkillDetail_ShouldHidePromotionWhenSkillAlreadyPromoted() throws Exception { + void testGetSkillDetail_ShouldAllowSubmittingAnotherVersionAfterInitialPromotion() throws Exception { String namespaceSlug = "team-ns"; String skillSlug = "team-skill"; String userId = "owner-1"; @@ -1111,12 +1110,10 @@ class SkillQueryServiceTest { when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty()); - when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)) - .thenReturn(Optional.of(mock(com.iflytek.skillhub.domain.review.PromotionRequest.class))); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); - assertFalse(result.canSubmitPromotion()); + assertTrue(result.canSubmitPromotion()); } @Test diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java index 93e61a9f..9c5eaf2e 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java @@ -23,6 +23,9 @@ public interface PromotionRequestJpaRepository extends JpaRepository findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status); + @Query("SELECT p FROM PromotionRequest p WHERE p.sourceSkillId = :sourceSkillId AND p.requestKind = com.iflytek.skillhub.domain.review.PromotionRequestKind.INITIAL AND p.status = com.iflytek.skillhub.domain.review.ReviewTaskStatus.APPROVED AND p.targetSkillId IS NOT NULL") + Optional findActiveInitialBySourceSkillId(@Param("sourceSkillId") Long sourceSkillId); + Page findByStatus(ReviewTaskStatus status, Pageable pageable); @Query( @@ -63,7 +66,7 @@ public interface PromotionRequestJpaRepository extends JpaRepository, PromotionRevocationRequestRepository { + boolean existsByTargetSkillIdAndStatus(Long targetSkillId, ReviewTaskStatus status); + Optional findByTargetSkillIdAndStatus(Long targetSkillId, ReviewTaskStatus status); + List findBySourceSkillIdOrderBySubmittedAtDesc(Long sourceSkillId); + List findByStatusOrderBySubmittedAtAsc(ReviewTaskStatus status); + Page findByStatusIn(List statuses, Pageable pageable); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillFileJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillFileJpaRepository.java index 422e51ab..3a356bff 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillFileJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillFileJpaRepository.java @@ -15,4 +15,5 @@ public interface SkillFileJpaRepository extends JpaRepository, List findByVersionId(Long versionId); List findByVersionIdIn(List versionIds); void deleteByVersionId(Long versionId); + boolean existsByStorageKeyAndVersionIdNotIn(String storageKey, List versionIds); } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index aeef6d67..01fad2c5 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -22,6 +22,8 @@ import type { PromotionSortDirection, PromotionStatus, PromotionTask, + PromotionSourceState, + PromotionRevocation, AuditLogItem, SkillSummary, SkillReport, @@ -1049,6 +1051,10 @@ export const reviewApi = { } export const promotionApi = { + async getSourceState(sourceSkillId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/promotions/source/${sourceSkillId}/state`) + }, + async submit(request: { sourceSkillId: number; sourceVersionId: number; targetNamespaceId: number }): Promise { await fetchJson(`${WEB_API_PREFIX}/promotions`, { method: 'POST', @@ -1100,6 +1106,46 @@ export const promotionApi = { }, } +export const promotionRevocationApi = { + submit(sourceSkillId: number, reason: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/promotion-revocations`, { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ sourceSkillId, reason }), + }) + }, + direct(sourceSkillId: number, reason: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/promotion-revocations/source/${sourceSkillId}/direct`, { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ sourceSkillId, reason }), + }) + }, + approve(id: number, comment: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/promotion-revocations/${id}/approve`, { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ comment }), + }) + }, + reject(id: number, comment: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/promotion-revocations/${id}/reject`, { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ comment }), + }) + }, + pending(): Promise { + return fetchJson(`${WEB_API_PREFIX}/promotion-revocations/pending`) + }, + history(sourceSkillId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/promotion-revocations/source/${sourceSkillId}/history`) + }, + adminHistory(page: number, size: number): Promise> { + return fetchJson>(`${WEB_API_PREFIX}/promotion-revocations/history?page=${page}&size=${size}`) + }, +} + export const reportApi = { async submitSkillReport(namespace: string, slug: string, request: { reason: string; details?: string }): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 0103827d..a972eafc 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1612,6 +1612,134 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/promotion-revocations/{id}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["reject"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotion-revocations/{id}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["reject_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotion-revocations/{id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["approve"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/promotion-revocations/{id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["approve_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/promotion-revocations/source/{sourceSkillId}/direct": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["revokeDirect"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotion-revocations/source/{sourceSkillId}/direct": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["revokeDirect_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/promotion-revocations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["submit"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotion-revocations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["submit_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/namespaces/{slug}/unfreeze": { parameters: { query?: never; @@ -2357,7 +2485,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["reject"]; + post: operations["reject_2"]; delete?: never; options?: never; head?: never; @@ -2373,7 +2501,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["approve"]; + post: operations["approve_2"]; delete?: never; options?: never; head?: never; @@ -3757,6 +3885,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/promotions/source/{sourceSkillId}/state": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPromotionSourceState"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/promotions/source/{sourceSkillId}/state": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPromotionSourceState_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/promotions/pending": { parameters: { query?: never; @@ -3789,6 +3949,134 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/promotion-revocations/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotion-revocations/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/promotion-revocations/source/{sourceSkillId}/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["history"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotion-revocations/source/{sourceSkillId}/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["history_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/promotion-revocations/pending": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["pending"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotion-revocations/pending": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["pending_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/promotion-revocations/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["reviewedHistory"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/promotion-revocations/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["reviewedHistory_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/notifications/unread-count": { parameters: { query?: never; @@ -5560,6 +5848,10 @@ export interface components { submittedAt?: string; /** Format: date-time */ reviewedAt?: string; + requestKind?: string; + targetCurrentVersion?: string; + /** Format: int64 */ + targetVersionId?: number; }; PromotionRequestDto: { /** Format: int64 */ @@ -5569,6 +5861,47 @@ export interface components { /** Format: int64 */ targetNamespaceId?: number; }; + PromotionRevocationActionRequest: { + comment?: string; + }; + ApiResponsePromotionRevocationResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PromotionRevocationResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PromotionRevocationResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + initialPromotionRequestId?: number; + /** Format: int64 */ + sourceSkillId?: number; + /** Format: int64 */ + targetSkillId?: number; + /** Format: int64 */ + sourceNamespaceId?: number; + /** Format: int64 */ + targetNamespaceId?: number; + skillSlug?: string; + status?: string; + reason?: string; + submittedBy?: string; + reviewedBy?: string; + reviewComment?: string; + /** Format: date-time */ + submittedAt?: string; + /** Format: date-time */ + reviewedAt?: string; + }; + PromotionRevocationSubmitRequest: { + /** Format: int64 */ + sourceSkillId?: number; + reason?: string; + }; TransferOwnershipRequest: { newOwnerId: string; }; @@ -6645,6 +6978,23 @@ export interface components { updatedAt?: string; labels?: components["schemas"]["SkillLabelDto"][]; }; + ApiResponsePromotionState: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PromotionState"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PromotionState: { + requestKind?: string; + /** Format: int64 */ + targetSkillId?: number; + targetCurrentVersion?: string; + /** Format: int64 */ + pendingPromotionId?: number; + }; ApiResponsePageResponsePromotionResponseDto: { /** Format: int32 */ code?: number; @@ -6663,6 +7013,33 @@ export interface components { /** Format: int32 */ size?: number; }; + ApiResponseListPromotionRevocationResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PromotionRevocationResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponsePageResponsePromotionRevocationResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponsePromotionRevocationResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponsePromotionRevocationResponse: { + items?: components["schemas"]["PromotionRevocationResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; ApiResponseMapStringLong: { /** Format: int32 */ code?: number; @@ -10798,6 +11175,210 @@ export interface operations { }; }; }; + reject: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PromotionRevocationActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionRevocationResponse"]; + }; + }; + }; + }; + reject_1: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PromotionRevocationActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionRevocationResponse"]; + }; + }; + }; + }; + approve: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PromotionRevocationActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionRevocationResponse"]; + }; + }; + }; + }; + approve_1: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PromotionRevocationActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionRevocationResponse"]; + }; + }; + }; + }; + revokeDirect: { + parameters: { + query?: never; + header?: never; + path: { + sourceSkillId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PromotionRevocationSubmitRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionRevocationResponse"]; + }; + }; + }; + }; + revokeDirect_1: { + parameters: { + query?: never; + header?: never; + path: { + sourceSkillId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PromotionRevocationSubmitRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionRevocationResponse"]; + }; + }; + }; + }; + submit: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PromotionRevocationSubmitRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionRevocationResponse"]; + }; + }; + }; + }; + submit_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PromotionRevocationSubmitRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionRevocationResponse"]; + }; + }; + }; + }; unfreezeNamespace: { parameters: { query?: never; @@ -12064,7 +12645,7 @@ export interface operations { }; }; }; - reject: { + reject_2: { parameters: { query?: never; header?: never; @@ -12090,7 +12671,7 @@ export interface operations { }; }; }; - approve: { + approve_2: { parameters: { query?: never; header?: never; @@ -14336,6 +14917,50 @@ export interface operations { }; }; }; + getPromotionSourceState: { + parameters: { + query?: never; + header?: never; + path: { + sourceSkillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionState"]; + }; + }; + }; + }; + getPromotionSourceState_1: { + parameters: { + query?: never; + header?: never; + path: { + sourceSkillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionState"]; + }; + }; + }; + }; listPendingPromotions: { parameters: { query?: { @@ -14382,6 +15007,180 @@ export interface operations { }; }; }; + get: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionRevocationResponse"]; + }; + }; + }; + }; + get_1: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePromotionRevocationResponse"]; + }; + }; + }; + }; + history: { + parameters: { + query?: never; + header?: never; + path: { + sourceSkillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListPromotionRevocationResponse"]; + }; + }; + }; + }; + history_1: { + parameters: { + query?: never; + header?: never; + path: { + sourceSkillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListPromotionRevocationResponse"]; + }; + }; + }; + }; + pending: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListPromotionRevocationResponse"]; + }; + }; + }; + }; + pending_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListPromotionRevocationResponse"]; + }; + }; + }; + }; + reviewedHistory: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponsePromotionRevocationResponse"]; + }; + }; + }; + }; + reviewedHistory_1: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponsePromotionRevocationResponse"]; + }; + }; + }; + }; unreadCount: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b9978ef2..b6360ebf 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -328,6 +328,30 @@ export interface SubmitPromotionRequest { targetNamespaceId: number } +export interface PromotionSourceState { + requestKind: 'INITIAL' | 'UPDATE' | 'PENDING' + targetSkillId: number | null + targetCurrentVersion: string | null + pendingPromotionId: number | null +} + +export interface PromotionRevocation { + id: number + initialPromotionRequestId: number + sourceSkillId: number + targetSkillId: number + sourceNamespaceId: number + targetNamespaceId: number + skillSlug: string + status: PromotionStatus + reason: string | null + submittedBy: string + reviewedBy: string | null + reviewComment: string | null + submittedAt: string + reviewedAt: string | null +} + export interface SkillVersion { id: number version: string @@ -587,6 +611,9 @@ export type PromotionSortBy = 'reviewedAt' export interface PromotionTask { id: number + requestKind?: 'INITIAL' | 'UPDATE' + targetCurrentVersion?: string | null + targetVersionId?: number | null sourceSkillId: number sourceSkillDisplayName: string sourceSkillSummary?: string | null diff --git a/web/src/features/promotion/use-promotion-revocations.ts b/web/src/features/promotion/use-promotion-revocations.ts new file mode 100644 index 00000000..3fb23fce --- /dev/null +++ b/web/src/features/promotion/use-promotion-revocations.ts @@ -0,0 +1,58 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { promotionRevocationApi } from '@/api/client' + +export function usePromotionRevocationHistory(sourceSkillId: number, enabled: boolean) { + return useQuery({ + queryKey: ['promotion-revocations', 'source', sourceSkillId], + queryFn: () => promotionRevocationApi.history(sourceSkillId), + enabled, + }) +} + +export function usePendingPromotionRevocations() { + return useQuery({ + queryKey: ['promotion-revocations', 'pending'], + queryFn: promotionRevocationApi.pending, + }) +} + +export function useAdminPromotionRevocationHistory(page: number, size: number) { + return useQuery({ + queryKey: ['promotion-revocations', 'history', page, size], + queryFn: () => promotionRevocationApi.adminHistory(page, size), + }) +} + +function useRevocationMutation(mutationFn: (input: T) => Promise) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['promotion-revocations'] }) + queryClient.invalidateQueries({ queryKey: ['promotion-source-state'] }) + queryClient.invalidateQueries({ queryKey: ['promotions'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + queryClient.invalidateQueries({ queryKey: ['governance'] }) + }, + }) +} + +export function useSubmitPromotionRevocation() { + return useRevocationMutation(({ sourceSkillId, reason }: { sourceSkillId: number; reason: string }) => + promotionRevocationApi.submit(sourceSkillId, reason)) +} + +export function useDirectPromotionRevocation() { + return useRevocationMutation(({ sourceSkillId, reason }: { sourceSkillId: number; reason: string }) => + promotionRevocationApi.direct(sourceSkillId, reason)) +} + +export function useApprovePromotionRevocation() { + return useRevocationMutation(({ id, comment }: { id: number; comment: string }) => + promotionRevocationApi.approve(id, comment)) +} + +export function useRejectPromotionRevocation() { + return useRevocationMutation(({ id, comment }: { id: number; comment: string }) => + promotionRevocationApi.reject(id, comment)) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 0d493073..05e2afed 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -769,8 +769,29 @@ }, "promotions": { "title": "Promotion Review", + "kindInitial": "Initial promotion", + "kindUpdate": "Update global skill", + "targetVersionTag": "Current global v{{version}}", + "updateReviewHint": "Approval adds a version to the existing global skill. Check the source and current global versions.", "subtitle": "Review team skill promotion requests to global namespace", "tabPending": "Pending", + "tabRevocations": "Revocations to review", + "tabRevocationHistory": "Revocation history", + "revocationEmpty": "No revocation requests", + "revocationHistoryEmpty": "No reviewed revocation requests", + "revocationStatus": { "PENDING": "Pending", "APPROVED": "Revoked", "REJECTED": "Rejected" }, + "revocationActors": "Requested by {{submitter}} · Reviewed by {{reviewer}}", + "revocationReviewComment": "Review comment: {{comment}}", + "revocationLoadError": "Could not load revocation requests. Please try again.", + "revocationIds": "Source skill #{{source}} · Global skill #{{target}}", + "revocationReason": "Reason: {{reason}}", + "approveRevocation": "Approve revocation", + "rejectRevocation": "Reject revocation", + "revocationConfirmTitle": "Confirm revocation approval", + "revocationConfirmDescription": "Approval deletes every global version, rating, and statistic for @global/{{slug}}. The address may be reused. The team skill remains, and installed files cannot be recalled.", + "revocationApproved": "Revocation approved", + "revocationRejected": "Revocation request rejected", + "revocationActionError": "Could not process revocation request", "tabApproved": "Approved", "tabRejected": "Rejected", "commentPlaceholder": "Review comment (optional)", @@ -1220,6 +1241,34 @@ "promoteToGlobal": "Promote to Global", "promotionSectionTitle": "Promote to Global", "promotionSectionDescription": "Submit the currently published version v{{version}} for review into the global namespace.", + "promotionSourceVersion": "Choose a published team version", + "promotionStateLoading": "Loading global publication status…", + "promotionStateError": "Could not load global publication status. Please try again.", + "promotionNoGlobalSkill": "No global skill has been published yet.", + "promotionNoGlobalVersion": "None", + "promotionGlobalCurrent": "Current latest global version: v{{version}}", + "promotionPending": "A request is awaiting platform review. You can submit again after it is resolved.", + "submitGlobalUpdate": "Submit this version for global review", + "globalUpdateConfirmTitle": "Confirm global skill update", + "globalUpdateConfirmDescription": "Submit team v{{source}} of “{{skill}}” for global review. The current latest global version is v{{target}} and may change during review. If approved without a version conflict, this version becomes the latest published version even if its number is lower.", + "promotionVersionConflictTitle": "Global version already exists", + "promotionVersionConflictDescription": "The global skill already has this version number. Choose another published team version.", + "revocationDescription": "Revocation deletes the entire linked global skill, including independently uploaded versions. The team skill remains.", + "requestRevocation": "Request global revocation", + "directRevocation": "Revoke directly as admin", + "revocationConfirmTitle": "Confirm global revocation", + "revocationConfirmDescription": "This deletes @global/{{slug}} for “{{skill}}”, including all global versions, ratings, and statistics. The team skill remains. Installed local files cannot be recalled, and another skill may later use this address.", + "revocationReasonLabel": "Reason for revocation", + "revocationReasonPlaceholder": "Reason (optional)", + "revocationPending": "A revocation request is awaiting platform review.", + "revocationRequestSuccess": "Revocation request submitted", + "revocationDirectSuccess": "Global skill revoked", + "revocationError": "Revocation failed", + "revocationHistoryTitle": "Revocation history", + "revocationHistoryItem": "{{date}} · {{status}}", + "revocationHistoryLoading": "Loading revocation history…", + "revocationHistoryError": "Could not load revocation history. Please try again.", + "revocationStatus": { "PENDING": "Pending", "APPROVED": "Revoked", "REJECTED": "Rejected" }, "promotionConfirmTitle": "Submit promotion request", "promotionConfirmDescription": "Submit v{{version}} of \"{{skill}}\" for promotion into the global namespace?", "promotionSuccessTitle": "Promotion request submitted", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 1b844de3..1f6a71ff 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -769,8 +769,29 @@ }, "promotions": { "title": "Рецензирование продвижений", + "kindInitial": "Первое продвижение", + "kindUpdate": "Обновление глобального скилла", + "targetVersionTag": "Текущая глобальная v{{version}}", + "updateReviewHint": "После одобрения новая версия будет добавлена к существующему глобальному скиллу. Проверьте исходную и текущую версии.", "subtitle": "Запросы на продвижение командных скиллов в глобальное пространство имён", "tabPending": "Ожидают", + "tabRevocations": "Заявки на отзыв", + "tabRevocationHistory": "История отзывов", + "revocationEmpty": "Нет заявок на отзыв", + "revocationHistoryEmpty": "Нет рассмотренных заявок на отзыв", + "revocationStatus": { "PENDING": "Ожидает", "APPROVED": "Отозван", "REJECTED": "Отклонён" }, + "revocationActors": "Отправитель: {{submitter}} · Рецензент: {{reviewer}}", + "revocationReviewComment": "Комментарий рецензии: {{comment}}", + "revocationLoadError": "Не удалось загрузить заявки на отзыв. Повторите попытку.", + "revocationIds": "Исходный скилл #{{source}} · Глобальный скилл #{{target}}", + "revocationReason": "Причина: {{reason}}", + "approveRevocation": "Одобрить отзыв", + "rejectRevocation": "Отклонить отзыв", + "revocationConfirmTitle": "Подтвердите одобрение отзыва", + "revocationConfirmDescription": "Будут удалены все глобальные версии, оценки и статистика @global/{{slug}}. Адрес может быть использован снова. Командный скилл останется, а установленные файлы нельзя отозвать.", + "revocationApproved": "Отзыв одобрен", + "revocationRejected": "Запрос на отзыв отклонён", + "revocationActionError": "Не удалось обработать запрос на отзыв", "tabApproved": "Одобрены", "tabRejected": "Отклонены", "commentPlaceholder": "Комментарий рецензии (необязательно)", @@ -1285,6 +1306,34 @@ "promoteToGlobal": "Продвинуть в Global", "promotionSectionTitle": "Продвинуть в Global", "promotionSectionDescription": "Отправить текущую опубликованную версию v{{version}} на ревью в глобальное пространство имён.", + "promotionSourceVersion": "Выберите опубликованную версию команды", + "promotionStateLoading": "Загружается статус глобальной публикации…", + "promotionStateError": "Не удалось загрузить статус глобальной публикации. Повторите попытку.", + "promotionNoGlobalSkill": "Глобальный скилл ещё не опубликован.", + "promotionNoGlobalVersion": "Нет", + "promotionGlobalCurrent": "Текущая последняя глобальная версия: v{{version}}", + "promotionPending": "Заявка ожидает проверки платформой. После её рассмотрения можно отправить новую.", + "submitGlobalUpdate": "Отправить эту версию на глобальное ревью", + "globalUpdateConfirmTitle": "Подтвердите обновление глобального скилла", + "globalUpdateConfirmDescription": "Отправить командную v{{source}} скилла «{{skill}}» на глобальное ревью. Сейчас последняя глобальная версия — v{{target}}; во время проверки она может измениться. Если версия не конфликтует, после одобрения она станет последней опубликованной, даже если её номер меньше.", + "promotionVersionConflictTitle": "Глобальная версия уже существует", + "promotionVersionConflictDescription": "У глобального скилла уже есть версия с таким номером. Выберите другую опубликованную командную версию.", + "revocationDescription": "Отзыв удалит весь связанный глобальный скилл, включая независимо загруженные версии. Командный скилл останется.", + "requestRevocation": "Запросить отзыв глобальной публикации", + "directRevocation": "Отозвать напрямую как администратор", + "revocationConfirmTitle": "Подтвердите отзыв глобальной публикации", + "revocationConfirmDescription": "Будет удалён @global/{{slug}} для «{{skill}}» со всеми глобальными версиями, оценками и статистикой. Командный скилл останется. Уже установленные файлы нельзя отозвать, а адрес позже может занять другой скилл.", + "revocationReasonLabel": "Причина отзыва", + "revocationReasonPlaceholder": "Причина (необязательно)", + "revocationPending": "Запрос на отзыв ожидает проверки платформой.", + "revocationRequestSuccess": "Запрос на отзыв отправлен", + "revocationDirectSuccess": "Глобальный скилл отозван", + "revocationError": "Не удалось отозвать публикацию", + "revocationHistoryTitle": "История отзывов", + "revocationHistoryItem": "{{date}} · {{status}}", + "revocationHistoryLoading": "Загрузка истории отзывов…", + "revocationHistoryError": "Не удалось загрузить историю отзывов. Повторите попытку.", + "revocationStatus": { "PENDING": "Ожидает", "APPROVED": "Отозван", "REJECTED": "Отклонён" }, "promotionConfirmTitle": "Отправить запрос на продвижение", "promotionConfirmDescription": "Отправить v{{version}} скилла «{{skill}}» на продвижение в глобальное пространство имён?", "promotionSuccessTitle": "Запрос на продвижение отправлен", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 15b25cac..7a9869dd 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -769,8 +769,29 @@ }, "promotions": { "title": "提升审核", + "kindInitial": "首次提升", + "kindUpdate": "更新全局版", + "targetVersionTag": "当前全局版 v{{version}}", + "updateReviewHint": "通过后会在现有全局技能新增版本;请核对来源和当前全局版本。", "subtitle": "审核团队技能提升到全局空间的申请", "tabPending": "待审核", + "tabRevocations": "撤销待审核", + "tabRevocationHistory": "撤销历史", + "revocationEmpty": "暂无撤销申请", + "revocationHistoryEmpty": "暂无已处理的撤销记录", + "revocationStatus": { "PENDING": "待审核", "APPROVED": "已撤销", "REJECTED": "已拒绝" }, + "revocationActors": "申请人 {{submitter}} · 审核人 {{reviewer}}", + "revocationReviewComment": "审核意见:{{comment}}", + "revocationLoadError": "无法读取撤销申请,请稍后重试。", + "revocationIds": "来源技能 #{{source}} · 全局技能 #{{target}}", + "revocationReason": "申请原因:{{reason}}", + "approveRevocation": "批准撤销", + "rejectRevocation": "拒绝撤销", + "revocationConfirmTitle": "确认批准撤销", + "revocationConfirmDescription": "批准后将删除 @global/{{slug}} 的全部全局版本、评价和统计,旧地址可能重新被占用;团队技能保留,已安装文件无法收回。", + "revocationApproved": "撤销已批准", + "revocationRejected": "撤销申请已拒绝", + "revocationActionError": "处理撤销申请失败", "tabApproved": "已通过", "tabRejected": "已拒绝", "commentPlaceholder": "审核意见(可选)", @@ -1220,6 +1241,34 @@ "promoteToGlobal": "申请提升到全局", "promotionSectionTitle": "提升到全局", "promotionSectionDescription": "将当前已发布版本 v{{version}} 提交到全局空间审核。", + "promotionSourceVersion": "选择团队已发布版本", + "promotionStateLoading": "正在读取全局发布状态…", + "promotionStateError": "无法读取全局发布状态,请稍后重试。", + "promotionNoGlobalSkill": "尚未发布全局技能。", + "promotionNoGlobalVersion": "暂无", + "promotionGlobalCurrent": "当前全局最新版本:v{{version}}", + "promotionPending": "已有申请等待平台审核,完成后可再次提交。", + "submitGlobalUpdate": "提交此版本到全局审核", + "globalUpdateConfirmTitle": "确认更新全局技能", + "globalUpdateConfirmDescription": "将“{{skill}}”的团队 v{{source}} 提交全局审核。当前全局最新版本为 v{{target}};审核期间全局版仍可能独立更新。如审核通过且版本号未冲突,此版本将成为最新发布版,即使版本号较小。", + "promotionVersionConflictTitle": "全局版本号已存在", + "promotionVersionConflictDescription": "全局技能已有同号版本。请选择另一个已发布的团队版本。", + "revocationDescription": "撤销会删除整条关联的全局技能,包括独立上传的版本。团队技能保留。", + "requestRevocation": "申请撤销全局发布", + "directRevocation": "管理员直接撤销", + "revocationConfirmTitle": "确认撤销全局发布", + "revocationConfirmDescription": "将删除“{{skill}}”对应的 @global/{{slug}} 及其所有全局版本、评价和统计;团队技能保留。已安装到客户端的文件无法收回,旧地址以后可能被其他技能使用。", + "revocationReasonLabel": "撤销原因", + "revocationReasonPlaceholder": "填写撤销原因(可选)", + "revocationPending": "撤销申请等待平台审核。", + "revocationRequestSuccess": "撤销申请已提交", + "revocationDirectSuccess": "全局技能已撤销", + "revocationError": "撤销操作失败", + "revocationHistoryTitle": "撤销记录", + "revocationHistoryItem": "{{date}} · {{status}}", + "revocationHistoryLoading": "正在读取撤销记录…", + "revocationHistoryError": "无法读取撤销记录,请稍后重试。", + "revocationStatus": { "PENDING": "待审核", "APPROVED": "已撤销", "REJECTED": "已拒绝" }, "promotionConfirmTitle": "确认提交提升申请", "promotionConfirmDescription": "确认将“{{skill}}”的 v{{version}} 提交为提升到全局空间的申请吗?", "promotionSuccessTitle": "提升申请已提交", diff --git a/web/src/pages/dashboard/promotions.test.tsx b/web/src/pages/dashboard/promotions.test.tsx index 7f924af9..bdd5b66c 100644 --- a/web/src/pages/dashboard/promotions.test.tsx +++ b/web/src/pages/dashboard/promotions.test.tsx @@ -6,10 +6,15 @@ import type { PromotionStatus, PromotionTask } from '@/api/types' const mocks = vi.hoisted(() => ({ approveMutate: vi.fn(), rejectMutate: vi.fn(), + approveRevocation: vi.fn(), + rejectRevocation: vi.fn(), + pendingRevocations: vi.fn(), + revocationHistory: vi.fn(), usePromotionList: vi.fn(), paginationProps: [] as Array<{ page: number; totalPages: number; onPageChange: (page: number) => void }>, translations: { 'promotions.approve': 'Approve', + 'promotions.approveRevocation': 'Approve revocation', 'promotions.colReviewComment': 'Review Comment', 'promotions.colReviewedAt': 'Reviewed At', 'promotions.colReviewer': 'Reviewer', @@ -21,9 +26,20 @@ const mocks = vi.hoisted(() => ({ 'promotions.empty': 'No promotion requests', 'promotions.emptyValue': '-', 'promotions.fileCountTag': '{{count}} files', + 'promotions.kindInitial': 'Initial promotion', + 'promotions.kindUpdate': 'Update global skill', + 'promotions.targetVersionTag': 'Current global v{{version}}', + 'promotions.updateReviewHint': 'Approval adds a version to the existing global skill.', 'promotions.historyTableLabel': 'Promotion history', 'promotions.packageSizeTag': '{{size}}', 'promotions.reject': 'Reject', + 'promotions.rejectRevocation': 'Reject revocation', + 'promotions.revocationConfirmTitle': 'Confirm revocation approval', + 'promotions.revocationConfirmDescription': 'Approval deletes @global/{{slug}}.', + 'promotions.revocationIds': 'Source skill #{{source}} · Global skill #{{target}}', + 'promotions.revocationReason': 'Reason: {{reason}}', + 'promotions.revocationActors': 'Requested by {{submitter}} · Reviewed by {{reviewer}}', + 'promotions.revocationStatus.APPROVED': 'Revoked', 'promotions.sortReviewedTimeAsc': 'Sort by reviewed time ascending', 'promotions.sortReviewedTimeDesc': 'Sort by reviewed time descending', 'promotions.starCountTag': '{{value}} stars', @@ -31,6 +47,8 @@ const mocks = vi.hoisted(() => ({ 'promotions.subtitle': 'Review promotion requests', 'promotions.tabApproved': 'Approved', 'promotions.tabPending': 'Pending', + 'promotions.tabRevocations': 'Revocations to review', + 'promotions.tabRevocationHistory': 'Revocation history', 'promotions.tabRejected': 'Rejected', 'promotions.title': 'Promotion Review', 'promotions.versionTag': 'v{{version}}', @@ -60,6 +78,15 @@ vi.mock('@/features/promotion/use-promotion-list', () => ({ useRejectPromotion: () => ({ mutate: mocks.rejectMutate, isPending: false }), })) +vi.mock('@/features/promotion/use-promotion-revocations', () => ({ + useApprovePromotionRevocation: () => ({ mutateAsync: mocks.approveRevocation, isPending: false }), + usePendingPromotionRevocations: () => mocks.pendingRevocations(), + useAdminPromotionRevocationHistory: (...args: unknown[]) => mocks.revocationHistory(...args), + useRejectPromotionRevocation: () => ({ mutateAsync: mocks.rejectRevocation, isPending: false }), +})) + +vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + vi.mock('@/shared/components/dashboard-page-header', () => ({ DashboardPageHeader: ({ title, subtitle }: { title: string; subtitle: string }) => (
@@ -199,6 +226,8 @@ describe('PromotionsPage', () => { vi.clearAllMocks() mocks.paginationProps.length = 0 installPromotionListMock() + mocks.pendingRevocations.mockReturnValue({ data: [], isLoading: false, error: null }) + mocks.revocationHistory.mockReturnValue({ data: { items: [], total: 0, page: 0, size: 20 }, isLoading: false, error: null }) }) afterEach(() => cleanup()) @@ -218,6 +247,61 @@ describe('PromotionsPage', () => { expect(screen.getByText('5 stars')).toBeTruthy() }) + it('reviews a revocation only after a destructive confirmation', async () => { + mocks.pendingRevocations.mockReturnValue({ + data: [{ id: 7, sourceSkillId: 101, targetSkillId: 202, skillSlug: 'knowledge-helper', submittedAt: '2026-06-18T12:00:00Z', submittedBy: 'owner-1', reason: 'Outdated' }], + isLoading: false, + error: null, + }) + mocks.approveRevocation.mockResolvedValue(undefined) + render() + + fireEvent.click(screen.getByRole('tab', { name: 'Revocations to review' })) + expect(screen.getByText('@global/knowledge-helper')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Approve revocation' })) + expect(mocks.approveRevocation).not.toHaveBeenCalled() + const dialog = screen.getByRole('dialog', { name: 'Confirm revocation approval' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Approve revocation' })) + await waitFor(() => expect(mocks.approveRevocation).toHaveBeenCalledWith({ id: 7, comment: '' })) + }) + + it('shows reviewed revocations in the admin history tab', () => { + mocks.revocationHistory.mockReturnValue({ + data: { items: [{ id: 8, status: 'APPROVED', sourceSkillId: 101, targetSkillId: 202, skillSlug: 'knowledge-helper', submittedBy: 'owner-1', reviewedBy: 'admin-1', reviewedAt: '2026-06-18T13:00:00Z', reason: 'Outdated', reviewComment: null }], total: 1, page: 0, size: 20 }, + isLoading: false, + error: null, + }) + render() + fireEvent.click(screen.getByRole('tab', { name: 'Revocation history' })) + expect(mocks.revocationHistory).toHaveBeenCalledWith(0, 20) + expect(screen.getByText('@global/knowledge-helper')).toBeTruthy() + expect(screen.getByText(/Revoked/)).toBeTruthy() + }) + + it('rejects a revocation request with the entered review comment', async () => { + mocks.pendingRevocations.mockReturnValue({ + data: [{ id: 7, sourceSkillId: 101, targetSkillId: 202, skillSlug: 'knowledge-helper', submittedAt: '2026-06-18T12:00:00Z', submittedBy: 'owner-1', reason: null }], + isLoading: false, + error: null, + }) + mocks.rejectRevocation.mockResolvedValue(undefined) + render() + fireEvent.click(screen.getByRole('tab', { name: 'Revocations to review' })) + fireEvent.change(screen.getByRole('textbox', { name: 'Review comment (optional)' }), { target: { value: 'Keep the global version' } }) + fireEvent.click(screen.getByRole('button', { name: 'Reject revocation' })) + await waitFor(() => expect(mocks.rejectRevocation).toHaveBeenCalledWith({ id: 7, comment: 'Keep the global version' })) + }) + + it('shows the source and current global versions for an update request', () => { + installPromotionListMock({ pending: [createPromotion({ requestKind: 'UPDATE', sourceVersion: '1.1', targetCurrentVersion: '1.3', targetSkillId: 202 })] }) + render() + + expect(screen.getByText('Update global skill')).toBeTruthy() + expect(screen.getByText('v1.1')).toBeTruthy() + expect(screen.getByText('Current global v1.3')).toBeTruthy() + expect(screen.getByText('Approval adds a version to the existing global skill.')).toBeTruthy() + }) + it('paginates pending and history queues independently', () => { installPromotionListMock({ pendingTotal: 21, approvedTotal: 21 }) render() diff --git a/web/src/pages/dashboard/promotions.tsx b/web/src/pages/dashboard/promotions.tsx index 5ac266eb..787da676 100644 --- a/web/src/pages/dashboard/promotions.tsx +++ b/web/src/pages/dashboard/promotions.tsx @@ -1,6 +1,9 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useApprovePromotion, usePromotionList, useRejectPromotion } from '@/features/promotion/use-promotion-list' +import { useAdminPromotionRevocationHistory, useApprovePromotionRevocation, usePendingPromotionRevocations, useRejectPromotionRevocation } from '@/features/promotion/use-promotion-revocations' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { toast } from '@/shared/lib/toast' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Pagination } from '@/shared/components/pagination' import { formatLocalDateTime } from '@/shared/lib/date-time' @@ -112,6 +115,7 @@ function PendingPromotionCard({
+ {t(item.requestKind === 'UPDATE' ? 'promotions.kindUpdate' : 'promotions.kindInitial')}

{item.sourceSkillDisplayName}

{promotionCoordinate(item)}

@@ -124,12 +128,20 @@ function PendingPromotionCard({ ) : null}
{t('promotions.versionTag', { version: item.sourceVersion })} + {item.requestKind === 'UPDATE' && ( + {t('promotions.targetVersionTag', { version: item.targetCurrentVersion ?? t('promotions.emptyValue') })} + )} {t('promotions.submitterTag', { user: submitter })} {t('promotions.fileCountTag', { count: item.sourceVersionFileCount })} {t('promotions.packageSizeTag', { size: formatFileSize(item.sourceVersionTotalSize) })} {t('promotions.downloadCountTag', { value: formatCompactCount(item.sourceSkillDownloadCount) })} {t('promotions.starCountTag', { value: formatCompactCount(item.sourceSkillStarCount) })}
+ {item.requestKind === 'UPDATE' && ( +

+ {t('promotions.updateReviewHint')} +

+ )} >({}) + const [approveId, setApproveId] = useState(null) + const target = data?.find((item) => item.id === approveId) + + if (isLoading) return
+ if (error) return

{t('promotions.revocationLoadError')}

+ if (!data?.length) return
{t('promotions.revocationEmpty')}
+ + const handleApprove = async () => { + if (!target) return + try { + await approveMutation.mutateAsync({ id: target.id, comment: commentById[target.id] ?? '' }) + setApproveId(null) + toast.success(t('promotions.revocationApproved')) + } catch (failure) { + toast.error(t('promotions.revocationActionError'), failure instanceof Error ? failure.message : '') + } + } + + const handleReject = async (id: number) => { + try { + await rejectMutation.mutateAsync({ id, comment: commentById[id] ?? '' }) + toast.success(t('promotions.revocationRejected')) + } catch (failure) { + toast.error(t('promotions.revocationActionError'), failure instanceof Error ? failure.message : '') + } + } + + return ( +
+ {data.map((item) => ( + +
+
+

@global/{item.skillSlug}

+

{t('promotions.revocationIds', { source: item.sourceSkillId, target: item.targetSkillId })}

+
+ {formatLocalDateTime(item.submittedAt, i18n.language)} +
+

{t('promotions.submitterTag', { user: item.submittedBy })}

+ {item.reason &&

{t('promotions.revocationReason', { reason: item.reason })}

} + setCommentById((previous) => ({ ...previous, [item.id]: event.target.value }))} + /> +
+ + +
+
+ ))} + { if (!open) setApproveId(null) }} + title={t('promotions.revocationConfirmTitle')} + description={t('promotions.revocationConfirmDescription', { slug: target?.skillSlug ?? '' })} + confirmText={t('promotions.approveRevocation')} + variant="destructive" + onConfirm={handleApprove} + /> +
+ ) +} + +function RevocationHistoryList() { + const { t, i18n } = useTranslation() + const [page, setPage] = useState(0) + const { data, isLoading, error } = useAdminPromotionRevocationHistory(page, PAGE_SIZE) + + if (isLoading) return
+ if (error) return

{t('promotions.revocationLoadError')}

+ if (!data?.items.length) return
{t('promotions.revocationHistoryEmpty')}
+ + const totalPages = data.size > 0 ? Math.ceil(data.total / data.size) : 0 + return ( +
+ {data.items.map((item) => ( + +
+

@global/{item.skillSlug}

+ {item.reviewedAt ? formatLocalDateTime(item.reviewedAt, i18n.language) : t('promotions.emptyValue')} +
+

{t(`promotions.revocationStatus.${item.status}`)} · {t('promotions.revocationIds', { source: item.sourceSkillId, target: item.targetSkillId })}

+

{t('promotions.revocationActors', { submitter: item.submittedBy, reviewer: item.reviewedBy ?? t('promotions.emptyValue') })}

+ {item.reason &&

{t('promotions.revocationReason', { reason: item.reason })}

} + {item.reviewComment &&

{t('promotions.revocationReviewComment', { comment: item.reviewComment })}

} +
+ ))} + {totalPages > 1 && } +
+ ) +} + function PromotionHistoryTable({ status, sortDirection, @@ -255,6 +371,7 @@ function PromotionHistoryTable({
{item.sourceSkillDisplayName}
+
{t(item.requestKind === 'UPDATE' ? 'promotions.kindUpdate' : 'promotions.kindInitial')}
{sourceCoordinate(item)}
@@ -315,14 +432,24 @@ export function PromotionsPage() {
- - {t('promotions.tabPending')} - {t('promotions.tabApproved')} - {t('promotions.tabRejected')} - +
+ + {t('promotions.tabPending')} + {t('promotions.tabRevocations')} + {t('promotions.tabRevocationHistory')} + {t('promotions.tabApproved')} + {t('promotions.tabRejected')} + +
changePage('PENDING', page)} /> + + + + + + ({ value: { returnTo: '/dashboard/skills', version: undefined as string | undefined } })) let authState: { user: { userId: string; platformRoles: string[] } | null @@ -30,7 +36,7 @@ let authState: { vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, - useParams: () => ({ namespace: 'global', slug: 'demo-skill' }), + useParams: () => routeParams, useRouterState: () => ({ pathname: '/space/global/demo-skill', searchStr: '', hash: '' }), useSearch: () => searchMock.value, Link: ({ @@ -227,7 +233,14 @@ vi.mock('@/shared/hooks/use-label-queries', () => ({ })) vi.mock('@/shared/hooks/use-user-queries', () => ({ - useSubmitPromotion: () => ({ mutateAsync: vi.fn(), isPending: false }), + useSubmitPromotion: () => ({ mutateAsync: submitPromotionMock, isPending: false }), + usePromotionSourceState: (...args: unknown[]) => usePromotionSourceStateMock(...args), +})) + +vi.mock('@/features/promotion/use-promotion-revocations', () => ({ + usePromotionRevocationHistory: (...args: unknown[]) => useRevocationHistoryMock(...args), + useSubmitPromotionRevocation: () => ({ mutateAsync: submitRevocationMock, isPending: false }), + useDirectPromotionRevocation: () => ({ mutateAsync: directRevocationMock, isPending: false }), })) import { SkillDetailPage } from './skill-detail' @@ -274,6 +287,13 @@ describe('SkillDetailPage', () => { afterEach(() => cleanup()) beforeEach(() => { + routeParams.namespace = 'global' + routeParams.slug = 'demo-skill' + submitPromotionMock.mockReset() + submitRevocationMock.mockReset() + directRevocationMock.mockReset() + useRevocationHistoryMock.mockReturnValue({ data: [], isLoading: false, error: null }) + usePromotionSourceStateMock.mockReturnValue({ data: undefined, isLoading: false, error: null }) searchMock.value = { returnTo: '/dashboard/skills', version: undefined } navigateMock.mockReset() useSkillFilesMock.mockReset() @@ -314,6 +334,147 @@ describe('SkillDetailPage', () => { useSkillFileMock.mockReturnValue({ data: null, isLoading: false, error: null }) }) + it('offers a published team version for review against the current global version', async () => { + routeParams.namespace = 'team-ai' + useSkillDetailMock.mockReturnValue({ + data: createSkill({ namespace: 'team-ai', canSubmitPromotion: true }), + isLoading: false, + isFetching: false, + error: null, + }) + usePromotionSourceStateMock.mockReturnValue({ + data: { requestKind: 'UPDATE', targetSkillId: 42, targetCurrentVersion: '1.3.0', pendingPromotionId: null }, + isLoading: false, + error: null, + }) + submitPromotionMock.mockResolvedValue(undefined) + + render() + + expect(usePromotionSourceStateMock).toHaveBeenCalledWith(1, true) + expect(screen.getByText('skillDetail.promotionGlobalCurrent')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'skillDetail.submitGlobalUpdate' })) + const dialog = screen.getByRole('dialog', { name: 'skillDetail.globalUpdateConfirmTitle' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'skillDetail.submitGlobalUpdate' })) + await waitFor(() => expect(submitPromotionMock).toHaveBeenCalledWith({ sourceSkillId: 1, sourceVersionId: 10 })) + }) + + it('shows a pending request without another submission action', () => { + routeParams.namespace = 'team-ai' + useSkillDetailMock.mockReturnValue({ + data: createSkill({ namespace: 'team-ai', canSubmitPromotion: false }), + isLoading: false, + isFetching: false, + error: null, + }) + usePromotionSourceStateMock.mockReturnValue({ + data: { requestKind: 'PENDING', targetSkillId: 42, targetCurrentVersion: '1.3.0', pendingPromotionId: 7 }, + isLoading: false, + error: null, + }) + + render() + + expect(screen.getByText('skillDetail.promotionPending')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'skillDetail.submitGlobalUpdate' })).toBeNull() + }) + + it('submits a revocation request for the linked global skill', async () => { + routeParams.namespace = 'team-ai' + useSkillDetailMock.mockReturnValue({ + data: createSkill({ namespace: 'team-ai', canSubmitPromotion: true }), + isLoading: false, + isFetching: false, + error: null, + }) + usePromotionSourceStateMock.mockReturnValue({ + data: { requestKind: 'UPDATE', targetSkillId: 42, targetCurrentVersion: '1.3.0', pendingPromotionId: null }, + isLoading: false, + error: null, + }) + submitRevocationMock.mockResolvedValue(undefined) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'skillDetail.requestRevocation' })) + const dialog = screen.getByRole('dialog', { name: 'skillDetail.revocationConfirmTitle' }) + fireEvent.change(within(dialog).getByRole('textbox', { name: 'skillDetail.revocationReasonLabel' }), { target: { value: 'No longer safe' } }) + fireEvent.click(within(dialog).getByRole('button', { name: 'skillDetail.requestRevocation' })) + await waitFor(() => expect(submitRevocationMock).toHaveBeenCalledWith({ sourceSkillId: 1, reason: 'No longer safe' })) + }) + + it('blocks duplicate revocation and global update actions while revocation is pending', () => { + routeParams.namespace = 'team-ai' + useSkillDetailMock.mockReturnValue({ + data: createSkill({ namespace: 'team-ai', canSubmitPromotion: true }), + isLoading: false, + isFetching: false, + error: null, + }) + usePromotionSourceStateMock.mockReturnValue({ + data: { requestKind: 'UPDATE', targetSkillId: 42, targetCurrentVersion: '1.3.0', pendingPromotionId: null }, + isLoading: false, + error: null, + }) + useRevocationHistoryMock.mockReturnValue({ + data: [{ id: 7, status: 'PENDING', submittedAt: '2026-06-18T12:00:00Z' }], + isLoading: false, + error: null, + }) + + render() + + expect(screen.getByText('skillDetail.revocationPending')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'skillDetail.requestRevocation' })).toBeNull() + expect((screen.getByRole('button', { name: 'skillDetail.submitGlobalUpdate' }) as HTMLButtonElement).disabled).toBe(true) + }) + + it('keeps revocation available when the source has no published versions left', () => { + routeParams.namespace = 'team-ai' + useSkillDetailMock.mockReturnValue({ + data: createSkill({ namespace: 'team-ai', canSubmitPromotion: false, publishedVersion: undefined, headlineVersion: undefined }), + isLoading: false, + isFetching: false, + error: null, + }) + useSkillVersionsMock.mockReturnValue({ data: [] }) + usePromotionSourceStateMock.mockReturnValue({ + data: { requestKind: 'UPDATE', targetSkillId: 42, targetCurrentVersion: '1.3.0', pendingPromotionId: null }, + isLoading: false, + error: null, + }) + + render() + + expect(screen.getByRole('button', { name: 'skillDetail.requestRevocation' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'skillDetail.submitGlobalUpdate' })).toBeNull() + }) + + it('offers direct revocation to a platform administrator outside the source team', async () => { + routeParams.namespace = 'team-ai' + hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN') + useSkillDetailMock.mockReturnValue({ + data: createSkill({ namespace: 'team-ai', canManageLifecycle: false, canSubmitPromotion: false }), + isLoading: false, + isFetching: false, + error: null, + }) + usePromotionSourceStateMock.mockReturnValue({ + data: { requestKind: 'UPDATE', targetSkillId: 42, targetCurrentVersion: '1.3.0', pendingPromotionId: null }, + isLoading: false, + error: null, + }) + directRevocationMock.mockResolvedValue(undefined) + + render() + + expect(usePromotionSourceStateMock).toHaveBeenCalledWith(1, true) + fireEvent.click(screen.getByRole('button', { name: 'skillDetail.directRevocation' })) + const dialog = screen.getByRole('dialog', { name: 'skillDetail.revocationConfirmTitle' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'skillDetail.directRevocation' })) + await waitFor(() => expect(directRevocationMock).toHaveBeenCalledWith({ sourceSkillId: 1, reason: '' })) + }) + it('loads the exact version requested by a Suite member link', () => { searchMock.value = { returnTo: '/suite/global/care-workflow?version=1.0.0', version: '0.9.0' } useSkillVersionsMock.mockReturnValue({ diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index c44b4a16..db17304d 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -47,6 +47,7 @@ import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog' import { Input } from '@/shared/ui/input' import { Textarea } from '@/shared/ui/textarea' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/lib/toast' import { cn } from '@/shared/lib/utils' import { @@ -65,7 +66,8 @@ import { useSubmitForReview, useConfirmPublish, } from '@/shared/hooks/use-skill-queries' -import { useSubmitPromotion } from '@/shared/hooks/use-user-queries' +import { usePromotionSourceState, useSubmitPromotion } from '@/shared/hooks/use-user-queries' +import { useDirectPromotionRevocation, usePromotionRevocationHistory, useSubmitPromotionRevocation } from '@/features/promotion/use-promotion-revocations' /** * Detail page for one skill and its version history. @@ -108,13 +110,16 @@ function createPackageFilePreviewNode(file: SkillFile): FileTreeNode { } } -function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' | 'promotion.already_promoted' | null { +function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' | 'promotion.already_promoted' | 'promotion.target_version_conflict' | null { if (error.serverMessageKey === 'promotion.duplicate_pending') { return 'promotion.duplicate_pending' } if (error.serverMessageKey === 'promotion.already_promoted') { return 'promotion.already_promoted' } + if (error.serverMessageKey === 'promotion.target_version_conflict') { + return 'promotion.target_version_conflict' + } return null } @@ -131,6 +136,9 @@ export function SkillDetailPage() { const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false) const [unarchiveConfirmOpen, setUnarchiveConfirmOpen] = useState(false) const [promotionConfirmOpen, setPromotionConfirmOpen] = useState(false) + const [promotionVersionId, setPromotionVersionId] = useState(null) + const [revocationMode, setRevocationMode] = useState<'request' | 'direct' | null>(null) + const [revocationReason, setRevocationReason] = useState('') const [deleteSkillConfirmOpen, setDeleteSkillConfirmOpen] = useState(false) const [deleteSkillInputOpen, setDeleteSkillInputOpen] = useState(false) const [deleteSkillInput, setDeleteSkillInput] = useState('') @@ -158,14 +166,28 @@ export function SkillDetailPage() { const overviewQuietGenerationRef = useRef(0) const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' }) const { user, hasRole } = useAuth() + const isPromotionAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const detailQueriesEnabled = isSkillDetailQueriesEnabled(skillDeleted) const qns = detailQueriesEnabled ? namespace : '' const qslug = detailQueriesEnabled ? slug : '' const { data: skill, isLoading: isLoadingSkill, isFetching: isFetchingSkill, error: skillError } = useSkillDetail(qns, qslug, detailQueriesEnabled) const skillReady = detailQueriesEnabled && Boolean(skill) && !isLoadingSkill && !isFetchingSkill && !skillError const { data: versions } = useSkillVersions(qns, qslug, skillReady) + const { data: promotionState, isLoading: isLoadingPromotionState, error: promotionStateError } = usePromotionSourceState( + skill?.id ?? 0, + skillReady && Boolean(user) && namespace !== 'global' && Boolean(skill?.canManageLifecycle || skill?.canSubmitPromotion || isPromotionAdmin), + ) + const { data: revocationHistory, isLoading: isLoadingRevocationHistory, error: revocationHistoryError } = usePromotionRevocationHistory( + skill?.id ?? 0, + skillReady && Boolean(user) && namespace !== 'global' && Boolean(skill?.canManageLifecycle || skill?.canSubmitPromotion || isPromotionAdmin), + ) + const pendingRevocation = revocationHistory?.find((request) => request.status === 'PENDING') const headlineVersion = skill ? getHeadlineVersion(skill) : null const publishedVersion = skill ? getPublishedVersion(skill) : null + const publishedVersions = versions?.filter((version) => version.status === 'PUBLISHED') ?? [] + const promotionVersion = publishedVersions.find((version) => String(version.id) === promotionVersionId) + ?? publishedVersions.find((version) => version.id === publishedVersion?.id) + ?? publishedVersions[0] const ownerPreviewVersion = skill ? getOwnerPreviewVersion(skill) : null const requestedVersion = search.version const selectedVersion = versions?.some(version => version.version === requestedVersion) @@ -315,6 +337,8 @@ export function SkillDetailPage() { const withdrawReviewMutation = useWithdrawSkillReview() const rereleaseVersionMutation = useRereleaseSkillVersion() const submitPromotionMutation = useSubmitPromotion() + const submitRevocationMutation = useSubmitPromotionRevocation() + const directRevocationMutation = useDirectPromotionRevocation() const reportMutation = useSubmitSkillReport(namespace, slug) const submitForReviewMutation = useSubmitForReview() const confirmPublishMutation = useConfirmPublish() @@ -714,17 +738,17 @@ export function SkillDetailPage() { } const handleSubmitPromotion = async () => { - if (!skill || !publishedVersion) { + if (!skill || !promotionVersion || !promotionState || promotionState.pendingPromotionId) { return } try { await submitPromotionMutation.mutateAsync({ sourceSkillId: skill.id, - sourceVersionId: publishedVersion.id, + sourceVersionId: promotionVersion.id, }) toast.success( t('skillDetail.promotionSuccessTitle'), - t('skillDetail.promotionSuccessDescription', { skill: skill.displayName, version: publishedVersion.version }), + t('skillDetail.promotionSuccessDescription', { skill: skill.displayName, version: promotionVersion.version }), ) setPromotionConfirmOpen(false) } catch (error) { @@ -738,12 +762,35 @@ export function SkillDetailPage() { toast.error(t('skillDetail.promotionAlreadyPromotedTitle'), t('skillDetail.promotionAlreadyPromotedDescription')) return } + if (conflictKey === 'promotion.target_version_conflict') { + toast.error(t('skillDetail.promotionVersionConflictTitle'), t('skillDetail.promotionVersionConflictDescription')) + return + } } toast.error(t('skillDetail.promotionErrorTitle'), error instanceof Error ? error.message : '') throw error } } + const handleSubmitRevocation = async () => { + if (!skill || !promotionState?.targetSkillId || !revocationMode) { + return + } + try { + const input = { sourceSkillId: skill.id, reason: revocationReason.trim() } + if (revocationMode === 'direct') { + await directRevocationMutation.mutateAsync(input) + } else { + await submitRevocationMutation.mutateAsync(input) + } + toast.success(t(revocationMode === 'direct' ? 'skillDetail.revocationDirectSuccess' : 'skillDetail.revocationRequestSuccess')) + setRevocationMode(null) + setRevocationReason('') + } catch (error) { + toast.error(t('skillDetail.revocationError'), error instanceof Error ? error.message : '') + } + } + if (isLoadingSkill) { return (
@@ -1447,18 +1494,80 @@ export function SkillDetailPage() { )} - {skill.canSubmitPromotion && publishedVersion && ( + {namespace !== 'global' && (skill.canManageLifecycle || skill.canSubmitPromotion || isPromotionAdmin) + && (publishedVersions.length > 0 || Boolean(promotionState?.targetSkillId) || Boolean(revocationHistory?.length)) && (
{t('skillDetail.promotionSectionTitle')}
-

- {t('skillDetail.promotionSectionDescription', { version: publishedVersion.version })} -

- + {isLoadingPromotionState &&

{t('skillDetail.promotionStateLoading')}

} + {promotionStateError &&

{t('skillDetail.promotionStateError')}

} + {promotionState && ( + <> +

+ {promotionState.targetSkillId + ? t('skillDetail.promotionGlobalCurrent', { version: promotionState.targetCurrentVersion ?? t('skillDetail.promotionNoGlobalVersion') }) + : t('skillDetail.promotionNoGlobalSkill')} +

+ {publishedVersions.length > 0 && ( + <> + + + + )} + {promotionState.pendingPromotionId ? ( +

{t('skillDetail.promotionPending')}

+ ) : promotionVersion ? ( + + ) : null} + {promotionState.targetSkillId && ( +
+

{t('skillDetail.revocationDescription')}

+ {isLoadingRevocationHistory &&

{t('skillDetail.revocationHistoryLoading')}

} + {revocationHistoryError &&

{t('skillDetail.revocationHistoryError')}

} + {pendingRevocation ? ( +

{t('skillDetail.revocationPending')}

+ ) : !isLoadingRevocationHistory && !revocationHistoryError && ( +
+ + {isPromotionAdmin && ( + + )} +
+ )} +
+ )} + + )} + {revocationHistory && revocationHistory.length > 0 && ( +
+

{t('skillDetail.revocationHistoryTitle')}

+ {revocationHistory.map((request) => ( +

+ {t('skillDetail.revocationHistoryItem', { + status: t(`skillDetail.revocationStatus.${request.status}`), + date: formatLocalDateTime(request.submittedAt, i18n.language), + })} +

+ ))} +
+ )}
)} @@ -1524,15 +1633,43 @@ export function SkillDetailPage() { + { if (!open) setRevocationMode(null) }}> + + + {t('skillDetail.revocationConfirmTitle')} + {t('skillDetail.revocationConfirmDescription', { skill: skill.displayName, slug: skill.slug })} + +