Merge pull request #933 from iflytek/feat/promotion-update-revoke-signed
Some checks are pending
Deploy Docs / Deploy (push) Blocked by required conditions
Deploy Docs / build (push) Waiting to run
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run

feat(promotion): support global updates and revocation
This commit is contained in:
XiaoSeS 2026-10-09 19:39:57 +08:00 • committed by GitHub
commit 72429e6f69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 3771 additions and 173 deletions

View file

@ -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

View file

@ -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 删除;已下载到客户端的文件无法收回
- 重新提升须重新申请、审核并通过同名冲突检查;原地址可能已被其他技能占用
提升流程当前严格绑定已发布版本:

View file

@ -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` 语义说明

View file

@ -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 | 操作日志查询 |

View file

@ -141,6 +141,12 @@ Status: current code-aligned reference
- 不再新增旧兼容字段依赖
- `hidden` 仅作为治理标记展示,不参与版本状态拼装
### 5.3 提升更新与撤销
- 提升先要求团队来源版本 `PUBLISHED`,再单独进入平台提升审核;后续更新同一全局 Skill 时为它新增不可变的已发布版本。
- 全局 Skill 也可独立上传并审核。提升更新与独立上传使用相同版本号时不能互相覆盖;不同号版本分别发布,latest 指向最后通过审核的版本,不按版本字符串大小排序。
- 撤销提升不同于归档或隐藏:平台审核通过后移除全局派生 Skill 的公众入口及记录,保留来源团队 Skill、提升和撤销审计,并保护共用对象存储文件。重新提升形成新的全局 Skill ID。
## 6. 权限边界
- `withdraw-review`:仅提交人本人

View file

@ -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 身份模型。
- 全局其他版本可能由独立上传形成,撤销整条全局派生技能时一并删除;确认窗口须列出删除范围。

View file

@ -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 跨命名空间迁移。

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,14 @@
## Implementation
- [x] 扩展提升关系和申请持久化,兼容既有首次提升记录。
- [x] 实现再次提升提交、审核和原全局技能新增不可变版本;处理并发、权限、版本冲突及审核期间状态变化。
- [x] 实现撤销申请、审核、审计与全局派生技能安全删除,保护来源文件和历史。
- [x] 更新详情页提升区域和管理员审核区域,补齐中英文等现有语言文案与可访问状态。
- [x] 更新 API 类型、产品/域模型文档和提升烟测。
## Verification
- [x] 后端单元测试及本地 PostgreSQL 烟测覆盖首次、再次提升、撤销、重新提升和权限;独立全局版本较高时的发布顺序、版本冲突与并发保护由领域测试和数据库约束覆盖。
- [x] 前端类型检查、lint、相关组件测试与构建通过。
- [x] OpenAPI 类型由本地运行中的后端重新生成,类型检查通过。
- [x] 本地端到端烟测完成团队发布、全局审核、撤销、再次申请及公众不可见验收。

View file

@ -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" <<EOF
---
name: Promotion Smoke $SLUG
description: Promotion smoke test updated
version: 1.1.0
---
Updated Body
EOF
python3 - "$WORK_DIR" <<'PY'
from pathlib import Path
import sys
import zipfile
work_dir = Path(sys.argv[1])
with zipfile.ZipFile(work_dir / "skill-update.zip", "w", zipfile.ZIP_DEFLATED) as archive:
archive.write(work_dir / "SKILL.md", "SKILL.md")
PY
UPDATE_PUBLISH_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
-H "X-XSRF-TOKEN: $USER_CSRF" \
-F "file=@$WORK_DIR/skill-update.zip;type=application/zip" \
-F "visibility=PUBLIC" \
"$BASE_URL/api/web/skills/$SLUG/publish")"
assert_code "Owner can submit a new team version" "$UPDATE_PUBLISH_RESPONSE" "0"
UPDATE_REVIEW_READY=false
for _ in $(seq 1 60); do
SKILL_DETAIL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
"$BASE_URL/api/web/skills/$SLUG/$SKILL_SLUG")"
if JSON_INPUT="$SKILL_DETAIL_RESPONSE" python3 - <<'PY'
import json
import os
data = json.loads(os.environ["JSON_INPUT"]).get("data") or {}
version = data.get("ownerPreviewVersion") or {}
raise SystemExit(0 if version.get("version") == "1.1.0" and version.get("status") == "PENDING_REVIEW" else 1)
PY
then
UPDATE_REVIEW_READY=true
break
fi
sleep 1
done
if [[ "$UPDATE_REVIEW_READY" != "true" ]]; then
fail "New team version did not finish scanning within 60 seconds"
exit 1
fi
pass "New team version is ready for review"
UPDATE_REVIEW_ID=""
for _ in $(seq 1 60); do
PENDING_REVIEWS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
"$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$NAMESPACE_ID")"
UPDATE_REVIEW_ID="$(JSON_INPUT="$PENDING_REVIEWS_RESPONSE" python3 - "$SKILL_SLUG" <<'PY'
import json
import os
import sys
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
match = next((item for item in items if item["skillSlug"] == sys.argv[1]), None)
print(match["id"] if match else "")
PY
)"
if [[ -n "$UPDATE_REVIEW_ID" ]]; then
break
fi
sleep 1
done
if [[ -z "$UPDATE_REVIEW_ID" ]]; then
fail "New team version should become reviewable"
exit 1
fi
APPROVE_UPDATE_REVIEW_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
-H "X-XSRF-TOKEN: $ADMIN_CSRF" -H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/reviews/$UPDATE_REVIEW_ID/approve" \
-d '{"comment":"approved team update"}')"
assert_code "Admin approves the team update first" "$APPROVE_UPDATE_REVIEW_RESPONSE" "0"
TEAM_VERSIONS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
"$BASE_URL/api/web/skills/$SLUG/$SKILL_SLUG/versions")"
TEAM_UPDATE_VERSION_ID="$(JSON_INPUT="$TEAM_VERSIONS_RESPONSE" python3 - <<'PY'
import json
import os
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
match = next((item for item in items if item["version"] == "1.1.0" and item["status"] == "PUBLISHED"), None)
print(match["id"] if match else "")
PY
)"
if [[ -z "$TEAM_UPDATE_VERSION_ID" ]]; then
fail "Team update must be PUBLISHED before global submission"
exit 1
fi
pass "Team update is published before global submission"
SUBMIT_UPDATE_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
-H "X-XSRF-TOKEN: $USER_CSRF" -H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/promotions" \
-d "{\"sourceSkillId\":$SKILL_ID,\"sourceVersionId\":$TEAM_UPDATE_VERSION_ID,\"targetNamespaceId\":$GLOBAL_NAMESPACE_ID}")"
assert_code "Owner submits the published team version to existing global skill" "$SUBMIT_UPDATE_RESPONSE" "0"
UPDATE_PROMOTION_ID="$(json_field "$SUBMIT_UPDATE_RESPONSE" "data.id")"
if JSON_INPUT="$SUBMIT_UPDATE_RESPONSE" python3 - "$TARGET_SKILL_ID" <<'PY'
import json
import os
import sys
data = json.loads(os.environ["JSON_INPUT"])["data"]
raise SystemExit(0 if data["requestKind"] == "UPDATE" and data["targetSkillId"] == int(sys.argv[1]) else 1)
PY
then
pass "Update request remains linked to the original global skill"
else
fail "Update request should target the original global skill"
fi
APPROVE_UPDATE_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
-H "X-XSRF-TOKEN: $ADMIN_CSRF" -H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/promotions/$UPDATE_PROMOTION_ID/approve" \
-d '{"comment":"approved global update"}')"
assert_code "Admin approves update to existing global skill" "$APPROVE_UPDATE_RESPONSE" "0"
GLOBAL_VERSIONS_RESPONSE="$(curl -sS "$BASE_URL/api/web/skills/global/$SKILL_SLUG/versions")"
if JSON_INPUT="$GLOBAL_VERSIONS_RESPONSE" python3 - <<'PY'
import json
import os
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
published = {item["version"] for item in items if item["status"] == "PUBLISHED"}
raise SystemExit(0 if {"1.0.0", "1.1.0"}.issubset(published) else 1)
PY
then
pass "Global skill retains both published versions"
else
fail "Global skill should retain both published versions"
fi
SUBMIT_REVOCATION_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
-H "X-XSRF-TOKEN: $USER_CSRF" -H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/promotion-revocations" \
-d "{\"sourceSkillId\":$SKILL_ID,\"reason\":\"promotion smoke cleanup\"}")"
assert_code "Owner requests revocation of the global derivative" "$SUBMIT_REVOCATION_RESPONSE" "0"
REVOCATION_ID="$(json_field "$SUBMIT_REVOCATION_RESPONSE" "data.id")"
UNAUTHORIZED_REVOCATION_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
-H "X-XSRF-TOKEN: $USER_CSRF" -H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/promotion-revocations/$REVOCATION_ID/approve" \
-d '{"comment":"unauthorized"}')"
assert_code "Owner cannot approve their own revocation request" "$UNAUTHORIZED_REVOCATION_RESPONSE" "403"
APPROVE_REVOCATION_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
-H "X-XSRF-TOKEN: $ADMIN_CSRF" -H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/promotion-revocations/$REVOCATION_ID/approve" \
-d '{"comment":"approved revocation"}')"
assert_code "Admin approves global revocation" "$APPROVE_REVOCATION_RESPONSE" "0"
REVOKED_GLOBAL_STATUS="$(curl -sS -o "$WORK_DIR/revoked-global.json" -w '%{http_code}' \
"$BASE_URL/api/web/skills/global/$SKILL_SLUG")"
if [[ "$REVOKED_GLOBAL_STATUS" == "400" || "$REVOKED_GLOBAL_STATUS" == "404" ]] \
&& JSON_INPUT="$(cat "$WORK_DIR/revoked-global.json")" python3 - <<'PY'
import json
import os
response = json.loads(os.environ["JSON_INPUT"])
raise SystemExit(0 if response.get("code") in (400, 404)
and response.get("data") is None
and "not found" in response.get("msg", "").lower() else 1)
PY
then
pass "Revoked global skill detail is unavailable"
else
fail "Revoked global skill detail should be unavailable (got HTTP $REVOKED_GLOBAL_STATUS)"
fi
TEAM_BUNDLE="$WORK_DIR/team-update.zip"
TEAM_DOWNLOAD_STATUS="$(curl -sS -L -o "$TEAM_BUNDLE" -w '%{http_code}' \
-H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" \
"$BASE_URL/api/web/skills/$SLUG/$SKILL_SLUG/versions/1.1.0/download")"
if [[ "$TEAM_DOWNLOAD_STATUS" == "200" ]] && python3 - "$TEAM_BUNDLE" <<'PY'
import sys
import zipfile
with zipfile.ZipFile(sys.argv[1]) as archive:
content = archive.read("SKILL.md").decode("utf-8")
raise SystemExit(0 if "Updated Body" in content else 1)
PY
then
pass "Team source files remain downloadable after revocation"
else
fail "Team source files must remain readable after global revocation"
fi
REPROMOTION_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \
-H "X-XSRF-TOKEN: $USER_CSRF" -H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/promotions" \
-d "{\"sourceSkillId\":$SKILL_ID,\"sourceVersionId\":$TEAM_UPDATE_VERSION_ID,\"targetNamespaceId\":$GLOBAL_NAMESPACE_ID}")"
assert_code "Owner can submit a new initial promotion after revocation" "$REPROMOTION_RESPONSE" "0"
REPROMOTION_ID="$(json_field "$REPROMOTION_RESPONSE" "data.id")"
if [[ "$(json_field "$REPROMOTION_RESPONSE" "data.requestKind")" == "INITIAL" ]]; then
pass "Re-promotion starts a new application instead of restoring the old target"
else
fail "Re-promotion should start a new initial application"
fi
APPROVE_REPROMOTION_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
-H "X-XSRF-TOKEN: $ADMIN_CSRF" -H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/promotions/$REPROMOTION_ID/approve" \
-d '{"comment":"approved re-promotion"}')"
assert_code "Admin can approve re-promotion using the released address" "$APPROVE_REPROMOTION_RESPONSE" "0"
NEW_TARGET_SKILL_ID="$(json_field "$APPROVE_REPROMOTION_RESPONSE" "data.targetSkillId")"
if [[ "$NEW_TARGET_SKILL_ID" != "$TARGET_SKILL_ID" ]]; then
pass "Re-promotion creates a new global skill ID"
else
fail "Re-promotion must not restore the deleted global skill ID"
fi
DIRECT_REVOKE_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \
-H "X-XSRF-TOKEN: $ADMIN_CSRF" -H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/promotion-revocations/source/$SKILL_ID/direct" \
-d '{"reason":"promotion smoke cleanup after re-promotion"}')"
assert_code "Admin can directly revoke the new global derivative" "$DIRECT_REVOKE_RESPONSE" "0"
echo
echo "Results: $PASS passed, $FAIL failed"
if [[ "$FAIL" -ne 0 ]]; then

View file

@ -108,4 +108,13 @@ public class PromotionController extends BaseApiController {
@RequestAttribute("userId") String userId) {
return ok("response.success.read", governanceWorkflowAppService.getPromotionDetail(id, userId));
}
@GetMapping("/source/{sourceSkillId}/state")
public ApiResponse<com.iflytek.skillhub.domain.review.PromotionState> getPromotionSourceState(
@PathVariable Long sourceSkillId,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read", governanceWorkflowAppService.getPromotionSourceState(
sourceSkillId, userId, userNsRoles));
}
}

View file

@ -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<PromotionRevocationResponse> submit(@RequestBody PromotionRevocationSubmitRequest body,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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<PromotionRevocationResponse> 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<PromotionRevocationResponse> 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<PromotionRevocationResponse> 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<List<PromotionRevocationResponse>> pending(@RequestAttribute("userId") String userId) {
return ok("response.success.read", appService.pending(userId));
}
@GetMapping("/history")
public ApiResponse<PageResponse<PromotionRevocationResponse>> 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<List<PromotionRevocationResponse>> history(@PathVariable Long sourceSkillId,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles) {
return ok("response.success.read", appService.history(sourceSkillId, userId, roles));
}
@GetMapping("/{id}")
public ApiResponse<PromotionRevocationResponse> get(@PathVariable Long id,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles) {
return ok("response.success.read", appService.get(id, userId, roles));
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.dto;
public record PromotionRevocationActionRequest(String comment) {}

View file

@ -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());
}
}

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.dto;
public record PromotionRevocationSubmitRequest(Long sourceSkillId, String reason) {}

View file

@ -161,14 +161,23 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
private PromotionReadBundle loadPromotionBundle(List<PromotionRequest> requests) {
List<Long> sourceSkillIds = distinct(requests.stream().map(PromotionRequest::getSourceSkillId).toList());
Map<Long, Skill> skillsById = sourceSkillIds.isEmpty()
List<Long> targetSkillIds = distinct(requests.stream().map(PromotionRequest::getTargetSkillId)
.filter(Objects::nonNull).toList());
List<Long> allSkillIds = distinct(java.util.stream.Stream.concat(
sourceSkillIds.stream(), targetSkillIds.stream()).toList());
Map<Long, Skill> skillsById = allSkillIds.isEmpty()
? Map.of()
: skillRepository.findByIdIn(sourceSkillIds).stream()
: skillRepository.findByIdIn(allSkillIds).stream()
.collect(Collectors.toMap(Skill::getId, Function.identity()));
List<Long> sourceVersionIds = distinct(requests.stream().map(PromotionRequest::getSourceVersionId).toList());
Map<Long, SkillVersion> versionsById = sourceVersionIds.isEmpty()
List<Long> latestVersionIds = distinct(targetSkillIds.stream()
.map(skillsById::get).filter(Objects::nonNull)
.map(Skill::getLatestVersionId).filter(Objects::nonNull).toList());
List<Long> allVersionIds = distinct(java.util.stream.Stream.concat(
sourceVersionIds.stream(), latestVersionIds.stream()).toList());
Map<Long, SkillVersion> versionsById = allVersionIds.isEmpty()
? Map.of()
: skillVersionRepository.findByIdIn(sourceVersionIds).stream()
: skillVersionRepository.findByIdIn(allVersionIds).stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
Set<Long> 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());

View file

@ -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());
}

View file

@ -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<Long, NamespaceRole> userNsRoles) {
return promotionPortalAppService.getSourceState(sourceSkillId, userId, userNsRoles);
}
public SkillLifecycleMutationResponse archiveSkill(String namespace,
String slug,
AdminSkillActionRequest request,

View file

@ -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<Long, NamespaceRole> userNsRoles) {
return promotionService.getSourceState(sourceSkillId, userId, normalizeRoles(userNsRoles),
platformRoles(userId));
}
private ReviewTaskStatus parsePromotionStatus(String status) {
if (status == null) {
return ReviewTaskStatus.PENDING;

View file

@ -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<Long, NamespaceRole> 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<Long, NamespaceRole> namespaceRoles) {
PromotionRevocationRequest request = revocationRepository.findById(requestId)
.orElseThrow(() -> new DomainNotFoundException("promotion.revocation.not_found", requestId));
requireRead(request, userId, namespaceRoles);
return PromotionRevocationResponse.from(request);
}
public List<PromotionRevocationResponse> history(Long sourceSkillId, String userId,
Map<Long, NamespaceRole> namespaceRoles) {
requireReadSource(sourceSkillId, userId, namespaceRoles);
return revocationRepository.findBySourceSkillIdOrderBySubmittedAtDesc(sourceSkillId).stream()
.map(PromotionRevocationResponse::from).toList();
}
public List<PromotionRevocationResponse> pending(String userId) {
requirePlatformAdmin(userId);
return revocationRepository.findByStatusOrderBySubmittedAtAsc(ReviewTaskStatus.PENDING).stream()
.map(PromotionRevocationResponse::from).toList();
}
public PageResponse<PromotionRevocationResponse> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<String> roles) {
return roles.contains("SKILL_ADMIN") || roles.contains("SUPER_ADMIN");
}
private Set<String> platformRoles(String userId) { return rbacService.getUserRoleCodes(userId); }
private Map<Long, NamespaceRole> roles(Map<Long, NamespaceRole> 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());
}
}

View file

@ -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;

View file

@ -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);

View file

@ -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

View file

@ -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 之间

View file

@ -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);
}
}

View file

@ -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")))

View file

@ -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");

View file

@ -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);

View file

@ -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));

View file

@ -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");
}
}

View file

@ -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;
}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.domain.review;
public enum PromotionRequestKind {
INITIAL,
UPDATE
}

View file

@ -13,6 +13,7 @@ public interface PromotionRequestRepository {
Optional<PromotionRequest> findById(Long id);
Optional<PromotionRequest> findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status);
Optional<PromotionRequest> findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status);
Optional<PromotionRequest> findActiveInitialBySourceSkillId(Long sourceSkillId);
Page<PromotionRequest> findByStatus(ReviewTaskStatus status, Pageable pageable);
Page<PromotionRequest> findHistoryByStatusOrderByReviewedAtAsc(ReviewTaskStatus status, Pageable pageable);
Page<PromotionRequest> findHistoryByStatusOrderByReviewedAtDesc(ReviewTaskStatus status, Pageable pageable);

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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<PromotionRevocationRequest> findById(Long id);
boolean existsByTargetSkillIdAndStatus(Long targetSkillId, ReviewTaskStatus status);
Optional<PromotionRevocationRequest> findByTargetSkillIdAndStatus(Long targetSkillId, ReviewTaskStatus status);
List<PromotionRevocationRequest> findBySourceSkillIdOrderBySubmittedAtDesc(Long sourceSkillId);
List<PromotionRevocationRequest> findByStatusOrderBySubmittedAtAsc(ReviewTaskStatus status);
Page<PromotionRevocationRequest> findByStatusIn(List<ReviewTaskStatus> statuses, Pageable pageable);
}

View file

@ -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<Long, NamespaceRole> namespaceRoles,
Set<String> 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<String> 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<String> 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<String> 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<Long, NamespaceRole> namespaceRoles,
Set<String> 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<String> 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<String> roles) {
return roles.contains("SKILL_ADMIN") || roles.contains("SUPER_ADMIN");
}
}

View file

@ -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<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
return submitPromotionInternal(sourceSkillId, sourceVersionId, targetNamespaceId,
userId, userNamespaceRoles, platformRoles, false);
}
private PromotionRequest submitPromotionInternal(Long sourceSkillId, Long sourceVersionId,
Long targetNamespaceId, String userId,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> 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<Long, NamespaceRole> 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<String> platformRoles) {
String comment, Set<String> platformRoles,
Set<String> 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<Long, NamespaceRole> 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<SkillFile> 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<Long, NamespaceRole> userNamespaceRoles,
Set<String> 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());

View file

@ -0,0 +1,4 @@
package com.iflytek.skillhub.domain.review;
public record PromotionState(String requestKind, Long targetSkillId,
String targetCurrentVersion, Long pendingPromotionId) {}

View file

@ -11,4 +11,5 @@ public interface SkillFileRepository {
SkillFile save(SkillFile file);
<S extends SkillFile> List<S> saveAll(Iterable<S> files);
void deleteByVersionId(Long versionId);
boolean existsByStorageKeyAndVersionIdNotIn(String storageKey, List<Long> versionIds);
}

View file

@ -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<SkillVersion> versions = skillVersionRepository.findBySkillId(skill.getId());
List<Long> versionIds = versions.stream().map(SkillVersion::getId).toList();
List<String> storageKeys = new ArrayList<>();
for (SkillVersion version : versions) {
List<SkillFile> 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,

View file

@ -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);
}

View file

@ -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);
}
}
}

View file

@ -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<Skill> 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 {

View file

@ -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");

View file

@ -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);

View file

@ -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

View file

@ -23,6 +23,9 @@ public interface PromotionRequestJpaRepository extends JpaRepository<PromotionRe
Optional<PromotionRequest> 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<PromotionRequest> findActiveInitialBySourceSkillId(@Param("sourceSkillId") Long sourceSkillId);
Page<PromotionRequest> findByStatus(ReviewTaskStatus status, Pageable pageable);
@Query(
@ -63,7 +66,7 @@ public interface PromotionRequestJpaRepository extends JpaRepository<PromotionRe
SET p.status = :status,
p.reviewedBy = :reviewedBy,
p.reviewComment = :reviewComment,
p.targetSkillId = :targetSkillId,
p.targetSkillId = COALESCE(:targetSkillId, p.targetSkillId),
p.reviewedAt = CURRENT_TIMESTAMP,
p.version = p.version + 1
WHERE p.id = :id AND p.version = :expectedVersion

View file

@ -0,0 +1,49 @@
package com.iflytek.skillhub.infra.jpa;
import com.iflytek.skillhub.domain.review.PromotionRevocationHistoryRepository;
import jakarta.persistence.EntityManager;
import java.time.Instant;
import org.springframework.stereotype.Repository;
/** Native update is required because historical target FKs must be cleared before deleting versions. */
@Repository
public class PromotionRevocationHistoryJpaRepository implements PromotionRevocationHistoryRepository {
private final EntityManager entityManager;
public PromotionRevocationHistoryJpaRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
public boolean lockTarget(Long targetSkillId) {
return !entityManager.createNativeQuery("SELECT id FROM skill WHERE id = :id FOR UPDATE")
.setParameter("id", targetSkillId).getResultList().isEmpty();
}
@Override
public int detachTarget(Long targetSkillId, String reviewerId, Instant revokedAt) {
entityManager.flush();
int count = entityManager.createNativeQuery("""
UPDATE promotion_request
SET target_skill_id_snapshot = target_skill_id,
target_version_id_snapshot = target_version_id,
target_skill_id = NULL,
target_version_id = NULL,
revoked_at = :revokedAt,
revoked_by = :reviewerId,
status = CASE WHEN status = 'PENDING' THEN 'REJECTED' ELSE status END,
reviewed_by = CASE WHEN status = 'PENDING' THEN :reviewerId ELSE reviewed_by END,
reviewed_at = CASE WHEN status = 'PENDING' THEN :revokedAt ELSE reviewed_at END,
review_comment = CASE WHEN status = 'PENDING'
THEN 'Target promotion revoked during review' ELSE review_comment END,
version = version + 1
WHERE target_skill_id = :targetSkillId
""")
.setParameter("targetSkillId", targetSkillId)
.setParameter("reviewerId", reviewerId)
.setParameter("revokedAt", revokedAt)
.executeUpdate();
entityManager.clear();
return count;
}
}

View file

@ -0,0 +1,21 @@
package com.iflytek.skillhub.infra.jpa;
import com.iflytek.skillhub.domain.review.PromotionRevocationRequest;
import com.iflytek.skillhub.domain.review.PromotionRevocationRequestRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
@Repository
public interface PromotionRevocationRequestJpaRepository
extends JpaRepository<PromotionRevocationRequest, Long>, PromotionRevocationRequestRepository {
boolean existsByTargetSkillIdAndStatus(Long targetSkillId, ReviewTaskStatus status);
Optional<PromotionRevocationRequest> findByTargetSkillIdAndStatus(Long targetSkillId, ReviewTaskStatus status);
List<PromotionRevocationRequest> findBySourceSkillIdOrderBySubmittedAtDesc(Long sourceSkillId);
List<PromotionRevocationRequest> findByStatusOrderBySubmittedAtAsc(ReviewTaskStatus status);
Page<PromotionRevocationRequest> findByStatusIn(List<ReviewTaskStatus> statuses, Pageable pageable);
}

View file

@ -15,4 +15,5 @@ public interface SkillFileJpaRepository extends JpaRepository<SkillFile, Long>,
List<SkillFile> findByVersionId(Long versionId);
List<SkillFile> findByVersionIdIn(List<Long> versionIds);
void deleteByVersionId(Long versionId);
boolean existsByStorageKeyAndVersionIdNotIn(String storageKey, List<Long> versionIds);
}

View file

@ -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<PromotionSourceState> {
return fetchJson<PromotionSourceState>(`${WEB_API_PREFIX}/promotions/source/${sourceSkillId}/state`)
},
async submit(request: { sourceSkillId: number; sourceVersionId: number; targetNamespaceId: number }): Promise<void> {
await fetchJson<void>(`${WEB_API_PREFIX}/promotions`, {
method: 'POST',
@ -1100,6 +1106,46 @@ export const promotionApi = {
},
}
export const promotionRevocationApi = {
submit(sourceSkillId: number, reason: string): Promise<PromotionRevocation> {
return fetchJson<PromotionRevocation>(`${WEB_API_PREFIX}/promotion-revocations`, {
method: 'POST',
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ sourceSkillId, reason }),
})
},
direct(sourceSkillId: number, reason: string): Promise<PromotionRevocation> {
return fetchJson<PromotionRevocation>(`${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<PromotionRevocation> {
return fetchJson<PromotionRevocation>(`${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<PromotionRevocation> {
return fetchJson<PromotionRevocation>(`${WEB_API_PREFIX}/promotion-revocations/${id}/reject`, {
method: 'POST',
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ comment }),
})
},
pending(): Promise<PromotionRevocation[]> {
return fetchJson<PromotionRevocation[]>(`${WEB_API_PREFIX}/promotion-revocations/pending`)
},
history(sourceSkillId: number): Promise<PromotionRevocation[]> {
return fetchJson<PromotionRevocation[]>(`${WEB_API_PREFIX}/promotion-revocations/source/${sourceSkillId}/history`)
},
adminHistory(page: number, size: number): Promise<PagedResponse<PromotionRevocation>> {
return fetchJson<PagedResponse<PromotionRevocation>>(`${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<void> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace

View file

@ -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;

View file

@ -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

View file

@ -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<T>(mutationFn: (input: T) => Promise<unknown>) {
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))
}

View file

@ -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",

View file

@ -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": "Запрос на продвижение отправлен",

View file

@ -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": "提升申请已提交",

View file

@ -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 }) => (
<header>
@ -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(<PromotionsPage />)
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(<PromotionsPage />)
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(<PromotionsPage />)
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(<PromotionsPage />)
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(<PromotionsPage />)

View file

@ -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({
<Card className="space-y-4 p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-1">
<span className="text-xs font-semibold text-primary">{t(item.requestKind === 'UPDATE' ? 'promotions.kindUpdate' : 'promotions.kindInitial')}</span>
<h3 className="break-words font-heading text-base font-semibold text-foreground">{item.sourceSkillDisplayName}</h3>
<p className="break-words text-sm text-muted-foreground [overflow-wrap:anywhere]">{promotionCoordinate(item)}</p>
</div>
@ -124,12 +128,20 @@ function PendingPromotionCard({
) : null}
<div className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2 lg:grid-cols-3">
<span>{t('promotions.versionTag', { version: item.sourceVersion })}</span>
{item.requestKind === 'UPDATE' && (
<span>{t('promotions.targetVersionTag', { version: item.targetCurrentVersion ?? t('promotions.emptyValue') })}</span>
)}
<span>{t('promotions.submitterTag', { user: submitter })}</span>
<span>{t('promotions.fileCountTag', { count: item.sourceVersionFileCount })}</span>
<span>{t('promotions.packageSizeTag', { size: formatFileSize(item.sourceVersionTotalSize) })}</span>
<span>{t('promotions.downloadCountTag', { value: formatCompactCount(item.sourceSkillDownloadCount) })}</span>
<span>{t('promotions.starCountTag', { value: formatCompactCount(item.sourceSkillStarCount) })}</span>
</div>
{item.requestKind === 'UPDATE' && (
<p className="rounded-lg bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{t('promotions.updateReviewHint')}
</p>
)}
<Input
placeholder={t('promotions.commentPlaceholder')}
value={comment}
@ -182,6 +194,110 @@ function PendingPromotionList({ page, onPageChange }: { page: number; onPageChan
)
}
function PendingRevocationList() {
const { t, i18n } = useTranslation()
const { data, isLoading, error } = usePendingPromotionRevocations()
const approveMutation = useApprovePromotionRevocation()
const rejectMutation = useRejectPromotionRevocation()
const [commentById, setCommentById] = useState<Record<number, string>>({})
const [approveId, setApproveId] = useState<number | null>(null)
const target = data?.find((item) => item.id === approveId)
if (isLoading) return <div className="h-32 animate-shimmer rounded-xl" />
if (error) return <p className="text-sm text-destructive">{t('promotions.revocationLoadError')}</p>
if (!data?.length) return <div className="rounded-xl border border-dashed border-border/70 p-10 text-center text-muted-foreground">{t('promotions.revocationEmpty')}</div>
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 (
<div className="space-y-4">
{data.map((item) => (
<Card key={item.id} className="space-y-3 p-5">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="font-semibold text-foreground">@global/{item.skillSlug}</p>
<p className="text-sm text-muted-foreground">{t('promotions.revocationIds', { source: item.sourceSkillId, target: item.targetSkillId })}</p>
</div>
<span className="text-sm text-muted-foreground">{formatLocalDateTime(item.submittedAt, i18n.language)}</span>
</div>
<p className="text-sm text-muted-foreground">{t('promotions.submitterTag', { user: item.submittedBy })}</p>
{item.reason && <p className="text-sm text-muted-foreground">{t('promotions.revocationReason', { reason: item.reason })}</p>}
<Input
aria-label={t('promotions.commentPlaceholder')}
placeholder={t('promotions.commentPlaceholder')}
value={commentById[item.id] ?? ''}
onChange={(event) => setCommentById((previous) => ({ ...previous, [item.id]: event.target.value }))}
/>
<div className="flex flex-wrap gap-3">
<Button variant="destructive" onClick={() => setApproveId(item.id)} disabled={approveMutation.isPending || rejectMutation.isPending}>
{t('promotions.approveRevocation')}
</Button>
<Button variant="outline" onClick={() => handleReject(item.id)} disabled={approveMutation.isPending || rejectMutation.isPending}>
{t('promotions.rejectRevocation')}
</Button>
</div>
</Card>
))}
<ConfirmDialog
open={approveId !== null}
onOpenChange={(open) => { if (!open) setApproveId(null) }}
title={t('promotions.revocationConfirmTitle')}
description={t('promotions.revocationConfirmDescription', { slug: target?.skillSlug ?? '' })}
confirmText={t('promotions.approveRevocation')}
variant="destructive"
onConfirm={handleApprove}
/>
</div>
)
}
function RevocationHistoryList() {
const { t, i18n } = useTranslation()
const [page, setPage] = useState(0)
const { data, isLoading, error } = useAdminPromotionRevocationHistory(page, PAGE_SIZE)
if (isLoading) return <div className="h-32 animate-shimmer rounded-xl" />
if (error) return <p className="text-sm text-destructive">{t('promotions.revocationLoadError')}</p>
if (!data?.items.length) return <div className="rounded-xl border border-dashed border-border/70 p-10 text-center text-muted-foreground">{t('promotions.revocationHistoryEmpty')}</div>
const totalPages = data.size > 0 ? Math.ceil(data.total / data.size) : 0
return (
<div className="space-y-4">
{data.items.map((item) => (
<Card key={item.id} className="space-y-2 p-5 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-semibold text-foreground">@global/{item.skillSlug}</p>
<span className="text-muted-foreground">{item.reviewedAt ? formatLocalDateTime(item.reviewedAt, i18n.language) : t('promotions.emptyValue')}</span>
</div>
<p className="text-muted-foreground">{t(`promotions.revocationStatus.${item.status}`)} · {t('promotions.revocationIds', { source: item.sourceSkillId, target: item.targetSkillId })}</p>
<p className="text-muted-foreground">{t('promotions.revocationActors', { submitter: item.submittedBy, reviewer: item.reviewedBy ?? t('promotions.emptyValue') })}</p>
{item.reason && <p className="break-words text-muted-foreground">{t('promotions.revocationReason', { reason: item.reason })}</p>}
{item.reviewComment && <p className="break-words text-muted-foreground">{t('promotions.revocationReviewComment', { comment: item.reviewComment })}</p>}
</Card>
))}
{totalPages > 1 && <Pagination page={data.page} totalPages={totalPages} onPageChange={setPage} />}
</div>
)
}
function PromotionHistoryTable({
status,
sortDirection,
@ -255,6 +371,7 @@ function PromotionHistoryTable({
<TableCell>
<div className="min-w-0">
<div className="break-words font-medium text-foreground">{item.sourceSkillDisplayName}</div>
<div className="text-xs text-primary">{t(item.requestKind === 'UPDATE' ? 'promotions.kindUpdate' : 'promotions.kindInitial')}</div>
<div className="break-words text-xs text-muted-foreground [overflow-wrap:anywhere]">{sourceCoordinate(item)}</div>
</div>
</TableCell>
@ -315,14 +432,24 @@ export function PromotionsPage() {
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader title={t('promotions.title')} subtitle={t('promotions.subtitle')} />
<Tabs defaultValue="PENDING">
<TabsList>
<TabsTrigger value="PENDING">{t('promotions.tabPending')}</TabsTrigger>
<TabsTrigger value="APPROVED">{t('promotions.tabApproved')}</TabsTrigger>
<TabsTrigger value="REJECTED">{t('promotions.tabRejected')}</TabsTrigger>
</TabsList>
<div className="max-w-full overflow-x-auto">
<TabsList>
<TabsTrigger value="PENDING">{t('promotions.tabPending')}</TabsTrigger>
<TabsTrigger value="REVOCATIONS">{t('promotions.tabRevocations')}</TabsTrigger>
<TabsTrigger value="REVOCATION_HISTORY">{t('promotions.tabRevocationHistory')}</TabsTrigger>
<TabsTrigger value="APPROVED">{t('promotions.tabApproved')}</TabsTrigger>
<TabsTrigger value="REJECTED">{t('promotions.tabRejected')}</TabsTrigger>
</TabsList>
</div>
<TabsContent value="PENDING" className="mt-6">
<PendingPromotionList page={pages.PENDING} onPageChange={(page) => changePage('PENDING', page)} />
</TabsContent>
<TabsContent value="REVOCATIONS" className="mt-6">
<PendingRevocationList />
</TabsContent>
<TabsContent value="REVOCATION_HISTORY" className="mt-6">
<RevocationHistoryList />
</TabsContent>
<TabsContent value="APPROVED" className="mt-6">
<PromotionHistoryTable
status="APPROVED"

View file

@ -1,7 +1,7 @@
/** @vitest-environment jsdom */
import { renderToStaticMarkup } from 'react-dom/server'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MouseEvent, ReactNode } from 'react'
import type { SkillFile } from '@/api/types'
@ -19,6 +19,12 @@ const useSkillVersionsMock = vi.fn()
const useSkillFilesMock = vi.fn()
const useSkillReadmeMock = vi.fn()
const useSkillFileMock = vi.fn()
const usePromotionSourceStateMock = vi.fn()
const submitPromotionMock = vi.fn()
const submitRevocationMock = vi.fn()
const directRevocationMock = vi.fn()
const useRevocationHistoryMock = vi.fn()
const routeParams = { namespace: 'global', slug: 'demo-skill' }
const searchMock = vi.hoisted(() => ({ 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(<SkillDetailPage />)
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(<SkillDetailPage />)
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(<SkillDetailPage />)
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(<SkillDetailPage />)
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(<SkillDetailPage />)
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(<SkillDetailPage />)
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({

View file

@ -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<string | null>(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 (
<div className="space-y-6 animate-fade-up">
@ -1447,18 +1494,80 @@ export function SkillDetailPage() {
</Card>
)}
{skill.canSubmitPromotion && publishedVersion && (
{namespace !== 'global' && (skill.canManageLifecycle || skill.canSubmitPromotion || isPromotionAdmin)
&& (publishedVersions.length > 0 || Boolean(promotionState?.targetSkillId) || Boolean(revocationHistory?.length)) && (
<Card className="p-5 space-y-3">
<div className="flex items-center gap-2">
<ArrowUpCircle className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold font-heading text-foreground">{t('skillDetail.promotionSectionTitle')}</span>
</div>
<p className="text-sm text-muted-foreground">
{t('skillDetail.promotionSectionDescription', { version: publishedVersion.version })}
</p>
<Button variant="outline" onClick={() => setPromotionConfirmOpen(true)} disabled={submitPromotionMutation.isPending}>
{submitPromotionMutation.isPending ? t('skillDetail.processing') : t('skillDetail.promoteToGlobal')}
</Button>
{isLoadingPromotionState && <p className="text-sm text-muted-foreground">{t('skillDetail.promotionStateLoading')}</p>}
{promotionStateError && <p className="text-sm text-destructive">{t('skillDetail.promotionStateError')}</p>}
{promotionState && (
<>
<p className="text-sm text-muted-foreground">
{promotionState.targetSkillId
? t('skillDetail.promotionGlobalCurrent', { version: promotionState.targetCurrentVersion ?? t('skillDetail.promotionNoGlobalVersion') })
: t('skillDetail.promotionNoGlobalSkill')}
</p>
{publishedVersions.length > 0 && (
<>
<label htmlFor="promotion-source-version" className="block text-sm font-medium text-foreground">{t('skillDetail.promotionSourceVersion')}</label>
<Select value={String(promotionVersion?.id ?? '')} onValueChange={setPromotionVersionId}>
<SelectTrigger id="promotion-source-version" aria-label={t('skillDetail.promotionSourceVersion')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{publishedVersions.map((version) => (
<SelectItem key={version.id} value={String(version.id)}>v{version.version}</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
{promotionState.pendingPromotionId ? (
<p className="text-sm text-muted-foreground">{t('skillDetail.promotionPending')}</p>
) : promotionVersion ? (
<Button variant="outline" onClick={() => setPromotionConfirmOpen(true)} disabled={!(skill.canSubmitPromotion || isPromotionAdmin) || Boolean(pendingRevocation) || submitPromotionMutation.isPending || !promotionVersion}>
{submitPromotionMutation.isPending ? t('skillDetail.processing') : t(promotionState.requestKind === 'UPDATE' ? 'skillDetail.submitGlobalUpdate' : 'skillDetail.promoteToGlobal')}
</Button>
) : null}
{promotionState.targetSkillId && (
<div className="space-y-2 border-t border-border/60 pt-3">
<p className="text-sm text-muted-foreground">{t('skillDetail.revocationDescription')}</p>
{isLoadingRevocationHistory && <p className="text-sm text-muted-foreground">{t('skillDetail.revocationHistoryLoading')}</p>}
{revocationHistoryError && <p className="text-sm text-destructive">{t('skillDetail.revocationHistoryError')}</p>}
{pendingRevocation ? (
<p className="text-sm text-muted-foreground">{t('skillDetail.revocationPending')}</p>
) : !isLoadingRevocationHistory && !revocationHistoryError && (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setRevocationMode('request')}>
{t('skillDetail.requestRevocation')}
</Button>
{isPromotionAdmin && (
<Button variant="destructive" onClick={() => setRevocationMode('direct')}>
{t('skillDetail.directRevocation')}
</Button>
)}
</div>
)}
</div>
)}
</>
)}
{revocationHistory && revocationHistory.length > 0 && (
<div className="space-y-1 border-t border-border/60 pt-3 text-sm text-muted-foreground">
<p className="font-medium text-foreground">{t('skillDetail.revocationHistoryTitle')}</p>
{revocationHistory.map((request) => (
<p key={request.id}>
{t('skillDetail.revocationHistoryItem', {
status: t(`skillDetail.revocationStatus.${request.status}`),
date: formatLocalDateTime(request.submittedAt, i18n.language),
})}
</p>
))}
</div>
)}
</Card>
)}
@ -1524,15 +1633,43 @@ export function SkillDetailPage() {
<ConfirmDialog
open={promotionConfirmOpen}
onOpenChange={setPromotionConfirmOpen}
title={t('skillDetail.promotionConfirmTitle')}
description={t('skillDetail.promotionConfirmDescription', {
skill: skill.displayName,
version: publishedVersion?.version ?? '',
})}
confirmText={t('skillDetail.promoteToGlobal')}
title={t(promotionState?.requestKind === 'UPDATE' ? 'skillDetail.globalUpdateConfirmTitle' : 'skillDetail.promotionConfirmTitle')}
description={promotionState?.requestKind === 'UPDATE'
? t('skillDetail.globalUpdateConfirmDescription', {
skill: skill.displayName,
source: promotionVersion?.version ?? '',
target: promotionState.targetCurrentVersion ?? t('skillDetail.promotionNoGlobalVersion'),
})
: t('skillDetail.promotionConfirmDescription', {
skill: skill.displayName,
version: promotionVersion?.version ?? '',
})}
confirmText={t(promotionState?.requestKind === 'UPDATE' ? 'skillDetail.submitGlobalUpdate' : 'skillDetail.promoteToGlobal')}
onConfirm={handleSubmitPromotion}
/>
<Dialog open={revocationMode !== null} onOpenChange={(open) => { if (!open) setRevocationMode(null) }}>
<DialogContent aria-label={t('skillDetail.revocationConfirmTitle')}>
<DialogHeader>
<DialogTitle>{t('skillDetail.revocationConfirmTitle')}</DialogTitle>
<DialogDescription>{t('skillDetail.revocationConfirmDescription', { skill: skill.displayName, slug: skill.slug })}</DialogDescription>
</DialogHeader>
<Textarea
aria-label={t('skillDetail.revocationReasonLabel')}
placeholder={t('skillDetail.revocationReasonPlaceholder')}
value={revocationReason}
onChange={(event) => setRevocationReason(event.target.value)}
rows={3}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setRevocationMode(null)}>{t('dialog.cancel')}</Button>
<Button variant="destructive" onClick={handleSubmitRevocation} disabled={submitRevocationMutation.isPending || directRevocationMutation.isPending}>
{t(revocationMode === 'direct' ? 'skillDetail.directRevocation' : 'skillDetail.requestRevocation')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={archiveConfirmOpen}
onOpenChange={setArchiveConfirmOpen}

View file

@ -78,8 +78,17 @@ export function useSubmitPromotion() {
mutationFn: submitPromotion,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['promotions'] })
queryClient.invalidateQueries({ queryKey: ['promotion-source-state'] })
queryClient.invalidateQueries({ queryKey: ['governance'] })
queryClient.invalidateQueries({ queryKey: ['skills'] })
},
})
}
export function usePromotionSourceState(sourceSkillId: number, enabled: boolean) {
return useQuery({
queryKey: ['promotion-source-state', sourceSkillId],
queryFn: () => promotionApi.getSourceState(sourceSkillId),
enabled,
})
}