mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-10 03:27:54 +00:00
feat(promotion): support reviewed global updates and revocation
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
753af7280a
commit
84bda97dee
66 changed files with 3726 additions and 170 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 删除;已下载到客户端的文件无法收回
|
||||
- 重新提升须重新申请、审核并通过同名冲突检查;原地址可能已被其他技能占用
|
||||
|
||||
提升流程当前严格绑定已发布版本:
|
||||
|
||||
|
|
|
|||
|
|
@ -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` 语义说明
|
||||
|
||||
|
|
|
|||
|
|
@ -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 | 操作日志查询 |
|
||||
|
|
|
|||
|
|
@ -141,6 +141,12 @@ Status: current code-aligned reference
|
|||
- 不再新增旧兼容字段依赖
|
||||
- `hidden` 仅作为治理标记展示,不参与版本状态拼装
|
||||
|
||||
### 5.3 提升更新与撤销
|
||||
|
||||
- 提升先要求团队来源版本 `PUBLISHED`,再单独进入平台提升审核;后续更新同一全局 Skill 时为它新增不可变的已发布版本。
|
||||
- 全局 Skill 也可独立上传并审核。提升更新与独立上传使用相同版本号时不能互相覆盖;不同号版本分别发布,latest 指向最后通过审核的版本,不按版本字符串大小排序。
|
||||
- 撤销提升不同于归档或隐藏:平台审核通过后移除全局派生 Skill 的公众入口及记录,保留来源团队 Skill、提升和撤销审计,并保护共用对象存储文件。重新提升形成新的全局 Skill ID。
|
||||
|
||||
## 6. 权限边界
|
||||
|
||||
- `withdraw-review`:仅提交人本人
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 1 MiB |
|
|
@ -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 身份模型。
|
||||
- 全局其他版本可能由独立上传形成,撤销整条全局派生技能时一并删除;确认窗口须列出删除范围。
|
||||
|
|
@ -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 跨命名空间迁移。
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
## Implementation
|
||||
|
||||
- [x] 扩展提升关系和申请持久化,兼容既有首次提升记录。
|
||||
- [x] 实现再次提升提交、审核和原全局技能新增不可变版本;处理并发、权限、版本冲突及审核期间状态变化。
|
||||
- [x] 实现撤销申请、审核、审计与全局派生技能安全删除,保护来源文件和历史。
|
||||
- [x] 更新详情页提升区域和管理员审核区域,补齐中英文等现有语言文案与可访问状态。
|
||||
- [x] 更新 API 类型、产品/域模型文档和提升烟测。
|
||||
|
||||
## Verification
|
||||
|
||||
- [x] 后端单元测试及本地 PostgreSQL 烟测覆盖首次、再次提升、撤销、重新提升和权限;独立全局版本较高时的发布顺序、版本冲突与并发保护由领域测试和数据库约束覆盖。
|
||||
- [x] 前端类型检查、lint、相关组件测试与构建通过。
|
||||
- [x] OpenAPI 类型由本地运行中的后端重新生成,类型检查通过。
|
||||
- [x] 本地端到端烟测完成团队发布、全局审核、撤销、再次申请及公众不可见验收。
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record PromotionRevocationActionRequest(String comment) {}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record PromotionRevocationSubmitRequest(Long sourceSkillId, String reason) {}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 之间
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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")))
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.domain.review;
|
||||
|
||||
public enum PromotionRequestKind {
|
||||
INITIAL,
|
||||
UPDATE
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
package com.iflytek.skillhub.domain.review;
|
||||
|
||||
public record PromotionState(String requestKind, Long targetSkillId,
|
||||
String targetCurrentVersion, Long pendingPromotionId) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -272,8 +279,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 +335,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 +369,11 @@ class PromotionServiceTest {
|
|||
@Nested
|
||||
class ReviewPromotion {
|
||||
|
||||
@BeforeEach
|
||||
void allowSubmitter() {
|
||||
allowCurrentSubmitter();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotifySubmitterWhenPromotionApproved() {
|
||||
PromotionRequest request = createPendingPromotion();
|
||||
|
|
@ -381,7 +398,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 +444,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 +468,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 +532,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 +591,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 +601,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 +611,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 +619,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 +646,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 +677,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 +688,172 @@ 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()));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(NEW_SKILL_ID, version.getVersion()))
|
||||
.thenReturn(Optional.of(new SkillVersion(NEW_SKILL_ID, version.getVersion(), USER_ID)));
|
||||
|
||||
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 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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
807
web/src/api/generated/schema.d.ts
vendored
807
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
58
web/src/features/promotion/use-promotion-revocations.ts
Normal file
58
web/src/features/promotion/use-promotion-revocations.ts
Normal 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))
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Запрос на продвижение отправлен",
|
||||
|
|
|
|||
|
|
@ -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": "提升申请已提交",
|
||||
|
|
|
|||
|
|
@ -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 />)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue