feat(suite): publish suites from multi-skill bundles

This commit is contained in:
XiaoSeS 2026-09-17 11:16:39 +08:00 committed by GitHub
parent 36de54157b
commit b4779735bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
233 changed files with 22249 additions and 926 deletions

View file

@ -1,4 +1,4 @@
.PHONY: build build-backend build-backend-app build-builtin-skills build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke suite-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-builtin-skills test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci
.PHONY: build build-backend build-backend-app build-builtin-skills build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke suite-smoke suite-bundle-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-builtin-skills test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci
DEV_DIR := .dev
DEV_SERVER_PID := $(DEV_DIR)/server.pid
@ -150,6 +150,9 @@ namespace-smoke: ## 运行命名空间工作流 smoke test
suite-smoke: ## 运行 Skill Suite 生命周期 smoke test
./scripts/suite-smoke-test.sh $(DEV_API_URL)
suite-bundle-smoke: ## 运行带真实认证的 Suite Bundle 创建/更新 smoke test
./scripts/suite-bundle-smoke-test.sh $(DEV_API_URL)
dev-down: ## 停止本地开发环境(含 skill-scanner
$(DEV_COMPOSE) down --remove-orphans

35
design-qa.md Normal file
View file

@ -0,0 +1,35 @@
# Suite 发布任务设计验收
## 验收范围
- 参考方案:方案 3 的任务列表分层、方案 2 的独立详情页。
- 实现页面:`/dashboard/suites?tab=publishing``/dashboard/suites/publishing/:operationId`
- 视口:桌面端 1487 × 1058移动端 390 × 844。
## 对照结论
| 检查项 | 结果 | 证据 |
|---|---|---|
| 控制台任务分层 | 通过 | 保留“需要处理 / 进行中 / 最近完成”,终态记录位于“最近完成” |
| 独立任务详情 | 通过 | 查看任务进入独立路由,不再回到创建 Suite 页面 |
| 取消后的可追溯性 | 通过 | 任务记录保留,并明确说明已有 SkillVersion 与审核任务不会撤回 |
| 成员审核入口 | 通过 | 取消后仍显示“查看技能版本与审核状态” |
| 状态表现 | 通过 | 任务使用静态状态图标,没有持续旋转图标 |
| 响应式 | 通过 | 390px 视口没有横向溢出,操作与成员信息仍可访问 |
| 现有设计系统 | 通过 | 复用 DashboardPageHeader、Card、Tabs、Button、Pagination 与 Lucide 图标 |
参考图包含多条示例任务;真实预览库只有一条已停止记录,因此实际页面保留空分层并展示真实数据。参考详情图展示到“套件审核”,而当前 Bundle 工作流的职责止于“创建套件草稿”,实际实现按领域状态保留三阶段,避免暗示系统会自动提交 Suite 审核。
## 缺陷分级
- P0无。
- P1无。
- P2无。
## 自动化结果
- 桌面端任务列表与详情页完成同视口截图对照。
- 真实普通用户会话完成任务列表、详情、返回、成员审核入口和移动端检查。
- 浏览器过程无 console error 或 pageerror。
final result: passed

View file

@ -39,6 +39,7 @@ export default defineConfig({
text: '核心功能',
items: [
{ text: 'Skill 发布与版本管理', link: '/guide/skill-publish' },
{ text: 'Suite Bundle 批量导入', link: '/guide/suite-bundle' },
{ text: 'Skill 搜索与发现', link: '/guide/skill-discovery' },
{ text: '命名空间与团队管理', link: '/guide/namespace' },
{ text: '审核与治理', link: '/guide/review' },
@ -86,6 +87,7 @@ export default defineConfig({
text: 'Core Features',
items: [
{ text: 'Skill Publishing & Versioning', link: '/en/guide/skill-publish' },
{ text: 'Suite Bundle Import', link: '/en/guide/suite-bundle' },
{ text: 'Skill Search & Discovery', link: '/en/guide/skill-discovery' },
{ text: 'Namespace & Team Management', link: '/en/guide/namespace' },
{ text: 'Review & Governance', link: '/en/guide/review' },

View file

@ -0,0 +1,50 @@
# Suite Bundle Import
A Suite Bundle uploads one ZIP or folder to create a Suite or create a new version from a published base. SkillHub first shows the member diff. Member publication and review start only after explicit confirmation.
## Archive format
The archive root must contain exactly one `SUITE.yaml`. Every packaged member has its own directory with a root `SKILL.md`. Undeclared skills, overlapping directories, unsafe or duplicate paths, and configured file limits block preview.
```yaml
apiVersion: skillhub.iflytek.com/v1alpha1
kind: SkillSuiteBundle
metadata:
namespace: global
slug: clinical-workflow
spec:
mode: CREATE
version: 1.0.0
displayName: Clinical workflow
summary: Validate intake data and produce a summary
overview: The entry Skill validates and dispatches input; the summary Skill produces the result.
visibility: PUBLIC
entry: "@global/intake"
members:
- skill: "@global/intake"
package:
path: skills/intake
visibility: PUBLIC
- skill: "@global/shared-dictionary"
reference:
version: 2.3.1
```
Use exactly one source per member: `package` publishes the uploaded Skill directory; `reference` pins an existing accessible `PUBLISHED` version without copying or changing it. Creating or updating a packaged Skill requires the normal Namespace and lifecycle permissions.
## Create, update, and review
Use `mode: CREATE` for a new coordinate. For `mode: UPDATE`, set `baseVersion` and describe the complete desired member order. Preview reports `ADDED`, `UPDATED`, `UNCHANGED`, and `REMOVED`; order and Entry changes count as updates, while a completely unchanged update cannot be confirmed.
Choose local import from the Suite create or new-version page, upload once, inspect the preview, acknowledge warnings per affected member and removals separately, then confirm. Preview creates no SkillVersion, review task, or SuiteVersion. Confirmation requires an `Idempotency-Key` and rechecks permissions, versions, references, and staged hashes. A Suite draft is created only after every required member completes its normal publication and review workflow.
## Deployment flags
Confirmation is disabled by default. Enable both server-side write paths for the complete workflow:
```bash
SKILLHUB_SUITE_BUNDLE_CONFIRMATION_ENABLED=true
SKILLHUB_SUITE_REVIEW_WRITES_ENABLED=true
```
The same protocol works against a self-hosted Registry and does not depend on a SaaS endpoint.

View file

@ -0,0 +1,75 @@
# Suite Bundle 批量导入
Suite Bundle 用一次 ZIP 或文件夹上传,为 Suite 创建首个版本,或基于一个已发布版本创建新版本。系统先展示成员差异,只有用户明确确认后才进入成员 Skill 的正常发布和审核流程。
## 归档结构
归档根目录必须且只能有一个 `SUITE.yaml`。每个携带包的成员使用独立目录,目录根部必须包含 `SKILL.md`。未声明的 `SKILL.md`、目录重叠、路径穿越、重复路径、超出大小或文件数限制都会阻止预览。
```text
SUITE.yaml
skills/
intake/
SKILL.md
summarizer/
SKILL.md
```
```yaml
apiVersion: skillhub.iflytek.com/v1alpha1
kind: SkillSuiteBundle
metadata:
namespace: global
slug: clinical-workflow
spec:
mode: CREATE
version: 1.0.0
displayName: 临床工作流
summary: 串联病历接收与摘要生成
overview: |
接收结构化病历,完成字段校验后生成摘要。
入口技能负责接收和分派,摘要技能负责输出最终结果。
visibility: PUBLIC
entry: "@global/intake"
members:
- skill: "@global/intake"
package:
path: skills/intake
visibility: PUBLIC
- skill: "@global/shared-dictionary"
reference:
version: 2.3.1
```
成员只能选择一种来源:
- `package`:发布本次上传的 Skill 文件夹。新 Skill 必须明确填写 `visibility`;已有 Skill 只能由其所有者、Namespace 管理员或超级管理员更新。
- `reference`:精确引用市场中已发布且当前用户可访问的版本,不复制、不修改该 Skill。
## 创建与更新
创建 Suite 时使用 `mode: CREATE`,目标坐标必须不存在。更新时使用 `mode: UPDATE`,并增加 `baseVersion`Manifest 必须描述新版本的完整成员顺序,基准成员未出现时会被标记为移除。
更新预览会显示 `ADDED``UPDATED``UNCHANGED``REMOVED`。成员顺序或 Entry 身份变化也属于更新;展示内容和成员均无变化的 Bundle 不能确认。`summary``overview` 在更新时可以继承基准版本,但最终值必须非空。
## 预览、确认与审核
1. 在“创建 Suite”或“创建新版本”页面选择“从本地导入”。
2. 选择 ZIP或选择包含 `SUITE.yaml` 的根文件夹。
3. 检查每个成员的来源、目标版本、关系变化、发布动作、错误和警告。
4. 对每个存在警告的成员分别确认;更新时还要单独确认移除成员。
5. 确认后查看持久化操作进度。刷新页面后仍可恢复;权限撤销或 Namespace 冻结时可在恢复条件后重试。
6. 所有需要发布的成员都完成原有审核后,系统才创建 Suite 草稿;再按 Suite 审核流程提交。
预览不会创建 SkillVersion、审核任务或 SuiteVersion。确认请求使用 `Idempotency-Key` 防止网络重试重复创建操作,并重新检查权限、版本、引用和暂存文件摘要。
## 部署开关
Bundle 确认默认关闭。启用完整流程需要服务端同时允许 Bundle 确认和 Suite 审核写入:
```bash
SKILLHUB_SUITE_BUNDLE_CONFIRMATION_ENABLED=true
SKILLHUB_SUITE_REVIEW_WRITES_ENABLED=true
```
可以先保持确认关闭,仅验证上传和差异预览。私有化部署使用相同协议和本地 Registry 数据,不依赖 SaaS 地址。

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-11

View file

@ -0,0 +1,10 @@
# add-suite-bundle-publishing
本变更将 Issue #847 整理为建立在 Issue #715 / PR #828 Suite 模型之上的三组交付能力:
创建与更新共用的 Suite 导入编排、任意成员 Skill 的所属 Suite 展示,以及 Suite 展示信息与标签。
提案明确记录 #847 中哪些需求继续保留、哪些需要调整。用户可以从技能市场组合精确版本,也可以
通过一个 ZIP 或受支持浏览器中的根目录,一次提交多个 Skill 文件夹来创建或更新 Suite。导入可以
在有权限的 Namespace 中创建新 Skill也可以更新操作者有权发布的已有 Skill非本人所有 Skill
只能作为精确 PUBLISHED 版本引用。技能市场和套件专区继续分开。Suite 拥有自己的展示信息和标签,
但不修改成员 Skill 的标签或版本 Tag。

View file

@ -0,0 +1,305 @@
## Context
需求背景与范围见 `proposal.md`。当前 Web 可以手工创建 Suite、编辑 DRAFT并基于已有快照创建
新 SuiteVersion但不能一次提交多个本地 Skill 文件夹、自动比较内容并统一跟踪成员发布。当前
Suite 使用不可变 SuiteVersion 快照,成员必须是精确的
PUBLISHED SkillVersion并允许引用符合可见性要求的跨 Namespace PUBLIC Skill。普通 Skill 发布
可能涉及对象存储、异步扫描和独立人工审核。技能市场已经使用 `/search`,套件专区已经使用
`/suites`、类型化资源接口和 Suite 卡片。Skill 详情目前只有在该 Skill 是 Entry Skill 时才展示
所属 Suite。Suite 的 summary 和 overview 当前可为空,因此已发布 Suite 可能只能显示空内容提示。
现有 Label 用于 Skill 容器分类Tag 是后端版本别名。
## Goals / Non-Goals
**目标:**
- 将一次 ZIP 或根目录上传转换为稳定、可检查的 Suite 创建或更新计划。
- 允许在操作者具备 Skill 创建权限的 Namespace 中,从携带包创建新 Skill。
- 允许继续精确引用合规的非本人所有 Skill且不尝试发布它们。
- 只发布操作者已经具备独立发布权限的携带包成员。
- 扫描和审核期间不创建临时或部分有效的 SuiteVersion。
- 让重试、异步审核、权限变化和终止失败都可观察。
- 在任意成员 Skill 上安全展示当前用户可见的所属 Suite。
- 让 Suite 拥有自己的 Label并要求新发布版本具备有效摘要和概述。
- 限制归档处理、数据库查询、扫描和反向引用投影的资源消耗。
**非目标:**
- 为操作者只能引用、不能管理的 Skill 发布新内容。
- 替代单 Skill 的独立扫描/审核或 Suite 自身审核。
- 合并技能市场和套件专区,或者改变两个入口的路由与产品定位。
- 批量修改成员 Label、增加 Suite Tag、迁移 Namespace、按成员关键词参与排名、CLI 上传
Bundle、嵌套 Suite 或跨 Registry 成员。
## Decisions
### 控制台套件清单:采用方案 22026-09-16 用户确认)
移除“我的套件”的独立发布任务页签。套件、尚未形成套件的创建操作统一服务端分页;按坐标归并,
生成套件后不重复列出成功操作。顶部采用 288px 搜索框、状态下拉和轻量“需处理”数量提示。
数量针对当前搜索所有页,而非本页;筛选、分页和计数在数据库中完成。新工作台接口保留旧列表接口
兼容性,每次固定两条 SQL不读取成员、执行计划 JSON 或包内容;每页 12 条,搜索只在点击搜索
或按 Enter 后执行,输入与已提交条件分离,取消过时请求,仅存在运行/等待操作时每 5 秒轮询。正式套件版本审核复用现有“我的审核进度”。
### 1. 创建和更新共用 Manifest成员分为“携带包”和“精确引用”
Manifest 描述完整目标成员集合和顺序,每个成员只能选择一种形式:
- **携带包成员**:指向一个以 `SKILL.md` 为根的包目录。Skill 不存在时,操作者必须在目标
Namespace 具备创建权限Skill 已存在时,操作者必须具备该 Skill 的发布权限。通过规范化包
fingerprint 判断创建 Skill、创建 SkillVersion还是复用现有精确版本。
- **精确引用成员**指向一个已有、精确、PUBLISHED 的 SkillVersion不携带包Bundle 不修改它。
操作者只需满足现有 Suite 编排和读取权限,不需要拥有该 Skill。
创建模式没有基准快照,全部成员均为新增。更新模式以明确的 SuiteVersion 为基准,只有基准成员
未出现在 Manifest 中时才视为移除,不能因为没有包目录就视为移除。引用成员可以新增,也可以
显式重新固定到另一个合规 PUBLISHED 版本。Entry Skill 可以使用任一成员形式。
实现协议使用归档根级唯一文件 `SUITE.yaml`。首版结构固定为:
```yaml
apiVersion: skillhub.iflytek.com/v1alpha1
kind: SkillSuiteBundle
metadata:
namespace: global
slug: clinical-workflow
spec:
mode: CREATE # 或 UPDATE
baseVersion: 1.0.0 # 仅 UPDATE 必填CREATE 禁止提供
version: 1.1.0
displayName: 临床工作流
summary: 套件摘要
overview: 套件 Markdown 概述
visibility: PUBLIC
changelog: 本次更新说明
entry: "@global/intake"
members:
- skill: "@global/intake"
package:
path: skills/intake
visibility: PUBLIC # 仅新 Skill 必填
- skill: "@global/shared-dictionary"
reference:
version: 2.3.1
```
协议采用严格字段集合,不识别的字段直接报错,避免拼写错误被静默忽略。`metadata` 坐标和
`spec.version` 是目标 Suite 身份;成员 `skill` 是唯一 Skill 身份;`SKILL.md``name`
`description` 和可选 `version` 继续作为 Skill 自身发布元数据。若携带包的 `SKILL.md` 解析出的
slug 与成员 `skill` 的 slug 不同,或显式版本与预览解析出的目标版本冲突,预览阻塞,不设置覆盖
优先级。引用成员只能提供精确 `version`,不能同时携带包。已有 Skill 的 `package.visibility`
可以省略并继承当前值;若显式提供则必须与当前值一致。新 Skill 必须明确提供该字段。
**备选方案:要求每个成员都有包目录。** 不采用。Suite 可以合法引用其他用户的公开 Skill强制
打包会错误暗示操作者可以重新发布或复制这些内容。
### 2. Suite 创建和更新使用同一个持久化 Saga
预览阶段保存有期限且归属当前操作者的 `PreviewSession`、临时归档和完整计划,但不占用 Suite 坐标
或版本。`PREVIEW_READY` 是 PreviewSession 状态,不是执行操作状态。确认事务先创建持久化
`ExecutionOperation` 并原子获取目标 Suite 坐标或版本占用;获取失败时必须在任何成员生命周期
副作用前结束,并要求重新预览。
ExecutionOperation 保存创建/更新模式、目标 Namespace/Suite/版本、操作者、归档摘要、完整成员计划、
已创建 Skill/SkillVersion ID、精确引用 ID 和失败原因。对外状态为 `RUNNING`
`WAITING_FOR_MEMBERS``BLOCKED_RETRYABLE``REPREVIEW_REQUIRED``SUITE_DRAFT_CREATED`
`CANCELLED`。只有 PreviewSession 使用 `PREVIEW_READY``CONFIRMED``EXPIRED`;等待人工审核的
ExecutionOperation 不受 PreviewSession TTL 影响。
只有需要创建或发生变化且有权限的携带包成员进入现有 Skill 发布流程。所有新版本成为 PUBLISHED
并且复用成员和引用成员最终仍符合要求后,更新模式才创建 SuiteVersion DRAFT创建模式原子创建
Suite 容器及首个 DRAFT。任一成员失败都会阻止生成 Suite 草稿,但不会生成部分 SuiteVersion。
领域事件触发状态协调,同时使用有界定时恢复处理事件丢失或乱序。
**备选方案SuiteVersion 暂时引用未发布成员。** 不采用。这会破坏 #828 的精确 PUBLISHED
版本不变量,并把临时成员处理扩散到 Suite 校验、审核、详情、删除和安装全链路。
### 3. 文件只提交一次,先预览再确认
Web 接受一个 ZIP支持目录选择的浏览器还可以选择一个根目录并以同一归档协议提交。服务端将
归档流式写入临时对象存储,校验 Manifest 和携带包成员,计算 fingerprint批量读取基准快照、
精确引用和操作者权限。预览为每个成员分别返回成员关系变化 `ADDED``UPDATED``UNCHANGED`
`REMOVED`,以及发布动作 `CREATE_SKILL``CREATE_VERSION``REUSE_VERSION``REFERENCE_VERSION`
`NONE`,避免混淆“加入 Suite”和“创建 Skill”。预览不产生任何生命周期副作用。
确认使用不透明 token并绑定操作者、模式、目标坐标、归档摘要、目标 Suite 版本和完整计划。
只有确认成功创建的非终态 ExecutionOperation 才独占目标 Suite 坐标或版本。预览阶段解析出的包
版本和引用 ID 在确认与重试过程中保持稳定。创建模式在成员就绪前只通过执行操作占用目标坐标,
不创建对普通用户可见的空 Suite。
### 4. 每个异步边界都保留独立权限
预览明确区分四种权限:
- 在目标 Namespace 创建 Suite或管理已有 Suite 并创建新版本的权限;
- 在目标 Namespace 创建新 Skill 的权限;
- 读取和编排精确引用版本的权限;
- 发布携带包 Skill 的权限。
服务端在预览、确认、每次创建 Skill/SkillVersion 和 Suite 草稿创建时重新检查对应权限。新 Skill
坐标冲突、目标 Namespace 不可写、失去包发布权限或引用失效都会阻止对应写入或最终创建 Suite
草稿。Suite 所有权不会扩大成员 Skill 权限,错误响应不得泄露无权读取的资源元数据。
### 5. 部分成员发布必须可观察且不可破坏性补偿
确认前尽量发现包、版本、所有权、可见性和策略等确定性错误。但运行期仍可能出现某个成员版本
已经创建、另一个成员失败的情况。重试只处理未完成工作。取消会停止后续编排并阻止创建
SuiteVersion但不会删除独立创建的 SkillVersion 或已经形成的审核历史。
Bundle 不得调用会自动撤回其他待审版本或删除替换已有版本的发布路径。预览发现同一 Skill 已有
`PENDING_REVIEW` 版本,或目标版本号已经对应任一非 `PUBLISHED` 版本时,直接阻塞并要求用户先在
现有 Skill 流程中处理。这里复用的是普通 Skill 的校验、存储、扫描、审核和审计规则,不是无条件
复用当前具有替换副作用的内存型发布方法。
成员状态收敛规则如下:
| SkillVersion 状态或事件 | Bundle 状态 | 可执行动作 | ID 处理 |
|---|---|---|---|
| `SCANNING``PENDING_REVIEW` | `WAITING_FOR_MEMBERS` | 查看扫描或审核进度 | 保持预览绑定 ID |
| PRIVATE 新版本进入 `UPLOADED` | `RUNNING` | 使用 Bundle 确认中已明确授予的私有发布授权,重新鉴权后执行现有 confirm-publish 转换 | 保持 ID |
| `PUBLISHED` | 该成员完成 | 等待其他成员或创建 Suite 草稿 | 保持 ID |
| `SCAN_FAILED` | 能以同一 ID 重扫时为 `BLOCKED_RETRYABLE`,否则为 `REPREVIEW_REQUIRED` | 重扫或重新上传预览 | 不得静默换 ID |
| `REJECTED` | `REPREVIEW_REQUIRED` | 修改内容后重新上传并预览 | 原 ID 不再自动恢复 |
| 待审版本被撤回为 `UPLOADED` | `REPREVIEW_REQUIRED` | 重新预览 | 不自动重新提交审核 |
| 已绑定版本被删除、替换、下架或引用身份变化 | `REPREVIEW_REQUIRED` | 重新预览 | 不跟随新 ID |
| 权限暂时撤销或 Namespace 冻结且计划身份未变化 | `BLOCKED_RETRYABLE` | 恢复权限或状态后重试,也可由有权角色取消 | 保持 ID |
`BLOCKED_RETRYABLE` 保留坐标/版本占用,只允许同一 ExecutionOperation 按原计划和原 ID 重试。
`REPREVIEW_REQUIRED` 是终态,进入时与坐标/版本占用在同一事务中释放。`CANCELLED`
`SUITE_DRAFT_CREATED` 同样在终态事务中释放占用。数据库使用仅覆盖占用状态的唯一约束,防止释放
与新确认并发时出现双写。等待审核和可重试阻塞不会因 PreviewSession 过期而自动释放;原操作者或
当前治理角色可以按权限取消长期操作。
状态读取只允许原操作者或当前有权治理目标 Suite/Namespace 的角色。读取时仍按当前权限过滤成员
元数据;重试和取消必须重新授权。最终创建 Suite 草稿前再次检查 Namespace 可写、Suite 创建或
管理权限、已有 Suite 仍为 ACTIVE、目标坐标/版本占用和全部成员资格。权限或 Namespace 状态暂时
不可用且计划身份未变化时进入 `BLOCKED_RETRYABLE`;资源身份或目标计划失效时进入
`REPREVIEW_REQUIRED`
新 Skill 的目标可见性必须由 Manifest 明确提供。已有 Skill 始终继承当前 Skill 可见性,本功能不
承担可见性迁移Manifest 提供不同值时阻止确认。预览展示每个携带包成员的最终可见性以及审核或
PRIVATE 直接发布路径,并在确认、成员写入和 Suite 草稿创建前使用现有受众兼容规则重新检查。
预览还保存每个成员当前 warning 集合及其摘要。确认页逐成员展示 warning用户必须对 warning
执行独立于通用 Bundle 确认的明确确认;确认请求绑定已展示的 warning 摘要。warning 变化会使
预览失效,服务端不得因为用户只点击通用确认就静默启用普通发布的 `confirmWarnings`
### 6. 保持现有 Skill fingerprint 和 Suite 快照模型
每个包目录都规范化为以自身 `SKILL.md` 为根的普通 Skill 包,并使用现有校验器和 fingerprint
算法。ZIP 文件顺序、压缩元数据、压缩级别和外层目录不参与内容身份。纯引用成员只比较精确
SkillVersion 身份和有效性,不下载或计算其包内容。
Manifest 提供目标 Suite/SuiteVersion 的展示信息、完整顺序和 Entry Skill。新建和变化包继续
使用现有发布版本规则;自动解析的版本在预览时固定。最终结果仍是一个只包含精确 PUBLISHED
引用的普通 DRAFT SuiteVersion。
### 7. 限制资源成本并保证归档安全
归档采用流式解析,拒绝危险路径、链接、重复规范化名称、过度解压以及超过配置的归档/成员/文件
限制。预览把成员文件保存为临时对象定位信息、大小、内容类型和已计算摘要,而不是把最大 Bundle
展开为一组常驻内存的 `byte[]`。确认后的发布边界从暂存对象流式读取并复用预览摘要,不重新解压
或计算 fingerprint普通发布需要的文件 hash、包归档和存储对象也从该流式输入生成。当前成员、
引用、版本和权限批量读取。纯引用和未变化成员不复制对象、不扫描、不审核。
首版复用 `skillhub.publish.max-package-size` 同时限制 ZIP 压缩体积和整个 Bundle 的解压后总量,
单文件限制继续复用 `skillhub.publish.max-single-file-size`Bundle 总文件数上限为普通单 Skill 文件数
上限乘以协议允许的 100 个成员。上传流先写入独立临时目录,每个 ZIP 文件只在解压时计算一次
SHA-256 并写入临时对象,分析阶段只读取本地暂存文件,返回计划不保留文件字节。
外层解析和成员解析分开执行。外层只负责唯一 Manifest、成员目录边界和文件归属成员目录去除
自身前缀后,必须成为一个普通的、根部含 `SKILL.md` 的 Skill 包,并复用现有
`SkillPackageValidator`、元数据解析、合规规则和错误/警告等级。成员目录不得重叠或嵌套,未声明的
`SKILL.md` 和无法归属的普通文件均阻止确认避免服务端猜测用户意图。Manifest 与 `SKILL.md`
重复字段的优先级必须在协议中唯一确定;必须一致的字段发生冲突时直接报错。
格式校验、fingerprint 和权限分析都发生在预览阶段Scanner 仍属于确认后的普通 Skill 生命周期,
不为了预览创建扫描任务。任一成员存在阻塞性格式错误时,页面可以展示全部已发现问题,但整个
Bundle 不可确认。
协调任务依靠带索引的操作/成员状态,不重新读取归档。反向引用使用集合式、有上限或分页的查询,
不得逐条查询 Suite。
### 8. 技能市场与套件专区保持分开
技能市场不增加类型切换。`/search` 继续只展示 Skill并保留 Skill Label、收藏和排序。
`/suites` 继续作为 Suite 专区,使用类型化资源接口和现有 Suite 卡片。
唯一新增的发现能力是反向成员关系Skill 详情查询最新、ACTIVE、非隐藏、PUBLISHED 且包含当前
Skill 的 SuiteVersion不再要求当前 Skill 必须是 Entry。结果标明 Entry 身份,只返回用户有权
读取的兄弟成员摘要;无权读取的成员仅显示数量。成员关键词暂不影响 Suite 排名。
### 9. Suite 拥有自己的 Label但不向成员级联
复用 `LabelDefinition`、本地化、`visibleInFilter`、排序以及 NORMAL/PRIVILEGED 权限语义,但使用
独立的 Suite-to-Label 关联。Suite Label 用于 Suite 容器展示和套件专区筛选。增删 Label 不创建
SuiteVersion也不修改任何成员 Skill Label。
**备选方案:把现有 Skill Label 关联迁移为通用多态资源表。** 暂不采用。这会迁移稳定的 Skill
数据并削弱数据库外键约束,当前收益不足。独立关联可以复用 Label 定义,同时保持所有权清晰。
Tag 继续属于 SkillVersion。SuiteVersion 已经有显式版本,因此本次不增加 Suite Tag也不向
成员批量设置 Tag。
### 10. 在发布边界要求有效摘要和概述
DRAFT SuiteVersion 可以在 summary 或 overview 尚未完成时保存。提交审核、PRIVATE 直接发布和
最终批准时两者都必须非空。summary 是套件专区、卡片和引用使用的简短说明overview 是
Markdown说明用途、成员职责或顺序、预期输入输出和使用边界。服务端负责强制校验不自动写入
“精选技能组合”之类的泛化文案。
已有字段为空的 PUBLISHED SuiteVersion 仍可读取和安装,避免破坏历史数据;创建下一个版本时
必须补齐。Bundle 可以显式提供新值,也可以继承当前 SuiteVersion 的非空值,预览必须展示最终
解析结果。
### 11. Suite 概述和 Entry Skill 说明各自承担职责
Suite overview 说明组合用途、适用场景、成员分工或调用顺序、预期输入输出和使用边界。编辑器提供
这些章节的结构化模板和完整性提示,但不以机械字数门槛鼓励填充内容,也不自动拿成员文档冒充
Suite 作者的概述。
Suite 详情可以在独立折叠区域按需展示 Entry Skill 固定 SkillVersion 的 `SKILL.md`。内容必须延迟
加载、复用该精确版本现有读取权限和安全 Markdown 渲染并明确标注来源坐标与版本。Entry Skill
说明不能替代 Suite overview无权访问、版本失效或加载失败时只保留合规的成员状态或跳转入口。
### 12. Web 将导入作为现有创建新版本流程的一种方式
“创建 Suite”和“创建新版本”均先选择“从技能市场组合”或“从本地导入”。手工组合保留现有页面
和 API本地导入进入上传、差异预览、明确确认和操作进度。更新预览必须突出显示因未出现在完整
Manifest 中而被移除的成员。确认文案列出将创建的 Skill/SkillVersion 数量、复用和引用数量、
移除数量以及最终只创建 Suite 草稿这一结果。
进度页逐成员展示创建、扫描、审核、复用、引用、失败和阻塞状态,并提供允许范围内的重试、取消、
审核详情和最终草稿入口。页面刷新或重新登录后可以依靠操作 ID 恢复进度,不依赖仍保留在浏览器
内存中的文件。
## Risks / Trade-offs
- **引用的公开 Skill 后续可能失效** → 创建 Suite 草稿前重新校验;发布后继续使用现有 degraded
机制。
- **Bundle 阻塞后可能留下独立成员版本** → 展示每个成员结果;重试和取消不得破坏成员生命周期。
- **事件可能重复、丢失或乱序** → 使用幂等协调和有界恢复任务。
- **预览后权限可能变化** → 每个异步边界重新检查对应权限,失败时阻止而不是放宽权限。
- **并发创建可能争用坐标,更新可能争用版本** → 操作记录独占目标 Suite 坐标和版本,最终创建
草稿时重新检查唯一性并保留乐观锁。
- **创建模式的某些 Skill 已发布、Suite 最终失败** → 独立 Skill 生命周期不回滚;进度页明确展示,
重试只继续未完成工作。
- **恶意归档可能消耗 CPU 或内存** → 流式处理、限制解压与文件规模,每个包只计算一次 hash。
- **反向引用可能泄露私有成员构成** → 服务端过滤 Suite 和兄弟成员,只暴露不可访问成员数量。
- **新必填展示信息可能阻塞历史 Suite 更新** → 历史发布快照保持可读,仅在提交新版本时要求补齐。
- **普通作者可能尝试自助配置特权 Label** → 复用现有 Label 权限类型并由服务端执行。
## Migration Plan
1. 增加操作表和索引,不修改现有 Skill/Suite 生命周期表。
2. 先部署预览和状态读取,再启用确认接口与协调任务。
3. 所有实例均能识别操作记录和协调事件后,再开启确认。
4. Bundle 进度、Suite Label/展示信息校验和任意成员反向引用可以独立部署;`/search`
`/suites` 路由保持不变。
5. 回滚时关闭新增接口和任务。已有 SkillVersion、SuiteVersion 继续有效;非终态操作保留,供兼容
版本取消或过期处理。
预览默认有效 30 分钟,可通过 `skillhub.suite.bundle.preview-ttl`(环境变量
`SKILLHUB_SUITE_BUNDLE_PREVIEW_TTL`)调整。确认入口默认关闭,待所有实例完成升级后通过
`skillhub.suite.bundle.confirmation-enabled`(环境变量
`SKILLHUB_SUITE_BUNDLE_CONFIRMATION_ENABLED`)显式开启。

View file

@ -0,0 +1,97 @@
## Why
PR #828 已经完成 Suite 版本模型、套件专区搜索和原子安装,但创建或更新包含多个本地 Skill 的
Suite 时,维护者仍需逐个创建或上传 Skill再手工把 Suite 固定到对应版本。Suite 还可能引用
维护者并不拥有的公开 Skill因此 Bundle 发布必须区分“操作者有权创建或发布的包内容”和
“操作者只能复用的外部精确引用”。
## What Changes
- 为创建和更新 Suite 增加共用的两阶段导入:通过一个 ZIP或受支持浏览器选择的一个根目录
一次提交多个 Skill 文件夹;先预览成员与发布动作,再明确确认。确认前不得创建 Skill、
SkillVersion、Suite、SuiteVersion、扫描或审核任务。
- Manifest 使用两种成员形式描述完整的目标 Suite 快照:一种是操作者有权发布的已有 Skill 包,
或操作者有权在目标 Namespace 创建的新 Skill 包;另一种是不修改、也不要求归 Suite 维护者
所有的精确 PUBLISHED SkillVersion 引用。
- Bundle 先校验唯一 Manifest、成员目录边界和文件归属再将每个目录独立还原为以 `SKILL.md`
为根的普通 Skill 包复用现有路径、大小、扩展名、内容签名、YAML、元数据和合规校验。任一成员
存在阻塞错误时不得确认,也不得通过猜测目录或静默选择冲突字段继续。
- 新增持久化 Bundle 发布操作。只有具备独立创建或发布权限且携带包的成员进入现有校验、扫描和
审核流程;所有包版本均为 PUBLISHED 且全部引用仍然有效后,才原子创建新 Suite 及首个草稿,
或为已有 Suite 创建 DRAFT SuiteVersion。
- Bundle 确认后进入独立发布任务详情;“我的套件”以“技能套件/发布任务”页签分离结果和过程,
发布任务按“需要处理/进行中/最近完成”分层。停止创建 Suite 后保留任务、已创建的 SkillVersion
和审核任务,并继续提供成员版本与审核入口。
- 未变化成员继续使用原精确版本,不创建版本、扫描或审核;支持添加、移除、调整顺序和显式重新
固定引用成员版本。
- 任意成员 Skill 均可展示当前用户可见的所属 Suite不再只支持 Entry Skill。
- 技能市场(`/search`)与套件专区(`/suites`)保持独立。#828 已经交付的套件专区和类型化 Suite
搜索无需替换。
- 新 SuiteVersion 提交或发布前必须具备非空摘要和 Markdown 概述;不完整的 DRAFT 仍可保存,
已发布历史数据仍可读取。
- Suite 可以配置自己的 Label并复用 Registry 的 Label 定义和权限类型,用于套件专区筛选。
Suite Label 不向成员 Skill 级联。Skill Tag 仍是指向 PUBLISHED SkillVersion 的版本别名,不引入 Suite。
- 暂不支持覆盖无权管理的已有 Skill、批量迁移 Namespace、按成员关键词提升 Suite 排名、CLI 上传
Bundle、嵌套 Suite 和跨 Registry 成员。
## 相对 Issue #847 的需求取舍
| Issue 原提议 | 结论 | OpenSpec 决策 |
|---|---|---|
| 上传一个多 Skill 归档 | 保留 | 归档只上传一次;预览使用不占位且有期限的 PreviewSession确认后才创建并占位的 ExecutionOperation。 |
| 每个 Suite 成员都必须在归档中有目录 | 调整 | 只有需要发布内容的成员才提供包目录;纯引用成员在 Manifest 中填写精确已发布版本。 |
| 被引用 Skill 必须属于 Suite 维护者 | 拒绝 | Suite 可以引用其他人发布的合规公开 Skill引用权限与发布权限相互独立。 |
| 通过 fingerprint 判断包内容变化 | 保留并澄清 | 携带包的成员使用现有 Skill 规范化 fingerprint纯引用成员比较精确 SkillVersion 身份。 |
| 未变化的包不升版、不重新扫描 | 保留 | 继续引用当前精确 SkillVersion。 |
| 变化的包沿用现有 Skill 发布流程 | 保留 | 仅限操作者已经具备该 Skill 的独立发布权限。 |
| 包成员变化后立刻创建 SuiteVersion | 调整 | 等全部包版本 PUBLISHED、全部引用仍有效后再创建一个 DRAFT SuiteVersion创建模式同时原子创建 Suite 容器。 |
| 消除 N 次上传和 N 个审核任务 | 调整 | 消除 N 次人工上传,但变化 Skill 仍独立审核Suite 仍保留自身审核。 |
| Bundle 自动创建不存在的 Skill | 保留并收紧 | 仅允许在操作者具备 Skill 创建权限的 Namespace 中创建;坐标冲突、越权或 Namespace 不可写时阻塞整个预览。 |
| 批量设置 labels/tags | 调整 | 为 Suite 自身配置 Label不向成员 Skill 扩散Suite 不支持 SkillVersion Tag。 |
| 把 Suite 结果并入技能市场 | 拒绝 | 技能市场与套件专区保持分开;`/suites` 已提供 Suite 搜索和卡片。 |
| 成员关键词提升 Suite 排名 | 延后 | 相关性和隐私安全索引需要单独提案。 |
| 任意成员展示所属 Suite | 保留 | 仅返回最新、可见、ACTIVE、非隐藏、PUBLISHED 的 Suite 引用,并标明是否为 Entry。 |
| Suite 摘要或概述为空 | 调整 | DRAFT 可暂时不完整,但新提交或直接发布必须同时具备摘要和概述,发布页不能用泛化文案冒充。 |
| 保留 #828 精确引用模型 | 保持不变 | SuiteVersion 仍只引用精确 PUBLISHED SkillVersion发布后保持不可变。 |
## Capabilities
### New Capabilities
- `suite-bundle-publishing`:从一个归档或根目录预览、确认、跟踪、恢复并完成 Suite 创建或更新,
同时严格区分有权创建/发布的包内容与非本人所有的精确版本引用。
- `suite-member-discovery`:保持技能市场与套件专区独立,同时让任意成员 Skill 展示经过隐私过滤的
所属 Suite。
- `suite-metadata`:要求 Suite 具备有效展示内容并支持 Suite 自有 Label不修改成员 Skill 元数据
或 SkillVersion Tag。
### Modified Capabilities
无。现有 `add-skill-suites` 变更尚未归档为主 OpenSpec capability这些增量 capability 依赖它,
但不修改其精确引用和生命周期要求。
## Impact
- **领域与持久化**:新增 Bundle 操作、目标坐标占用、成员结果和 Suite-to-Label 关联;不增加
SkillVersion 或 SuiteVersion 生命周期状态。
- **API / OpenAPI**:新增预览、确认、状态、取消、重试和 Suite Label 接口;扩展 Skill 详情中的
Suite 引用和套件专区标签筛选;技能与套件发现 API 继续分开。
- **对象存储**:按操作临时保存一个上传归档,支持过期和补偿清理;日志不得保存归档内容。
- **安全与治理**复用包校验、Scanner、Namespace 权限、单 Skill 审核、Suite 审核和审计规则;
管理 Suite 不会获得被引用 Skill 的发布权限。
- **性能**:每个携带包成员只解压和计算一次 hash批量读取引用与权限未变化成员不扫描限制
归档和成员规模;反向引用不得产生 N+1 查询。
- **Web**:在“创建 Suite”和“创建新版本”中增加手工组合/本地导入选择,新增 Bundle 差异预览、
权限提示和进度页;增加所属 Suite、发布信息校验、Suite Label以及按需展开的 Entry Skill
固定版本说明。技能市场与套件专区导航保持不变。
- **兼容性**:现有单 Skill 发布、单 Skill Label/Tag 管理、Suite 管理和安装 API、旧客户端以及
已发布 SuiteVersion 快照保持不变。
## 分阶段交付
OpenSpec 保留完整产品方向,但实现和 PR 按以下边界拆分,后续阶段不得绕过第一阶段建立的权限、
生命周期和性能约束:
1. Suite 创建/更新 Bundle、成员格式验证、权限重检、生命周期协调和 Web 进度闭环。
2. 基于 Suite 当前 `latestVersionId` 的任意成员反向发现。
3. Suite Label、展示信息发布校验、概述模板和 Entry Skill 固定版本说明。

View file

@ -0,0 +1,558 @@
## Purpose
让维护者通过一个经过安全校验的 ZIP 或根目录创建或更新 Suite同时保持 Skill 独立所有权、
独立审核以及 Suite 精确版本快照不可变。
## ADDED Requirements
### Requirement: REQ-SBP-01 Bundle 导入 SHALL 支持创建和更新 Suite
系统 SHALL 使用同一套预览、确认和进度协议支持创建 Suite 或为已有 Suite 创建新版本。创建模式
要求目标 Namespace 可写且操作者具备现有 Suite 创建权限;更新模式要求 Suite 为 ACTIVE 且操作者
具备创建新版本权限。确认前不得创建 Suite 或 SuiteVersion。
#### Scenario: 创建新 Suite
- **WHEN** 有权限的操作者为尚不存在的 Suite 坐标提交合法 Bundle
- **THEN** 系统在没有基准 SuiteVersion 的情况下预览全部目标成员
- **AND** 确认及成员发布成功前不创建空 Suite
#### Scenario: 刷新已有 Suite
- **WHEN** 有权限的操作者为 ACTIVE Suite 上传合法 Bundle
- **THEN** 系统以用户明确选择的 SuiteVersion 为基准分析 Bundle
#### Scenario: 创建目标 Suite 坐标已存在
- **WHEN** 创建模式指向已经存在或被其他非终态操作占用的 Suite 坐标
- **THEN** 预览或确认以可操作冲突拒绝请求
- **AND** 不覆盖现有 Suite
#### Scenario: 操作者不能管理 Suite
- **WHEN** 当前用户没有 Suite 管理权限却上传 Bundle
- **THEN** 系统拒绝请求,且不泄露其无权访问的成员元数据
### Requirement: REQ-SBP-02 Bundle Manifest SHALL 区分携带包成员和精确引用成员
Manifest SHALL 描述完整目标成员顺序和 Entry Skill。每个成员 SHALL 只能是以下一种形式:
操作者有权创建或发布的 Skill 包,或者一个合规的精确 PUBLISHED SkillVersion 引用。纯引用成员
SHALL NOT 要求操作者拥有该 Skill也不要求提供包目录。创建模式把全部成员视为新增更新模式
只有基准成员未出现在完整 Manifest 中时,才视为移除。新 Skill 的目标可见性 SHALL 在 Manifest
中明确提供;已有 Skill SHALL 继承当前可见性,本功能不得通过 Manifest 改变已有 Skill 可见性。
#### Scenario: 从文件夹创建新 Skill
- **WHEN** 携带包成员指向尚不存在的 Skill 坐标
- **AND** 操作者在目标 Namespace 具备 Skill 创建权限且 Namespace 可写
- **THEN** 预览把发布动作标记为 `CREATE_SKILL`
- **AND** 只有确认后才能创建 Skill 及其首个 SkillVersion
#### Scenario: 无权在目标 Namespace 创建 Skill
- **WHEN** 携带包成员指向不存在的 Skill但操作者没有对应 Namespace 的创建权限
- **THEN** 预览阻塞整个计划
- **AND** 不创建 Skill、Suite、版本、扫描或审核任务
#### Scenario: 更新有权发布的已有 Skill
- **WHEN** 携带包成员指向已有 Skill 且内容变化,并且操作者具备独立发布权限
- **THEN** 预览把发布动作标记为 `CREATE_VERSION`
#### Scenario: 新 Skill 坐标与不可管理 Skill 冲突
- **WHEN** 携带包坐标已经属于操作者无权发布的 Skill
- **THEN** 预览拒绝携带包,而不是将其作为新 Skill 或覆盖现有内容
#### Scenario: 新 Skill 缺少目标可见性
- **WHEN** 携带包成员指向不存在的 Skill 且 Manifest 没有提供目标可见性
- **THEN** 预览以阻塞性协议错误拒绝该成员
#### Scenario: 尝试通过 Bundle 修改已有 Skill 可见性
- **WHEN** Manifest 为已有 Skill 提供了不同于当前值的目标可见性
- **THEN** 预览阻止确认并要求使用现有 Skill 生命周期独立处理可见性
#### Scenario: 成员可见性与 Suite 受众不兼容
- **WHEN** 新 Skill 的目标可见性或已有 Skill 的继承可见性不能覆盖目标 Suite 受众
- **THEN** 预览拒绝该成员
- **AND** 确认、成员写入和 Suite 草稿创建前均重新执行同一兼容性检查
#### Scenario: 保留非本人所有的公开成员
- **WHEN** Manifest 以纯引用形式保留其他用户拥有的合规精确 PUBLIC SkillVersion
- **THEN** 预览按照现有 Suite 可见性规则接受该引用
- **AND** 不复制、发布、扫描、审核、设置 Label/Tag 或以其他方式修改该 Skill
#### Scenario: 添加已有精确引用
- **WHEN** 操作者不提供包,仅添加一个合规精确 PUBLISHED SkillVersion
- **THEN** 预览将成员关系标记为 `ADDED`,发布动作标记为 `REFERENCE_VERSION`
- **AND** 最终 Suite 草稿可以引用它,但不改变其所有权或生命周期
#### Scenario: 重新固定引用成员版本
- **WHEN** Manifest 将引用成员从一个合规 PUBLISHED SkillVersion 改为另一个
- **THEN** 预览将成员关系标记为 `UPDATED`,发布动作标记为 `REFERENCE_VERSION`
- **AND** 不发布新的 SkillVersion
#### Scenario: 为非本人所有 Skill 提供包
- **WHEN** 操作者可以引用某个 Skill但没有独立发布权限
- **AND** Manifest 为该 Skill 提供包内容
- **THEN** 预览拒绝该携带包成员,并返回不泄露隐私的可操作原因
#### Scenario: 移除当前成员
- **WHEN** 当前成员未出现在完整 Manifest 中
- **THEN** 预览将其标记为 `REMOVED`
- **AND** 在后续 SuiteVersion 正式发布前,现有已发布 Suite 保持不变
### Requirement: REQ-SBP-03 Bundle 预览 SHALL 完整且无业务副作用
确认前,预览 SHALL 返回完整目标 Suite 快照,并区分携带包成员和纯引用成员。预览 SHALL NOT
创建 Skill、SkillVersion、Suite、SuiteVersion、扫描、审核任务、Label 或 Tag 变更。临时归档和
PreviewSession 不属于生命周期对象,并且 SHALL 受有效期和清理策略约束;预览 SHALL NOT 占用
Suite 坐标或目标版本。预览 SHALL 展示每个携带包成员的最终可见性、对应审核/PRIVATE 发布路径和
当前 warning 集合。
#### Scenario: 预览混合变更
- **WHEN** 合法 Bundle 同时包含未变化/变化的包、新增/更新/未变化的引用以及被移除成员
- **THEN** 预览分别返回成员关系变化 `ADDED/UPDATED/UNCHANGED/REMOVED`、发布动作
`CREATE_SKILL/CREATE_VERSION/REUSE_VERSION/REFERENCE_VERSION/NONE`、当前/目标版本、顺序、
Entry 标记、警告和阻塞原因
- **AND** 不产生发布或元数据副作用
#### Scenario: 预览不存在有效变化
- **WHEN** 包 fingerprint、精确引用、顺序、Entry Skill、可见性和 Suite 元数据均与当前快照一致
- **THEN** 预览报告没有有效变化
- **AND** 确认不能创建冗余 SkillVersion 或 SuiteVersion
#### Scenario: 成员存在可确认 warning
- **WHEN** 普通 Skill 预发布规则为至少一个成员返回非阻塞 warning
- **THEN** 预览按成员展示 warning 内容并绑定 warning 集合摘要
- **AND** warning 不得被当成已自动确认
### Requirement: REQ-SBP-04 携带包成员差异 SHALL 使用规范化 Skill fingerprint
系统 SHALL 使用普通 Skill fingerprint 相同的规范化路径和文件内容 hash 比较携带包成员。ZIP
顺序、压缩元数据、压缩级别和外层目录 SHALL NOT 导致版本变化。纯引用成员 SHALL 比较精确
SkillVersion 身份,不下载或计算包内容。
#### Scenario: 重新打包但内容未变化
- **WHEN** 携带包成员的规范化路径和文件内容相同,只改变 ZIP 元数据或归档顺序
- **THEN** 预览将发布动作标记为 `REUSE_VERSION`
- **AND** 继续使用当前精确 SkillVersion
#### Scenario: 自有成员内容发生变化
- **WHEN** 至少一个规范化路径或文件内容 hash 发生变化
- **THEN** 预览将发布动作标记为 `CREATE_VERSION`
#### Scenario: 保留相同精确引用
- **WHEN** 纯引用成员指向与当前快照相同的 SkillVersion
- **THEN** 预览将成员关系标记为 `UNCHANGED`,发布动作标记为 `REFERENCE_VERSION`
- **AND** 不下载引用包,也不计算引用 fingerprint
### Requirement: REQ-SBP-05 Bundle 归档 SHALL 在发布前完成安全校验
系统 SHALL 对每个携带包成员执行现有 Skill 校验,并执行配置的归档总量、解压膨胀、成员数、
文件数和单文件大小限制。危险路径、链接、重复规范化成员、同时声明包和引用、非法 Manifest 或
非法包 SHALL 在产生副作用前拒绝整个预览。
#### Scenario: 一个携带包成员不合法
- **WHEN** 任一包未通过现有 Skill 包校验或预发布校验
- **THEN** 预览返回对应成员和可操作原因
- **AND** 不发布任何成员
#### Scenario: 归档尝试路径穿越或过度解压
- **WHEN** 归档包含危险路径、不支持的链接或超过配置的安全限制
- **THEN** 系统在写入 Skill/Suite 生命周期数据前拒绝归档
#### Scenario: 一个成员同时声明包和引用
- **WHEN** Manifest 中同一个成员同时使用两种形式
- **THEN** 预览拒绝 Manifest而不是猜测发布意图
### Requirement: REQ-SBP-06 Bundle 外层结构 SHALL 唯一映射每个 Skill 文件夹
Bundle 根 SHALL 包含且仅包含一个可识别的 Manifest。每个携带包成员 SHALL 在 Manifest 中声明一个
规范化相对目录,该目录必须唯一、不得与其他成员目录重叠或互相嵌套。每个声明目录必须且只能把
一个位于该目录根部的 `SKILL.md` 识别为成员入口。除明确允许忽略的操作系统元数据外,未归属于
Manifest 或任何声明成员目录的文件 SHALL 作为格式错误处理。
#### Scenario: 一个合法的多 Skill Bundle
- **WHEN** Manifest 声明三个互不重叠的成员目录,且每个目录根部各有一个 `SKILL.md`
- **THEN** 解析器生成三个彼此隔离的成员包
- **AND** 任一成员的文件不会进入另一个成员的校验或 fingerprint
#### Scenario: 成员目录缺少根部 SKILL.md
- **WHEN** 声明目录没有 `SKILL.md`,或者只在更深层级出现 `SKILL.md`
- **THEN** 预览把该成员报告为阻塞性格式错误
- **AND** 不通过猜测目录结构自动选择入口
#### Scenario: 成员目录重叠或嵌套
- **WHEN** 两个 Manifest 成员指向相同目录,或者一个成员目录位于另一个成员目录内
- **THEN** 系统拒绝整个 Bundle
- **AND** 不允许同一文件归属于多个 Skill
#### Scenario: 存在未声明的 Skill 文件夹
- **WHEN** 归档中出现包含 `SKILL.md`、但未被 Manifest 声明的目录
- **THEN** 系统报告该目录未被声明并阻止确认
#### Scenario: ZIP 与目录选择产生相同内容
- **WHEN** 用户分别通过 ZIP 和浏览器根目录选择提交相同的规范化文件树
- **THEN** 服务端得到相同的 Manifest、成员边界和 fingerprint 结果
#### Scenario: 不同目录解析为同一逻辑 Skill
- **WHEN** 两个携带包目录、或携带包与精确引用在路径大小写、slug 规范化和元数据解析后指向同一 Skill 身份
- **THEN** 系统以重复成员阻止整个 Bundle
- **AND** 目标 Suite 快照中的同一 Skill 最多出现一次
### Requirement: REQ-SBP-07 每个成员包 SHALL 独立通过现有 Skill 协议校验
解析外层结构后,系统 SHALL 把每个成员目录去除自身前缀,并作为以 `SKILL.md` 为根的普通 Skill
包交给现有 SkillPackageValidator、元数据解析和合规校验。路径、扩展名、内容签名、文件数量、
单文件大小、总大小、YAML 安全限制和必填字段 SHALL 复用普通 Skill 发布的同一策略及错误/警告
等级,不得为 Bundle 放宽。Manifest 与 `SKILL.md` 重复表达的信息 SHALL 按协议定义的唯一优先级
解析;互相矛盾时必须阻止确认。
#### Scenario: 一个成员 SKILL.md frontmatter 无效
- **WHEN** 任一成员缺少必填字段、YAML 语法错误或超过解析安全限制
- **THEN** 预览在对应成员下返回定位明确的错误
- **AND** 整个 Bundle 不能确认
#### Scenario: 两个路径规范化后冲突
- **WHEN** 同一成员内两个原始路径规范化或规范化 `SKILL.md` 大小写后得到相同路径
- **THEN** 成员校验以重复路径错误失败
#### Scenario: Manifest 与 SKILL.md 元数据冲突
- **WHEN** Manifest 和成员 `SKILL.md` 对协议规定必须一致的坐标或版本信息给出不同值
- **THEN** 预览展示冲突字段并阻止确认
- **AND** 服务端不静默选择其中一个值
#### Scenario: 一个成员超过普通 Skill 包限制
- **WHEN** 单个成员的文件数、单文件大小或总大小超过普通 Skill 发布限制
- **THEN** 即使整个 Bundle 未超过总限制,该成员仍然校验失败
#### Scenario: 所有成员格式校验通过
- **WHEN** 外层结构和每个成员包均通过阻塞性格式校验
- **THEN** 预览才继续执行 fingerprint、权限和目标版本分析
- **AND** 普通安全扫描仍在用户确认后的现有 Skill 生命周期中独立执行
### Requirement: REQ-SBP-08 Bundle 确认 SHALL 绑定已检查的预览并重新授权
确认 SHALL 使用带有效期的不透明预览标识,并绑定操作者、创建/更新模式、目标 Namespace/Suite
坐标、归档摘要、目标 Suite 版本和完整成员计划。确认和重试 SHALL 保持预览阶段解析出的包版本
和精确引用 ID并满足幂等性。确认时 SHALL 重新检查 Suite、Namespace、Skill 和引用权限,并在
一个事务中创建 ExecutionOperation、原子获取目标 Suite 坐标或版本占用。获取占用失败时SHALL
在创建任何 Skill 或 SkillVersion 前结束。存在 warning 时,确认 SHALL 额外携带用户对已展示
warning 集合的明确确认并匹配预览摘要;通用 Bundle 确认不得隐式代替 warning 确认。
#### Scenario: 确认已检查计划
- **WHEN** 同一有权限操作者确认仍有效且相关状态未变化的预览
- **THEN** 系统针对该精确计划启动一个持久化 Bundle 操作
- **AND** 返回操作 ID
#### Scenario: 预览过期或相关状态变化
- **WHEN** 确认使用过期预览,或者 Suite、权限、包、引用相关状态已经变化
- **THEN** 不产生新的发布副作用
- **AND** 要求重新预览
#### Scenario: 确认前权限被撤销
- **WHEN** 操作者在预览后失去 Suite 创建/管理、Skill 创建/发布或引用读取权限
- **THEN** 确认拒绝对应计划且不启动成员发布
- **AND** 错误不泄露操作者已经无权读取的资源元数据
#### Scenario: 响应丢失后重试
- **WHEN** 操作者使用相同幂等标识重试确认
- **THEN** 系统返回已有操作
- **AND** 不重复创建成员版本或审核任务
#### Scenario: 两个预览针对同一目标
- **WHEN** 多个用户同时预览同一 Suite 坐标或目标版本
- **THEN** 系统允许生成彼此隔离且有期限的 PreviewSession
- **AND** 只有确认事务中成功获得占用的一个计划可以执行
#### Scenario: warning 未确认或发生变化
- **WHEN** 用户没有明确确认全部当前 warning或者确认时 warning 集合摘要与预览不一致
- **THEN** 系统不创建 ExecutionOperation 或成员版本
- **AND** warning 变化时要求重新预览
### Requirement: REQ-SBP-09 Bundle 预览 SHALL 解析可发布的 Suite 展示信息
预览 SHALL 展示目标 SuiteVersion 最终使用的 summary 和 overview。更新模式可以使用 Manifest
提供的值,也可以继承基准 SuiteVersion 的非空值;创建模式必须由 Manifest 提供。任一最终值为空
时 SHALL 阻止确认。Bundle 处理 SHALL NOT
增加、删除或向成员扩散 Suite/Skill Label 与 Tag。
#### Scenario: 继承当前完整展示信息
- **WHEN** Manifest 未填写 summary 和 overview且当前 SuiteVersion 两者均非空
- **THEN** 预览展示继承后的最终值
- **AND** 最终 DRAFT 保留这些值
#### Scenario: 展示信息仍不完整
- **WHEN** Manifest 和当前 SuiteVersion 无法得到非空 summary 或 overview
- **THEN** 预览返回可操作的阻塞错误
- **AND** 在发布包内容前阻止确认
#### Scenario: Bundle 只修改成员组成
- **WHEN** 已确认 Bundle 更新包或引用
- **THEN** Suite Label 及全部成员 Skill Label/Tag 保持不变
### Requirement: REQ-SBP-10 携带包成员 SHALL 保持独立 Skill 权限和生命周期
只有操作者在对应 Namespace 具备创建权限的新 Skill或已经具备独立发布权限的已有 Skill才能
进入普通所有权、版本、校验、存储、扫描、可见性、审核和审计流程。未变化的包和全部纯引用成员
SHALL 不产生 Skill 发布副作用。服务端 SHALL 在每次实际创建 Skill 或 SkillVersion 前重新授权。
Bundle SHALL 复用普通发布的规则与生命周期,但 SHALL NOT 自动撤回其他 `PENDING_REVIEW` 版本,
也不得删除或替换已有非 PUBLISHED 版本。
#### Scenario: 确认后创建新 Skill
- **WHEN** 已确认计划包含通过预览的新 Skill 包,且写入时权限仍然有效
- **THEN** 系统通过现有 Skill 创建和首版发布流程处理该成员
- **AND** 操作记录新 Skill、SkillVersion、最终可见性及其状态
#### Scenario: 发布有权限的变化包
- **WHEN** 已确认计划包含操作者有权发布的变化包
- **THEN** 独立 SkillVersion 进入现有扫描和审核流程
- **AND** 操作记录每个版本及其状态
#### Scenario: 新 Skill 使用显式可见性
- **WHEN** 已确认计划创建新 Skill
- **THEN** 创建动作使用预览绑定且重新校验过的 Manifest 目标可见性
- **AND** 进度页展示该成员进入审核还是 PRIVATE 直接发布路径
#### Scenario: 复用未变化包
- **WHEN** 携带包成员的发布动作被判断为 `REUSE_VERSION`
- **THEN** 继续使用当前精确 PUBLISHED SkillVersion
- **AND** 不复制对象、不扫描、不审核、不创建版本
#### Scenario: Suite 管理权限不授予 Skill 发布权限
- **WHEN** 操作者可以管理 Suite但不能发布某个携带包 Skill
- **THEN** 该包不能进入发布流程
- **AND** Suite 所有权不会扩大 Skill 权限
#### Scenario: 异步写入前 Skill 权限被撤销
- **WHEN** 操作者在确认后、创建成员版本前失去对应权限
- **THEN** 计划身份未变化时操作进入 `BLOCKED_RETRYABLE` 并保留目标占用,且不创建新版本
- **AND** 其他已创建成员保持独立生命周期
#### Scenario: 已有 Skill 存在待审版本
- **WHEN** 预览发现目标 Skill 存在任意 `PENDING_REVIEW` 版本
- **THEN** 计划阻塞并要求用户先完成或撤回现有审核
- **AND** Bundle 不自动撤回该版本或删除审核任务
#### Scenario: 目标版本号已存在但未发布
- **WHEN** 预览解析出的目标版本号已经对应 DRAFT、UPLOADED、SCANNING、SCAN_FAILED、PENDING_REVIEW 或 REJECTED 版本
- **THEN** 计划阻塞并要求用户通过现有 Skill 流程处理该版本
- **AND** Bundle 不删除、替换或改写已有版本 ID
### Requirement: REQ-SBP-11 纯引用成员 SHALL 保持独立所有权和生命周期
系统 SHALL 只校验引用身份、当前操作者可见性、目标 Suite 受众兼容性和可安装性。系统 SHALL NOT
修改引用成员的包、版本、所有者、Label、Tag、审核、扫描或生命周期。
#### Scenario: 引用其他所有者的公开 Skill
- **WHEN** Suite 维护者选择其他用户拥有的合规精确 PUBLIC SkillVersion
- **THEN** 系统不要求管理权限即可接受该引用
#### Scenario: 操作者个人可见但目标受众不兼容
- **WHEN** 操作者可以读取精确 SkillVersion但 Suite 目标受众不能读取
- **THEN** 系统按照现有 Suite 可见性规则拒绝引用
### Requirement: REQ-SBP-12 Suite 草稿创建 SHALL 等待包发布成功和引用最终有效
只有全部变化包版本均为 PUBLISHED且全部未变化/纯引用成员仍然有效后,系统 SHALL 创建且仅创建
一个 DRAFT SuiteVersion。更新模式在已有 Suite 下创建草稿;创建模式 SHALL 原子创建 Suite 容器
和首个 DRAFT。草稿 SHALL 保存已确认的精确引用、顺序、Entry Skill、元数据和移除结果并继续
执行现有 Suite 审核生命周期。创建前 SHALL 重新检查目标 Namespace 可写、操作者仍具备 Suite
创建或管理权限、更新目标 Suite 仍为 ACTIVE、坐标/版本占用仍归当前操作,以及全部成员最终资格。
#### Scenario: 全部包已发布且引用仍有效
- **WHEN** 所有需要发布的包均达到 PUBLISHED且最终成员检查通过
- **THEN** 系统根据已确认快照创建一个 SuiteVersion DRAFT
- **AND** 操作状态变为 `SUITE_DRAFT_CREATED`
#### Scenario: 创建模式全部成员就绪
- **WHEN** 创建模式的全部包版本均为 PUBLISHED引用最终检查通过目标坐标仍可用
- **THEN** 系统在一个事务中创建 Suite 及其首个 DRAFT SuiteVersion
- **AND** 不向普通发现入口暴露无版本的空 Suite
#### Scenario: 包仍在扫描或审核
- **WHEN** 至少一个必要包仍处于扫描或审核中
- **THEN** 操作状态为 `WAITING_FOR_MEMBERS`
- **AND** 不创建临时 SuiteVersion
#### Scenario: 引用成员失效
- **WHEN** 创建草稿前精确引用被下架、隐藏、删除或不再符合受众可见性
- **THEN** 操作状态为 `REPREVIEW_REQUIRED`,并在同一事务中释放目标占用
- **AND** 不创建 SuiteVersion
#### Scenario: 异步完成前 Suite 权限或状态变化
- **WHEN** 成员就绪前 Namespace 变为不可写、操作者失去 Suite 创建/管理权限,或更新目标 Suite 不再 ACTIVE
- **THEN** 计划身份未变化时操作状态变为 `BLOCKED_RETRYABLE` 并保留目标占用
- **AND** 不创建 Suite 或 SuiteVersion
### Requirement: REQ-SBP-13 Bundle 恢复 SHALL 幂等且不破坏成员
系统 SHALL 展示成员级进度,并能处理重复、延迟或丢失的生命周期通知。`BLOCKED_RETRYABLE`
SHALL 保留目标占用,只允许同一操作按原版本 ID 重试;`REPREVIEW_REQUIRED` SHALL 作为终态释放
占用并要求新预览。取消 SHALL 停止后续编排和 SuiteVersion 创建,但 SHALL NOT 修改已创建
SkillVersion、精确引用或已完成审核。状态读取只允许原操作者或当前具备目标 Suite/Namespace 治理
权限的角色;重试和取消 SHALL 重新检查当前操作权限,且响应 SHALL 对当前无权读取的成员信息脱敏。
#### Scenario: 生命周期通知重复到达
- **WHEN** 同一成员生命周期通知被重复处理
- **THEN** 成员结果和 Suite 草稿数量保持不变
#### Scenario: 恢复丢失的通知
- **WHEN** 所有必要包已经 PUBLISHED但对应事件丢失
- **THEN** 有界恢复任务发现最终状态
- **AND** 最多创建一个 Suite 草稿
#### Scenario: 取消等待中的操作
- **WHEN** 有权限操作者在 Suite 草稿创建前取消操作
- **THEN** 操作变为 `CANCELLED`,后续不能再创建 SuiteVersion
- **AND** 成员 Skill 保持独立状态
#### Scenario: 成员扫描失败
- **WHEN** 已绑定成员版本进入 `SCAN_FAILED`
- **THEN** 现有重扫动作保留同一版本 ID 时,操作变为 `BLOCKED_RETRYABLE` 并保留占用
- **AND** 否则操作变为 `REPREVIEW_REQUIRED` 并释放占用
#### Scenario: 成员审核被拒绝
- **WHEN** 已绑定成员版本进入 `REJECTED`
- **THEN** 操作变为 `REPREVIEW_REQUIRED`,释放占用并要求修改内容后重新上传预览
- **AND** 不自动删除被拒绝版本或改绑新版本 ID
#### Scenario: PRIVATE 成员完成上传
- **WHEN** Bundle 确认已明确包含 PRIVATE 成员发布影响,且该成员进入 `UPLOADED`
- **THEN** 协调器重新鉴权后使用现有 PRIVATE confirm-publish 转换推进到 PUBLISHED
- **AND** 不要求用户对同一计划重复确认
#### Scenario: 已绑定成员发生外部状态漂移
- **WHEN** 待审成员被其他操作撤回、删除、替换、下架或变为当前操作者不可读
- **THEN** 操作变为 `REPREVIEW_REQUIRED`,释放占用并要求重新预览
- **AND** 不自动提交、恢复或跟随另一个 SkillVersion ID
#### Scenario: 未授权用户读取或操作进度
- **WHEN** 非原操作者且不具备当前治理权限的用户读取、重试或取消操作
- **THEN** 系统拒绝请求且不泄露成员坐标、版本、审核状态或错误详情
#### Scenario: 可重试阻塞恢复
- **WHEN** 权限或 Namespace 可写状态恢复,且绑定资源身份和版本计划未变化
- **THEN** 有权操作者可以重试同一 `BLOCKED_RETRYABLE` 操作
- **AND** 系统保持原目标占用和成员版本 ID
#### Scenario: 执行操作进入终态
- **WHEN** 操作变为 `REPREVIEW_REQUIRED``CANCELLED``SUITE_DRAFT_CREATED`
- **THEN** 系统在同一状态事务中释放 Suite 坐标或版本占用
- **AND** 后续确认可以按照唯一约束重新竞争该目标
#### Scenario: 等待审核超过预览有效期
- **WHEN** ExecutionOperation 正在等待成员审核且原 PreviewSession 已到期
- **THEN** 执行操作和目标占用保持有效
- **AND** 不因 PreviewSession TTL 自动过期或释放占用
### Requirement: REQ-SBP-14 并发导入 SHALL NOT 占用相同 Suite 坐标或目标版本
系统 SHALL 阻止两个非终态创建操作占用相同 Suite 坐标,并阻止两个非终态更新操作或已有
SuiteVersion 使用同一 Suite 和目标版本。最终创建草稿时 SHALL 重新检查坐标、版本和当前状态。
#### Scenario: 并发创建相同 Suite 坐标
- **WHEN** 两个操作者同时确认创建相同 Namespace 和 Suite slug
- **THEN** 最多一个操作获得目标坐标占用
- **AND** 另一个操作在创建任何 Skill 前失败或必须重新预览
#### Scenario: 并发确认指向相同版本
- **WHEN** 两个操作者同时确认同一 Suite 和目标版本的计划
- **THEN** 最多一个操作获得目标版本占用
- **AND** 另一个操作必须重新预览或选择新版本
### Requirement: REQ-SBP-15 Bundle 处理 SHALL 控制资源消耗
系统 SHALL 流式校验归档,每次预览对每个携带包成员最多解压和计算一次 fingerprint批量解析成员、
引用和权限,并避免读取、扫描或写入纯引用和未变化成员。预览 SHALL 将确认后发布所需的临时对象
定位、大小、内容类型和文件摘要绑定到计划;发布 SHALL 从暂存对象流式读取并复用摘要,不把最大
Bundle 展开为常驻内存字节数组。状态轮询 SHALL NOT 重新读取归档。
#### Scenario: 预览最大合法 Suite
- **WHEN** Bundle 使用包和精确引用描述允许的最大成员数
- **THEN** 预览使用有界归档处理和集合查询
- **AND** 返回完整计划且不产生逐成员查询放大
#### Scenario: 轮询等待中的操作
- **WHEN** 客户端反复查询操作状态
- **THEN** 系统直接读取持久化进度
- **AND** 不解压归档、不计算 hash、不下载引用、不重复扫描包
### Requirement: REQ-SBP-16 现有 Skill 和 Suite 契约 SHALL 保持兼容
Bundle 接口 SHALL 是增量接口。单 Skill 发布、精确 Suite 管理和安装、技能市场、套件专区、
单 Skill Label/Tag API 和旧客户端 SHALL 保持现有行为。已发布 SuiteVersion SHALL 继续是不可变的
精确 PUBLISHED SkillVersion 快照。
#### Scenario: 使用现有客户端和页面
- **WHEN** 用户从不调用 Bundle 接口
- **THEN** 现有 Skill 和 Suite 行为保持不变
#### Scenario: Bundle 操作完成
- **WHEN** Bundle 操作创建 SuiteVersion DRAFT
- **THEN** 现有 Suite API 可以查看、在允许时编辑、提交、审核和发布该草稿
- **AND** 安装客户端不需要理解 Bundle 归档
### Requirement: REQ-SBP-17 Web 导入 SHALL 复用现有 Suite 创建和新版本入口
“创建 Suite”和“创建新版本”页面 SHALL 同时提供“从技能市场组合”和“从本地导入”。本地导入
SHALL 支持上传一个 ZIP浏览器支持安全目录选择时也可以选择一个根目录并按同一归档协议提交。
更新模式 SHALL 把未出现在完整 Manifest 中的基准成员作为高风险移除项单独展示。
#### Scenario: 创建 Suite 时选择本地导入
- **WHEN** 用户在创建入口选择本地导入并提交合法文件
- **THEN** 页面展示没有基准版本的创建预览
- **AND** 不要求用户先逐个进入 Skill 发布页
#### Scenario: 更新 Suite 时选择本地导入
- **WHEN** 用户从已有 Suite 的“创建新版本”入口选择本地导入
- **THEN** 页面展示相对于明确基准版本的成员和发布动作差异
- **AND** 现有手工组合入口保持可用
#### Scenario: 预览包含移除成员
- **WHEN** 更新 Manifest 未包含至少一个基准成员
- **THEN** 页面在确认区单独列出移除项及数量
- **AND** 确认文案说明当前已发布 Suite 不受影响
### Requirement: REQ-SBP-18 Web 确认和进度 SHALL 准确展示副作用
确认页面 SHALL 展示将创建的 Skill、将创建的 SkillVersion、复用版本、纯引用和移除成员数量
每个携带包成员的最终可见性、发布路径和 warning并说明成员就绪后只生成 Suite 草稿。warning
必须逐成员明确确认。确认后,进度页 SHALL 展示每个成员的创建、扫描、审核、复用、引用、失败或
阻塞状态,并能通过操作 ID 在刷新或重新登录后恢复。“我的套件” SHALL 使用统一分页清单,将当前
创建或更新进度归入对应套件行,不再设置独立“发布任务”页签。尚未生成 Suite 的操作 SHALL 保留为
临时行,生成 Suite 后合并为正式行;取消的新建操作仍可在清单中找到。清单 SHALL 提供紧凑搜索、
状态筛选和覆盖搜索结果所有页的“需处理”数量提示,不另外堆待办卡片。点击过程操作 SHALL 进入独立发布详情页,不得
返回创建或新版本编辑页面。公开套件专区 SHALL 继续只展示已发布 Suite不得把执行操作伪装成已经
创建的 Suite。清单 SHALL 在服务端合并、筛选、计数和分页,不得逐套件请求详情或加载所有操作后
在浏览器分页;搜索 SHALL 仅在点击搜索或按 Enter 后执行,输入期间不得更新查询条件,且取消过时请求。只有仍可能自动变化的运行或等待状态才进行轮询。
正式 SuiteVersion 审核 SHALL 在现有“我的审核进度”中查看,名称、资源类型和详情链接必须正确。
清单和详情首屏 SHALL 使用文本明确展示整体状态及与该状态对应的下一步,
不得仅依赖颜色或图标表达。需要重新预览的更新操作 SHALL 保持更新语义并返回同一 Suite 的新版本
入口;原基准版本仍存在时预选该版本,基准版本已删除时不预选,但不得降级为创建新 Suite。
#### Scenario: 用户确认混合计划
- **WHEN** 预览同时包含创建 Skill、创建版本、复用、引用和移除
- **THEN** 确认界面分别列出每类数量和关键成员
- **AND** 不使用仅包含“是否继续”的笼统确认
#### Scenario: 成员等待审核
- **WHEN** 已确认操作至少有一个成员等待独立审核
- **THEN** 进度页展示 `WAITING_FOR_MEMBERS` 和成员审核入口
- **AND** 不把操作展示为 Suite 已更新
#### Scenario: 从我的套件继续查看发布
- **WHEN** 当前用户发起的 Bundle 操作仍为 `RUNNING``WAITING_FOR_MEMBERS``BLOCKED_RETRYABLE`
- **THEN** “我的套件”统一清单展示对应套件或临时创建行的目标版本、当前状态及下一步操作
- **AND** 用户进入独立任务详情页继续查看或处理,不进入 Suite 创建或新版本编辑页
- **AND** 操作数量超过单页容量时仍可通过分页访问
- **AND** 公开套件专区不展示该未完成操作
#### Scenario: 取消后保留发布记录和成员审核入口
- **WHEN** 用户停止一个已经创建部分 SkillVersion 或审核任务的 Bundle 操作
- **THEN** 尚未形成 Suite 的操作以 `CANCELLED` 保留为临时记录,已有 Suite 的历史操作保留在套件内记录中;详情明确说明不会再创建 Suite 草稿
- **AND** 已创建的 SkillVersion 和审核任务不撤回、不删除,仍可从成员行进入对应版本处理
- **AND** 长时间等待状态使用静态状态图标而不是持续旋转的加载图标
#### Scenario: 操作创建 Suite 草稿
- **WHEN** 操作达到 `SUITE_DRAFT_CREATED`
- **THEN** 页面提供新 Suite 草稿入口
- **AND** 下一步说明根据可见性区分提交审核或直接发布
#### Scenario: 更新操作需要重新预览且原基准版本已删除
- **WHEN** 更新操作达到 `REPREVIEW_REQUIRED` 且原基准 SuiteVersion 已删除
- **THEN** 页面返回同一 Suite 的创建新版本入口且不预选已删除版本
- **AND** 不跳转到创建新 Suite 入口

View file

@ -0,0 +1,70 @@
## Purpose
保持技能市场和套件专区两个独立入口,同时让任意成员 Skill 都能安全展示所属 Suite包括不是
Suite 入口的普通成员。
## ADDED Requirements
### Requirement: REQ-SMD-01 技能市场与套件专区 SHALL 保持独立
技能市场 SHALL 继续通过 `/search` 发现 Skill并保留 Skill 搜索、Label、收藏和排序行为。
套件专区 SHALL 继续通过 `/suites` 发现 Suite并使用类型化 Suite 搜索和 Suite 卡片。本变更
SHALL NOT 在技能市场增加 Skill/Suite 类型切换。
#### Scenario: 搜索技能市场
- **WHEN** 用户在 `/search` 搜索
- **THEN** 搜索结果仍然是 Skill
- **AND** 现有 Skill 筛选语义保持不变
#### Scenario: 搜索套件专区
- **WHEN** 用户在 `/suites` 搜索
- **THEN** 搜索结果仍然是当前用户可见的 Suite
- **AND** 结果链接到 Suite 详情
### Requirement: REQ-SMD-02 任意成员 Skill SHALL 展示当前用户可见的所属 Suite
Skill 详情 SHALL 只依据每个 ACTIVE、非隐藏 Suite 当前 `latestVersionId` 指向的 PUBLISHED
SuiteVersion 判断是否包含当前 Skill不再只查询当前 Skill 是 Entry Skill 的情况。每条引用
SHALL 标明当前 Skill 是否为 Entry Skill已从当前 latestVersionId 快照移除的 Skill 不得继续展示
该 Suite即使历史版本仍包含它。
#### Scenario: 当前 Skill 是普通成员
- **WHEN** 当前用户可见的最新 SuiteVersion 将该 Skill 作为非 Entry 成员
- **THEN** Skill 详情展示该 Suite 和精确 Suite 版本
- **AND** 标明当前 Skill 是普通成员
#### Scenario: 当前 Skill 是 Entry 成员
- **WHEN** 当前 Skill 是当前用户可见 SuiteVersion 的 Entry Skill
- **THEN** 所属 Suite 信息保留明确的 Entry 标记
#### Scenario: 引用该 Skill 的 Suite 不可见
- **WHEN** Suite 属于其他人的 PRIVATE 内容,或者已隐藏、归档、未发布、当前用户无权访问
- **THEN** Skill 详情既不暴露 Suite 坐标,也不暴露成员关系
#### Scenario: Skill 只存在于 Suite 历史版本
- **WHEN** 当前 Skill 存在于历史 PUBLISHED SuiteVersion但不在 Suite 当前 latestVersionId 快照中
- **THEN** Skill 详情不再把该 Suite 显示为当前所属 Suite
### Requirement: REQ-SMD-03 所属 Suite 信息 SHALL 保护兄弟成员元数据
所属 Suite 信息 SHALL 保护兄弟成员元数据。它可以展示紧凑的兄弟成员列表,但只允许返回当前用户有权读取的 Skill。不可访问或已删除
的兄弟成员只能按数量表示不得暴露坐标、名称、摘要、版本、fingerprint 或下载信息。
#### Scenario: 所有兄弟成员均可见
- **WHEN** 当前用户可以读取所属 Suite 的全部成员
- **THEN** 所属 Suite 信息可以按顺序展示紧凑成员列表和 Entry 标记
#### Scenario: 存在不可访问的兄弟成员
- **WHEN** 当前用户不能读取至少一个兄弟成员
- **THEN** 返回结果省略该成员的身份信息
- **AND** 只能选择性展示受限或不可用成员数量
### Requirement: REQ-SMD-04 Suite 成员反向发现 SHALL 控制查询规模
所属 Suite 引用 SHALL 分页或设置明确上限,并通过集合查询完成,不得为每个 Suite 或兄弟成员
分别发起查询。
#### Scenario: 一个 Skill 属于大量 Suite
- **WHEN** 引用该 Skill 的 Suite 数量超过详情响应上限
- **THEN** 响应使用确定性上限或分页契约
- **AND** 提供继续查看其他结果所需的信息

View file

@ -0,0 +1,173 @@
## Purpose
确保每个新发布 Suite 都有可理解、可发现的摘要、概述和自有 Label同时不修改被引用成员 Skill
的元数据或版本别名。
## ADDED Requirements
### Requirement: REQ-SMT-01 新 Suite 发布 SHALL 要求摘要和概述
SuiteVersion DRAFT 可以在展示信息未完成时保存但提交审核、PRIVATE 直接发布和审核批准时,
summary 与 Markdown overview SHALL 均为非空。服务端 SHALL 独立于 Web 校验执行该规则。
#### Scenario: 保存展示信息不完整的草稿
- **WHEN** 有权限的作者尚未完成 summary 或 overview
- **THEN** DRAFT 可以保存并继续编辑
- **AND** 作者可以看到其未完成状态
#### Scenario: 提交缺少摘要的 Suite
- **WHEN** 作者提交或直接发布 summary 为空的 SuiteVersion
- **THEN** 系统以可操作的摘要必填提示拒绝该动作
- **AND** SuiteVersion 保持 DRAFT
#### Scenario: 提交缺少概述的 Suite
- **WHEN** 作者提交或直接发布 overview 为空的 SuiteVersion
- **THEN** 系统以可操作的概述必填提示拒绝该动作
- **AND** SuiteVersion 保持 DRAFT
#### Scenario: 审核批准时展示信息已不合规
- **WHEN** PENDING_REVIEW SuiteVersion 在批准时不再满足展示信息要求
- **THEN** 系统拒绝批准且不发布该 SuiteVersion
### Requirement: REQ-SMT-02 摘要和概述 SHALL 承担不同展示职责
summary SHALL 为套件专区、卡片和所属 Suite 引用提供简明说明。overview SHALL 说明 Suite 用途、
成员职责或使用顺序、预期输入输出和重要使用边界。发布后的展示 SHALL 使用作者提供的内容,不得
静默使用泛化生成文案冒充真实内容。
#### Scenario: 展示信息完整的已发布 Suite
- **WHEN** 用户在套件专区或详情查看新发布 Suite
- **THEN** 卡片和引用展示 summary
- **AND** 详情将 Markdown overview 与成员列表分开呈现
#### Scenario: 通用兜底文案不算作者内容
- **WHEN** SuiteVersion 没有作者提供的 summary 或 overview
- **THEN** “精选技能组合”等通用文案不能通过发布校验
#### Scenario: 编辑 Suite 概述
- **WHEN** 作者创建或编辑 SuiteVersion
- **THEN** 编辑器提供适用场景、使用准备、成员分工或顺序、输入输出和注意事项的结构化写作提示
- **AND** 系统不以机械字数门槛替代内容完整性,也不自动复制成员文档充当概述
### Requirement: REQ-SMT-03 历史空展示信息 SHALL 保持兼容
本要求上线前已经发布的 SuiteVersion 即使 summary 或 overview 为空,仍 SHALL 保持可读取和可安装。
该 Suite 后续提交的新版本 SHALL 满足新的展示信息要求。
#### Scenario: 读取历史空概述 Suite
- **WHEN** 已有 PUBLISHED SuiteVersion 的展示信息为空
- **THEN** 用户仍可按照现有可见性和可用性规则查看与安装
- **AND** UI 如实提示缺少内容,不得编造说明
#### Scenario: 创建历史 Suite 的下一个版本
- **WHEN** 作者基于展示信息为空的历史快照提交新 SuiteVersion
- **THEN** 发布前必须补齐非空 summary 和 overview
### Requirement: REQ-SMT-04 Bundle 导入 SHALL 解析完整展示信息
Bundle 预览 SHALL 展示目标 SuiteVersion 最终使用的 summary 和 overview。Manifest 可以提供新值,
也可以继承当前 SuiteVersion 的非空值;最终任一字段为空时 SHALL NOT 允许确认。
#### Scenario: 继承当前完整展示信息
- **WHEN** Bundle 未填写展示信息,且当前 SuiteVersion 的 summary 和 overview 均非空
- **THEN** 预览将继承值作为目标展示信息展示
#### Scenario: 当前 Suite 的概述为空
- **WHEN** Bundle 未提供 overview且当前 SuiteVersion overview 也为空
- **THEN** 预览报告阻塞性展示信息错误
- **AND** 用户必须更新 Manifest 或 Suite 草稿后才能确认
#### Scenario: 通过 Bundle 创建 Suite
- **WHEN** 创建模式的 Manifest 没有提供非空 summary 或 overview
- **THEN** 预览报告阻塞性展示信息错误
- **AND** 不从 Entry Skill 的 `SKILL.md` 自动生成或复制 Suite 概述
### Requirement: REQ-SMT-05 Suite SHALL 支持自己的 Label
系统 SHALL 允许有权限的用户把已有 Registry LabelDefinition 关联到 Suite 容器或解除关联。
Suite Label SHALL 使用独立于成员 Skill Label 的关联,并 SHALL NOT 创建或修改 SuiteVersion。
#### Scenario: 为 Suite 添加普通 Label
- **WHEN** 有权限的 Suite 管理者添加一个允许使用的普通 Label
- **THEN** 该 Label 出现在 Suite 及其发现投影中
- **AND** 所有成员 Skill Label 保持不变
#### Scenario: 删除 Suite Label
- **WHEN** 有权限的 Suite 管理者解除一个 Suite Label
- **THEN** 只删除 Suite-to-Label 关联
- **AND** SuiteVersion 和成员元数据保持不变
#### Scenario: Suite 引用了其他人的 Skill
- **WHEN** 带 Label 的 Suite 包含非本人所有的引用 Skill
- **THEN** 除非该 Label 在 Skill 上被独立配置,否则不会出现在这些 Skill 上
### Requirement: REQ-SMT-06 Suite Label 权限 SHALL 复用现有 Label 权限类型
Suite Label 变更 SHALL 同时检查 Suite 管理权限和现有 LabelDefinition 权限类型。PRIVILEGED Label
继续仅允许现有 Label 策略授权的平台角色操作。读取响应 SHALL 按现有可见规则过滤 LabelDefinition。
#### Scenario: 普通管理者尝试配置特权 Label
- **WHEN** Suite 管理者没有使用 PRIVILEGED Label 的平台权限
- **THEN** 系统拒绝变更且不修改 Suite 或成员
#### Scenario: 平台管理员配置特权 Label
- **WHEN** 获得授权的平台角色为可管理 Suite 添加 PRIVILEGED Label
- **THEN** 系统建立关联并记录实际操作者
### Requirement: REQ-SMT-07 套件专区 SHALL 展示并筛选 Suite Label
套件专区卡片和详情 SHALL 展示 Suite 直接关联的可见 Label。套件专区 SHALL 支持按 Suite Label
筛选,不得修改技能市场筛选,也不得把成员 Skill Label 当成 Suite Label。
#### Scenario: 按 Label 筛选套件专区
- **WHEN** 用户在套件专区选择一个可见 Label
- **THEN** 结果只匹配直接关联该 Label 的可见 Suite
- **AND** 不会因为某个成员 Skill 有该 Label 就匹配 Suite
#### Scenario: Skill 与 Suite 使用同一个 LabelDefinition
- **WHEN** 同一个 LabelDefinition 分别关联 Skill 和 Suite
- **THEN** 技能市场筛选 Skill 关联
- **AND** 套件专区筛选 Suite 关联
### Requirement: REQ-SMT-08 Suite SHALL NOT 支持 SkillVersion Tag
本变更 SHALL NOT 创建 Suite Tag也不向成员批量设置 Tag。现有 Skill Tag 继续指向精确 PUBLISHED
SkillVersion并保留系统现有 `latest` 行为。
#### Scenario: 管理 Suite 元数据
- **WHEN** 作者修改 Suite Label、summary 或 overview
- **THEN** 不创建、移动或删除任何 Skill Tag
### Requirement: REQ-SMT-09 Suite Label 读取 SHALL 批量完成并可审计
Suite 列表和搜索 SHALL 以集合方式加载 Label 投影,不得逐 Suite 查询。每次 Suite Label 变更
SHALL 记录操作者、Suite ID、Label、动作和请求关联信息不得记录成员包内容。
#### Scenario: 展示一页带 Label 的 Suite
- **WHEN** 套件专区展示一页结果
- **THEN** 可见 LabelDefinition 和关联通过有界集合查询完成
#### Scenario: 审计 Suite Label 变更
- **WHEN** Suite 添加或删除 Label
- **THEN** 审计记录指向 Suite而不是任何成员 Skill
### Requirement: REQ-SMT-10 Entry Skill 说明 SHALL 与 Suite 概述分开展示
Suite 详情 MAY 在独立折叠区域按需展示 Entry Skill 固定 SkillVersion 的 `SKILL.md`,但 SHALL NOT
将其作为 Suite overview 的替代或兜底。内容 SHALL 标明精确坐标和版本,并复用现有版本读取权限、
内容安全策略和 Markdown 渲染。
#### Scenario: 用户展开可读的 Entry Skill 说明
- **WHEN** 当前用户有权读取 Entry Skill 固定版本并主动展开说明
- **THEN** 页面延迟加载并展示该精确 SkillVersion 的 `SKILL.md`
- **AND** 明确标注内容来源坐标和版本
#### Scenario: Entry Skill 存在更新版本
- **WHEN** Suite 固定的 Entry SkillVersion 不是该 Skill 的最新版本
- **THEN** 页面仍读取 Suite 固定版本的说明
- **AND** 不使用 `latest` 内容替换固定快照
#### Scenario: Entry Skill 不可读或内容加载失败
- **WHEN** 当前用户无权读取、固定版本失效或说明加载失败
- **THEN** 页面不展示 `SKILL.md` 内容
- **AND** Suite 作者编写的 overview 和经过权限过滤的成员状态仍可正常展示

View file

@ -0,0 +1,49 @@
## 1. 协议与操作基础
- [x] 1.1 `[REQ-SBP-02, REQ-SBP-06, REQ-SBP-07]` 确定同时支持创建/更新模式、“携带包成员”和“精确引用成员”的 Manifest包括唯一根 Manifest、成员相对目录、新 Skill 必填可见性、已有 Skill 可见性继承、重复字段优先级和冲突规则;通过 fixture 验证新 Skill、自有已有 Skill、非本人所有的公开引用、重新固定版本、移除、歧义定义、危险路径和非法可见性。
- [x] 1.2 `[REQ-SBP-01, REQ-SBP-08, REQ-SBP-13, REQ-SBP-14]` 新增有期限且不占位的 PreviewSession以及确认后才创建的 ExecutionOperation、目标 Suite 坐标/版本占用、成员结果、幂等字段和索引;通过 PostgreSQL 集成测试验证并发创建/更新唯一性、重启恢复和回滚不会修改现有生命周期数据。
- [x] 1.3 `[REQ-SBP-08, REQ-SBP-13, REQ-SBP-17, REQ-SBP-18]` 新增预览、确认、状态、重试和取消的 API 契约,明确状态读取脱敏和操作重新授权;运行 `make generate-api``scripts/check-openapi-generated.sh` 验证生成类型无漂移。
## 2. Bundle 预览
- [x] 2.1 `[REQ-SBP-05, REQ-SBP-06, REQ-SBP-07]` 实现流式两层解析:先验证唯一 Manifest、目录不重叠、文件唯一归属、逻辑 Skill 唯一和未声明内容,再把每个目录去前缀后交给现有 SkillPackageValidator、元数据与合规校验通过 fixture 覆盖缺失/嵌套 `SKILL.md`、重复规范化路径/坐标、目录嵌套、未声明目录、元数据冲突和单成员限制。
- [x] 2.2 `[REQ-SBP-04, REQ-SBP-15]` 复用现有 fingerprint并以临时对象定位和摘要连接预览与发布保证每个文件最多解压和计算一次通过测试证明 ZIP 元数据、合法外层目录以及 ZIP/目录选择不影响成员边界和比较结果,最大合法 Bundle 不常驻内存。
- [x] 2.3 `[REQ-SBP-01, REQ-SBP-02, REQ-SBP-08, REQ-SBP-10, REQ-SBP-11, REQ-SBP-14]` 批量解析基准成员、精确引用、Suite 创建/管理权限、新 Skill 创建权限、已有 Skill 发布权限、最终可见性/受众兼容、warning、已有待审/未发布版本和目标坐标/版本冲突;通过测试验证 100 个成员时查询次数仍受控,且不会自动确认 warning、撤回或替换现有版本。
- [x] 2.4 `[REQ-SBP-03, REQ-SBP-09]` 分别生成成员关系变化和发布动作,且不产生生命周期副作用;通过测试确认预览不占用目标坐标,也不会创建 Skill、SkillVersion、Suite、SuiteVersion、扫描、审核、Label 或 Tag 变更。
- [x] 2.5 `[REQ-SBP-08]` 将带有效期的预览与操作者、创建/更新模式、目标坐标、归档摘要、精确引用、已解析包版本和计划绑定;通过测试确认过期或相关状态变化后必须重新预览,多个预览可以并存且只有确认事务获取占用。
- [x] 2.6 `[REQ-SBP-05, REQ-SBP-06, REQ-SBP-07, REQ-SBP-15, REQ-SBP-17]` 实现 ZIP 上传和受支持浏览器的根目录选择,保证两者生成同一协议并只上传一次;通过大包、取消、重试和兼容性测试验证内存与网络消耗受控。
- [x] 2.7 `[REQ-SBP-03, REQ-SBP-17, REQ-SBP-18]` 实现 Web 差异预览按成员目录展示格式错误、warning、最终可见性和审核/PRIVATE 发布路径,分别展示新增/更新/不变/移除和创建 Skill/创建版本/复用/引用,突出高风险移除、已有待审版本、加载、过期和无变化状态;通过 Vitest 和浏览器用例验证。
## 3. Bundle 发布编排
- [x] 3.1 `[REQ-SBP-08, REQ-SBP-14]` 实现幂等确认事务,并在任何成员写入前原子获取目标 Suite 坐标/版本占用;通过并发创建、并发更新和响应丢失测试确认最多创建一个操作且不重复创建包版本。
- [x] 3.2 `[REQ-SBP-10, REQ-SBP-11]` 将有 Namespace 创建权限的新 Skill 和有独立发布权限的变化包送入非破坏性的现有 Skill 规则/生命周期边界,并在每次写入前重新授权;验证 Suite 权限不能扩大 Skill 权限,不会撤回或替换已有版本,纯引用和未变化成员不会产生发布副作用。
- [x] 3.3 `[REQ-SBP-12, REQ-SBP-13]` 记录包和引用结果,并按规范状态矩阵通过领域事件与有界恢复任务完成协调;覆盖 PRIVATE `UPLOADED``SCANNING``SCAN_FAILED``PENDING_REVIEW``REJECTED`、外部撤回/删除/下架、`BLOCKED_RETRYABLE``REPREVIEW_REQUIRED`,验证占用随终态原子释放,重复、延迟、丢失和乱序时按原 ID 收敛或明确要求重新预览。
- [x] 3.4 `[REQ-SBP-12]` 仅在包发布成功、引用最终有效且最终权限/状态检查通过时,原子创建新 Suite 及首个 DRAFT或为已有 Suite 创建一个 SuiteVersion DRAFT验证权限撤销、Namespace 冻结、待审核、被拒绝、失败包和失效引用不会产生空 Suite 或部分草稿。
- [x] 3.5 `[REQ-SBP-13, REQ-SBP-15]` 实现带当前授权和响应脱敏的状态读取、原 ID 重试、非破坏性取消、终态占用释放、PreviewSession/暂存对象过期和限定范围补偿清理;验证等待审核的 ExecutionOperation 不随预览过期,已有 SkillVersion 和引用保持不变,清理失败仍有可操作记录。
- [x] 3.6 `[REQ-SBP-17, REQ-SBP-18]` 在“创建 Suite”和“创建新版本”入口提供手工组合/本地导入选择,并实现准确列出副作用、最终可见性、发布路径和逐成员 warning 确认的确认页;确认后统一进入独立发布任务详情页,展示包/引用状态、Skill 审核链接、阻塞原因、重试/停止和生成的 Suite 草稿。“我的套件”以“技能套件/发布任务”页签分离创作结果和过程记录,发布任务通过单次分页数据库聚合按“需要处理/进行中/最近完成”分层展示,保留取消记录,只对运行或等待状态短间隔轮询;任务列表和详情首屏明确展示文本状态及下一步,需要重新预览的更新操作始终返回同一 Suite 的新版本入口。通过正常、等待、可重试阻塞、必须重预览、基准版本已删除、取消后保留记录和成员审核、权限变化、刷新、重新登录、超过单页容量和离开导入页后恢复用例验证。
## 4. 任意成员的所属 Suite
- [x] 4.1 `[REQ-SMD-01]` 保持技能市场和套件专区的路由、API、筛选和卡片相互独立验证现有 `/search``/suites` 测试不变。
- [x] 4.2 `[REQ-SMD-02, REQ-SMD-03, REQ-SMD-04]` 将 Entry-only 查询改为基于 Suite 当前 `latestVersionId` 的集合式、隐私过滤任意成员查询,返回 Entry 标记和有界兄弟成员摘要验证历史已移除成员、PUBLIC、NAMESPACE_ONLY、PRIVATE、隐藏、归档、删除和无权访问场景且无信息泄露或 N+1 查询。
- [x] 4.3 `[REQ-SMD-02, REQ-SMD-03]` 更新 Skill 详情中的所属 Suite 和可见兄弟成员展示通过浏览器验证加载、空态、历史移除、降级、受限、Entry/非 Entry、多 Suite 和响应式状态。
## 5. Suite 展示信息与 Label
- [x] 5.1 `[REQ-SMT-01, REQ-SMT-02, REQ-SMT-03]` 在 Suite 提交、直接发布和批准边界要求非空摘要和概述,同时允许不完整 DRAFT 和历史发布数据读取;通过生命周期测试覆盖所有边界和兼容场景。
- [x] 5.2 `[REQ-SMT-04]` Bundle 从 Manifest 显式值或当前非空值解析展示内容;验证创建/更新模式展示信息不完整时,在发布包副作用发生前阻止确认。
- [x] 5.3 `[REQ-SMT-05, REQ-SMT-06, REQ-SMT-08, REQ-SMT-09]` 新增 Suite-to-Label 关联并复用 Label 定义、本地化和权限类型;通过持久化与权限测试覆盖普通、特权、重复、数量上限和删除行为,确保不修改成员或 Skill Tag。
- [x] 5.4 `[REQ-SMT-05, REQ-SMT-06, REQ-SMT-07, REQ-SMT-09]` 新增 Suite Label 查询、变更和筛选 API 并重新生成 OpenAPI 类型验证批量投影、Suite 专属审计和搜索筛选不会使用成员 Skill 关联。
- [x] 5.5 `[REQ-SMT-01, REQ-SMT-02, REQ-SMT-03, REQ-SMT-05, REQ-SMT-06, REQ-SMT-07]` 在 Suite 编辑、详情、卡片和套件专区增加必填展示信息与 Suite Label通过测试验证草稿校验、历史缺失信息、国际化标签、筛选、权限和响应式状态。
- [x] 5.6 `[REQ-SMT-02, REQ-SMT-03, REQ-SMT-10]` 为 Suite 概述编辑器增加结构化提示,并在详情页增加 Entry Skill 固定版本说明的独立按需展开区域;通过测试验证精确版本、延迟加载、读取权限、安全 Markdown、无权访问和加载失败不会替代或泄露概述内容。
## 6. 兼容性、安全、性能与交付验证
- [ ] 6.6 `[REQ-SBP-18]` 2026-09-16 方案 2统一分页套件清单、紧凑搜索/筛选与轻量需处理提示;验证临时记录合并、取消保留、审核跳转、跨页计数、确认后搜索/取消、固定两条 SQL 和仅变化状态轮询。执行结果见本变更的 `workspace-validation.md`
- [x] 6.1 `[REQ-SBP-16, REQ-SMD-01, REQ-SMT-03, REQ-SMT-08]` 增加单 Skill 发布、Suite 草稿/审核/安装、技能与套件独立发现、单 Skill Label/Tag API 和旧客户端回归覆盖,验证现有契约保持兼容。
- [x] 6.2 `[REQ-SBP-03, REQ-SBP-04, REQ-SBP-05, REQ-SBP-06, REQ-SBP-07, REQ-SBP-08, REQ-SBP-09, REQ-SBP-10, REQ-SBP-11, REQ-SBP-12, REQ-SBP-13, REQ-SBP-14, REQ-SBP-15, REQ-SMD-02, REQ-SMD-03, REQ-SMD-04, REQ-SMT-05, REQ-SMT-06, REQ-SMT-07, REQ-SMT-08, REQ-SMT-09, REQ-SMT-10]` 增加目录/ZIP 等价、归档限制、ZIP traversal/bomb、fingerprint 等价、100 成员批量解析、状态轮询、新 Skill 坐标冲突、重复逻辑 Skill、跨所有者引用、多阶段权限撤销、成员状态矩阵、Label 权限、Suite Label 批量读取和私有信息保护测试,并以确定性断言验证边界。
- [x] 6.3 `[REQ-SBP-01, REQ-SBP-02, REQ-SBP-03, REQ-SBP-04, REQ-SBP-05, REQ-SBP-06, REQ-SBP-07, REQ-SBP-08, REQ-SBP-09, REQ-SBP-10, REQ-SBP-11, REQ-SBP-12, REQ-SBP-13, REQ-SBP-14, REQ-SBP-15, REQ-SBP-16, REQ-SBP-17, REQ-SBP-18, REQ-SMD-01, REQ-SMD-02, REQ-SMD-03, REQ-SMD-04, REQ-SMT-01, REQ-SMT-02, REQ-SMT-03, REQ-SMT-04, REQ-SMT-05, REQ-SMT-06, REQ-SMT-07, REQ-SMT-08, REQ-SMT-09, REQ-SMT-10]` 运行后端、前端、CLI、OpenAPI 漂移检查和 `openspec validate add-suite-bundle-publishing --strict`,按精确 feature SHA 记录结果。
- [x] 6.4 `[REQ-SBP-01, REQ-SBP-03, REQ-SBP-08, REQ-SBP-10, REQ-SBP-11, REQ-SBP-12, REQ-SBP-13, REQ-SBP-14, REQ-SBP-15, REQ-SBP-17, REQ-SBP-18, REQ-SMD-02, REQ-SMD-03, REQ-SMD-04, REQ-SMT-01, REQ-SMT-04, REQ-SMT-05, REQ-SMT-06, REQ-SMT-07, REQ-SMT-09, REQ-SMT-10]` 构建精确 SHA 的 Server/Web 镜像并运行带认证的 release Compose 场景,覆盖创建 Suite、更新 Suite、新建 Skill、已有 Skill 发布/审核、非本人所有引用、协调恢复、Suite 草稿/审核、展示信息校验、Entry Skill 说明权限、Suite Label 和反向引用,同时使用本地 S3 与 Scanner 路径。
- [x] 6.5 `[REQ-SBP-01..18, REQ-SMD-01..04, REQ-SMT-01..10]` 完成独立实现评审、测试设计评审、CI/隐私/就绪检查、人工 Web 复测说明和中文合并报告,再申请合并授权;最终追踪矩阵必须展开每个完整 REQ ID不得只保留范围缩写。

1
openspec/config.yaml Normal file
View file

@ -0,0 +1 @@
schema: spec-driven

View file

@ -0,0 +1,407 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-http://localhost:8080}"
ADMIN_USERNAME="${SMOKE_ADMIN_USERNAME:-}"
ADMIN_PASSWORD="${SMOKE_ADMIN_PASSWORD:-}"
WORK_DIR="$(mktemp -d)"
ADMIN_COOKIE="$(mktemp)"
RECOVERY_COOKIE="$(mktemp)"
USER_COOKIE="$(mktemp)"
TOKEN="$(date +%s)${RANDOM}"
ENTRY_SLUG="bundle-entry-${TOKEN}"
REFERENCE_SLUG="bundle-reference-${TOKEN}"
SUITE_SLUG="bundle-suite-${TOKEN}"
LABEL_SLUG="bundle-label-${TOKEN}"
USER_NAME="bundle_user_${TOKEN}"
USER_PASSWORD="BundleUser${TOKEN}!Aa9"
SUITE_ID=""
ENTRY_SKILL_ID=""
REFERENCE_SKILL_ID=""
LABEL_CREATED=false
json_field() {
JSON_INPUT="$1" python3 - "$2" <<'PY'
import json
import os
import sys
value = json.loads(os.environ["JSON_INPUT"])
for part in sys.argv[1].split("."):
value = value[int(part)] if part.isdigit() else value[part]
print(json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else value)
PY
}
assert_code() {
local description="$1"
local response="$2"
local expected="${3:-0}"
local actual
actual="$(json_field "$response" code)"
if [[ "$actual" != "$expected" ]]; then
echo "FAIL: $description (expected code $expected, got $actual)" >&2
exit 1
fi
echo "PASS: $description"
}
csrf_token() {
awk '$6 == "XSRF-TOKEN" { print $7 }' "$1" | tail -n 1
}
bootstrap_cookie() {
curl -sS -c "$1" "$BASE_URL/api/v1/auth/me" >/dev/null
}
login_admin() {
local cookie_file="$1"
bootstrap_cookie "$cookie_file"
local csrf
csrf="$(csrf_token "$cookie_file")"
local response
response="$(curl -fsS -b "$cookie_file" -c "$cookie_file" \
-H "X-XSRF-TOKEN: $csrf" -H "Content-Type: application/json" \
-X POST "$BASE_URL/api/v1/auth/local/login" \
-d "{\"username\":\"$ADMIN_USERNAME\",\"password\":\"$ADMIN_PASSWORD\"}")"
assert_code "authenticate local administrator" "$response"
}
cleanup() {
local csrf=""
csrf="$(csrf_token "$ADMIN_COOKIE" || true)"
if [[ -n "$csrf" ]]; then
if [[ -n "$SUITE_ID" ]]; then
curl -sS -o /dev/null -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $csrf" \
-X DELETE "$BASE_URL/api/web/suites/$SUITE_ID" || true
fi
if [[ -n "$ENTRY_SKILL_ID" ]]; then
curl -sS -o /dev/null -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $csrf" \
-X DELETE "$BASE_URL/api/v1/skills/id/$ENTRY_SKILL_ID" || true
fi
if [[ -n "$REFERENCE_SKILL_ID" ]]; then
curl -sS -o /dev/null -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $csrf" \
-X DELETE "$BASE_URL/api/v1/skills/id/$REFERENCE_SKILL_ID" || true
fi
if [[ "$LABEL_CREATED" == true ]]; then
curl -sS -o /dev/null -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $csrf" \
-X DELETE "$BASE_URL/api/v1/admin/labels/$LABEL_SLUG" || true
fi
fi
rm -f "$ADMIN_COOKIE" "$RECOVERY_COOKIE" "$USER_COOKIE"
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
if [[ -z "$ADMIN_USERNAME" || -z "$ADMIN_PASSWORD" ]]; then
echo "FAIL: SMOKE_ADMIN_USERNAME and SMOKE_ADMIN_PASSWORD are required" >&2
exit 1
fi
make_skill_zip() {
local slug="$1"
local version="$2"
local output="$3"
SLUG="$slug" VERSION="$version" OUTPUT="$output" python3 - "$WORK_DIR" <<'PY'
from pathlib import Path
import os
import sys
import zipfile
root = Path(sys.argv[1])
skill_md = root / f"{os.environ['SLUG']}-{os.environ['VERSION']}.md"
skill_md.write_text(
"---\n"
f"name: {os.environ['SLUG']}\n"
f"description: Release Compose Bundle smoke member {os.environ['SLUG']}\n"
f"version: {os.environ['VERSION']}\n"
"---\n\n# Bundle smoke member\n",
encoding="utf-8",
)
with zipfile.ZipFile(os.environ["OUTPUT"], "w", zipfile.ZIP_DEFLATED) as archive:
archive.write(skill_md, "SKILL.md")
PY
}
make_bundle_zip() {
local mode="$1"
local version="$2"
local base_version="$3"
local output="$4"
MODE="$mode" VERSION="$version" BASE_VERSION="$base_version" OUTPUT="$output" \
SUITE_SLUG="$SUITE_SLUG" ENTRY_SLUG="$ENTRY_SLUG" REFERENCE_SLUG="$REFERENCE_SLUG" \
python3 - "$WORK_DIR" <<'PY'
from pathlib import Path
import os
import sys
import zipfile
root = Path(sys.argv[1])
member = root / f"entry-{os.environ['VERSION']}.md"
member.write_text(
"---\n"
f"name: {os.environ['ENTRY_SLUG']}\n"
"description: Entry member created and updated through Suite Bundle smoke\n"
f"version: {os.environ['VERSION']}\n"
"---\n\n# Bundle entry\n",
encoding="utf-8",
)
base = "" if not os.environ["BASE_VERSION"] else f" baseVersion: {os.environ['BASE_VERSION']}\n"
manifest = (
"apiVersion: skillhub.iflytek.com/v1alpha1\n"
"kind: SkillSuiteBundle\n"
"metadata:\n"
" namespace: global\n"
f" slug: {os.environ['SUITE_SLUG']}\n"
"spec:\n"
f" mode: {os.environ['MODE']}\n"
f" version: {os.environ['VERSION']}\n"
f"{base}"
" displayName: Release Compose Bundle smoke\n"
" summary: Authenticated release Compose Bundle smoke workflow\n"
" overview: |\n"
" # Release Compose Bundle smoke\n\n"
" Creates and updates a Suite with one package and one exact reference.\n"
" visibility: PUBLIC\n"
f" entry: \"@global/{os.environ['ENTRY_SLUG']}\"\n"
" members:\n"
f" - skill: \"@global/{os.environ['ENTRY_SLUG']}\"\n"
" package:\n"
" path: skills/entry\n"
" visibility: PUBLIC\n"
f" - skill: \"@global/{os.environ['REFERENCE_SLUG']}\"\n"
" reference:\n"
" version: 1.0.0\n"
)
with zipfile.ZipFile(os.environ["OUTPUT"], "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr("SUITE.yaml", manifest)
archive.write(member, "skills/entry/SKILL.md")
PY
}
poll_skill_status() {
local cookie_file="$1"
local slug="$2"
local expected="$3"
local response=""
for _ in $(seq 1 120); do
response="$(curl -fsS -b "$cookie_file" "$BASE_URL/api/web/skills/global/$slug")"
if JSON_INPUT="$response" EXPECTED="$expected" python3 - <<'PY'
import json
import os
data = json.loads(os.environ["JSON_INPUT"]).get("data") or {}
versions = [data.get("headlineVersion") or {}, data.get("ownerPreviewVersion") or {}, data.get("publishedVersion") or {}]
raise SystemExit(0 if any(item.get("status") == os.environ["EXPECTED"] for item in versions) else 1)
PY
then
printf '%s' "$response"
return 0
fi
sleep 1
done
echo "FAIL: $slug did not reach $expected" >&2
return 1
}
poll_operation() {
local cookie_file="$1"
local operation_id="$2"
local response=""
for _ in $(seq 1 120); do
response="$(curl -fsS -b "$cookie_file" "$BASE_URL/api/web/suite-bundles/operations/$operation_id")"
if [[ "$(json_field "$response" data.status)" == "SUITE_DRAFT_CREATED" ]]; then
printf '%s' "$response"
return 0
fi
sleep 1
done
echo "FAIL: Bundle operation $operation_id did not create a Suite draft" >&2
return 1
}
echo "=== Suite Bundle Release Compose Smoke Test ==="
echo "Target: $BASE_URL"
echo "Suite: @global/$SUITE_SLUG"
login_admin "$ADMIN_COOKIE"
ADMIN_CSRF="$(csrf_token "$ADMIN_COOKIE")"
bootstrap_cookie "$USER_COOKIE"
USER_CSRF="$(csrf_token "$USER_COOKIE")"
REGISTER_RESPONSE="$(curl -fsS -b "$USER_COOKIE" -c "$USER_COOKIE" \
-H "X-XSRF-TOKEN: $USER_CSRF" -H "Content-Type: application/json" \
-X POST "$BASE_URL/api/v1/auth/local/register" \
-d "{\"username\":\"$USER_NAME\",\"password\":\"$USER_PASSWORD\",\"email\":\"$USER_NAME@example.test\"}")"
assert_code "register a non-admin Skill owner" "$REGISTER_RESPONSE"
USER_CSRF="$(csrf_token "$USER_COOKIE")"
make_skill_zip "$REFERENCE_SLUG" 1.0.0 "$WORK_DIR/reference.zip"
REFERENCE_PUBLISH="$(curl -fsS -b "$USER_COOKIE" -c "$USER_COOKIE" \
-H "X-XSRF-TOKEN: $USER_CSRF" \
-F "file=@$WORK_DIR/reference.zip;type=application/zip" -F "visibility=PUBLIC" \
"$BASE_URL/api/web/skills/global/publish")"
assert_code "non-admin publishes reference Skill for review" "$REFERENCE_PUBLISH"
REFERENCE_SKILL_ID="$(json_field "$REFERENCE_PUBLISH" data.skillId)"
poll_skill_status "$USER_COOKIE" "$REFERENCE_SLUG" PENDING_REVIEW >/dev/null
echo "PASS: reference Skill reaches PENDING_REVIEW"
GLOBAL_NAMESPACE="$(curl -fsS -b "$ADMIN_COOKIE" "$BASE_URL/api/web/namespaces/global")"
GLOBAL_NAMESPACE_ID="$(json_field "$GLOBAL_NAMESPACE" data.id)"
REVIEWS="$(curl -fsS -b "$ADMIN_COOKIE" "$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$GLOBAL_NAMESPACE_ID")"
REVIEW_ID="$(JSON_INPUT="$REVIEWS" SLUG="$REFERENCE_SLUG" python3 - <<'PY'
import json
import os
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
match = next((item for item in items if item["skillSlug"] == os.environ["SLUG"]), None)
print(match["id"] if match else "")
PY
)"
[[ -n "$REVIEW_ID" ]] || { echo "FAIL: pending review was not found" >&2; exit 1; }
APPROVE="$(curl -fsS -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $ADMIN_CSRF" \
-H "Content-Type: application/json" -X POST \
"$BASE_URL/api/web/reviews/$REVIEW_ID/approve" -d '{"comment":"release compose Bundle smoke"}')"
assert_code "administrator approves the foreign-owned reference Skill" "$APPROVE"
poll_skill_status "$USER_COOKIE" "$REFERENCE_SLUG" PUBLISHED >/dev/null
echo "PASS: foreign-owned reference Skill is PUBLISHED"
run_bundle() {
local mode="$1"
local version="$2"
local base_version="$3"
local archive="$WORK_DIR/bundle-${version}.zip"
make_bundle_zip "$mode" "$version" "$base_version" "$archive"
local preview
preview="$(curl -fsS -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $ADMIN_CSRF" \
-F "file=@$archive;type=application/zip" "$BASE_URL/api/web/suite-bundles/preview")"
assert_code "$mode Bundle preview" "$preview"
JSON_INPUT="$preview" MODE="$mode" VERSION="$version" ENTRY="$ENTRY_SLUG" REFERENCE="$REFERENCE_SLUG" python3 - <<'PY'
import json
import os
data = json.loads(os.environ["JSON_INPUT"])["data"]
members = {item["coordinate"]: item for item in data["members"]}
assert data["confirmable"] is True
assert data["target"]["mode"] == os.environ["MODE"]
assert data["target"]["targetVersion"] == os.environ["VERSION"]
entry = members[f"@global/{os.environ['ENTRY']}"]
reference = members[f"@global/{os.environ['REFERENCE']}"]
assert entry["sourceType"] == "PACKAGE"
assert entry["packagePath"] == "skills/entry"
assert entry["publishAction"] in {"CREATE_SKILL", "CREATE_VERSION"}
assert reference["sourceType"] == "REFERENCE"
assert reference["publishAction"] == "REFERENCE_VERSION"
PY
echo "PASS: $mode preview exposes package path, actions, and exact foreign reference"
local token digest confirmation operation_id
token="$(json_field "$preview" data.previewToken)"
digest="$(json_field "$preview" data.warningDigest)"
confirmation="$(curl -fsS -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $ADMIN_CSRF" \
-H "Content-Type: application/json" -H "Idempotency-Key: $mode-$TOKEN" \
-X POST "$BASE_URL/api/web/suite-bundles/previews/$token/confirm" \
-d "{\"warningDigest\":\"$digest\"}")"
assert_code "$mode Bundle confirmation" "$confirmation"
operation_id="$(json_field "$confirmation" data.operationId)"
login_admin "$RECOVERY_COOKIE"
local recovered
recovered="$(poll_operation "$RECOVERY_COOKIE" "$operation_id")"
assert_code "$mode operation is recoverable after a fresh login" "$recovered"
echo "PASS: $mode operation reaches SUITE_DRAFT_CREATED"
}
publish_public_suite() {
local version_id="$1"
local submit reviews review_id approve
submit="$(curl -fsS -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $ADMIN_CSRF" \
-X POST "$BASE_URL/api/web/suites/$SUITE_ID/versions/$version_id/submit")"
assert_code "submit public Suite version for review" "$submit"
reviews="$(curl -fsS -b "$ADMIN_COOKIE" \
"$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$GLOBAL_NAMESPACE_ID")"
review_id="$(JSON_INPUT="$reviews" VERSION_ID="$version_id" python3 - <<'PY'
import json
import os
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
match = next((item for item in items
if item.get("subjectType") == "SUITE_VERSION"
and str(item.get("subjectVersionId")) == os.environ["VERSION_ID"]), None)
print(match["id"] if match else "")
PY
)"
[[ -n "$review_id" ]] || { echo "FAIL: pending Suite review was not found" >&2; exit 1; }
approve="$(curl -fsS -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $ADMIN_CSRF" \
-H "Content-Type: application/json" -X POST \
"$BASE_URL/api/web/suites/reviews/$review_id/approve" \
-d '{"comment":"release compose Bundle smoke"}')"
assert_code "approve public Suite version review" "$approve"
}
run_bundle CREATE 1.0.0 ""
CREATE_DETAIL="$(curl -fsS -b "$ADMIN_COOKIE" "$BASE_URL/api/web/suites/global/$SUITE_SLUG?version=1.0.0")"
assert_code "load created Suite draft" "$CREATE_DETAIL"
SUITE_ID="$(json_field "$CREATE_DETAIL" data.id)"
SUITE_VERSION_ID="$(json_field "$CREATE_DETAIL" data.versionId)"
ENTRY_SKILL_ID="$(json_field "$CREATE_DETAIL" data.members.0.skillId)"
LABEL_CREATE="$(curl -fsS -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $ADMIN_CSRF" \
-H "Content-Type: application/json" -X POST "$BASE_URL/api/v1/admin/labels" \
-d "{\"slug\":\"$LABEL_SLUG\",\"type\":\"RECOMMENDED\",\"visibleInFilter\":true,\"sortOrder\":10,\"translations\":[{\"locale\":\"en\",\"displayName\":\"Bundle smoke\"}]}")"
assert_code "create Suite smoke label definition" "$LABEL_CREATE"
LABEL_CREATED=true
LABEL_ATTACH="$(curl -fsS -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $ADMIN_CSRF" \
-X PUT "$BASE_URL/api/web/suites/global/$SUITE_SLUG/labels/$LABEL_SLUG")"
assert_code "attach direct Suite label" "$LABEL_ATTACH"
publish_public_suite "$SUITE_VERSION_ID"
ENTRY_DETAIL="$(curl -fsS -b "$ADMIN_COOKIE" "$BASE_URL/api/web/skills/global/$ENTRY_SLUG")"
JSON_INPUT="$ENTRY_DETAIL" SUITE="$SUITE_SLUG" python3 - <<'PY'
import json
import os
items = json.loads(os.environ["JSON_INPUT"])["data"]["memberOfSuites"]["items"]
match = next(item for item in items if item["slug"] == os.environ["SUITE"])
assert match["currentSkillEntry"] is True
PY
echo "PASS: Entry Skill reverse discovery identifies the published Suite"
REFERENCE_DETAIL="$(curl -fsS -b "$USER_COOKIE" "$BASE_URL/api/web/skills/global/$REFERENCE_SLUG")"
JSON_INPUT="$REFERENCE_DETAIL" SUITE="$SUITE_SLUG" python3 - <<'PY'
import json
import os
items = json.loads(os.environ["JSON_INPUT"])["data"]["memberOfSuites"]["items"]
match = next(item for item in items if item["slug"] == os.environ["SUITE"])
assert match["currentSkillEntry"] is False
PY
echo "PASS: non-entry foreign Skill reverse discovery identifies the published Suite"
ENTRY_FILE="$(curl -fsS -b "$ADMIN_COOKIE" \
"$BASE_URL/api/web/skills/global/$ENTRY_SLUG/versions/1.0.0/file?path=SKILL.md")"
if [[ "$ENTRY_FILE" != *"# Bundle entry"* ]]; then
echo "FAIL: pinned Entry Skill instructions do not contain the expected content" >&2
exit 1
fi
echo "PASS: read the pinned Entry Skill instructions"
run_bundle UPDATE 1.1.0 1.0.0
UPDATE_DETAIL="$(curl -fsS -b "$ADMIN_COOKIE" "$BASE_URL/api/web/suites/global/$SUITE_SLUG?version=1.1.0")"
assert_code "load updated Suite draft" "$UPDATE_DETAIL"
UPDATE_VERSION_ID="$(json_field "$UPDATE_DETAIL" data.versionId)"
publish_public_suite "$UPDATE_VERSION_ID"
LABELS="$(curl -fsS -b "$ADMIN_COOKIE" "$BASE_URL/api/web/suites/global/$SUITE_SLUG/labels")"
JSON_INPUT="$LABELS" LABEL="$LABEL_SLUG" python3 - <<'PY'
import json
import os
items = json.loads(os.environ["JSON_INPUT"])["data"]
assert any(item["slug"] == os.environ["LABEL"] for item in items)
PY
echo "PASS: Suite label persists after Bundle version update"
echo "=== Suite Bundle Release Compose Smoke Test Passed ==="

View file

@ -216,6 +216,7 @@ print(json.dumps({
"slug": sys.argv[1],
"displayName": "Suite smoke test",
"summary": "Temporary private Suite",
"overview": "## Smoke test\n\nValidates exact member publication and installation.",
"version": "1.0.0",
"visibility": "PRIVATE",
"changelog": "Initial smoke version",
@ -233,6 +234,15 @@ payload.pop("entrySkill")
print(json.dumps(payload))
PY
)"
INCOMPLETE_METADATA_PAYLOAD="$(JSON_INPUT="$SUITE_PAYLOAD" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["JSON_INPUT"])
payload.pop("overview")
print(json.dumps(payload))
PY
)"
MISSING_ENTRY_STATUS="$(curl -sS -o "$WORK_DIR/missing-entry.json" -w '%{http_code}' \
-b "$COOKIE_FILE" -c "$COOKIE_FILE" "${AUTH_HEADERS[@]}" \
-H "X-XSRF-TOKEN: $CSRF_TOKEN" -H "Content-Type: application/json" \
@ -246,11 +256,32 @@ echo "PASS: creating a Suite without an Entry Skill is rejected"
CREATE_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Content-Type: application/json" -X POST "$BASE_URL/api/web/suites" \
-d "$SUITE_PAYLOAD")"
assert_code "create a Suite draft with one exact member" "$CREATE_RESPONSE" 0
-d "$INCOMPLETE_METADATA_PAYLOAD")"
assert_code "save an incomplete Suite draft with one exact member" "$CREATE_RESPONSE" 0
SUITE_ID="$(json_field "$CREATE_RESPONSE" data.id)"
SUITE_VERSION_ID="$(json_field "$CREATE_RESPONSE" data.versionId)"
INCOMPLETE_PUBLISH_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-X POST "$BASE_URL/api/web/suites/$SUITE_ID/versions/$SUITE_VERSION_ID/publish")"
assert_code "reject publishing a Suite draft without an overview" "$INCOMPLETE_PUBLISH_RESPONSE" 400
INCOMPLETE_DETAIL="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" "$BASE_URL/api/web/suites/global/$SUITE_SLUG?version=1.0.0")"
assert_code "reload the incomplete Suite draft" "$INCOMPLETE_DETAIL" 0
if [[ "$(json_field "$INCOMPLETE_DETAIL" data.status)" != "DRAFT" ]]; then
echo "FAIL: incomplete Suite should remain DRAFT"
exit 1
fi
echo "PASS: incomplete Suite remains DRAFT"
UPDATE_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Content-Type: application/json" -X PUT \
"$BASE_URL/api/web/suites/$SUITE_ID/versions/$SUITE_VERSION_ID" \
-d "$SUITE_PAYLOAD")"
assert_code "complete the Suite draft metadata" "$UPDATE_RESPONSE" 0
PUBLISH_SUITE_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-X POST "$BASE_URL/api/web/suites/$SUITE_ID/versions/$SUITE_VERSION_ID/publish")"

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.config;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifestParser;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -25,6 +26,11 @@ public class DomainBeanConfig {
return new SkillMetadataParser();
}
@Bean
public SkillSuiteBundleManifestParser skillSuiteBundleManifestParser() {
return new SkillSuiteBundleManifestParser();
}
@Bean
public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser,
SkillPublishProperties skillPublishProperties) {

View file

@ -0,0 +1,34 @@
package com.iflytek.skillhub.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
/** Runtime controls for staged Suite Bundle previews and execution rollout. */
@Component
@ConfigurationProperties(prefix = "skillhub.suite.bundle")
public class SkillSuiteBundleProperties {
private Duration previewTtl = Duration.ofMinutes(30);
private boolean confirmationEnabled;
public Duration getPreviewTtl() {
return previewTtl;
}
public void setPreviewTtl(Duration previewTtl) {
if (previewTtl == null || previewTtl.isZero() || previewTtl.isNegative()) {
throw new IllegalArgumentException("skillhub.suite.bundle.preview-ttl must be positive");
}
this.previewTtl = previewTtl;
}
public boolean isConfirmationEnabled() {
return confirmationEnabled;
}
public void setConfirmationEnabled(boolean confirmationEnabled) {
this.confirmationEnabled = confirmationEnabled;
}
}

View file

@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.MySkillSuiteSummaryResponse;
import com.iflytek.skillhub.dto.MySkillSuiteWorkspaceResponse;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.service.SkillSuiteAppService;
import io.swagger.v3.oas.annotations.Operation;
@ -42,4 +43,18 @@ public class MySkillSuiteController extends BaseApiController {
return ok("response.success.read", appService.listMine(
userId, roles == null ? Map.of() : roles, q, page, size));
}
@GetMapping("/workspace")
@Operation(operationId = "listMySkillSuiteWorkspace", summary = "List owner workbench with merged Suite creation progress")
public ApiResponse<MySkillSuiteWorkspaceResponse> workspace(
@RequestParam(required = false) String q,
@RequestParam(required = false) String state,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "12") int size,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles
) {
return ok("response.success.read", appService.workspace(
userId, roles == null ? Map.of() : roles, q, state, page, size));
}
}

View file

@ -10,6 +10,7 @@ import com.iflytek.skillhub.service.ResourceDiscoveryAppService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.Map;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
@ -43,10 +44,11 @@ public class ResourceDiscoveryController extends BaseApiController {
@RequestParam(defaultValue = "newest") String sort,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(name = "label", required = false) List<String> labels,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles
) {
return ok("response.success.read", appService.search(
q, namespace, resourceType, sort, page, size,
roles == null ? java.util.Set.of() : roles.keySet()));
roles == null ? java.util.Set.of() : roles.keySet(), labels));
}
}

View file

@ -143,6 +143,7 @@ public class ReviewController extends BaseApiController {
@GetMapping("/my-progress")
public ApiResponse<ReviewProgressPageResponse> listMyProgress(
@RequestParam(required = false) String subjectType,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "") String q,
@RequestParam(defaultValue = "0") int page,
@ -150,7 +151,7 @@ public class ReviewController extends BaseApiController {
@RequestAttribute("userId") String userId) {
return ok(
"response.success.read",
governanceWorkflowAppService.listMyReviewProgress(status, q, page, size, userId)
governanceWorkflowAppService.listMyReviewProgress(subjectType, status, q, page, size, userId)
);
}

View file

@ -15,6 +15,7 @@ import com.iflytek.skillhub.dto.ResolveVersionResponse;
import com.iflytek.skillhub.dto.SkillDetailResponse;
import com.iflytek.skillhub.dto.SkillFileResponse;
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
import com.iflytek.skillhub.dto.SkillSuiteReferenceResponse;
import com.iflytek.skillhub.dto.SkillVersionCompareFileResponse;
import com.iflytek.skillhub.dto.SkillVersionCompareHunkResponse;
import com.iflytek.skillhub.dto.SkillVersionCompareLineResponse;
@ -89,8 +90,13 @@ public class SkillController extends BaseApiController {
@AuthenticationPrincipal PlatformPrincipal principal) {
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
Set<String> platformRoles = principal == null || principal.platformRoles() == null
? Set.of() : principal.platformRoles();
SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail(
namespace, slug, userId, namespaceRoles);
PageResponse<SkillSuiteReferenceResponse> memberOfSuites =
skillSuiteAppService.findVisibleMemberships(
detail.id(), userId, namespaceRoles, platformRoles, 0, 20);
SkillDetailResponse response = new SkillDetailResponse(
detail.id(),
@ -118,15 +124,36 @@ public class SkillController extends BaseApiController {
toLifecycleVersion(detail.ownerPreviewVersion()),
detail.ownerPreviewReviewComment(),
detail.resolutionMode(),
skillSuiteAppService.findVisibleEntryReferences(
detail.id(), userId, namespaceRoles,
principal == null || principal.platformRoles() == null
? Set.of() : principal.platformRoles())
memberOfSuites.items().stream()
.filter(SkillSuiteReferenceResponse::currentSkillEntry)
.toList(),
memberOfSuites
);
return ok("response.success.read", response);
}
/** Returns a bounded page of visible current Suite snapshots containing this Skill. */
@GetMapping("/{namespace}/{slug}/suite-memberships")
public ApiResponse<PageResponse<SkillSuiteReferenceResponse>>
listSuiteMemberships(
@PathVariable String namespace,
@PathVariable String slug,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false)
Map<Long, NamespaceRole> userNsRoles,
@AuthenticationPrincipal PlatformPrincipal principal) {
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
Set<String> platformRoles = principal == null || principal.platformRoles() == null
? Set.of() : principal.platformRoles();
SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail(
namespace, slug, userId, namespaceRoles);
return ok("response.success.read", skillSuiteAppService.findVisibleMemberships(
detail.id(), userId, namespaceRoles, platformRoles, page, size));
}
/**
* Lists versions visible to the caller rather than every persisted version
* of the skill.

View file

@ -0,0 +1,164 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
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.PageResponse;
import com.iflytek.skillhub.dto.SkillSuiteBundleConfirmRequest;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationDetailResponse;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationPageResponse;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationResponse;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationSummaryResponse;
import com.iflytek.skillhub.dto.SkillSuiteBundlePreviewResponse;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleConfirmationAppService;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleOperationCommandService;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleOperationQueryService;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundlePreviewAppService;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleResponseMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
/** Transport-only endpoints for the two-stage Suite Bundle import workflow. */
@RestController
@Tag(name = "Skill Suite Bundles")
@RequestMapping({"/api/v1/suite-bundles", "/api/web/suite-bundles"})
public class SkillSuiteBundleController extends BaseApiController {
private final SkillSuiteBundlePreviewAppService previewService;
private final SkillSuiteBundleConfirmationAppService confirmationService;
private final SkillSuiteBundleOperationQueryService operationQueryService;
private final SkillSuiteBundleOperationCommandService operationCommandService;
private final SkillSuiteBundleResponseMapper responseMapper;
public SkillSuiteBundleController(
SkillSuiteBundlePreviewAppService previewService,
SkillSuiteBundleConfirmationAppService confirmationService,
SkillSuiteBundleOperationQueryService operationQueryService,
SkillSuiteBundleOperationCommandService operationCommandService,
SkillSuiteBundleResponseMapper responseMapper,
ApiResponseFactory responseFactory
) {
super(responseFactory);
this.previewService = previewService;
this.confirmationService = confirmationService;
this.operationQueryService = operationQueryService;
this.operationCommandService = operationCommandService;
this.responseMapper = responseMapper;
}
@PostMapping(value = "/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
operationId = "previewSkillSuiteBundle",
summary = "Validate and preview one Suite Bundle archive",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true)
)
@RateLimit(category = "publish", authenticated = 10, anonymous = 0)
public ApiResponse<SkillSuiteBundlePreviewResponse> preview(
@RequestPart("file") MultipartFile file,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal
) throws IOException {
return ok("response.success.read", responseMapper.toResponse(previewService.preview(
file, userId, roles == null ? Map.of() : roles, platformRoles(principal))));
}
@PostMapping("/previews/{previewToken}/confirm")
@Operation(operationId = "confirmSkillSuiteBundle", summary = "Confirm one exact Suite Bundle preview")
@RateLimit(category = "publish", authenticated = 10, anonymous = 0)
public ApiResponse<SkillSuiteBundleOperationResponse> confirm(
@PathVariable String previewToken,
@RequestHeader("Idempotency-Key") String clientRequestId,
@Valid @RequestBody SkillSuiteBundleConfirmRequest request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal
) {
return ok("response.success.created", responseMapper.toResponse(confirmationService.confirm(
previewToken, clientRequestId, request.warningDigest(), userId,
roles == null ? Map.of() : roles, platformRoles(principal))));
}
@GetMapping("/operations/{operationId}")
@Operation(operationId = "getSkillSuiteBundleOperation", summary = "Get one authorized Suite Bundle operation")
public ApiResponse<SkillSuiteBundleOperationDetailResponse> getOperation(
@PathVariable String operationId,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal
) {
return ok("response.success.read", operationQueryService.get(
operationId, userId, roles == null ? Map.of() : roles, platformRoles(principal)));
}
@GetMapping("/operations/active")
@Operation(operationId = "listActiveSkillSuiteBundleOperations", summary = "List active Bundle operations started by the current user")
public ApiResponse<PageResponse<SkillSuiteBundleOperationSummaryResponse>> listActiveOperations(
@RequestAttribute("userId") String userId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "12") int size
) {
return ok("response.success.read", operationQueryService.listActive(userId, page, size));
}
@GetMapping("/operations/mine")
@Operation(operationId = "listMySkillSuiteBundleOperations", summary = "List current and completed Bundle operations started by the current user")
public ApiResponse<SkillSuiteBundleOperationPageResponse> listMyOperations(
@RequestAttribute("userId") String userId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "12") int size
) {
return ok("response.success.read", operationQueryService.listMine(userId, page, size));
}
@PostMapping("/operations/{operationId}/cancel")
@Operation(operationId = "cancelSkillSuiteBundleOperation", summary = "Cancel one active Suite Bundle operation")
public ApiResponse<SkillSuiteBundleOperationResponse> cancelOperation(
@PathVariable String operationId,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal
) {
return ok("response.success.updated", operationCommandService.cancel(
operationId, userId, roles == null ? Map.of() : roles, platformRoles(principal)));
}
@PostMapping("/operations/{operationId}/retry")
@Operation(operationId = "retrySkillSuiteBundleOperation", summary = "Retry one blocked Suite Bundle operation")
public ApiResponse<SkillSuiteBundleOperationResponse> retryOperation(
@PathVariable String operationId,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal
) {
return ok("response.success.updated", operationCommandService.retry(
operationId, userId, roles == null ? Map.of() : roles, platformRoles(principal)));
}
private Set<String> platformRoles(PlatformPrincipal principal) {
return principal == null || principal.platformRoles() == null
? Set.of()
: principal.platformRoles();
}
}

View file

@ -0,0 +1,100 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
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.MessageResponse;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.SkillSuiteLabelAppService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** Transport-only endpoints for direct Suite-to-Label associations. */
@RestController
@Tag(name = "Skill Suite Labels")
@RequestMapping({
"/api/v1/suites/{namespace}/{slug}/labels",
"/api/web/suites/{namespace}/{slug}/labels"
})
public class SkillSuiteLabelController extends BaseApiController {
private final SkillSuiteLabelAppService appService;
public SkillSuiteLabelController(
SkillSuiteLabelAppService appService,
ApiResponseFactory responseFactory
) {
super(responseFactory);
this.appService = appService;
}
@GetMapping
@Operation(operationId = "listSkillSuiteLabels", summary = "List direct labels on one visible Suite")
public ApiResponse<List<SkillLabelDto>> listLabels(
@PathVariable String namespace,
@PathVariable String slug,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles,
@AuthenticationPrincipal PlatformPrincipal principal
) {
return ok("response.success.read", appService.listLabels(
namespace, slug, userId, roles(namespaceRoles), platformRoles(principal)));
}
@PutMapping("/{labelSlug}")
@Operation(operationId = "attachSkillSuiteLabel", summary = "Attach an existing Registry label to a Suite")
public ApiResponse<SkillLabelDto> attachLabel(
@PathVariable String namespace,
@PathVariable String slug,
@PathVariable String labelSlug,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request
) {
return ok("response.success.updated", appService.attachLabel(
namespace, slug, labelSlug, userId, roles(namespaceRoles),
platformRoles(principal), AuditRequestContext.from(request)));
}
@DeleteMapping("/{labelSlug}")
@Operation(operationId = "detachSkillSuiteLabel", summary = "Detach a Registry label from a Suite")
public ApiResponse<MessageResponse> detachLabel(
@PathVariable String namespace,
@PathVariable String slug,
@PathVariable String labelSlug,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request
) {
return ok("response.success.deleted", appService.detachLabel(
namespace, slug, labelSlug, userId, roles(namespaceRoles),
platformRoles(principal), AuditRequestContext.from(request)));
}
private Map<Long, NamespaceRole> roles(Map<Long, NamespaceRole> roles) {
return roles == null ? Map.of() : roles;
}
private Set<String> platformRoles(PlatformPrincipal principal) {
return principal == null || principal.platformRoles() == null
? Set.of()
: principal.platformRoles();
}
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
import java.util.List;
/** Paginated owner workbench, including creation operations before a Suite exists. */
public record MySkillSuiteWorkspaceResponse(
List<Item> items, long total, int page, int size,
long attentionCount, boolean hasChangingOperations
) {
public record Item(
Long suiteId, String namespace, String slug, String displayName, String summary,
String version, String suiteVersion, String state, Instant updatedAt,
String operationId, String operationStatus, String failureCode
) {
}
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
import java.util.List;
/** Type-explicit discovery item used by new clients without changing the legacy Skill search API. */
public record ResourceSummaryResponse(
@ -15,6 +16,7 @@ public record ResourceSummaryResponse(
String visibility,
long installCount,
boolean available,
Instant updatedAt
Instant updatedAt,
List<SkillLabelDto> labels
) {
}

View file

@ -29,5 +29,6 @@ public record SkillDetailResponse(
SkillLifecycleVersionResponse ownerPreviewVersion,
String ownerPreviewReviewComment,
String resolutionMode,
List<SkillSuiteReferenceResponse> entryForSuites
List<SkillSuiteReferenceResponse> entryForSuites,
PageResponse<SkillSuiteReferenceResponse> memberOfSuites
) {}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record SkillSuiteBundleConfirmRequest(
@NotBlank String warningDigest
) {
}

View file

@ -0,0 +1,48 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange;
import java.time.Instant;
import java.util.List;
public record SkillSuiteBundleOperationDetailResponse(
String operationId,
SkillSuiteBundleOperationStatus status,
SkillSuiteBundleMode mode,
String targetCoordinate,
Long targetNamespaceId,
Long targetSuiteId,
String targetVersion,
String baseVersion,
String failureCode,
Long resultSuiteId,
Long resultSuiteVersionId,
Instant createdAt,
Instant updatedAt,
Instant completedAt,
List<OperationMember> members
) {
public record OperationMember(
int position,
boolean redacted,
String coordinate,
SkillSuiteBundleMemberSourceType sourceType,
String packagePath,
SkillSuiteBundleRelationshipChange relationship,
SkillSuiteBundlePublishAction publishAction,
SkillSuiteBundleMemberResultStatus status,
SkillVisibility visibility,
String version,
Long skillId,
Long skillVersionId,
List<String> errors,
List<String> warnings
) {
}
}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.dto;
import java.util.List;
/** One prioritized page of Bundle tasks plus collection-wide polling state. */
public record SkillSuiteBundleOperationPageResponse(
List<SkillSuiteBundleOperationSummaryResponse> items,
long total,
int page,
int size,
boolean hasChangingOperations
) {
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
public record SkillSuiteBundleOperationResponse(
String operationId,
String status,
boolean replayed
) {
}

View file

@ -0,0 +1,22 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
import java.time.Instant;
/** Bundle operation summary shown in the current user's publishing task list. */
public record SkillSuiteBundleOperationSummaryResponse(
String operationId,
SkillSuiteBundleMode mode,
String targetCoordinate,
String targetVersion,
SkillSuiteBundleOperationStatus status,
String failureCode,
String baseVersion,
int totalMembers,
int completedMembers,
int waitingMembers,
Instant updatedAt
) {
}

View file

@ -0,0 +1,61 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange;
import java.time.Instant;
import java.util.List;
public record SkillSuiteBundlePreviewResponse(
String previewToken,
Instant expiresAt,
boolean confirmable,
Target target,
List<PreviewMember> members,
List<RemovedMember> removedMembers,
List<String> errors,
List<String> warnings,
String warningDigest
) {
public record Target(
SkillSuiteBundleMode mode,
String coordinate,
Long namespaceId,
Long suiteId,
Long baseSuiteVersionId,
String targetVersion,
String displayName,
String summary,
String overview,
SkillVisibility visibility
) {
}
public record PreviewMember(
String coordinate,
SkillSuiteBundleMemberSourceType sourceType,
String packagePath,
SkillSuiteBundleRelationshipChange relationship,
SkillSuiteBundlePublishAction publishAction,
Long skillId,
Long skillVersionId,
SkillVisibility finalVisibility,
String resolvedVersion,
String fingerprint,
List<String> errors,
List<String> warnings
) {
}
public record RemovedMember(
String coordinate,
Long skillId,
Long skillVersionId,
String version,
boolean entry
) {
}
}

View file

@ -1,12 +1,18 @@
package com.iflytek.skillhub.dto;
/** One currently visible published Suite that uses this Skill as its orchestration entry. */
import java.util.List;
/** One currently visible published Suite whose latest snapshot contains this Skill. */
public record SkillSuiteReferenceResponse(
Long suiteId,
String namespace,
String slug,
String displayName,
String version,
int memberCount
int memberCount,
boolean currentSkillEntry,
List<SkillSuiteSiblingMemberResponse> visibleSiblingMembers,
int restrictedMemberCount,
int omittedVisibleMemberCount
) {
}

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.suite.SkillSuiteAllowedAction;
import java.time.Instant;
import java.util.List;
import java.util.Set;
@ -15,6 +16,12 @@ public record SkillSuiteResponse(
String displayName,
String summary,
String overview,
String changelog,
String createdBy,
String createdByName,
Instant createdAt,
Instant publishedAt,
Instant yankedAt,
String version,
String status,
SkillVisibility visibility,

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.dto;
/** Compact metadata for one Suite sibling Skill visible to the current viewer. */
public record SkillSuiteSiblingMemberResponse(
Long skillId,
String namespace,
String slug,
String displayName,
String version,
boolean entry,
boolean available
) {
}

View file

@ -10,6 +10,9 @@ public record SkillSuiteVersionSummaryResponse(
String version,
String status,
SkillVisibility visibility,
String changelog,
String createdBy,
String createdByName,
Instant publishedAt,
Instant yankedAt,
Instant createdAt

View file

@ -25,11 +25,13 @@ import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
/**
* Translates application, domain, auth, and infrastructure exceptions into the platform's JSON API
@ -102,7 +104,9 @@ public class GlobalExceptionHandler {
}
@ExceptionHandler({
MissingRequestHeaderException.class,
MissingServletRequestParameterException.class,
MissingServletRequestPartException.class,
HttpMessageNotReadableException.class,
MethodArgumentTypeMismatchException.class
})

View file

@ -0,0 +1,59 @@
package com.iflytek.skillhub.listener;
import com.iflytek.skillhub.domain.event.ReviewRejectedEvent;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.event.SkillSuiteBundleAdvanceRequestedEvent;
import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleCoordinator;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
/** Wakes durable Bundle coordination after confirmation and member lifecycle events. */
@Component
public class SkillSuiteBundleEventListener {
private final SkillSuiteBundleMemberResultRepository memberRepository;
private final SkillSuiteBundleCoordinator coordinator;
public SkillSuiteBundleEventListener(
SkillSuiteBundleMemberResultRepository memberRepository,
SkillSuiteBundleCoordinator coordinator
) {
this.memberRepository = memberRepository;
this.coordinator = coordinator;
}
@Async("skillhubEventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onAdvanceRequested(SkillSuiteBundleAdvanceRequestedEvent event) {
coordinator.advance(event.operationId());
}
@Async("skillhubEventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onSkillPublished(SkillPublishedEvent event) {
advanceBoundOperations(event.versionId());
}
@Async("skillhubEventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onReviewRejected(ReviewRejectedEvent event) {
advanceBoundOperations(event.versionId());
}
@Async("skillhubEventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onSkillVersionYanked(SkillVersionYankedEvent event) {
advanceBoundOperations(event.versionId());
}
private void advanceBoundOperations(Long skillVersionId) {
memberRepository.findBySkillVersionId(skillVersionId).stream()
.map(member -> member.getOperationId())
.distinct()
.forEach(coordinator::advance);
}
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.review.ReviewSubjectType;
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
import com.iflytek.skillhub.dto.ReviewProgressResponse;
import com.iflytek.skillhub.dto.ReviewProgressStatusCounts;
@ -79,15 +80,20 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
OR LOWER(namespace.slug) LIKE :queryPattern
)
AND (:status = '' OR latest.status = :status)
AND (:subjectType = '' OR latest.subject_type = :subjectType)
ORDER BY latest.submitted_at DESC, latest.id DESC
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY
""";
private static final String MY_PROGRESS_SUMMARY_SQL = RANKED_CTE + """
SELECT COUNT(*) FILTER (WHERE :status = '' OR latest.status = :status) AS filtered_total,
COUNT(*) FILTER (WHERE latest.status = 'PENDING') AS pending_count,
COUNT(*) FILTER (WHERE latest.status = 'APPROVED') AS approved_count,
COUNT(*) FILTER (WHERE latest.status = 'REJECTED') AS rejected_count
SELECT COUNT(*) FILTER (WHERE (:status = '' OR latest.status = :status)
AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS filtered_total,
COUNT(*) FILTER (WHERE latest.status = 'PENDING'
AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS pending_count,
COUNT(*) FILTER (WHERE latest.status = 'APPROVED'
AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS approved_count,
COUNT(*) FILTER (WHERE latest.status = 'REJECTED'
AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS rejected_count
FROM latest
LEFT JOIN skill
ON latest.subject_type = 'SKILL_VERSION' AND skill.id = latest.subject_id
@ -109,16 +115,19 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
@Transactional(readOnly = true)
public ReviewProgressPageResponse findMyProgress(
String userId,
ReviewSubjectType subjectType,
ReviewTaskStatus status,
String query,
int page,
int size) {
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(java.util.Locale.ROOT);
String statusName = status != null ? status.name() : "";
String subjectTypeName = subjectType != null ? subjectType.name() : "";
String queryPattern = "%" + normalizedQuery + "%";
Query nativeQuery = bindFilters(
entityManager.createNativeQuery(MY_PROGRESS_SQL),
userId,
subjectTypeName,
statusName,
normalizedQuery,
queryPattern)
@ -127,6 +136,7 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
Query summaryQuery = bindFilters(
entityManager.createNativeQuery(MY_PROGRESS_SUMMARY_SQL),
userId,
subjectTypeName,
statusName,
normalizedQuery,
queryPattern);
@ -147,11 +157,13 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
private Query bindFilters(
Query query,
String userId,
String subjectType,
String status,
String normalizedQuery,
String queryPattern) {
return query
.setParameter("userId", userId)
.setParameter("subjectType", subjectType)
.setParameter("status", status)
.setParameter("query", normalizedQuery)
.setParameter("queryPattern", queryPattern);

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.dto.MySkillSuiteSummaryResponse;
import com.iflytek.skillhub.dto.MySkillSuiteWorkspaceResponse;
import com.iflytek.skillhub.dto.PageResponse;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
@ -29,7 +30,8 @@ public class MySkillSuiteQueryRepository {
suite.id, version.id AS version_id, namespace.slug AS namespace_slug,
suite.slug, version.display_name, version.summary, version.version,
version.status AS version_status, suite.status AS suite_status,
version.visibility, suite.hidden, suite.updated_at
version.visibility, suite.hidden, suite.updated_at,
version.created_at AS version_created_at
FROM skill_suite suite
JOIN namespace ON namespace.id = suite.namespace_id
JOIN skill_suite_version version ON version.suite_id = suite.id
@ -47,6 +49,98 @@ public class MySkillSuiteQueryRepository {
private final EntityManager entityManager;
// The actor's newest operation is merged by coordinate, not fetched once per Suite. Operations
// without a Suite remain temporary rows. Page selection and metrics each require one SQL query;
// neither query loads plans, member results, or package contents.
private static final String WORKSPACE_CTE = CTE.stripTrailing() + """
, latest_operation AS (
SELECT DISTINCT ON (operation.namespace_id, operation.target_suite_slug)
operation.operation_id, operation.namespace_id, operation.target_suite_slug,
operation.target_version, operation.status, operation.failure_code,
operation.created_at, operation.updated_at, namespace.slug AS namespace_slug
FROM skill_suite_bundle_operation operation
JOIN namespace ON namespace.id = operation.namespace_id
WHERE operation.actor_id = :userId
AND operation.namespace_id IN (:memberNamespaceIds)
ORDER BY operation.namespace_id, operation.target_suite_slug,
operation.created_at DESC, operation.operation_id DESC
), workspace AS (
SELECT suite.id AS suite_id, suite.namespace_slug, suite.slug,
suite.display_name, suite.summary,
COALESCE(operation.target_version, suite.version) AS version,
suite.version AS suite_version,
CASE WHEN operation.status IN ('BLOCKED_RETRYABLE', 'REPREVIEW_REQUIRED') THEN 'ATTENTION'
WHEN operation.status IS NOT NULL THEN 'PREPARING'
WHEN suite.suite_status = 'ARCHIVED' THEN 'ARCHIVED'
ELSE suite.version_status END AS state,
GREATEST(suite.updated_at, operation.updated_at) AS updated_at,
operation.operation_id, operation.status AS operation_status, operation.failure_code
FROM manageable suite
LEFT JOIN latest_operation operation
ON operation.namespace_slug = suite.namespace_slug AND operation.target_suite_slug = suite.slug
AND operation.status IN ('RUNNING', 'WAITING_FOR_MEMBERS', 'BLOCKED_RETRYABLE', 'REPREVIEW_REQUIRED')
AND operation.created_at >= suite.version_created_at
UNION ALL
SELECT CAST(NULL AS bigint), operation.namespace_slug, operation.target_suite_slug,
operation.target_suite_slug, CAST(NULL AS text), operation.target_version, CAST(NULL AS text),
CASE WHEN operation.status IN ('BLOCKED_RETRYABLE', 'REPREVIEW_REQUIRED') THEN 'ATTENTION'
WHEN operation.status = 'CANCELLED' THEN 'CANCELLED'
ELSE 'PREPARING' END,
operation.updated_at, operation.operation_id, operation.status, operation.failure_code
FROM latest_operation operation
WHERE NOT EXISTS (
SELECT 1 FROM skill_suite suite
WHERE suite.namespace_id = operation.namespace_id AND suite.slug = operation.target_suite_slug
)
), searched AS (
SELECT * FROM workspace
WHERE (:query = '' OR LOWER(namespace_slug || '/' || slug) LIKE :pattern ESCAPE '!'
OR LOWER(display_name) LIKE :pattern ESCAPE '!'
OR LOWER(COALESCE(summary, '')) LIKE :pattern ESCAPE '!')
)
""";
@Transactional(readOnly = true)
public MySkillSuiteWorkspaceResponse findWorkspace(
String userId, Set<Long> memberNamespaceIds, Set<Long> adminNamespaceIds,
String keyword, String state, int page, int size
) {
String queryText = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT);
String pattern = "%" + queryText.replace("!", "!!").replace("%", "!%")
.replace("_", "!_") + "%";
Query select = bind(entityManager.createNativeQuery(WORKSPACE_CTE + """
SELECT suite_id, namespace_slug, slug, display_name, summary, version,
suite_version, state, updated_at, operation_id, operation_status, failure_code
FROM searched
WHERE (:state = '' OR state = :state
OR (:state = 'OTHER' AND state NOT IN ('ATTENTION', 'DRAFT', 'PENDING_REVIEW', 'PUBLISHED')))
ORDER BY CASE WHEN state = 'ATTENTION' THEN 0 WHEN state = 'PREPARING' THEN 1 ELSE 2 END,
updated_at DESC, namespace_slug, slug
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY
"""), userId, memberNamespaceIds, adminNamespaceIds, queryText);
select.setParameter("pattern", pattern).setParameter("state", state)
.setParameter("offset", (long) page * size).setParameter("size", size);
Query metrics = bind(entityManager.createNativeQuery(WORKSPACE_CTE + """
SELECT COUNT(*) FILTER (WHERE :state = '' OR state = :state
OR (:state = 'OTHER' AND state NOT IN ('ATTENTION', 'DRAFT', 'PENDING_REVIEW', 'PUBLISHED'))),
COUNT(*) FILTER (WHERE state = 'ATTENTION'),
COALESCE(BOOL_OR(operation_status IN ('RUNNING', 'WAITING_FOR_MEMBERS')), FALSE)
FROM searched
"""), userId, memberNamespaceIds, adminNamespaceIds, queryText);
metrics.setParameter("pattern", pattern).setParameter("state", state);
@SuppressWarnings("unchecked")
List<Object[]> rows = select.getResultList();
Object[] counts = (Object[]) metrics.getSingleResult();
return new MySkillSuiteWorkspaceResponse(rows.stream().map(row ->
new MySkillSuiteWorkspaceResponse.Item(
row[0] == null ? null : ((Number) row[0]).longValue(),
(String) row[1], (String) row[2], (String) row[3], (String) row[4],
(String) row[5], (String) row[6], (String) row[7], instant(row[8]),
(String) row[9], (String) row[10], (String) row[11])).toList(),
((Number) counts[0]).longValue(), page, size,
((Number) counts[1]).longValue(), (Boolean) counts[2]);
}
public MySkillSuiteQueryRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.review.ReviewSubjectType;
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
/**
@ -10,6 +11,7 @@ public interface ReviewProgressQueryRepository {
ReviewProgressPageResponse findMyProgress(
String userId,
ReviewSubjectType subjectType,
ReviewTaskStatus status,
String query,
int page,

View file

@ -0,0 +1,175 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationPageResponse;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationSummaryResponse;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
/**
* Dashboard read model for Bundle operations owned by one actor.
*
* <p>The native query pages operations before joining member rows, then computes status counts in
* PostgreSQL. This keeps each poll bounded and avoids loading member errors and warnings.</p>
*/
@Repository
public class SkillSuiteBundleOperationQueryRepository {
private static final List<String> ACTIVE_STATUSES = List.of(
SkillSuiteBundleOperationStatus.RUNNING.name(),
SkillSuiteBundleOperationStatus.WAITING_FOR_MEMBERS.name(),
SkillSuiteBundleOperationStatus.BLOCKED_RETRYABLE.name());
private final EntityManager entityManager;
public SkillSuiteBundleOperationQueryRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public PageResponse<SkillSuiteBundleOperationSummaryResponse> findActive(
String actorId,
int page,
int size
) {
Query select = entityManager.createNativeQuery("""
WITH active_operation AS (
SELECT operation.operation_id, operation.mode,
'@' || namespace.slug || '/' || operation.target_suite_slug AS target_coordinate,
operation.target_version, operation.status, operation.failure_code,
base_version.version AS base_version, operation.updated_at
FROM skill_suite_bundle_operation operation
JOIN namespace ON namespace.id = operation.namespace_id
LEFT JOIN skill_suite_version base_version
ON base_version.id = operation.base_suite_version_id
WHERE operation.actor_id = :actorId
AND operation.status IN (:statuses)
ORDER BY operation.updated_at DESC, operation.operation_id DESC
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY
)
SELECT operation.operation_id, operation.mode, operation.target_coordinate,
operation.target_version, operation.status, operation.failure_code,
operation.base_version, operation.updated_at,
COUNT(member.id) AS total_members,
COUNT(member.id) FILTER (WHERE member.status = 'COMPLETED') AS completed_members,
COUNT(member.id) FILTER (WHERE member.status = 'WAITING_FOR_MEMBER') AS waiting_members
FROM active_operation operation
LEFT JOIN skill_suite_bundle_member_result member
ON member.operation_id = operation.operation_id
GROUP BY operation.operation_id, operation.mode, operation.target_coordinate,
operation.target_version, operation.status, operation.failure_code,
operation.base_version, operation.updated_at
ORDER BY operation.updated_at DESC, operation.operation_id DESC
""");
bind(select, actorId)
.setParameter("offset", (long) page * size)
.setParameter("size", size);
Query count = bind(entityManager.createNativeQuery("""
SELECT COUNT(*)
FROM skill_suite_bundle_operation operation
WHERE operation.actor_id = :actorId
AND operation.status IN (:statuses)
"""), actorId);
@SuppressWarnings("unchecked")
List<Object[]> rows = select.getResultList();
return new PageResponse<>(
rows.stream().map(this::map).toList(),
((Number) count.getSingleResult()).longValue(), page, size);
}
@Transactional(readOnly = true)
public SkillSuiteBundleOperationPageResponse findMine(
String actorId,
int page,
int size
) {
Query select = entityManager.createNativeQuery("""
WITH selected_operation AS (
SELECT operation.operation_id, operation.mode,
'@' || namespace.slug || '/' || operation.target_suite_slug AS target_coordinate,
operation.target_version, operation.status, operation.failure_code,
base_version.version AS base_version, operation.updated_at
FROM skill_suite_bundle_operation operation
JOIN namespace ON namespace.id = operation.namespace_id
LEFT JOIN skill_suite_version base_version
ON base_version.id = operation.base_suite_version_id
WHERE operation.actor_id = :actorId
ORDER BY CASE
WHEN operation.status IN ('BLOCKED_RETRYABLE', 'REPREVIEW_REQUIRED') THEN 0
WHEN operation.status IN ('RUNNING', 'WAITING_FOR_MEMBERS') THEN 1
ELSE 2
END,
operation.updated_at DESC, operation.operation_id DESC
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY
)
SELECT operation.operation_id, operation.mode, operation.target_coordinate,
operation.target_version, operation.status, operation.failure_code,
operation.base_version, operation.updated_at,
COUNT(member.id) AS total_members,
COUNT(member.id) FILTER (WHERE member.status = 'COMPLETED') AS completed_members,
COUNT(member.id) FILTER (WHERE member.status = 'WAITING_FOR_MEMBER') AS waiting_members
FROM selected_operation operation
LEFT JOIN skill_suite_bundle_member_result member
ON member.operation_id = operation.operation_id
GROUP BY operation.operation_id, operation.mode, operation.target_coordinate,
operation.target_version, operation.status, operation.failure_code,
operation.base_version, operation.updated_at
ORDER BY CASE
WHEN operation.status IN ('BLOCKED_RETRYABLE', 'REPREVIEW_REQUIRED') THEN 0
WHEN operation.status IN ('RUNNING', 'WAITING_FOR_MEMBERS') THEN 1
ELSE 2
END,
operation.updated_at DESC, operation.operation_id DESC
""");
select.setParameter("actorId", actorId)
.setParameter("offset", (long) page * size)
.setParameter("size", size);
Query count = entityManager.createNativeQuery("""
SELECT (SELECT COUNT(*)
FROM skill_suite_bundle_operation operation
WHERE operation.actor_id = :actorId),
EXISTS(SELECT 1
FROM skill_suite_bundle_operation changing
WHERE changing.actor_id = :actorId
AND changing.status IN ('RUNNING', 'WAITING_FOR_MEMBERS'))
""").setParameter("actorId", actorId);
@SuppressWarnings("unchecked")
List<Object[]> rows = select.getResultList();
Object[] metrics = (Object[]) count.getSingleResult();
return new SkillSuiteBundleOperationPageResponse(
rows.stream().map(this::map).toList(),
((Number) metrics[0]).longValue(), page, size, (Boolean) metrics[1]);
}
private Query bind(Query query, String actorId) {
return query.setParameter("actorId", actorId).setParameter("statuses", ACTIVE_STATUSES);
}
private SkillSuiteBundleOperationSummaryResponse map(Object[] row) {
return new SkillSuiteBundleOperationSummaryResponse(
(String) row[0], SkillSuiteBundleMode.valueOf(String.valueOf(row[1])),
(String) row[2], (String) row[3],
SkillSuiteBundleOperationStatus.valueOf(String.valueOf(row[4])),
(String) row[5], (String) row[6],
((Number) row[8]).intValue(), ((Number) row[9]).intValue(),
((Number) row[10]).intValue(), instant(row[7]));
}
private Instant instant(Object value) {
if (value instanceof Instant instant) return instant;
if (value instanceof OffsetDateTime offsetDateTime) return offsetDateTime.toInstant();
if (value instanceof Timestamp timestamp) return timestamp.toInstant();
throw new IllegalStateException("Expected Bundle operation update timestamp, got " + value);
}
}

View file

@ -0,0 +1,87 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.label.LabelDefinition;
import com.iflytek.skillhub.domain.label.LabelDefinitionService;
import com.iflytek.skillhub.domain.label.LabelTranslation;
import com.iflytek.skillhub.domain.label.SkillSuiteLabel;
import com.iflytek.skillhub.domain.label.SkillSuiteLabelService;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.service.LabelLocalizationService;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Repository;
/** Assembles localized direct Suite labels for a page of Suites with bounded query count. */
@Repository
public class SkillSuiteLabelQueryRepository {
private final SkillSuiteLabelService suiteLabelService;
private final LabelDefinitionService labelDefinitionService;
private final LabelLocalizationService labelLocalizationService;
public SkillSuiteLabelQueryRepository(
SkillSuiteLabelService suiteLabelService,
LabelDefinitionService labelDefinitionService,
LabelLocalizationService labelLocalizationService
) {
this.suiteLabelService = suiteLabelService;
this.labelDefinitionService = labelDefinitionService;
this.labelLocalizationService = labelLocalizationService;
}
public Map<Long, List<SkillLabelDto>> labelsBySuiteIds(List<Long> suiteIds) {
if (suiteIds == null || suiteIds.isEmpty()) {
return Map.of();
}
List<Long> distinctSuiteIds = suiteIds.stream()
.filter(Objects::nonNull)
.distinct()
.toList();
if (distinctSuiteIds.isEmpty()) {
return Map.of();
}
List<SkillSuiteLabel> assignments =
suiteLabelService.listSuiteLabelsBySuiteIds(distinctSuiteIds);
if (assignments.isEmpty()) {
return Map.of();
}
List<Long> labelIds = assignments.stream()
.map(SkillSuiteLabel::getLabelId)
.distinct()
.toList();
Map<Long, LabelDefinition> definitionsById = labelDefinitionService.listByIds(labelIds).stream()
.collect(Collectors.toMap(LabelDefinition::getId, Function.identity()));
Map<Long, List<LabelTranslation>> translationsByLabelId =
labelDefinitionService.listTranslationsByLabelIds(labelIds);
return assignments.stream()
.filter(assignment -> definitionsById.containsKey(assignment.getLabelId()))
.collect(Collectors.groupingBy(
SkillSuiteLabel::getSuiteId,
Collectors.collectingAndThen(
Collectors.toList(),
suiteAssignments -> suiteAssignments.stream()
.map(assignment -> toDto(
definitionsById.get(assignment.getLabelId()),
translationsByLabelId))
.sorted(Comparator.comparing(SkillLabelDto::type)
.thenComparing(SkillLabelDto::slug))
.toList())));
}
private SkillLabelDto toDto(
LabelDefinition definition,
Map<Long, List<LabelTranslation>> translationsByLabelId
) {
return new SkillLabelDto(
definition.getSlug(),
definition.getType().name(),
labelLocalizationService.resolveDisplayName(
definition.getSlug(),
translationsByLabelId.getOrDefault(definition.getId(), List.of())));
}
}

View file

@ -1,38 +1,221 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillSuiteReferenceResponse;
import com.iflytek.skillhub.dto.SkillSuiteSiblingMemberResponse;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Skill-detail read model for current Suite entry references.
* Skill-detail read model for current Suite membership references.
*
* <p>The query starts from each Suite's latest published snapshot so historical Suite versions do
* not look like current installation recommendations. Visibility filtering happens in SQL to avoid
* leaking private Suite coordinates through a public Skill page.</p>
* <p>Only each Suite's latest published snapshot participates. Suite visibility and sibling Skill
* visibility are evaluated in bounded collection queries so callers cannot infer private
* coordinates and the projection does not perform an N+1 lookup.</p>
*/
@Repository
public class SkillSuiteReferenceQueryRepository {
public static final int MAX_PAGE_SIZE = 20;
private static final int MAX_VISIBLE_SIBLINGS = 8;
private final NamedParameterJdbcTemplate jdbcTemplate;
public SkillSuiteReferenceQueryRepository(NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/** Legacy entry-only projection retained for older clients. */
@Transactional(readOnly = true)
public List<SkillSuiteReferenceResponse> findVisibleEntryReferences(
Long skillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
return findVisibleMemberships(
skillId, userId, namespaceRoles, platformRoles, 0, MAX_PAGE_SIZE).items().stream()
.filter(SkillSuiteReferenceResponse::currentSkillEntry)
.toList();
}
@Transactional(readOnly = true)
public PageResponse<SkillSuiteReferenceResponse> findVisibleMemberships(
Long skillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
int page,
int size
) {
int boundedPage = Math.max(0, page);
int boundedSize = Math.min(Math.max(1, size), MAX_PAGE_SIZE);
MapSqlParameterSource parameters = viewerParameters(
skillId, userId, namespaceRoles, platformRoles)
.addValue("limit", boundedSize)
.addValue("offset", boundedPage * boundedSize);
List<SuiteRow> suites = jdbcTemplate.query("""
SELECT suite.id,
version.id AS suite_version_id,
suite_namespace.slug AS namespace_slug,
suite.slug,
version.display_name,
version.version,
current_member.entry AS current_skill_entry,
COUNT(all_members.id) AS member_count,
COUNT(*) OVER() AS total_count
FROM skill_suite suite
JOIN namespace suite_namespace ON suite_namespace.id = suite.namespace_id
JOIN skill_suite_version version ON version.id = suite.latest_version_id
JOIN skill_suite_version_member current_member
ON current_member.suite_version_id = version.id
AND current_member.skill_id = :skillId
JOIN skill_suite_version_member all_members
ON all_members.suite_version_id = version.id
WHERE %s
GROUP BY suite.id, version.id, suite_namespace.slug, suite.slug,
version.display_name, version.version, current_member.entry
ORDER BY current_member.entry DESC, LOWER(version.display_name), suite.id
LIMIT :limit OFFSET :offset
""".formatted(visibleSuitePredicate()), parameters, (resultSet, rowNumber) -> new SuiteRow(
resultSet.getLong("id"),
resultSet.getLong("suite_version_id"),
resultSet.getString("namespace_slug"),
resultSet.getString("slug"),
resultSet.getString("display_name"),
resultSet.getString("version"),
resultSet.getBoolean("current_skill_entry"),
resultSet.getInt("member_count"),
resultSet.getLong("total_count")));
if (suites.isEmpty()) {
long total = boundedPage == 0 ? 0 : countVisibleMemberships(parameters);
return new PageResponse<>(List.of(), total, boundedPage, boundedSize);
}
Map<Long, List<MemberRow>> membersBySuiteVersion = loadMembers(
suites.stream().map(SuiteRow::suiteVersionId).toList(),
skillId, userId, namespaceRoles, platformRoles);
List<SkillSuiteReferenceResponse> items = suites.stream()
.map(suite -> toResponse(suite, membersBySuiteVersion.getOrDefault(
suite.suiteVersionId(), List.of())))
.toList();
return new PageResponse<>(items, suites.getFirst().totalCount(), boundedPage, boundedSize);
}
private long countVisibleMemberships(MapSqlParameterSource parameters) {
Long counted = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM skill_suite suite
JOIN namespace suite_namespace ON suite_namespace.id = suite.namespace_id
JOIN skill_suite_version version ON version.id = suite.latest_version_id
JOIN skill_suite_version_member current_member
ON current_member.suite_version_id = version.id
AND current_member.skill_id = :skillId
WHERE %s
""".formatted(visibleSuitePredicate()), parameters, Long.class);
return counted == null ? 0 : counted;
}
private Map<Long, List<MemberRow>> loadMembers(
List<Long> suiteVersionIds,
Long currentSkillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
MapSqlParameterSource parameters = viewerParameters(
currentSkillId, userId, namespaceRoles, platformRoles)
.addValue("suiteVersionIds", suiteVersionIds);
List<MemberRow> rows = jdbcTemplate.query("""
SELECT member.suite_version_id,
member.position,
member.entry,
skill.id AS skill_id,
skill_namespace.slug AS namespace_slug,
skill.slug,
COALESCE(skill.display_name, skill.slug) AS display_name,
skill_version.version,
CASE WHEN skill.id IS NULL
OR skill_version.id IS NULL
OR skill_namespace.id IS NULL THEN FALSE
WHEN :superAdmin = TRUE THEN TRUE
WHEN skill.hidden = TRUE THEN (
skill.owner_id = :userId
OR skill.namespace_id IN (:adminNamespaceIds))
WHEN skill.latest_version_id IS NULL THEN skill.owner_id = :userId
WHEN skill.visibility = 'PUBLIC' THEN TRUE
WHEN skill.visibility = 'NAMESPACE_ONLY' THEN
skill.namespace_id IN (:memberNamespaceIds)
WHEN skill.visibility = 'PRIVATE' THEN (
skill.owner_id = :userId
OR skill.namespace_id IN (:adminNamespaceIds))
ELSE FALSE
END AS visible,
CASE WHEN skill.id IS NOT NULL
AND skill_version.id IS NOT NULL
AND skill_namespace.status = 'ACTIVE'
AND skill.status = 'ACTIVE'
AND skill.hidden = FALSE
AND skill_version.status = 'PUBLISHED'
AND skill_version.download_ready = TRUE
AND skill_version.yanked_at IS NULL
THEN TRUE ELSE FALSE
END AS available
FROM skill_suite_version_member member
LEFT JOIN skill ON skill.id = member.skill_id
LEFT JOIN skill_version
ON skill_version.id = member.skill_version_id
AND skill_version.skill_id = member.skill_id
LEFT JOIN namespace skill_namespace ON skill_namespace.id = skill.namespace_id
WHERE member.suite_version_id IN (:suiteVersionIds)
AND (member.skill_id IS NULL OR member.skill_id <> :skillId)
ORDER BY member.suite_version_id, member.position
""", parameters, (resultSet, rowNumber) -> new MemberRow(
resultSet.getLong("suite_version_id"),
resultSet.getObject("skill_id", Long.class),
resultSet.getString("namespace_slug"),
resultSet.getString("slug"),
resultSet.getString("display_name"),
resultSet.getString("version"),
resultSet.getBoolean("entry"),
resultSet.getBoolean("visible"),
resultSet.getBoolean("available")));
Map<Long, List<MemberRow>> grouped = new LinkedHashMap<>();
rows.forEach(row -> grouped.computeIfAbsent(row.suiteVersionId(), ignored -> new ArrayList<>()).add(row));
return grouped;
}
private SkillSuiteReferenceResponse toResponse(SuiteRow suite, List<MemberRow> members) {
List<MemberRow> visible = members.stream().filter(MemberRow::visible).toList();
List<SkillSuiteSiblingMemberResponse> summaries = visible.stream()
.limit(MAX_VISIBLE_SIBLINGS)
.map(member -> new SkillSuiteSiblingMemberResponse(
member.skillId(), member.namespace(), member.slug(), member.displayName(),
member.version(), member.entry(), member.available()))
.toList();
int restrictedCount = (int) members.stream().filter(member -> !member.visible()).count();
int omittedVisibleCount = Math.max(0, visible.size() - summaries.size());
return new SkillSuiteReferenceResponse(
suite.suiteId(), suite.namespace(), suite.slug(), suite.displayName(), suite.version(),
suite.memberCount(), suite.currentSkillEntry(), summaries,
restrictedCount, omittedVisibleCount);
}
private MapSqlParameterSource viewerParameters(
Long skillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
List<Long> memberNamespaceIds = namespaceRoles.keySet().stream().toList();
List<Long> adminNamespaceIds = namespaceRoles.entrySet().stream()
@ -40,32 +223,20 @@ public class SkillSuiteReferenceQueryRepository {
|| entry.getValue() == NamespaceRole.ADMIN)
.map(Map.Entry::getKey)
.toList();
MapSqlParameterSource parameters = new MapSqlParameterSource()
return new MapSqlParameterSource()
.addValue("skillId", skillId)
.addValue("userId", userId)
.addValue("memberNamespaceIds", nonEmpty(memberNamespaceIds))
.addValue("adminNamespaceIds", nonEmpty(adminNamespaceIds))
.addValue("authenticated", userId != null)
.addValue("superAdmin", platformRoles.contains("SUPER_ADMIN"));
}
return jdbcTemplate.query("""
SELECT suite.id,
namespace.slug AS namespace_slug,
suite.slug,
version.display_name,
version.version,
COUNT(all_members.id) AS member_count
FROM skill_suite suite
JOIN namespace ON namespace.id = suite.namespace_id
JOIN skill_suite_version version ON version.id = suite.latest_version_id
JOIN skill_suite_version_member entry_member
ON entry_member.suite_version_id = version.id AND entry_member.entry = TRUE
JOIN skill_suite_version_member all_members
ON all_members.suite_version_id = version.id
WHERE entry_member.skill_id = :skillId
AND suite.status = 'ACTIVE'
private String visibleSuitePredicate() {
return """
suite.status = 'ACTIVE'
AND suite.hidden = FALSE
AND namespace.status = 'ACTIVE'
AND suite_namespace.status = 'ACTIVE'
AND version.status = 'PUBLISHED'
AND (
:superAdmin = TRUE
@ -79,18 +250,36 @@ public class SkillSuiteReferenceQueryRepository {
AND suite.namespace_id IN (:memberNamespaceIds))
))
)
GROUP BY suite.id, namespace.slug, suite.slug, version.display_name, version.version
ORDER BY LOWER(version.display_name), suite.id
""", parameters, (resultSet, rowNumber) -> new SkillSuiteReferenceResponse(
resultSet.getLong("id"),
resultSet.getString("namespace_slug"),
resultSet.getString("slug"),
resultSet.getString("display_name"),
resultSet.getString("version"),
resultSet.getInt("member_count")));
""";
}
private List<Long> nonEmpty(List<Long> values) {
return values.isEmpty() ? List.of(-1L) : values;
}
private record SuiteRow(
Long suiteId,
Long suiteVersionId,
String namespace,
String slug,
String displayName,
String version,
boolean currentSkillEntry,
int memberCount,
long totalCount
) {
}
private record MemberRow(
Long suiteVersionId,
Long skillId,
String namespace,
String slug,
String displayName,
String version,
boolean entry,
boolean visible,
boolean available
) {
}
}

View file

@ -99,12 +99,13 @@ public class GovernanceWorkflowAppService {
}
public ReviewProgressPageResponse listMyReviewProgress(
String subjectType,
String status,
String query,
int page,
int size,
String userId) {
return reviewPortalAppService.listMyProgress(status, query, page, size, userId);
return reviewPortalAppService.listMyProgress(subjectType, status, query, page, size, userId);
}
public List<ReviewTaskResponse> listMyReviewAttempts(Long reviewTaskId, String userId) {

View file

@ -1,9 +1,15 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.repository.SkillSuiteLabelQueryRepository;
import com.iflytek.skillhub.dto.ResourceSearchResponse;
import com.iflytek.skillhub.dto.ResourceSummaryResponse;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.search.ResourceDiscoveryQueryService;
import com.iflytek.skillhub.search.ResourceDiscoveryQueryService.ResourceQuery;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Service;
@ -12,9 +18,14 @@ import org.springframework.stereotype.Service;
public class ResourceDiscoveryAppService {
private final ResourceDiscoveryQueryService queryService;
private final SkillSuiteLabelQueryRepository suiteLabelProjectionService;
public ResourceDiscoveryAppService(ResourceDiscoveryQueryService queryService) {
public ResourceDiscoveryAppService(
ResourceDiscoveryQueryService queryService,
SkillSuiteLabelQueryRepository suiteLabelProjectionService
) {
this.queryService = queryService;
this.suiteLabelProjectionService = suiteLabelProjectionService;
}
public ResourceSearchResponse search(
@ -25,11 +36,30 @@ public class ResourceDiscoveryAppService {
int page,
int size,
Set<Long> memberNamespaceIds
) {
return search(keyword, namespace, resourceType, sort, page, size, memberNamespaceIds, List.of());
}
public ResourceSearchResponse search(
String keyword,
String namespace,
String resourceType,
String sort,
int page,
int size,
Set<Long> memberNamespaceIds,
List<String> labelSlugs
) {
int safePage = Math.max(0, page);
int safeSize = Math.min(Math.max(size, 1), 100);
var result = queryService.search(new ResourceQuery(
keyword, namespace, resourceType, sort, safePage, safeSize, memberNamespaceIds));
keyword, namespace, resourceType, sort, safePage, safeSize, memberNamespaceIds,
normalizeLabelSlugs(labelSlugs)));
Map<Long, List<SkillLabelDto>> labelsBySuiteId =
suiteLabelProjectionService.labelsBySuiteIds(result.items().stream()
.filter(item -> "SUITE".equals(item.resourceType()))
.map(ResourceDiscoveryQueryService.ResourceHit::id)
.toList());
return new ResourceSearchResponse(
result.items().stream().map(item -> new ResourceSummaryResponse(
item.resourceType(),
@ -44,7 +74,21 @@ public class ResourceDiscoveryAppService {
item.visibility(),
item.installCount(),
item.available(),
item.updatedAt())).toList(),
item.updatedAt(),
"SUITE".equals(item.resourceType())
? labelsBySuiteId.getOrDefault(item.id(), List.of())
: List.of())).toList(),
result.total(), result.page(), result.size());
}
private List<String> normalizeLabelSlugs(List<String> labelSlugs) {
if (labelSlugs == null || labelSlugs.isEmpty()) {
return List.of();
}
return labelSlugs.stream()
.filter(value -> value != null && !value.isBlank())
.map(value -> value.trim().toLowerCase(Locale.ROOT))
.distinct()
.toList();
}
}

View file

@ -241,11 +241,15 @@ public class ReviewPortalAppService {
}
public ReviewProgressPageResponse listMyProgress(
String subjectType,
String status,
String query,
int page,
int size,
String userId) {
ReviewSubjectType reviewSubjectType = subjectType == null || subjectType.isBlank()
? null
: ReviewSubjectType.valueOf(subjectType.toUpperCase(java.util.Locale.ROOT));
ReviewTaskStatus reviewStatus = status == null || status.isBlank()
? null
: ReviewTaskStatus.valueOf(status.toUpperCase(java.util.Locale.ROOT));
@ -253,6 +257,7 @@ public class ReviewPortalAppService {
int safeSize = Math.min(Math.max(size, 1), 100);
return reviewProgressQueryRepository.findMyProgress(
userId,
reviewSubjectType,
reviewStatus,
query != null ? query : "",
safePage,

View file

@ -14,6 +14,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.service.SkillDownloadService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.dto.ReviewSkillDetailResponse;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillDetailResponse;
import com.iflytek.skillhub.dto.SkillFileResponse;
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
@ -85,7 +86,8 @@ public class ReviewSkillDetailAppService {
toLifecycleVersion(snapshot.activeVersion()),
null,
"REVIEW_TASK",
List.of()
List.of(),
new PageResponse<>(List.of(), 0, 0, 20)
);
List<SkillVersionResponse> versions = snapshot.versions().stream()

View file

@ -21,6 +21,8 @@ import com.iflytek.skillhub.domain.suite.SkillSuiteAllowedAction;
import com.iflytek.skillhub.domain.suite.SkillSuiteMemberSelection;
import com.iflytek.skillhub.domain.suite.SkillSuiteQueryService;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.SkillSuiteCreateRequest;
import com.iflytek.skillhub.dto.SkillSuiteMemberRequest;
import com.iflytek.skillhub.dto.SkillSuiteMemberResponse;
@ -36,6 +38,7 @@ import com.iflytek.skillhub.repository.SkillSuiteCandidateQueryRepository;
import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository;
import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository;
import com.iflytek.skillhub.dto.MySkillSuiteSummaryResponse;
import com.iflytek.skillhub.dto.MySkillSuiteWorkspaceResponse;
import com.iflytek.skillhub.dto.PageResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
@ -48,11 +51,13 @@ import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/** Application boundary for resolving Suite inputs and invoking domain workflows. */
@Service
@ -72,6 +77,7 @@ public class SkillSuiteAppService {
private final SkillSuiteCandidateQueryRepository candidateQueryRepository;
private final MySkillSuiteQueryRepository mySkillSuiteQueryRepository;
private final SkillSuiteReferenceQueryRepository referenceQueryRepository;
private final UserAccountRepository userAccountRepository;
public SkillSuiteAppService(
NamespaceRepository namespaceRepository,
@ -85,7 +91,8 @@ public class SkillSuiteAppService {
RequestIdAccessor requestIdAccessor,
SkillSuiteCandidateQueryRepository candidateQueryRepository,
MySkillSuiteQueryRepository mySkillSuiteQueryRepository,
SkillSuiteReferenceQueryRepository referenceQueryRepository
SkillSuiteReferenceQueryRepository referenceQueryRepository,
UserAccountRepository userAccountRepository
) {
this.namespaceRepository = namespaceRepository;
this.skillQueryService = skillQueryService;
@ -99,6 +106,7 @@ public class SkillSuiteAppService {
this.candidateQueryRepository = candidateQueryRepository;
this.mySkillSuiteQueryRepository = mySkillSuiteQueryRepository;
this.referenceQueryRepository = referenceQueryRepository;
this.userAccountRepository = userAccountRepository;
}
public PageResponse<MySkillSuiteSummaryResponse> listMine(
@ -118,6 +126,21 @@ public class SkillSuiteAppService {
Math.max(0, page), Math.min(Math.max(1, size), 100));
}
public MySkillSuiteWorkspaceResponse workspace(
String userId, Map<Long, NamespaceRole> namespaceRoles,
String query, String state, int page, int size
) {
String filter = state == null ? "" : state;
if (!Set.of("", "ATTENTION", "DRAFT", "PENDING_REVIEW", "PUBLISHED", "OTHER").contains(filter)) {
throw new DomainBadRequestException("error.suite.workspace.invalidFilter");
}
Set<Long> adminNamespaceIds = namespaceRoles.entrySet().stream()
.filter(entry -> entry.getValue() == NamespaceRole.OWNER || entry.getValue() == NamespaceRole.ADMIN)
.map(Map.Entry::getKey).collect(java.util.stream.Collectors.toUnmodifiableSet());
return mySkillSuiteQueryRepository.findWorkspace(userId, namespaceRoles.keySet(), adminNamespaceIds,
query, filter, Math.max(0, page), Math.min(Math.max(1, size), 100));
}
public List<SkillSuiteMemberCandidateResponse> searchCandidates(
String suiteNamespace,
SkillVisibility visibility,
@ -159,6 +182,18 @@ public class SkillSuiteAppService {
skillId, userId, namespaceRoles, platformRoles);
}
public PageResponse<SkillSuiteReferenceResponse> findVisibleMemberships(
Long skillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
int page,
int size
) {
return referenceQueryRepository.findVisibleMemberships(
skillId, userId, namespaceRoles, platformRoles, page, size);
}
@Transactional
public SkillSuiteInstallPlanResponse createInstallPlan(
String namespace,
@ -353,7 +388,9 @@ public class SkillSuiteAppService {
return new SkillSuiteResponse(
detail.suite().getId(), detail.version().getId(), detail.namespace().getSlug(),
detail.suite().getSlug(), detail.version().getDisplayName(), detail.version().getSummary(),
detail.version().getOverview(),
detail.version().getOverview(), detail.version().getChangelog(), detail.version().getCreatedBy(),
creatorName(detail.version().getCreatedBy()),
detail.version().getCreatedAt(), detail.version().getPublishedAt(), detail.version().getYankedAt(),
detail.version().getVersion(), detail.version().getStatus().name(),
detail.version().getVisibility(), detail.suite().getStatus().name(),
detail.suite().isHidden(), allowedActions, detail.available(), members);
@ -417,9 +454,18 @@ public class SkillSuiteAppService {
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
return queryService.listVersions(namespace, slug, userId, namespaceRoles, platformRoles).stream()
List<SkillSuiteQueryService.VersionSummary> versions =
queryService.listVersions(namespace, slug, userId, namespaceRoles, platformRoles);
Map<String, String> creatorNames = userAccountRepository.findByIdIn(versions.stream()
.map(SkillSuiteQueryService.VersionSummary::createdBy)
.filter(Objects::nonNull)
.distinct()
.toList()).stream()
.collect(Collectors.toMap(UserAccount::getId, UserAccount::getDisplayName));
return versions.stream()
.map(version -> new SkillSuiteVersionSummaryResponse(
version.id(), version.version(), version.status().name(), version.visibility(),
version.changelog(), version.createdBy(), creatorNames.get(version.createdBy()),
version.publishedAt(), version.yankedAt(), version.createdAt()))
.toList();
}
@ -559,14 +605,13 @@ public class SkillSuiteAppService {
Set<String> platformRoles
) {
List<SkillSuiteMemberSelection> selections = new ArrayList<>(request.members().size());
List<String> invalidMembers = new ArrayList<>();
Set<String> invalidMembers = new LinkedHashSet<>();
for (SkillSuiteMemberRequest member : request.members()) {
try {
selections.add(resolve(member, userId, namespaceRoles, platformRoles));
} catch (LocalizedDomainException exception) {
invalidMembers.add(String.format(
"@%s/%s@%s (%s)", member.namespace(), member.slug(), member.version(),
exception.messageCode()));
"@%s/%s@%s", member.namespace(), member.slug(), member.version()));
}
}
if (!invalidMembers.isEmpty()) {
@ -632,12 +677,20 @@ public class SkillSuiteAppService {
return new SkillSuiteResponse(
created.suite().getId(), created.version().getId(), namespace.getSlug(),
created.suite().getSlug(), created.version().getDisplayName(), created.version().getSummary(),
created.version().getOverview(),
created.version().getOverview(), created.version().getChangelog(), created.version().getCreatedBy(),
creatorName(created.version().getCreatedBy()),
created.version().getCreatedAt(), created.version().getPublishedAt(), created.version().getYankedAt(),
created.version().getVersion(), created.version().getStatus().name(),
created.version().getVisibility(), created.suite().getStatus().name(),
created.suite().isHidden(), allowedActions, false, members);
}
private String creatorName(String userId) {
return userId == null ? null : userAccountRepository.findById(userId)
.map(UserAccount::getDisplayName)
.orElse(null);
}
private SkillSuiteActionContext authorizationContext(
String userId,
Map<Long, NamespaceRole> namespaceRoles,

View file

@ -0,0 +1,159 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.repository.SkillSuiteLabelQueryRepository;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.label.SkillSuiteLabel;
import com.iflytek.skillhub.domain.label.SkillSuiteLabelService;
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.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteQueryService;
import com.iflytek.skillhub.domain.suite.SkillSuiteRepository;
import com.iflytek.skillhub.dto.MessageResponse;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Coordinates Suite label visibility, mutations, localization, and Suite-scoped audit. */
@Service
public class SkillSuiteLabelAppService {
private final NamespaceRepository namespaceRepository;
private final SkillSuiteRepository suiteRepository;
private final SkillSuiteQueryService suiteQueryService;
private final SkillSuiteLabelService suiteLabelService;
private final SkillSuiteLabelQueryRepository projectionService;
private final AuditLogService auditLogService;
private final RequestIdAccessor requestIdAccessor;
public SkillSuiteLabelAppService(
NamespaceRepository namespaceRepository,
SkillSuiteRepository suiteRepository,
SkillSuiteQueryService suiteQueryService,
SkillSuiteLabelService suiteLabelService,
SkillSuiteLabelQueryRepository projectionService,
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor
) {
this.namespaceRepository = namespaceRepository;
this.suiteRepository = suiteRepository;
this.suiteQueryService = suiteQueryService;
this.suiteLabelService = suiteLabelService;
this.projectionService = projectionService;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
}
@Transactional(readOnly = true)
public List<SkillLabelDto> listLabels(
String namespaceSlug,
String suiteSlug,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
SkillSuite suite = resolveSuite(namespaceSlug, suiteSlug);
Map<Long, NamespaceRole> safeNamespaceRoles = roles(namespaceRoles);
Set<String> safePlatformRoles = roles(platformRoles);
if (!canManageContainer(suite, userId, safeNamespaceRoles, safePlatformRoles)) {
suiteQueryService.getDetail(
namespaceSlug, suiteSlug, null, userId, safeNamespaceRoles, safePlatformRoles);
}
return projectionService.labelsBySuiteIds(List.of(suite.getId()))
.getOrDefault(suite.getId(), List.of());
}
@Transactional
public SkillLabelDto attachLabel(
String namespaceSlug,
String suiteSlug,
String labelSlug,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
AuditRequestContext auditContext
) {
SkillSuite suite = resolveSuite(namespaceSlug, suiteSlug);
SkillSuiteLabel attached = suiteLabelService.attachLabel(
suite.getId(), labelSlug, userId, roles(namespaceRoles), roles(platformRoles));
recordAudit("SKILL_SUITE_LABEL_ATTACH", userId, suite.getId(), labelSlug, auditContext);
return projectionService.labelsBySuiteIds(List.of(attached.getSuiteId()))
.getOrDefault(attached.getSuiteId(), List.of()).stream()
.filter(label -> label.slug().equalsIgnoreCase(labelSlug.trim()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Attached Suite label projection is missing"));
}
@Transactional
public MessageResponse detachLabel(
String namespaceSlug,
String suiteSlug,
String labelSlug,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
AuditRequestContext auditContext
) {
SkillSuite suite = resolveSuite(namespaceSlug, suiteSlug);
suiteLabelService.detachLabel(
suite.getId(), labelSlug, userId, roles(namespaceRoles), roles(platformRoles));
recordAudit("SKILL_SUITE_LABEL_DETACH", userId, suite.getId(), labelSlug, auditContext);
return new MessageResponse("Suite label detached");
}
private SkillSuite resolveSuite(String namespaceSlug, String suiteSlug) {
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceSlug));
return suiteRepository.findByNamespaceIdAndSlug(namespace.getId(), suiteSlug)
.orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteSlug));
}
private boolean canManageContainer(
SkillSuite suite,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
if (platformRoles.contains("SUPER_ADMIN")) {
return true;
}
NamespaceRole role = namespaceRoles.get(suite.getNamespaceId());
return role == NamespaceRole.OWNER
|| role == NamespaceRole.ADMIN
|| (role != null && userId != null && userId.equals(suite.getCreatedBy()));
}
private void recordAudit(
String action,
String userId,
Long suiteId,
String labelSlug,
AuditRequestContext auditContext
) {
auditLogService.record(
userId,
action,
"SKILL_SUITE",
suiteId,
requestIdAccessor.current(),
auditContext != null ? auditContext.clientIp() : null,
auditContext != null ? auditContext.userAgent() : null,
AuditDetail.of("labelSlug", labelSlug));
}
private Map<Long, NamespaceRole> roles(Map<Long, NamespaceRole> roles) {
return roles == null ? Map.of() : roles;
}
private Set<String> roles(Set<String> roles) {
return roles == null ? Set.of() : roles;
}
}

View file

@ -0,0 +1,50 @@
package com.iflytek.skillhub.service.bundle;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/** Resolves fresh persisted authorization for asynchronous Bundle boundaries. */
@Service
public class SkillSuiteBundleActorContextService {
private final UserAccountRepository userRepository;
private final NamespaceMemberRepository namespaceMemberRepository;
private final RbacService rbacService;
public SkillSuiteBundleActorContextService(
UserAccountRepository userRepository,
NamespaceMemberRepository namespaceMemberRepository,
RbacService rbacService
) {
this.userRepository = userRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.rbacService = rbacService;
}
@Transactional(readOnly = true)
public ActorContext requireCurrent(String actorId) {
if (userRepository.findById(actorId).filter(user -> user.isActive()).isEmpty()) {
throw new DomainForbiddenException("error.suite.bundle.actor.inactive");
}
Map<Long, NamespaceRole> namespaceRoles = namespaceMemberRepository.findByUserId(actorId).stream()
.collect(Collectors.toUnmodifiableMap(
member -> member.getNamespaceId(), member -> member.getRole()));
Set<String> platformRoles = Set.copyOf(rbacService.getUserRoleCodes(actorId));
return new ActorContext(namespaceRoles, platformRoles);
}
public record ActorContext(
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
}
}

View file

@ -0,0 +1,363 @@
package com.iflytek.skillhub.service.bundle;
import com.iflytek.skillhub.config.SkillPublishProperties;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.storage.ObjectStorageService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/** Stages and analyzes one uploaded Bundle without retaining the expanded archive in heap memory. */
@Service
public class SkillSuiteBundleArchiveService {
private static final Logger log = LoggerFactory.getLogger(SkillSuiteBundleArchiveService.class);
private static final int BUFFER_SIZE = 8192;
private static final int MAX_MEMBERS = 100;
private static final int ZIP_CENTRAL_HEADER_SIZE = 46;
private static final int ZIP_EOCD_MIN_SIZE = 22;
private static final int ZIP_EOCD_MAX_SEARCH = 65_557;
private static final int UNIX_FILE_TYPE_MASK = 0170000;
private static final int UNIX_SYMBOLIC_LINK = 0120000;
private final SkillSuiteBundlePackageAnalyzer analyzer;
private final ObjectStorageService objectStorageService;
private final long maxArchiveSize;
private final long maxExpandedSize;
private final long maxSingleFileSize;
private final int maxFileCount;
public SkillSuiteBundleArchiveService(
SkillSuiteBundlePackageAnalyzer analyzer,
ObjectStorageService objectStorageService,
SkillPublishProperties properties
) {
this.analyzer = analyzer;
this.objectStorageService = objectStorageService;
this.maxArchiveSize = properties.getMaxPackageSize();
this.maxExpandedSize = properties.getMaxPackageSize();
this.maxSingleFileSize = properties.getMaxSingleFileSize();
this.maxFileCount = Math.multiplyExact(properties.getMaxFileCount(), MAX_MEMBERS);
}
public StagedBundleAnalysis stageAndAnalyze(MultipartFile upload) throws IOException {
if (upload == null || upload.isEmpty()) {
throw invalid("Bundle archive is empty");
}
if (upload.getSize() > maxArchiveSize) {
throw invalid("Bundle archive exceeds max compressed size " + maxArchiveSize);
}
String stagingId = UUID.randomUUID().toString();
String prefix = "temporary/suite-bundles/" + stagingId;
String archiveObjectKey = prefix + "/bundle.zip";
Path tempDirectory = Files.createTempDirectory("skillhub-suite-bundle-");
Path archivePath = tempDirectory.resolve("bundle.zip");
List<Path> localEntries = new ArrayList<>();
List<String> uploadedObjectKeys = new ArrayList<>();
try {
String archiveSha256 = copyUpload(upload, archivePath);
rejectSymbolicLinks(archivePath);
try (InputStream archiveInput = Files.newInputStream(archivePath)) {
objectStorageService.putObject(
archiveObjectKey, archiveInput, Files.size(archivePath), "application/zip");
}
uploadedObjectKeys.add(archiveObjectKey);
List<SkillSuiteBundleStagedEntry> stagedEntries = extractEntries(
archivePath, tempDirectory, prefix, localEntries, uploadedObjectKeys);
SkillSuiteBundlePackageAnalyzer.BundleAnalysis analysis = analyzer.analyze(stagedEntries);
if (!analysis.confirmable()) {
cleanupStagedObjects(uploadedObjectKeys);
return new StagedBundleAnalysis(null, null, analysis, List.of());
}
Set<String> retainedKeys = analysis.packageMembers().stream()
.flatMap(member -> member.files().stream())
.map(SkillSuiteBundlePackageAnalyzer.StagedMemberFile::objectKey)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
retainedKeys.add(archiveObjectKey);
List<String> unusedKeys = uploadedObjectKeys.stream()
.filter(key -> !retainedKeys.contains(key))
.toList();
cleanupStagedObjects(unusedKeys);
return new StagedBundleAnalysis(
archiveObjectKey, archiveSha256, analysis, List.copyOf(retainedKeys));
} catch (IOException | RuntimeException exception) {
cleanupStagedObjects(uploadedObjectKeys);
throw exception;
} finally {
for (Path localEntry : localEntries) {
Files.deleteIfExists(localEntry);
}
Files.deleteIfExists(archivePath);
Files.deleteIfExists(tempDirectory);
}
}
private String copyUpload(MultipartFile upload, Path archivePath) throws IOException {
MessageDigest digest = sha256();
long copied;
try (InputStream raw = upload.getInputStream();
DigestInputStream input = new DigestInputStream(raw, digest);
OutputStream output = new BufferedOutputStream(Files.newOutputStream(archivePath))) {
copied = copyBounded(input, output, maxArchiveSize, "Bundle archive");
}
if (upload.getSize() >= 0 && upload.getSize() != copied) {
throw invalid("Bundle archive size changed during upload");
}
return HexFormat.of().formatHex(digest.digest());
}
private List<SkillSuiteBundleStagedEntry> extractEntries(
Path archivePath,
Path tempDirectory,
String objectPrefix,
List<Path> localEntries,
List<String> uploadedObjectKeys
) throws IOException {
List<SkillSuiteBundleStagedEntry> staged = new ArrayList<>();
long expandedSize = 0L;
try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(archivePath))) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (entry.isDirectory() || entry.getName().endsWith("/") || entry.getName().endsWith("\\")) {
zip.closeEntry();
continue;
}
if (isOsMetadata(entry.getName())) {
zip.closeEntry();
continue;
}
if (staged.size() >= maxFileCount) {
throw invalid("Bundle contains more than " + maxFileCount + " files");
}
int position = staged.size();
Path localPath = tempDirectory.resolve("entry-" + position);
localEntries.add(localPath);
MessageDigest digest = sha256();
long size;
try (OutputStream output = new BufferedOutputStream(Files.newOutputStream(localPath))) {
size = copyZipEntry(zip, output, digest, entry.getName());
}
expandedSize = Math.addExact(expandedSize, size);
if (expandedSize > maxExpandedSize) {
throw invalid("Bundle expanded content exceeds max size " + maxExpandedSize);
}
String objectKey = objectPrefix + "/entries/" + position;
String contentType = contentType(entry.getName());
try (InputStream entryInput = Files.newInputStream(localPath)) {
objectStorageService.putObject(objectKey, entryInput, size, contentType);
}
uploadedObjectKeys.add(objectKey);
String sha256 = HexFormat.of().formatHex(digest.digest());
staged.add(new SkillSuiteBundleStagedEntry(
entry.getName(), size, contentType, sha256, objectKey,
() -> Files.newInputStream(localPath)));
zip.closeEntry();
}
} catch (ArithmeticException exception) {
throw invalid("Bundle expanded content size overflow");
}
return staged;
}
private long copyZipEntry(
InputStream input, OutputStream output, MessageDigest digest, String path
) throws IOException {
byte[] buffer = new byte[BUFFER_SIZE];
long copied = 0L;
int read;
while ((read = input.read(buffer)) != -1) {
copied += read;
if (copied > maxSingleFileSize) {
throw invalid("Bundle file exceeds max size: " + path);
}
digest.update(buffer, 0, read);
output.write(buffer, 0, read);
}
return copied;
}
private long copyBounded(
InputStream input, OutputStream output, long maximum, String subject
) throws IOException {
byte[] buffer = new byte[BUFFER_SIZE];
long copied = 0L;
int read;
while ((read = input.read(buffer)) != -1) {
copied += read;
if (copied > maximum) {
throw invalid(subject + " exceeds max size " + maximum);
}
output.write(buffer, 0, read);
}
return copied;
}
/** java.util.zip does not expose Unix modes, so inspect central-directory attributes directly. */
private void rejectSymbolicLinks(Path archivePath) throws IOException {
try (FileChannel channel = FileChannel.open(archivePath, StandardOpenOption.READ)) {
long fileSize = channel.size();
int tailLength = (int) Math.min(fileSize, ZIP_EOCD_MAX_SEARCH);
ByteBuffer tail = ByteBuffer.allocate(tailLength).order(ByteOrder.LITTLE_ENDIAN);
readFully(channel, tail, fileSize - tailLength);
int eocd = findEndOfCentralDirectory(tail);
if (eocd < 0 || eocd + ZIP_EOCD_MIN_SIZE > tailLength) {
throw invalid("Invalid ZIP end-of-central-directory record");
}
int entryCount = Short.toUnsignedInt(tail.getShort(eocd + 10));
long centralSize = Integer.toUnsignedLong(tail.getInt(eocd + 12));
long centralOffset = Integer.toUnsignedLong(tail.getInt(eocd + 16));
if (entryCount == 0xffff || centralSize == 0xffffffffL || centralOffset == 0xffffffffL) {
throw invalid("ZIP64 Bundle archives are not supported");
}
if (centralOffset + centralSize > fileSize) {
throw invalid("Invalid ZIP central-directory bounds");
}
long cursor = centralOffset;
for (int index = 0; index < entryCount; index++) {
ByteBuffer header = ByteBuffer.allocate(ZIP_CENTRAL_HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN);
readFully(channel, header, cursor);
if (header.getInt(0) != 0x02014b50) {
throw invalid("Invalid ZIP central-directory entry");
}
int creatorSystem = Byte.toUnsignedInt(header.get(5));
int nameLength = Short.toUnsignedInt(header.getShort(28));
int extraLength = Short.toUnsignedInt(header.getShort(30));
int commentLength = Short.toUnsignedInt(header.getShort(32));
long externalAttributes = Integer.toUnsignedLong(header.getInt(38));
int unixMode = (int) (externalAttributes >>> 16);
if (creatorSystem == 3 && (unixMode & UNIX_FILE_TYPE_MASK) == UNIX_SYMBOLIC_LINK) {
throw invalid("Bundle archive must not contain symbolic links");
}
cursor += ZIP_CENTRAL_HEADER_SIZE + nameLength + extraLength + commentLength;
if (cursor > centralOffset + centralSize) {
throw invalid("Invalid ZIP central-directory entry bounds");
}
}
}
}
private void readFully(FileChannel channel, ByteBuffer buffer, long position) throws IOException {
while (buffer.hasRemaining()) {
int read = channel.read(buffer, position + buffer.position());
if (read < 0) {
throw invalid("Unexpected end of ZIP archive");
}
}
}
private int findEndOfCentralDirectory(ByteBuffer tail) {
byte[] bytes = tail.array();
int signature = 0x06054b50;
for (int index = bytes.length - ZIP_EOCD_MIN_SIZE; index >= 0; index--) {
if ((bytes[index] & 0xff) == (signature & 0xff)
&& (bytes[index + 1] & 0xff) == ((signature >>> 8) & 0xff)
&& (bytes[index + 2] & 0xff) == ((signature >>> 16) & 0xff)
&& (bytes[index + 3] & 0xff) == ((signature >>> 24) & 0xff)) {
int commentLength = Short.toUnsignedInt(tail.getShort(index + 20));
if (index + ZIP_EOCD_MIN_SIZE + commentLength == bytes.length) {
return index;
}
}
}
return -1;
}
private String contentType(String path) {
String lower = path.toLowerCase();
if (lower.endsWith(".py")) return "text/x-python";
if (lower.endsWith(".json")) return "application/json";
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
if (lower.endsWith(".txt")) return "text/plain";
if (lower.endsWith(".md")) return "text/markdown";
if (lower.endsWith(".html")) return "text/html";
if (lower.endsWith(".css")) return "text/css";
if (lower.endsWith(".csv")) return "text/csv";
if (lower.endsWith(".xml")) return "application/xml";
if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) {
return "text/javascript";
}
if (lower.endsWith(".ts")) return "text/typescript";
if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) {
return "text/x-shellscript";
}
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".svg")) return "image/svg+xml";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".ico")) return "image/x-icon";
if (lower.endsWith(".pdf")) return "application/pdf";
if (lower.endsWith(".toml")) return "application/toml";
return "application/octet-stream";
}
private boolean isOsMetadata(String path) {
String normalized = path.replace('\\', '/');
if (normalized.equals("__MACOSX") || normalized.startsWith("__MACOSX/")) {
return true;
}
int slash = normalized.lastIndexOf('/');
String fileName = slash < 0 ? normalized : normalized.substring(slash + 1);
return fileName.equals(".DS_Store") || fileName.startsWith("._");
}
private MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
public void cleanupStagedObjects(List<String> objectKeys) {
if (objectKeys.isEmpty()) {
return;
}
try {
objectStorageService.deleteObjects(List.copyOf(objectKeys));
} catch (RuntimeException exception) {
log.warn("Failed to clean staged Suite Bundle objects: count={}", objectKeys.size(), exception);
}
}
private DomainBadRequestException invalid(String detail) {
return new DomainBadRequestException("error.suite.bundle.manifest.invalid", detail);
}
public record StagedBundleAnalysis(
String archiveObjectKey,
String archiveSha256,
SkillSuiteBundlePackageAnalyzer.BundleAnalysis analysis,
List<String> objectKeys
) {
}
}

View file

@ -0,0 +1,177 @@
package com.iflytek.skillhub.service.bundle;
import com.iflytek.skillhub.config.SkillSuiteBundleProperties;
import com.iflytek.skillhub.domain.event.SkillSuiteBundleAdvanceRequestedEvent;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifest;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMember;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
/** Atomically confirms one exact PreviewSession and acquires its Suite target reservation. */
@Service
public class SkillSuiteBundleConfirmationAppService {
private final SkillSuiteBundlePreviewSessionRepository previewRepository;
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
private final SkillSuiteBundleMemberResultRepository memberRepository;
private final SkillSuiteBundlePreviewRevalidationService revalidationService;
private final SkillSuiteBundleProperties properties;
private final ApplicationEventPublisher eventPublisher;
private final Clock clock;
public SkillSuiteBundleConfirmationAppService(
SkillSuiteBundlePreviewSessionRepository previewRepository,
SkillSuiteBundleExecutionOperationRepository operationRepository,
SkillSuiteBundleMemberResultRepository memberRepository,
SkillSuiteBundlePreviewRevalidationService revalidationService,
SkillSuiteBundleProperties properties,
ApplicationEventPublisher eventPublisher,
Clock clock
) {
this.previewRepository = previewRepository;
this.operationRepository = operationRepository;
this.memberRepository = memberRepository;
this.revalidationService = revalidationService;
this.properties = properties;
this.eventPublisher = eventPublisher;
this.clock = clock;
}
@Transactional
public ConfirmationOutcome confirm(
String previewToken,
String clientRequestId,
String confirmedWarningDigest,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
requireEnabled();
String normalizedRequestId = normalizeRequestId(clientRequestId);
ConfirmationOutcome existing = findIdempotent(actorId, normalizedRequestId, previewToken);
if (existing != null) {
return existing;
}
SkillSuiteBundlePreviewSession preview = previewRepository.findByIdForUpdate(previewToken)
.orElseThrow(() -> new DomainNotFoundException("error.suite.bundle.preview.notFound"));
// A concurrent confirmation may have committed while this transaction waited for the row lock.
existing = findIdempotent(actorId, normalizedRequestId, previewToken);
if (existing != null) {
return existing;
}
Instant now = clock.instant();
preview.requireConfirmableBy(actorId, confirmedWarningDigest, now);
SkillSuiteBundlePreviewRevalidationService.ValidatedPreview validated =
revalidationService.requireUnchanged(preview, actorId, namespaceRoles, platformRoles);
SkillSuiteBundlePreviewPlanner.PreviewPlan previewPlan = validated.plan();
SkillSuiteBundleManifest manifest = validated.manifest();
String operationId = UUID.randomUUID().toString();
SkillSuiteBundleExecutionOperation operation = new SkillSuiteBundleExecutionOperation(
operationId, previewToken, normalizedRequestId, actorId, preview.getMode(),
preview.getNamespaceId(), preview.getTargetSuiteSlug(), preview.getTargetSuiteId(),
preview.getBaseSuiteVersionId(), preview.getTargetVersion(), preview.getArchiveObjectKey(),
preview.getArchiveSha256(), preview.getPlan(), preview.getWarningDigest(), now);
try {
operationRepository.save(operation);
// The partial unique index must win before any member lifecycle work can start.
operationRepository.flush();
} catch (DataIntegrityViolationException exception) {
throw new DomainConflictException("error.suite.bundle.confirmation.operationConflict");
}
try {
memberRepository.saveAll(toMemberResults(operationId, manifest, previewPlan, now));
memberRepository.flush();
preview.markConfirmed(now);
previewRepository.save(preview);
previewRepository.flush();
} catch (DataIntegrityViolationException exception) {
throw new DomainBadRequestException("error.suite.bundle.preview.stateChanged");
}
eventPublisher.publishEvent(new SkillSuiteBundleAdvanceRequestedEvent(operationId));
return new ConfirmationOutcome(operationId, operation.getStatus().name(), false);
}
private ConfirmationOutcome findIdempotent(
String actorId, String requestId, String previewToken
) {
SkillSuiteBundleExecutionOperation existing = operationRepository
.findByActorIdAndClientRequestId(actorId, requestId).orElse(null);
if (existing == null) {
return null;
}
if (!existing.getPreviewToken().equals(previewToken)) {
throw new DomainConflictException("error.suite.bundle.confirmation.operationConflict");
}
return new ConfirmationOutcome(existing.getOperationId(), existing.getStatus().name(), true);
}
private List<SkillSuiteBundleMemberResult> toMemberResults(
String operationId,
SkillSuiteBundleManifest manifest,
SkillSuiteBundlePreviewPlanner.PreviewPlan plan,
Instant now
) {
Map<SkillSuiteBundleCoordinate, SkillSuiteBundleMember> manifestMembers =
manifest.spec().members().stream().collect(Collectors.toMap(
SkillSuiteBundleMember::coordinate, Function.identity()));
List<SkillSuiteBundleMemberResult> results = new ArrayList<>(plan.members().size());
for (int position = 0; position < plan.members().size(); position++) {
SkillSuiteBundlePreviewPlanner.MemberPlan member = plan.members().get(position);
var declared = manifestMembers.get(member.coordinate());
String packagePath = declared.packageSource() == null ? null : declared.packageSource().path();
results.add(new SkillSuiteBundleMemberResult(
operationId, position, member.coordinate(), member.sourceType(), packagePath,
member.finalVisibility(), member.resolvedVersion(), member.relationship(),
member.publishAction(), member.fingerprint(), member.skillId(), member.skillVersionId(),
member.errors(), member.warnings(), now));
}
return results;
}
private String normalizeRequestId(String requestId) {
if (requestId == null || requestId.isBlank()) {
throw new DomainBadRequestException("error.suite.bundle.confirmation.idempotencyKey.invalid");
}
if (!RequestIdAccessor.isValid(requestId)) {
throw new DomainBadRequestException("error.suite.bundle.confirmation.idempotencyKey.invalid");
}
return requestId;
}
private void requireEnabled() {
if (!properties.isConfirmationEnabled()) {
throw new DomainBadRequestException("error.suite.bundle.confirmation.disabled");
}
}
public record ConfirmationOutcome(String operationId, String status, boolean replayed) {
}
}

View file

@ -0,0 +1,75 @@
package com.iflytek.skillhub.service.bundle;
import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/** Advances one durable Bundle operation through bounded, independently committed steps. */
@Service
public class SkillSuiteBundleCoordinator {
private static final Logger log = LoggerFactory.getLogger(SkillSuiteBundleCoordinator.class);
private static final int MAX_MEMBER_STEPS = 100;
private final SkillSuiteBundleMemberExecutionService executionService;
private final SkillSuiteBundleMemberProgressService progressService;
private final SkillSuiteBundleDraftCreationService draftCreationService;
private final SkillSuiteBundleOperationStateService stateService;
public SkillSuiteBundleCoordinator(
SkillSuiteBundleMemberExecutionService executionService,
SkillSuiteBundleMemberProgressService progressService,
SkillSuiteBundleDraftCreationService draftCreationService,
SkillSuiteBundleOperationStateService stateService
) {
this.executionService = executionService;
this.progressService = progressService;
this.draftCreationService = draftCreationService;
this.stateService = stateService;
}
public void advance(String operationId) {
try {
for (int step = 0; step < MAX_MEMBER_STEPS; step++) {
if (executionService.executeNext(operationId)
== SkillSuiteBundleMemberExecutionService.ExecutionOutcome.NONE) {
break;
}
}
SkillSuiteBundleMemberProgressService.ProgressOutcome outcome =
progressService.reconcile(operationId);
if (outcome == SkillSuiteBundleMemberProgressService.ProgressOutcome.READY_FOR_DRAFT) {
draftCreationService.create(operationId);
}
} catch (LocalizedDomainException exception) {
if (isRetryableBlock(exception)) {
stateService.markBlockedRetryable(
operationId, "AUTHORIZATION_OR_NAMESPACE_BLOCKED", exception.messageCode());
log.info("Suite Bundle is temporarily blocked [operationId={}, reason={}]",
operationId, exception.messageCode());
} else {
stateService.markRepreviewRequired(operationId, "BUNDLE_PLAN_CHANGED");
log.info("Suite Bundle requires a new preview [operationId={}, reason={}]",
operationId, exception.messageCode());
}
} catch (RuntimeException exception) {
stateService.markBlockedRetryable(
operationId, "MEMBER_EXECUTION_FAILED", exception.getClass().getSimpleName());
log.error("Suite Bundle execution blocked [operationId={}]", operationId, exception);
}
}
private boolean isRetryableBlock(LocalizedDomainException exception) {
if ("error.namespace.frozen".equals(exception.messageCode())
|| "error.skill.publish.publisher.notMember".equals(exception.messageCode())
|| "error.skill.lifecycle.noPermission".equals(exception.messageCode())
|| "error.suite.lifecycle.noPermission".equals(exception.messageCode())
|| "error.suite.bundle.actor.inactive".equals(exception.messageCode())) {
return true;
}
return "error.suite.namespace.notWritable".equals(exception.messageCode())
&& exception.messageArgs().length > 0
&& "FROZEN".equals(String.valueOf(exception.messageArgs()[0]));
}
}

View file

@ -0,0 +1,124 @@
package com.iflytek.skillhub.service.bundle;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.suite.CreateSkillSuiteDraftCommand;
import com.iflytek.skillhub.domain.suite.SkillSuiteActionContext;
import com.iflytek.skillhub.domain.suite.SkillSuiteDraftService;
import com.iflytek.skillhub.domain.suite.SkillSuiteMemberSelection;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifest;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.util.List;
import java.util.Objects;
/** Atomically creates the Suite draft only after every bound member is published. */
@Service
public class SkillSuiteBundleDraftCreationService {
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
private final SkillSuiteBundleMemberResultRepository memberRepository;
private final SkillSuiteBundlePreviewSessionRepository previewRepository;
private final SkillSuiteBundleActorContextService actorContextService;
private final SkillSuiteDraftService draftService;
private final ObjectMapper objectMapper;
private final Clock clock;
public SkillSuiteBundleDraftCreationService(
SkillSuiteBundleExecutionOperationRepository operationRepository,
SkillSuiteBundleMemberResultRepository memberRepository,
SkillSuiteBundlePreviewSessionRepository previewRepository,
SkillSuiteBundleActorContextService actorContextService,
SkillSuiteDraftService draftService,
ObjectMapper objectMapper,
Clock clock
) {
this.operationRepository = operationRepository;
this.memberRepository = memberRepository;
this.previewRepository = previewRepository;
this.actorContextService = actorContextService;
this.draftService = draftService;
this.objectMapper = objectMapper;
this.clock = clock;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean create(String operationId) {
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
.orElse(null);
if (operation == null || operation.getStatus() == SkillSuiteBundleOperationStatus.CANCELLED
|| operation.getStatus() == SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED) {
return false;
}
if (operation.getStatus() == SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED) {
return true;
}
List<SkillSuiteBundleMemberResult> members =
memberRepository.findByOperationIdOrderByPositionForUpdate(operationId);
if (members.isEmpty() || members.stream().anyMatch(
member -> member.getStatus() != SkillSuiteBundleMemberResultStatus.COMPLETED)) {
return false;
}
SkillSuiteBundlePreviewSession preview = previewRepository.findById(operation.getPreviewToken())
.orElseThrow(this::stateChanged);
SkillSuiteBundleManifest manifest = objectMapper.convertValue(
preview.getManifest(), SkillSuiteBundleManifest.class);
SkillSuiteBundlePreviewPlanner.PreviewPlan plan = objectMapper.convertValue(
operation.getPlan(), SkillSuiteBundlePreviewPlanner.PreviewPlan.class);
if (manifest.spec().mode() != operation.getMode()
|| plan.mode() != operation.getMode()
|| !manifest.metadata().coordinate().equals(plan.target())
|| !manifest.metadata().coordinate().slug().equals(operation.getTargetSuiteSlug())
|| !manifest.spec().version().equals(operation.getTargetVersion())
|| !Objects.equals(plan.targetNamespaceId(), operation.getNamespaceId())
|| !Objects.equals(plan.targetSuiteId(), operation.getTargetSuiteId())
|| !Objects.equals(plan.baseSuiteVersionId(), operation.getBaseSuiteVersionId())
|| !Objects.equals(plan.targetVersion(), operation.getTargetVersion())) {
throw stateChanged();
}
SkillSuiteBundleActorContextService.ActorContext actor =
actorContextService.requireCurrent(operation.getActorId());
Long entryVersionId = members.stream()
.filter(member -> member.getNamespaceSlug().equals(manifest.spec().entry().namespace()))
.filter(member -> member.getSkillSlug().equals(manifest.spec().entry().slug()))
.map(SkillSuiteBundleMemberResult::getSkillVersionId)
.findFirst()
.orElseThrow(this::stateChanged);
List<SkillSuiteMemberSelection> selections = members.stream()
.map(member -> new SkillSuiteMemberSelection(
member.getSkillId(), member.getSkillVersionId(), member.getNamespaceSlug(),
member.getSkillSlug(), member.getRequestedVersion(), member.getFingerprint()))
.toList();
CreateSkillSuiteDraftCommand command = new CreateSkillSuiteDraftCommand(
operation.getNamespaceId(), operation.getTargetSuiteSlug(), plan.displayName(),
plan.summary(), plan.overview(), operation.getTargetVersion(), plan.visibility(),
manifest.spec().changelog(), entryVersionId, selections);
SkillSuiteActionContext context = new SkillSuiteActionContext(
operation.getActorId(), actor.namespaceRoles(), actor.platformRoles(),
operationId, null, null);
SkillSuiteDraftService.CreatedDraft created = operation.getMode() == SkillSuiteBundleMode.CREATE
? draftService.create(command, context)
: draftService.createVersion(operation.getTargetSuiteId(), command, context);
operation.markSuiteDraftCreated(
created.suite().getId(), created.version().getId(), clock.instant());
operationRepository.save(operation);
operationRepository.flush();
return true;
}
private DomainBadRequestException stateChanged() {
return new DomainBadRequestException("error.suite.bundle.member.stateChanged");
}
}

View file

@ -0,0 +1,302 @@
package com.iflytek.skillhub.service.bundle;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillReviewSubmitService;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction;
import com.iflytek.skillhub.storage.ObjectStorageService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/** Executes at most one confirmed Bundle member in its own transaction. */
@Service
public class SkillSuiteBundleMemberExecutionService {
private static final Logger log = LoggerFactory.getLogger(SkillSuiteBundleMemberExecutionService.class);
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
private final SkillSuiteBundleMemberResultRepository memberRepository;
private final SkillSuiteBundleActorContextService actorContextService;
private final NamespaceRepository namespaceRepository;
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final VisibilityChecker visibilityChecker;
private final SkillPublishService skillPublishService;
private final SkillReviewSubmitService skillReviewSubmitService;
private final ObjectStorageService objectStorageService;
private final ObjectMapper objectMapper;
private final Clock clock;
public SkillSuiteBundleMemberExecutionService(
SkillSuiteBundleExecutionOperationRepository operationRepository,
SkillSuiteBundleMemberResultRepository memberRepository,
SkillSuiteBundleActorContextService actorContextService,
NamespaceRepository namespaceRepository,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
VisibilityChecker visibilityChecker,
SkillPublishService skillPublishService,
SkillReviewSubmitService skillReviewSubmitService,
ObjectStorageService objectStorageService,
ObjectMapper objectMapper,
Clock clock
) {
this.operationRepository = operationRepository;
this.memberRepository = memberRepository;
this.actorContextService = actorContextService;
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.visibilityChecker = visibilityChecker;
this.skillPublishService = skillPublishService;
this.skillReviewSubmitService = skillReviewSubmitService;
this.objectStorageService = objectStorageService;
this.objectMapper = objectMapper;
this.clock = clock;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public ExecutionOutcome executeNext(String operationId) {
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
.orElse(null);
if (operation == null || operation.getStatus() != SkillSuiteBundleOperationStatus.RUNNING) {
return ExecutionOutcome.NONE;
}
List<SkillSuiteBundleMemberResult> members =
memberRepository.findByOperationIdOrderByPositionForUpdate(operationId);
SkillSuiteBundleMemberResult member = members.stream()
.filter(candidate -> candidate.getStatus()
== com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus.PLANNED)
.findFirst()
.orElse(null);
if (member == null) {
return ExecutionOutcome.NONE;
}
Instant now = clock.instant();
if (!member.start(now)) {
return ExecutionOutcome.NONE;
}
SkillSuiteBundleActorContextService.ActorContext actor =
actorContextService.requireCurrent(operation.getActorId());
SkillSuiteBundlePreviewPlanner.PreviewPlan plan = objectMapper.convertValue(
operation.getPlan(), SkillSuiteBundlePreviewPlanner.PreviewPlan.class);
SkillSuiteBundlePreviewPlanner.MemberPlan planned = plan.members().stream()
.filter(candidate -> candidate.coordinate().namespace().equals(member.getNamespaceSlug()))
.filter(candidate -> candidate.coordinate().slug().equals(member.getSkillSlug()))
.findFirst()
.orElseThrow(this::stateChanged);
assertPlanBinding(member, planned);
if (planned.publishAction() == SkillSuiteBundlePublishAction.REUSE_VERSION
|| planned.publishAction() == SkillSuiteBundlePublishAction.REFERENCE_VERSION) {
completeExisting(operation, member, actor, now);
} else if (planned.publishAction() == SkillSuiteBundlePublishAction.CREATE_SKILL
|| planned.publishAction() == SkillSuiteBundlePublishAction.CREATE_VERSION) {
publishPackage(operation, member, planned, actor, now);
} else {
throw stateChanged();
}
memberRepository.saveAll(List.of(member));
memberRepository.flush();
return ExecutionOutcome.PROGRESSED;
}
private void publishPackage(
SkillSuiteBundleExecutionOperation operation,
SkillSuiteBundleMemberResult member,
SkillSuiteBundlePreviewPlanner.MemberPlan planned,
SkillSuiteBundleActorContextService.ActorContext actor,
Instant now
) {
if (planned.files().isEmpty() || member.getRequestedVisibility() == null) {
throw stateChanged();
}
Path localDirectory = null;
try {
localDirectory = Files.createTempDirectory("skillhub-suite-member-");
List<PackageEntry> entries = readEntries(planned.files(), localDirectory);
SkillPublishService.PublishResult result = skillPublishService.publishBundleMemberFromEntries(
member.getNamespaceSlug(), member.getSkillId(), member.getSkillSlug(),
member.getRequestedVersion(), entries, planned.files().stream().collect(Collectors.toUnmodifiableMap(
SkillSuiteBundlePackageAnalyzer.StagedMemberFile::relativePath,
SkillSuiteBundlePackageAnalyzer.StagedMemberFile::sha256)),
operation.getActorId(),
member.getRequestedVisibility(), actor.namespaceRoles(), actor.platformRoles(), true);
member.bindVersion(result.skillId(), result.version().getId(), now);
advanceCreatedVersion(operation, member, result.version(), actor, now);
} catch (IOException exception) {
throw new IllegalStateException("Failed to stage Bundle member locally", exception);
} finally {
deleteLocalDirectory(localDirectory);
}
}
private void advanceCreatedVersion(
SkillSuiteBundleExecutionOperation operation,
SkillSuiteBundleMemberResult member,
SkillVersion version,
SkillSuiteBundleActorContextService.ActorContext actor,
Instant now
) {
if (version.getStatus() == SkillVersionStatus.UPLOADED
&& member.getRequestedVisibility()
== com.iflytek.skillhub.domain.skill.SkillVisibility.PRIVATE) {
skillReviewSubmitService.confirmPublish(
member.getSkillId(), version.getId(), operation.getActorId(),
actor.namespaceRoles(), actor.platformRoles());
member.markCompleted(now);
return;
}
if (version.getStatus() == SkillVersionStatus.PUBLISHED) {
member.markCompleted(now);
return;
}
if (version.getStatus() == SkillVersionStatus.SCANNING
|| version.getStatus() == SkillVersionStatus.PENDING_REVIEW) {
member.markWaiting(now);
return;
}
throw stateChanged();
}
private void completeExisting(
SkillSuiteBundleExecutionOperation operation,
SkillSuiteBundleMemberResult member,
SkillSuiteBundleActorContextService.ActorContext actor,
Instant now
) {
Skill skill = skillRepository.findById(Objects.requireNonNull(member.getSkillId()))
.orElseThrow(this::stateChanged);
SkillVersion version = skillVersionRepository.findById(Objects.requireNonNull(member.getSkillVersionId()))
.orElseThrow(this::stateChanged);
Namespace namespace = namespaceRepository.findBySlug(member.getNamespaceSlug())
.orElseThrow(this::stateChanged);
if (!skill.getNamespaceId().equals(namespace.getId())
|| namespace.getStatus() != NamespaceStatus.ACTIVE
|| !skill.getSlug().equals(member.getSkillSlug())
|| !version.getSkillId().equals(skill.getId())
|| !version.getVersion().equals(member.getRequestedVersion())
|| version.getStatus() != SkillVersionStatus.PUBLISHED
|| !version.isDownloadReady()
|| version.getYankedAt() != null
|| skill.getStatus() != SkillStatus.ACTIVE
|| skill.isHidden()
|| skill.getVisibility() != member.getRequestedVisibility()
|| !visibilityChecker.canAccess(
skill, operation.getActorId(), actor.namespaceRoles(), actor.platformRoles())) {
throw stateChanged();
}
member.markCompleted(now);
}
private List<PackageEntry> readEntries(
List<SkillSuiteBundlePackageAnalyzer.StagedMemberFile> files, Path localDirectory
) throws IOException {
List<PackageEntry> entries = new ArrayList<>(files.size());
for (int index = 0; index < files.size(); index++) {
SkillSuiteBundlePackageAnalyzer.StagedMemberFile file = files.get(index);
if (file.size() < 0 || file.size() >= Integer.MAX_VALUE) {
throw stateChanged();
}
Path localFile = localDirectory.resolve("entry-" + index);
long copied = 0;
try (InputStream input = objectStorageService.getObject(file.objectKey());
OutputStream output = Files.newOutputStream(localFile)) {
byte[] buffer = new byte[64 * 1024];
int read;
while ((read = input.read(buffer)) != -1) {
copied += read;
if (copied > file.size()) {
throw stateChanged();
}
output.write(buffer, 0, read);
}
}
if (copied != file.size()) {
throw stateChanged();
}
entries.add(PackageEntry.streaming(
file.relativePath(), file.size(), file.contentType(),
() -> Files.newInputStream(localFile)));
}
return List.copyOf(entries);
}
private void deleteLocalDirectory(Path directory) {
if (directory == null) {
return;
}
try {
List<Path> localFiles;
try (var files = Files.list(directory)) {
localFiles = files.toList();
}
for (Path path : localFiles) {
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
log.warn("Failed to delete staged Bundle member file {}", path, exception);
}
}
Files.deleteIfExists(directory);
} catch (IOException exception) {
log.warn("Failed to delete staged Bundle member directory {}", directory, exception);
}
}
private void assertPlanBinding(
SkillSuiteBundleMemberResult member,
SkillSuiteBundlePreviewPlanner.MemberPlan planned
) {
if (planned.publishAction() != member.getPublishAction()
|| planned.sourceType() != member.getSourceType()
|| !Objects.equals(planned.skillId(), member.getSkillId())
|| !Objects.equals(planned.skillVersionId(), member.getSkillVersionId())
|| !Objects.equals(planned.resolvedVersion(), member.getRequestedVersion())
|| !Objects.equals(planned.fingerprint(), member.getFingerprint())) {
throw stateChanged();
}
}
private DomainBadRequestException stateChanged() {
return new DomainBadRequestException("error.suite.bundle.member.stateChanged");
}
public enum ExecutionOutcome {
PROGRESSED,
NONE
}
}

View file

@ -0,0 +1,195 @@
package com.iflytek.skillhub.service.bundle;
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.NamespaceStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillReviewSubmitService;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** Converges bound member versions without changing their identity. */
@Service
public class SkillSuiteBundleMemberProgressService {
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
private final SkillSuiteBundleMemberResultRepository memberRepository;
private final SkillSuiteBundleActorContextService actorContextService;
private final NamespaceRepository namespaceRepository;
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillReviewSubmitService skillReviewSubmitService;
private final Clock clock;
public SkillSuiteBundleMemberProgressService(
SkillSuiteBundleExecutionOperationRepository operationRepository,
SkillSuiteBundleMemberResultRepository memberRepository,
SkillSuiteBundleActorContextService actorContextService,
NamespaceRepository namespaceRepository,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillReviewSubmitService skillReviewSubmitService,
Clock clock
) {
this.operationRepository = operationRepository;
this.memberRepository = memberRepository;
this.actorContextService = actorContextService;
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillReviewSubmitService = skillReviewSubmitService;
this.clock = clock;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public ProgressOutcome reconcile(String operationId) {
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
.orElse(null);
if (operation == null || terminal(operation.getStatus())) {
return ProgressOutcome.TERMINAL;
}
SkillSuiteBundleActorContextService.ActorContext actor =
actorContextService.requireCurrent(operation.getActorId());
List<SkillSuiteBundleMemberResult> members =
memberRepository.findByOperationIdOrderByPositionForUpdate(operationId);
Instant now = clock.instant();
boolean waiting = false;
for (SkillSuiteBundleMemberResult member : members) {
if (member.getStatus() == SkillSuiteBundleMemberResultStatus.PLANNED
|| member.getStatus() == SkillSuiteBundleMemberResultStatus.RUNNING) {
operation.markRunning(now);
operationRepository.save(operation);
return ProgressOutcome.HAS_PLANNED;
}
if (member.getStatus() == SkillSuiteBundleMemberResultStatus.BLOCKED_RETRYABLE) {
operation.markBlockedRetryable("MEMBER_RETRY_REQUIRED", null, now);
operationRepository.save(operation);
return ProgressOutcome.TERMINAL;
}
if (member.getStatus() != SkillSuiteBundleMemberResultStatus.WAITING_FOR_MEMBER) {
continue;
}
MemberState state = loadState(member, operation, actor);
switch (state.version().getStatus()) {
case PUBLISHED -> {
if (!state.version().isDownloadReady() || state.version().getYankedAt() != null) {
throw stateChanged();
}
member.markCompleted(now);
}
case SCANNING, PENDING_REVIEW -> waiting = true;
case UPLOADED -> {
if (state.skill().getVisibility() != SkillVisibility.PRIVATE) {
throw stateChanged();
}
skillReviewSubmitService.confirmPublish(
state.skill().getId(), state.version().getId(), operation.getActorId(),
actor.namespaceRoles(), actor.platformRoles());
member.markCompleted(now);
}
case SCAN_FAILED -> {
member.markBlockedRetryable("MEMBER_SCAN_FAILED", now);
operation.markBlockedRetryable("MEMBER_SCAN_FAILED", null, now);
memberRepository.saveAll(members);
operationRepository.save(operation);
return ProgressOutcome.TERMINAL;
}
case REJECTED, DRAFT, YANKED -> throw stateChanged();
}
}
memberRepository.saveAll(members);
if (waiting) {
operation.markWaitingForMembers(now);
operationRepository.save(operation);
return ProgressOutcome.WAITING;
}
boolean allCompleted = members.stream().allMatch(
member -> member.getStatus() == SkillSuiteBundleMemberResultStatus.COMPLETED);
if (!allCompleted) {
throw stateChanged();
}
operation.markRunning(now);
operationRepository.save(operation);
return ProgressOutcome.READY_FOR_DRAFT;
}
private MemberState loadState(
SkillSuiteBundleMemberResult member,
SkillSuiteBundleExecutionOperation operation,
SkillSuiteBundleActorContextService.ActorContext actor
) {
Skill skill = skillRepository.findById(Objects.requireNonNull(member.getSkillId()))
.orElseThrow(this::stateChanged);
SkillVersion version = skillVersionRepository.findById(Objects.requireNonNull(member.getSkillVersionId()))
.orElseThrow(this::stateChanged);
Namespace namespace = namespaceRepository.findBySlug(member.getNamespaceSlug())
.orElseThrow(this::stateChanged);
if (!skill.getNamespaceId().equals(namespace.getId())
|| namespace.getStatus() != NamespaceStatus.ACTIVE
|| !skill.getSlug().equals(member.getSkillSlug())
|| skill.getStatus() != SkillStatus.ACTIVE
|| skill.isHidden()
|| !version.getSkillId().equals(skill.getId())
|| !version.getVersion().equals(member.getRequestedVersion())
|| skill.getVisibility() != member.getRequestedVisibility()
|| !canManage(skill, operation.getActorId(), actor.namespaceRoles(), actor.platformRoles())) {
throw stateChanged();
}
return new MemberState(skill, version);
}
private boolean canManage(
Skill skill,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
NamespaceRole role = namespaceRoles.get(skill.getNamespaceId());
return skill.getOwnerId().equals(actorId)
|| role == NamespaceRole.OWNER
|| role == NamespaceRole.ADMIN
|| platformRoles.contains("SUPER_ADMIN");
}
private boolean terminal(SkillSuiteBundleOperationStatus status) {
return status == SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED
|| status == SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED
|| status == SkillSuiteBundleOperationStatus.CANCELLED;
}
private DomainBadRequestException stateChanged() {
return new DomainBadRequestException("error.suite.bundle.member.stateChanged");
}
private record MemberState(Skill skill, SkillVersion version) {
}
public enum ProgressOutcome {
HAS_PLANNED,
WAITING,
READY_FOR_DRAFT,
TERMINAL
}
}

View file

@ -0,0 +1,168 @@
package com.iflytek.skillhub.service.bundle;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.event.SkillSuiteBundleAdvanceRequestedEvent;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationAuthorizationPolicy;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.SecurityScanRetryAppService;
import org.springframework.stereotype.Service;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Applies non-destructive control commands to a locked Bundle operation. */
@Service
public class SkillSuiteBundleOperationCommandService {
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
private final SkillSuiteBundleMemberResultRepository memberRepository;
private final SkillSuiteBundlePreviewSessionRepository previewRepository;
private final SkillSuiteBundlePreviewRevalidationService revalidationService;
private final SecurityScanRetryAppService securityScanRetryAppService;
private final ApplicationEventPublisher eventPublisher;
private final Clock clock;
public SkillSuiteBundleOperationCommandService(
SkillSuiteBundleExecutionOperationRepository operationRepository,
SkillSuiteBundleMemberResultRepository memberRepository,
SkillSuiteBundlePreviewSessionRepository previewRepository,
SkillSuiteBundlePreviewRevalidationService revalidationService,
SecurityScanRetryAppService securityScanRetryAppService,
ApplicationEventPublisher eventPublisher,
Clock clock
) {
this.operationRepository = operationRepository;
this.memberRepository = memberRepository;
this.previewRepository = previewRepository;
this.revalidationService = revalidationService;
this.securityScanRetryAppService = securityScanRetryAppService;
this.eventPublisher = eventPublisher;
this.clock = clock;
}
@Transactional
public SkillSuiteBundleOperationResponse cancel(
String operationId,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
.orElseThrow(this::notFound);
if (!SkillSuiteBundleOperationAuthorizationPolicy.canAccess(
operation, actorId, namespaceRoles, platformRoles)) {
throw notFound();
}
Instant now = clock.instant();
boolean changed = operation.cancel(now);
if (changed) {
List<SkillSuiteBundleMemberResult> members =
memberRepository.findByOperationIdOrderByPositionForUpdate(operationId);
members.forEach(member -> member.cancelUnlessCompleted(now));
memberRepository.saveAll(members);
operationRepository.save(operation);
operationRepository.flush();
}
return new SkillSuiteBundleOperationResponse(
operation.getOperationId(), operation.getStatus().name(), !changed);
}
@Transactional
public SkillSuiteBundleOperationResponse retry(
String operationId,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
.orElseThrow(this::notFound);
if (!SkillSuiteBundleOperationAuthorizationPolicy.canAccess(
operation, actorId, namespaceRoles, platformRoles)) {
throw notFound();
}
operation.requireRetryable();
SkillSuiteBundlePreviewSession preview = previewRepository.findById(operation.getPreviewToken())
.orElse(null);
Instant now = clock.instant();
List<SkillSuiteBundleMemberResult> members =
memberRepository.findByOperationIdOrderByPositionForUpdate(operationId);
if ("MEMBER_SCAN_FAILED".equals(operation.getFailureCode())) {
retryFailedScan(operation, members, actorId, namespaceRoles, platformRoles, now);
} else if (preview == null || (!hasProgress(members)
&& !planRemainsValid(preview, actorId, namespaceRoles, platformRoles))) {
operation.markRepreviewRequired("BUNDLE_PLAN_CHANGED", now);
members.forEach(member -> member.requireRepreviewUnlessCompleted(now));
} else {
operation.retry(now);
members.forEach(member -> member.retryUnlessCompleted(now));
}
memberRepository.saveAll(members);
operationRepository.save(operation);
operationRepository.flush();
if (operation.getStatus() == com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus.RUNNING) {
eventPublisher.publishEvent(new SkillSuiteBundleAdvanceRequestedEvent(operationId));
}
return new SkillSuiteBundleOperationResponse(
operation.getOperationId(), operation.getStatus().name(), false);
}
private void retryFailedScan(
SkillSuiteBundleExecutionOperation operation,
List<SkillSuiteBundleMemberResult> members,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
Instant now
) {
SkillSuiteBundleMemberResult failed = members.stream()
.filter(member -> member.getStatus()
== com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus.BLOCKED_RETRYABLE)
.filter(member -> member.getSkillId() != null && member.getSkillVersionId() != null)
.findFirst()
.orElseThrow(() -> new DomainBadRequestException("error.suite.bundle.preview.stateChanged"));
securityScanRetryAppService.retry(
failed.getSkillId(), failed.getSkillVersionId(), actorId, platformRoles, namespaceRoles,
new AuditRequestContext(null, "SkillSuiteBundle"));
operation.retry(now);
failed.markWaiting(now);
}
private boolean hasProgress(List<SkillSuiteBundleMemberResult> members) {
return members.stream().anyMatch(member -> member.getSkillVersionId() != null);
}
private boolean planRemainsValid(
SkillSuiteBundlePreviewSession preview,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
try {
revalidationService.requireUnchanged(preview, actorId, namespaceRoles, platformRoles);
return true;
} catch (DomainBadRequestException exception) {
if (!"error.suite.bundle.preview.stateChanged".equals(exception.messageCode())) {
throw exception;
}
return false;
}
}
private DomainNotFoundException notFound() {
return new DomainNotFoundException("error.suite.bundle.operation.notFound");
}
}

View file

@ -0,0 +1,183 @@
package com.iflytek.skillhub.service.bundle;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
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.SkillVisibility;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationAuthorizationPolicy;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationDetailResponse;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationPageResponse;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationSummaryResponse;
import com.iflytek.skillhub.repository.SkillSuiteBundleOperationQueryRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/** Reads one Bundle operation through an actor/governance authorization and redaction boundary. */
@Service
public class SkillSuiteBundleOperationQueryService {
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
private final SkillSuiteBundleMemberResultRepository memberRepository;
private final NamespaceRepository namespaceRepository;
private final SkillRepository skillRepository;
private final SkillSuiteVersionRepository suiteVersionRepository;
private final VisibilityChecker visibilityChecker;
private final SkillSuiteBundleOperationQueryRepository operationQueryRepository;
public SkillSuiteBundleOperationQueryService(
SkillSuiteBundleExecutionOperationRepository operationRepository,
SkillSuiteBundleMemberResultRepository memberRepository,
NamespaceRepository namespaceRepository,
SkillRepository skillRepository,
SkillSuiteVersionRepository suiteVersionRepository,
VisibilityChecker visibilityChecker,
SkillSuiteBundleOperationQueryRepository operationQueryRepository
) {
this.operationRepository = operationRepository;
this.memberRepository = memberRepository;
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.suiteVersionRepository = suiteVersionRepository;
this.visibilityChecker = visibilityChecker;
this.operationQueryRepository = operationQueryRepository;
}
@Transactional(readOnly = true)
public SkillSuiteBundleOperationDetailResponse get(
String operationId,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
SkillSuiteBundleExecutionOperation operation = operationRepository.findById(operationId)
.orElseThrow(this::notFound);
if (!SkillSuiteBundleOperationAuthorizationPolicy.canAccess(
operation, actorId, namespaceRoles, platformRoles)) {
// Do not reveal whether an operation ID exists to an unrelated caller.
throw notFound();
}
var persistedMembers = memberRepository.findByOperationIdOrderByPosition(operationId);
List<Long> skillIds = persistedMembers.stream()
.map(member -> member.getSkillId())
.filter(Objects::nonNull)
.distinct()
.toList();
Map<Long, Skill> skillsById = (skillIds.isEmpty() ? List.<Skill>of() : skillRepository.findByIdIn(skillIds))
.stream()
.collect(Collectors.toMap(Skill::getId, Function.identity()));
List<String> namespaceSlugs = persistedMembers.stream()
.map(member -> member.getNamespaceSlug())
.distinct()
.toList();
Map<String, Long> namespaceIdsBySlug = (namespaceSlugs.isEmpty()
? List.<com.iflytek.skillhub.domain.namespace.Namespace>of()
: namespaceRepository.findBySlugIn(namespaceSlugs)).stream()
.collect(Collectors.toMap(namespace -> namespace.getSlug(), namespace -> namespace.getId()));
var members = persistedMembers.stream()
.map(member -> canReadMember(
member.getSkillId() == null ? null : skillsById.get(member.getSkillId()),
namespaceIdsBySlug.get(member.getNamespaceSlug()), member.getRequestedVisibility(),
operation, actorId, namespaceRoles, platformRoles)
? visibleMember(member)
: redactedMember(member))
.toList();
String namespaceSlug = namespaceRepository.findById(operation.getNamespaceId())
.map(namespace -> namespace.getSlug())
.orElseThrow(this::notFound);
String baseVersion = operation.getBaseSuiteVersionId() == null
? null
: suiteVersionRepository.findById(operation.getBaseSuiteVersionId())
.map(version -> version.getVersion())
.orElse(null);
return new SkillSuiteBundleOperationDetailResponse(
operation.getOperationId(), operation.getStatus(), operation.getMode(),
"@" + namespaceSlug + "/" + operation.getTargetSuiteSlug(), operation.getNamespaceId(),
operation.getTargetSuiteId(), operation.getTargetVersion(), baseVersion,
operation.getFailureCode(), operation.getResultSuiteId(), operation.getResultSuiteVersionId(),
operation.getCreatedAt(), operation.getUpdatedAt(), operation.getCompletedAt(), members);
}
/** Returns one bounded page of the caller's active operations without exposing member metadata. */
@Transactional(readOnly = true)
public PageResponse<SkillSuiteBundleOperationSummaryResponse> listActive(
String actorId,
int page,
int size
) {
return operationQueryRepository.findActive(
actorId, Math.max(0, page), Math.min(Math.max(1, size), 50));
}
/** Returns one bounded page of the caller's operations, including terminal history. */
@Transactional(readOnly = true)
public SkillSuiteBundleOperationPageResponse listMine(
String actorId,
int page,
int size
) {
return operationQueryRepository.findMine(
actorId, Math.max(0, page), Math.min(Math.max(1, size), 50));
}
private boolean canReadMember(
Skill skill,
Long namespaceId,
SkillVisibility visibility,
SkillSuiteBundleExecutionOperation operation,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
if (skill != null) {
return visibilityChecker.canAccess(skill, actorId, namespaceRoles, platformRoles);
}
if (platformRoles.contains("SUPER_ADMIN") || visibility == SkillVisibility.PUBLIC) {
return true;
}
NamespaceRole role = namespaceId == null ? null : namespaceRoles.get(namespaceId);
if (visibility == SkillVisibility.NAMESPACE_ONLY) {
return role != null;
}
return operation.getActorId().equals(actorId)
|| role == NamespaceRole.OWNER
|| role == NamespaceRole.ADMIN;
}
private SkillSuiteBundleOperationDetailResponse.OperationMember visibleMember(
SkillSuiteBundleMemberResult member
) {
return new SkillSuiteBundleOperationDetailResponse.OperationMember(
member.getPosition(), false, "@" + member.getNamespaceSlug() + "/" + member.getSkillSlug(),
member.getSourceType(), member.getPackagePath(), member.getRelationshipChange(), member.getPublishAction(),
member.getStatus(), member.getRequestedVisibility(), member.getRequestedVersion(),
member.getSkillId(), member.getSkillVersionId(), member.getErrors(), member.getWarnings());
}
private SkillSuiteBundleOperationDetailResponse.OperationMember redactedMember(
SkillSuiteBundleMemberResult member
) {
return new SkillSuiteBundleOperationDetailResponse.OperationMember(
member.getPosition(), true, null, null, null, null, null, member.getStatus(),
null, null, null, null, List.of(), List.of());
}
private DomainNotFoundException notFound() {
return new DomainNotFoundException("error.suite.bundle.operation.notFound");
}
}

View file

@ -0,0 +1,84 @@
package com.iflytek.skillhub.service.bundle;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
/** Records recoverable or terminal Bundle failures outside a rolled-back member transaction. */
@Service
public class SkillSuiteBundleOperationStateService {
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
private final SkillSuiteBundleMemberResultRepository memberRepository;
private final Clock clock;
public SkillSuiteBundleOperationStateService(
SkillSuiteBundleExecutionOperationRepository operationRepository,
SkillSuiteBundleMemberResultRepository memberRepository,
Clock clock
) {
this.operationRepository = operationRepository;
this.memberRepository = memberRepository;
this.clock = clock;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markRepreviewRequired(String operationId, String code) {
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
.orElse(null);
if (operation == null || terminal(operation.getStatus())) {
return;
}
Instant now = clock.instant();
operation.markRepreviewRequired(code, now);
List<SkillSuiteBundleMemberResult> members =
memberRepository.findByOperationIdOrderByPositionForUpdate(operationId);
members.forEach(member -> {
if (member.getStatus()
!= com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus.COMPLETED) {
member.markRepreviewRequired(code, now);
}
});
memberRepository.saveAll(members);
operationRepository.save(operation);
operationRepository.flush();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markBlockedRetryable(String operationId, String code, String detail) {
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
.orElse(null);
if (operation == null || terminal(operation.getStatus())) {
return;
}
Instant now = clock.instant();
operation.markBlockedRetryable(code, detail, now);
List<SkillSuiteBundleMemberResult> members =
memberRepository.findByOperationIdOrderByPositionForUpdate(operationId);
members.stream()
.filter(member -> member.getStatus()
== com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus.PLANNED
|| member.getStatus()
== com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus.RUNNING)
.findFirst()
.ifPresent(member -> member.markBlockedRetryable(code, now));
memberRepository.saveAll(members);
operationRepository.save(operation);
operationRepository.flush();
}
private boolean terminal(SkillSuiteBundleOperationStatus status) {
return status == SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED
|| status == SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED
|| status == SkillSuiteBundleOperationStatus.CANCELLED;
}
}

View file

@ -0,0 +1,308 @@
package com.iflytek.skillhub.service.bundle;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.domain.skill.validation.NoOpPrePublishValidator;
import com.iflytek.skillhub.domain.skill.validation.PrePublishValidator;
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
import com.iflytek.skillhub.domain.skill.validation.ValidationResult;
import com.iflytek.skillhub.domain.namespace.SlugValidator;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifest;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifestParser;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMember;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Resolves the outer Bundle tree and validates each staged package as an independent Skill package.
* Only the file currently inspected by a content validator is materialized; returned plans keep
* staged locations rather than retaining member packages in heap memory.
*/
@Service
public class SkillSuiteBundlePackageAnalyzer {
private static final int MAX_MANIFEST_BYTES = 256_000;
private final SkillSuiteBundleManifestParser manifestParser;
private final SkillMetadataParser metadataParser;
private final SkillPackageValidator packageValidator;
private final PrePublishValidator prePublishValidator;
@Autowired
public SkillSuiteBundlePackageAnalyzer(
SkillSuiteBundleManifestParser manifestParser,
SkillMetadataParser metadataParser,
SkillPackageValidator packageValidator,
PrePublishValidator prePublishValidator
) {
this.manifestParser = manifestParser;
this.metadataParser = metadataParser;
this.packageValidator = packageValidator;
this.prePublishValidator = prePublishValidator;
}
SkillSuiteBundlePackageAnalyzer(
SkillSuiteBundleManifestParser manifestParser,
SkillMetadataParser metadataParser,
SkillPackageValidator packageValidator
) {
this(manifestParser, metadataParser, packageValidator, new NoOpPrePublishValidator());
}
public BundleAnalysis analyze(List<SkillSuiteBundleStagedEntry> stagedEntries) throws IOException {
List<String> errors = new ArrayList<>();
List<IndexedEntry> indexed = indexEntries(stagedEntries, errors);
IndexedEntry manifestEntry = findManifest(indexed);
String outerPrefix = parentPrefix(manifestEntry.normalizedPath());
List<IndexedEntry> rooted = removeOuterPrefix(indexed, outerPrefix, errors);
IndexedEntry rootedManifest = rooted.stream()
.filter(entry -> SkillSuiteBundleManifest.FILE_NAME.equals(entry.rootPath()))
.findFirst()
.orElseThrow(() -> invalid("SUITE.yaml must be at the Bundle root"));
SkillSuiteBundleManifest manifest = manifestParser.parse(readManifest(rootedManifest.staged()));
Map<String, List<IndexedEntry>> filesByDirectory = new LinkedHashMap<>();
for (SkillSuiteBundleMember member : manifest.spec().members()) {
if (member.packageSource() != null) {
filesByDirectory.put(member.packageSource().path(), new ArrayList<>());
}
}
for (IndexedEntry entry : rooted) {
if (SkillSuiteBundleManifest.FILE_NAME.equals(entry.rootPath())) {
continue;
}
String owner = owningDirectory(entry.rootPath(), filesByDirectory.keySet());
if (owner == null) {
errors.add("Unclaimed archive entry: " + entry.rootPath());
} else {
filesByDirectory.get(owner).add(entry);
}
}
List<MemberPackageAnalysis> packageMembers = new ArrayList<>();
for (SkillSuiteBundleMember member : manifest.spec().members()) {
if (member.packageSource() == null) {
continue;
}
packageMembers.add(analyzeMember(
member.coordinate(), member.packageSource().path(),
filesByDirectory.get(member.packageSource().path())));
}
return new BundleAnalysis(manifest, List.copyOf(packageMembers), List.copyOf(errors));
}
private List<IndexedEntry> indexEntries(
List<SkillSuiteBundleStagedEntry> stagedEntries, List<String> errors
) {
List<IndexedEntry> indexed = new ArrayList<>();
Set<String> paths = new HashSet<>();
for (SkillSuiteBundleStagedEntry staged : stagedEntries) {
if (isOsMetadata(staged.path())) {
continue;
}
if (staged.path().contains("\\")) {
errors.add("Archive entry must use '/' separators: " + staged.path());
continue;
}
String normalized;
try {
normalized = SkillPackagePolicy.normalizeEntryPath(staged.path());
} catch (IllegalArgumentException exception) {
errors.add(exception.getMessage());
continue;
}
if (!paths.add(normalized)) {
errors.add("Duplicate archive path: " + normalized);
continue;
}
indexed.add(new IndexedEntry(staged, normalized, normalized));
}
return indexed;
}
private IndexedEntry findManifest(List<IndexedEntry> entries) {
List<IndexedEntry> manifests = entries.stream()
.filter(entry -> entry.normalizedPath().equals(SkillSuiteBundleManifest.FILE_NAME)
|| entry.normalizedPath().endsWith("/" + SkillSuiteBundleManifest.FILE_NAME))
.toList();
if (manifests.size() != 1) {
throw invalid("Bundle must contain exactly one SUITE.yaml manifest");
}
return manifests.getFirst();
}
private List<IndexedEntry> removeOuterPrefix(
List<IndexedEntry> entries, String prefix, List<String> errors
) {
if (prefix.isEmpty()) {
return entries;
}
List<IndexedEntry> rooted = new ArrayList<>();
for (IndexedEntry entry : entries) {
if (!entry.normalizedPath().startsWith(prefix)) {
errors.add("Archive entry is outside the Bundle root: " + entry.normalizedPath());
continue;
}
rooted.add(new IndexedEntry(
entry.staged(), entry.normalizedPath(), entry.normalizedPath().substring(prefix.length())));
}
return rooted;
}
private MemberPackageAnalysis analyzeMember(
SkillSuiteBundleCoordinate coordinate, String directory, List<IndexedEntry> indexedEntries
) throws IOException {
List<PackageEntry> packageEntries = new ArrayList<>();
List<StagedMemberFile> stagedFiles = new ArrayList<>();
String prefix = directory + "/";
for (IndexedEntry indexed : indexedEntries) {
String relativePath = indexed.rootPath().substring(prefix.length());
packageEntries.add(PackageEntry.streaming(
relativePath, indexed.staged().size(), indexed.staged().contentType(),
indexed.staged().content()::open));
stagedFiles.add(new StagedMemberFile(
relativePath, indexed.staged().size(), indexed.staged().contentType(),
indexed.staged().sha256(), indexed.staged().objectKey()));
}
packageEntries.sort(Comparator.comparing(PackageEntry::path));
stagedFiles.sort(Comparator.comparing(StagedMemberFile::relativePath));
ValidationResult base = packageValidator.validate(packageEntries);
List<String> memberErrors = new ArrayList<>(base.errors());
List<String> memberWarnings = new ArrayList<>(base.warnings());
packageEntries.stream()
.map(PackageEntry::path)
.filter(path -> path.endsWith("/" + SkillPackagePolicy.SKILL_MD_PATH))
.forEach(path -> memberErrors.add("Nested SKILL.md is not allowed: " + path));
SkillMetadata metadata = null;
PackageEntry skillMd = packageEntries.stream()
.filter(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path()))
.findFirst()
.orElse(null);
if (skillMd != null && base.passed()) {
metadata = metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8));
String metadataSlug = SlugValidator.slugify(metadata.name());
if (!coordinate.slug().equals(metadataSlug)) {
memberErrors.add("SKILL.md name resolves to " + metadataSlug
+ " and does not match manifest skill " + coordinate.canonical());
}
ValidationResult prePublish = prePublishValidator.validate(
new PrePublishValidator.SkillPackageContext(packageEntries, metadata, null, null));
memberErrors.addAll(prePublish.errors());
memberWarnings.addAll(prePublish.warnings());
}
ValidationResult validation = ValidationResult.of(memberErrors, memberWarnings);
return new MemberPackageAnalysis(
coordinate, directory, metadata, validation, List.copyOf(stagedFiles),
fingerprint(stagedFiles));
}
private String owningDirectory(String path, Set<String> directories) {
for (String directory : directories) {
if (path.startsWith(directory + "/")) {
return directory;
}
}
return null;
}
private String readManifest(SkillSuiteBundleStagedEntry entry) throws IOException {
try (InputStream input = entry.content().open()) {
byte[] bytes = input.readNBytes(MAX_MANIFEST_BYTES + 1);
if (bytes.length > MAX_MANIFEST_BYTES) {
throw invalid("SUITE.yaml exceeds max size " + MAX_MANIFEST_BYTES);
}
return new String(bytes, StandardCharsets.UTF_8);
}
}
private String fingerprint(List<StagedMemberFile> files) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
files.stream()
.sorted(Comparator.comparing(StagedMemberFile::relativePath))
.forEach(file -> digest.update((file.relativePath() + ":" + file.sha256() + "\n")
.getBytes(StandardCharsets.UTF_8)));
return "sha256:" + HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
private String parentPrefix(String path) {
int slash = path.lastIndexOf('/');
return slash < 0 ? "" : path.substring(0, slash + 1);
}
private boolean isOsMetadata(String path) {
String normalized = path.replace('\\', '/');
if (normalized.equals("__MACOSX") || normalized.startsWith("__MACOSX/")) {
return true;
}
int slash = normalized.lastIndexOf('/');
String fileName = slash < 0 ? normalized : normalized.substring(slash + 1);
return fileName.equals(".DS_Store") || fileName.startsWith("._");
}
private DomainBadRequestException invalid(String detail) {
return new DomainBadRequestException("error.suite.bundle.manifest.invalid", detail);
}
private record IndexedEntry(
SkillSuiteBundleStagedEntry staged,
String normalizedPath,
String rootPath
) {
}
public record BundleAnalysis(
SkillSuiteBundleManifest manifest,
List<MemberPackageAnalysis> packageMembers,
List<String> errors
) {
public boolean confirmable() {
return errors.isEmpty() && packageMembers.stream().allMatch(member -> member.validation().passed());
}
}
public record MemberPackageAnalysis(
SkillSuiteBundleCoordinate coordinate,
String directory,
SkillMetadata metadata,
ValidationResult validation,
List<StagedMemberFile> files,
String fingerprint
) {
}
public record StagedMemberFile(
String relativePath,
long size,
String contentType,
String sha256,
String objectKey
) {
}
}

View file

@ -0,0 +1,108 @@
package com.iflytek.skillhub.service.bundle;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.config.SkillSuiteBundleProperties;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/** Creates actor-bound PreviewSessions after file and semantic checks complete without side effects. */
@Service
public class SkillSuiteBundlePreviewAppService {
private static final TypeReference<Map<String, Object>> JSON_OBJECT = new TypeReference<>() { };
private final SkillSuiteBundleArchiveService archiveService;
private final SkillSuiteBundlePreviewPlanner planner;
private final SkillSuiteBundlePreviewPersistenceService persistenceService;
private final SkillSuiteBundleProperties properties;
private final ObjectMapper objectMapper;
private final Clock clock;
public SkillSuiteBundlePreviewAppService(
SkillSuiteBundleArchiveService archiveService,
SkillSuiteBundlePreviewPlanner planner,
SkillSuiteBundlePreviewPersistenceService persistenceService,
SkillSuiteBundleProperties properties,
ObjectMapper objectMapper,
Clock clock
) {
this.archiveService = archiveService;
this.planner = planner;
this.persistenceService = persistenceService;
this.properties = properties;
this.objectMapper = objectMapper;
this.clock = clock;
}
public PreviewOutcome preview(
MultipartFile upload,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) throws IOException {
SkillSuiteBundleArchiveService.StagedBundleAnalysis staged = archiveService.stageAndAnalyze(upload);
if (!staged.analysis().confirmable()) {
return new PreviewOutcome(null, null, staged.analysis(), null);
}
SkillSuiteBundlePreviewPlanner.PreviewPlan plan = planner.plan(
staged.analysis(), actorId, namespaceRoles, platformRoles);
if (!plan.confirmable()) {
archiveService.cleanupStagedObjects(staged.objectKeys());
return new PreviewOutcome(null, null, staged.analysis(), plan);
}
Instant now = clock.instant();
Instant expiresAt = now.plus(properties.getPreviewTtl());
String token = UUID.randomUUID().toString();
try {
Map<String, Object> manifestJson = objectMapper.convertValue(
staged.analysis().manifest(), JSON_OBJECT);
Map<String, Object> planJson = objectMapper.convertValue(plan, JSON_OBJECT);
SkillSuiteBundlePreviewSession session = new SkillSuiteBundlePreviewSession(
token, actorId, plan.mode(), plan.targetNamespaceId(), plan.target().slug(),
plan.targetSuiteId(), plan.baseSuiteVersionId(), plan.targetVersion(),
staged.archiveObjectKey(), staged.archiveSha256(), manifestJson, planJson,
plan.warningDigest(), expiresAt, now);
persistenceService.save(session);
return new PreviewOutcome(token, expiresAt, staged.analysis(), plan);
} catch (RuntimeException exception) {
archiveService.cleanupStagedObjects(staged.objectKeys());
throw exception;
}
}
public record PreviewOutcome(
String previewToken,
Instant expiresAt,
SkillSuiteBundlePackageAnalyzer.BundleAnalysis packageAnalysis,
SkillSuiteBundlePreviewPlanner.PreviewPlan plan
) {
public boolean confirmable() {
return previewToken != null;
}
public List<String> errors() {
if (plan != null) {
return plan.errors();
}
return java.util.stream.Stream.concat(
packageAnalysis.errors().stream(),
packageAnalysis.packageMembers().stream().flatMap(member ->
member.validation().errors().stream()
.map(error -> member.coordinate().canonical() + ": " + error)))
.toList();
}
}
}

View file

@ -0,0 +1,26 @@
package com.iflytek.skillhub.service.bundle;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Keeps PreviewSession persistence atomic without holding a transaction during ZIP processing. */
@Service
public class SkillSuiteBundlePreviewPersistenceService {
private final SkillSuiteBundlePreviewSessionRepository previewRepository;
public SkillSuiteBundlePreviewPersistenceService(
SkillSuiteBundlePreviewSessionRepository previewRepository
) {
this.previewRepository = previewRepository;
}
@Transactional
public void save(SkillSuiteBundlePreviewSession preview) {
previewRepository.save(preview);
// Surface serialization and constraint failures before staged-object compensation runs.
previewRepository.flush();
}
}

View file

@ -0,0 +1,719 @@
package com.iflytek.skillhub.service.bundle;
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.NamespaceStatus;
import com.iflytek.skillhub.domain.security.SecurityScanService;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillFile;
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteActionContext;
import com.iflytek.skillhub.domain.suite.SkillSuiteAuthorizationPolicy;
import com.iflytek.skillhub.domain.suite.SkillSuiteRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteStatus;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersion;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMemberRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionStatus;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifest;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMember;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/** Builds a side-effect-free, batch-loaded publication plan from validated Bundle packages. */
@Service
public class SkillSuiteBundlePreviewPlanner {
private static final DateTimeFormatter AUTO_VERSION_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMdd.HHmmss").withZone(ZoneId.systemDefault());
private final NamespaceRepository namespaceRepository;
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillFileRepository skillFileRepository;
private final SkillSuiteRepository suiteRepository;
private final SkillSuiteVersionRepository suiteVersionRepository;
private final SkillSuiteVersionMemberRepository suiteMemberRepository;
private final VisibilityChecker visibilityChecker;
private final SecurityScanService securityScanService;
private final Clock clock;
public SkillSuiteBundlePreviewPlanner(
NamespaceRepository namespaceRepository,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillFileRepository skillFileRepository,
SkillSuiteRepository suiteRepository,
SkillSuiteVersionRepository suiteVersionRepository,
SkillSuiteVersionMemberRepository suiteMemberRepository,
VisibilityChecker visibilityChecker,
SecurityScanService securityScanService,
Clock clock
) {
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillFileRepository = skillFileRepository;
this.suiteRepository = suiteRepository;
this.suiteVersionRepository = suiteVersionRepository;
this.suiteMemberRepository = suiteMemberRepository;
this.visibilityChecker = visibilityChecker;
this.securityScanService = securityScanService;
this.clock = clock;
}
@Transactional(readOnly = true)
public PreviewPlan plan(
SkillSuiteBundlePackageAnalyzer.BundleAnalysis packageAnalysis,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
SkillSuiteBundleManifest manifest = packageAnalysis.manifest();
Map<Long, NamespaceRole> safeNamespaceRoles = namespaceRoles == null ? Map.of() : namespaceRoles;
Set<String> safePlatformRoles = platformRoles == null ? Set.of() : platformRoles;
List<String> errors = new ArrayList<>(packageAnalysis.errors());
List<String> warnings = new ArrayList<>();
Set<String> namespaceSlugs = manifest.spec().members().stream()
.map(member -> member.coordinate().namespace())
.collect(Collectors.toCollection(LinkedHashSet::new));
namespaceSlugs.add(manifest.metadata().coordinate().namespace());
Map<String, Namespace> namespaces = namespaceRepository.findBySlugIn(List.copyOf(namespaceSlugs)).stream()
.collect(Collectors.toMap(Namespace::getSlug, Function.identity()));
for (String namespaceSlug : namespaceSlugs) {
if (!namespaces.containsKey(namespaceSlug)) {
errors.add("Namespace not found: " + namespaceSlug);
}
}
Target target = resolveTarget(
manifest, namespaces.get(manifest.metadata().coordinate().namespace()), actorId,
safeNamespaceRoles, safePlatformRoles, errors);
List<Long> namespaceIds = namespaces.values().stream().map(Namespace::getId).distinct().toList();
List<String> skillSlugs = manifest.spec().members().stream()
.map(member -> member.coordinate().slug()).distinct().toList();
List<Skill> skills = namespaceIds.isEmpty() || skillSlugs.isEmpty()
? List.of()
: skillRepository.findByNamespaceIdInAndSlugIn(namespaceIds, skillSlugs);
List<Long> skillIds = skills.stream().map(Skill::getId).distinct().toList();
List<SkillVersion> pendingVersions = skillIds.isEmpty()
? List.of()
: skillVersionRepository.findBySkillIdInAndStatus(skillIds, SkillVersionStatus.PENDING_REVIEW);
List<String> requestedVersions = requestedVersions(packageAnalysis);
List<SkillVersion> namedVersions = skillIds.isEmpty() || requestedVersions.isEmpty()
? List.of()
: skillVersionRepository.findBySkillIdInAndVersionIn(skillIds, requestedVersions);
List<Long> latestVersionIds = skills.stream()
.map(Skill::getLatestVersionId)
.filter(Objects::nonNull)
.distinct()
.toList();
List<SkillVersion> latestVersions = latestVersionIds.isEmpty()
? List.of()
: skillVersionRepository.findByIdIn(latestVersionIds);
Map<Long, SkillVersion> publishCandidatesById = new LinkedHashMap<>();
latestVersions.stream()
.filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED)
.forEach(version -> publishCandidatesById.put(version.getId(), version));
namedVersions.stream()
.filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED)
.forEach(version -> publishCandidatesById.put(version.getId(), version));
List<SkillVersion> publishedVersions = List.copyOf(publishCandidatesById.values());
Map<Long, List<SkillVersion>> publishedBySkill = groupVersions(publishedVersions);
Map<Long, List<SkillVersion>> pendingBySkill = groupVersions(pendingVersions);
Map<Long, List<SkillVersion>> namedBySkill = groupVersions(namedVersions);
List<Long> publishedVersionIds = publishedVersions.stream().map(SkillVersion::getId).distinct().toList();
Map<Long, List<SkillFile>> filesByVersion = publishedVersionIds.isEmpty()
? Map.of()
: skillFileRepository.findByVersionIdIn(publishedVersionIds).stream()
.collect(Collectors.groupingBy(SkillFile::getVersionId));
Map<Long, String> fingerprints = publishedVersions.stream().collect(Collectors.toMap(
SkillVersion::getId,
version -> fingerprint(filesByVersion.getOrDefault(version.getId(), List.of())),
(left, right) -> left));
Map<SkillSuiteBundleCoordinate, SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis> packageMembers =
packageAnalysis.packageMembers().stream().collect(Collectors.toMap(
SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis::coordinate,
Function.identity()));
Map<SkillSuiteBundleCoordinate, SkillSuiteVersionMember> baseline = baselineMembers(target);
List<MemberPlan> members = new ArrayList<>();
Set<SkillSuiteBundleCoordinate> desiredCoordinates = new LinkedHashSet<>();
for (int position = 0; position < manifest.spec().members().size(); position++) {
SkillSuiteBundleMember member = manifest.spec().members().get(position);
desiredCoordinates.add(member.coordinate());
Namespace namespace = namespaces.get(member.coordinate().namespace());
List<Skill> coordinateSkills = namespace == null ? List.of() : skills.stream()
.filter(skill -> Objects.equals(skill.getNamespaceId(), namespace.getId()))
.filter(skill -> skill.getSlug().equals(member.coordinate().slug()))
.toList();
MemberPlan planned = member.packageSource() != null
? planPackageMember(
manifest, member, packageMembers.get(member.coordinate()), namespace,
coordinateSkills, publishedBySkill, pendingBySkill, namedBySkill,
fingerprints, actorId, safeNamespaceRoles, safePlatformRoles)
: planReferenceMember(
manifest, member, namespace, coordinateSkills, namedBySkill,
fingerprints, actorId, safeNamespaceRoles, safePlatformRoles);
SkillSuiteVersionMember previous = baseline.get(member.coordinate());
boolean requestedEntry = member.coordinate().equals(manifest.spec().entry());
SkillSuiteBundleRelationshipChange relationship = previous == null
? SkillSuiteBundleRelationshipChange.ADDED
: Objects.equals(previous.getSkillVersionId(), planned.skillVersionId())
&& previous.getPosition() == position
&& previous.isEntry() == requestedEntry
? SkillSuiteBundleRelationshipChange.UNCHANGED
: SkillSuiteBundleRelationshipChange.UPDATED;
planned = planned.withRelationship(relationship);
members.add(planned);
planned.errors().forEach(error ->
errors.add(member.coordinate().canonical() + ": " + error));
planned.warnings().forEach(warning ->
warnings.add(member.coordinate().canonical() + ": " + warning));
}
List<RemovedMemberPlan> removed = baseline.entrySet().stream()
.filter(entry -> !desiredCoordinates.contains(entry.getKey()))
.sorted(Comparator.comparingInt(entry -> entry.getValue().getPosition()))
.map(entry -> new RemovedMemberPlan(
entry.getKey(), entry.getValue().getSkillId(),
entry.getValue().getSkillVersionId(), entry.getValue().getSkillVersionSnapshot(),
entry.getValue().isEntry(), SkillSuiteBundleRelationshipChange.REMOVED,
SkillSuiteBundlePublishAction.NONE))
.toList();
ResolvedPresentation presentation = resolvePresentation(manifest, target, errors);
boolean memberRelationshipChanged = !removed.isEmpty() || members.stream()
.anyMatch(member -> member.relationship() != SkillSuiteBundleRelationshipChange.UNCHANGED);
if (manifest.spec().mode() == SkillSuiteBundleMode.UPDATE
&& !memberRelationshipChanged
&& !presentationChanged(manifest, target, presentation)) {
errors.add("Bundle does not contain an effective change from the base Suite version");
}
return new PreviewPlan(
manifest.spec().mode(), manifest.metadata().coordinate(), target.namespaceId(), target.suiteId(),
target.baseVersionId(), manifest.spec().version(), presentation.displayName(),
presentation.summary(), presentation.overview(), manifest.spec().visibility(),
List.copyOf(members), removed, List.copyOf(errors), List.copyOf(warnings),
warningDigest(warnings));
}
private Target resolveTarget(
SkillSuiteBundleManifest manifest,
Namespace namespace,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
List<String> errors
) {
if (namespace == null) {
return Target.empty();
}
if (namespace.getStatus() != NamespaceStatus.ACTIVE) {
errors.add("Target Namespace is not writable");
}
SkillSuiteBundleCoordinate coordinate = manifest.metadata().coordinate();
SkillSuite suite = suiteRepository.findByNamespaceIdAndSlug(namespace.getId(), coordinate.slug()).orElse(null);
boolean superAdmin = platformRoles.contains("SUPER_ADMIN");
if (manifest.spec().mode() == SkillSuiteBundleMode.CREATE) {
if (!superAdmin && !namespaceRoles.containsKey(namespace.getId())) {
errors.add("No permission to create Suite in target Namespace");
}
if (suite != null) {
errors.add("Target Suite coordinate already exists");
return new Target(namespace.getId(), suite.getId(), null, null, suite);
}
return Target.empty(namespace.getId());
}
if (suite == null) {
errors.add("Target Suite does not exist for UPDATE");
return Target.empty();
}
SkillSuiteActionContext context = new SkillSuiteActionContext(
actorId, namespaceRoles, platformRoles, null, null, null);
if (!SkillSuiteAuthorizationPolicy.canCreateVersion(suite, context)) {
errors.add("No permission to create a version for target Suite");
}
if (suite.getStatus() != SkillSuiteStatus.ACTIVE) {
errors.add("Target Suite is not active");
}
SkillSuiteVersion base = suiteVersionRepository
.findBySuiteIdAndVersion(suite.getId(), manifest.spec().baseVersion()).orElse(null);
if (base == null || base.getStatus() != SkillSuiteVersionStatus.PUBLISHED) {
errors.add("Base Suite version is not published or does not exist");
}
if (suiteVersionRepository.findBySuiteIdAndVersion(
suite.getId(), manifest.spec().version()).isPresent()) {
errors.add("Target Suite version already exists");
}
return new Target(namespace.getId(), suite.getId(), base == null ? null : base.getId(), base, suite);
}
private MemberPlan planPackageMember(
SkillSuiteBundleManifest manifest,
SkillSuiteBundleMember member,
SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis packageAnalysis,
Namespace namespace,
List<Skill> coordinateSkills,
Map<Long, List<SkillVersion>> publishedBySkill,
Map<Long, List<SkillVersion>> pendingBySkill,
Map<Long, List<SkillVersion>> namedBySkill,
Map<Long, String> fingerprints,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
List<String> errors = new ArrayList<>();
List<String> warnings = packageAnalysis == null
? List.of()
: packageAnalysis.validation().warnings();
if (packageAnalysis == null) {
errors.add("Package analysis is missing");
return MemberPlan.invalidPackage(member.coordinate(), errors);
}
errors.addAll(packageAnalysis.validation().errors());
if (namespace == null) {
errors.add("Member Namespace does not exist");
return MemberPlan.packageResult(member.coordinate(), null, null, null,
packageAnalysis.fingerprint(), null, null, packageAnalysis.files(), errors, warnings);
}
if (namespace.getStatus() != NamespaceStatus.ACTIVE) {
errors.add("Member Namespace is not writable");
}
Skill manageable = selectManageableSkill(
coordinateSkills, actorId, namespaceRoles.get(namespace.getId()), platformRoles, errors);
boolean hasExplicitVersion = packageAnalysis.metadata() != null
&& packageAnalysis.metadata().version() != null
&& !packageAnalysis.metadata().version().isBlank();
String initialResolvedVersion = !hasExplicitVersion
? AUTO_VERSION_FORMATTER.format(clock.instant())
: packageAnalysis.metadata().version();
if (coordinateSkills.isEmpty()) {
if (!platformRoles.contains("SUPER_ADMIN") && !namespaceRoles.containsKey(namespace.getId())) {
errors.add("No permission to create Skill in member Namespace");
}
SkillVisibility requestedVisibility = member.packageSource().visibility();
if (requestedVisibility == null) {
errors.add("New Skill requires package.visibility");
} else if (!audienceCompatible(
manifest.spec().visibility(), manifest.metadata().coordinate().namespace(),
requestedVisibility, member.coordinate().namespace())) {
errors.add("New Skill visibility is incompatible with Suite audience");
}
if (requestedVisibility != null
&& requestedVisibility != SkillVisibility.PRIVATE
&& !securityScanService.isEnabled()) {
errors.add("Security scanner is required for non-private Skill publication");
}
return MemberPlan.packageResult(
member.coordinate(), null, null, SkillSuiteBundlePublishAction.CREATE_SKILL,
packageAnalysis.fingerprint(), requestedVisibility, initialResolvedVersion,
packageAnalysis.files(), errors, warnings);
}
if (manageable == null) {
return MemberPlan.packageResult(
member.coordinate(), null, null, null, packageAnalysis.fingerprint(), null,
initialResolvedVersion, packageAnalysis.files(), errors, warnings);
}
if (manageable.getStatus() != SkillStatus.ACTIVE || manageable.isHidden()) {
errors.add("Existing Skill is not active and visible");
}
SkillVisibility requestedVisibility = member.packageSource().visibility();
if (requestedVisibility != null && requestedVisibility != manageable.getVisibility()) {
errors.add("Existing Skill visibility cannot be changed by Bundle");
}
SkillVisibility finalVisibility = manageable.getVisibility();
if (!audienceCompatible(
manifest.spec().visibility(), manifest.metadata().coordinate().namespace(),
finalVisibility, member.coordinate().namespace())) {
errors.add("Existing Skill visibility is incompatible with Suite audience");
}
if (!pendingBySkill.getOrDefault(manageable.getId(), List.of()).isEmpty()) {
errors.add("Existing Skill has a pending review version");
}
List<SkillVersion> named = namedBySkill.getOrDefault(manageable.getId(), List.of()).stream()
.filter(version -> initialResolvedVersion.equals(version.getVersion())).toList();
SkillVersion reusable = named.stream()
.filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED)
.filter(SkillVersion::isDownloadReady)
.filter(version -> version.getYankedAt() == null)
.filter(version -> packageAnalysis.fingerprint().equals(fingerprints.get(version.getId())))
.findFirst().orElse(null);
String resolvedVersion = initialResolvedVersion;
if (!hasExplicitVersion && reusable == null) {
reusable = publishedBySkill.getOrDefault(manageable.getId(), List.of()).stream()
.filter(SkillVersion::isDownloadReady)
.filter(version -> version.getYankedAt() == null)
.filter(version -> packageAnalysis.fingerprint().equals(fingerprints.get(version.getId())))
.findFirst().orElse(null);
if (reusable != null) {
resolvedVersion = reusable.getVersion();
}
}
SkillSuiteBundlePublishAction action;
if (!named.isEmpty() && reusable == null) {
errors.add("Target Skill version already exists with different content or non-published status");
action = null;
} else if (reusable != null) {
action = SkillSuiteBundlePublishAction.REUSE_VERSION;
} else {
action = SkillSuiteBundlePublishAction.CREATE_VERSION;
}
if ((action == SkillSuiteBundlePublishAction.CREATE_VERSION)
&& finalVisibility != SkillVisibility.PRIVATE
&& !securityScanService.isEnabled()) {
errors.add("Security scanner is required for non-private Skill publication");
}
return MemberPlan.packageResult(
member.coordinate(), manageable.getId(), reusable == null ? null : reusable.getId(),
action, packageAnalysis.fingerprint(), finalVisibility, resolvedVersion,
packageAnalysis.files(), errors, warnings);
}
private MemberPlan planReferenceMember(
SkillSuiteBundleManifest manifest,
SkillSuiteBundleMember member,
Namespace namespace,
List<Skill> coordinateSkills,
Map<Long, List<SkillVersion>> namedBySkill,
Map<Long, String> fingerprints,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
String requestedVersion = member.referenceSource().version();
List<ReferenceCandidate> candidates = new ArrayList<>();
if (namespace != null) {
for (Skill skill : coordinateSkills) {
for (SkillVersion version : namedBySkill.getOrDefault(skill.getId(), List.of())) {
if (!requestedVersion.equals(version.getVersion())
|| version.getStatus() != SkillVersionStatus.PUBLISHED) {
continue;
}
if (isReferenceEligible(
manifest, member.coordinate(), namespace, skill, version,
actorId, namespaceRoles, platformRoles)) {
candidates.add(new ReferenceCandidate(skill, version));
}
}
}
}
if (candidates.size() != 1) {
return new MemberPlan(
member.coordinate(), SkillSuiteBundleMemberSourceType.REFERENCE,
SkillSuiteBundleRelationshipChange.ADDED, null, null, null, null,
requestedVersion, null, List.of(),
List.of("Exact reference is unavailable or ambiguous"), List.of());
}
ReferenceCandidate candidate = candidates.getFirst();
return new MemberPlan(
member.coordinate(), SkillSuiteBundleMemberSourceType.REFERENCE,
SkillSuiteBundleRelationshipChange.ADDED,
SkillSuiteBundlePublishAction.REFERENCE_VERSION,
candidate.skill().getId(), candidate.version().getId(),
candidate.skill().getVisibility(), requestedVersion,
fingerprints.get(candidate.version().getId()), List.of(), List.of(), List.of());
}
private boolean isReferenceEligible(
SkillSuiteBundleManifest manifest,
SkillSuiteBundleCoordinate coordinate,
Namespace namespace,
Skill skill,
SkillVersion version,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
return namespace.getStatus() == NamespaceStatus.ACTIVE
&& skill.getStatus() == SkillStatus.ACTIVE
&& !skill.isHidden()
&& version.isDownloadReady()
&& version.getYankedAt() == null
&& visibilityChecker.canAccess(skill, actorId, namespaceRoles, platformRoles)
&& audienceCompatible(
manifest.spec().visibility(), manifest.metadata().coordinate().namespace(),
skill.getVisibility(), coordinate.namespace());
}
private Skill selectManageableSkill(
List<Skill> skills,
String actorId,
NamespaceRole namespaceRole,
Set<String> platformRoles,
List<String> errors
) {
boolean namespaceAdmin = namespaceRole == NamespaceRole.OWNER || namespaceRole == NamespaceRole.ADMIN;
boolean superAdmin = platformRoles.contains("SUPER_ADMIN");
List<Skill> manageable = skills.stream()
.filter(skill -> Objects.equals(skill.getOwnerId(), actorId) || namespaceAdmin || superAdmin)
.toList();
if (manageable.size() == 1) {
return manageable.getFirst();
}
if (!skills.isEmpty()) {
errors.add(manageable.isEmpty()
? "No permission to publish existing Skill"
: "Skill coordinate is ambiguous across owners");
}
return null;
}
private Map<SkillSuiteBundleCoordinate, SkillSuiteVersionMember> baselineMembers(Target target) {
if (target.baseVersionId() == null) {
return Map.of();
}
return suiteMemberRepository.findBySuiteVersionIdOrderByPosition(target.baseVersionId()).stream()
.collect(Collectors.toMap(
member -> new SkillSuiteBundleCoordinate(
member.getNamespaceSlugSnapshot(), member.getSkillSlugSnapshot()),
Function.identity(),
(left, right) -> left,
LinkedHashMap::new));
}
private ResolvedPresentation resolvePresentation(
SkillSuiteBundleManifest manifest, Target target, List<String> errors
) {
SkillSuiteBundleManifest.Spec spec = manifest.spec();
String displayName = spec.displayName();
String summary = spec.summary() != null
? spec.summary()
: target.baseVersion() == null ? null : target.baseVersion().getSummary();
String overview = spec.overview() != null
? spec.overview()
: target.baseVersion() == null ? null : target.baseVersion().getOverview();
if (summary == null || summary.isBlank()) {
errors.add("Suite summary is required after inheritance");
}
if (overview == null || overview.isBlank()) {
errors.add("Suite overview is required after inheritance");
}
return new ResolvedPresentation(displayName, summary, overview);
}
private boolean presentationChanged(
SkillSuiteBundleManifest manifest, Target target, ResolvedPresentation presentation
) {
SkillSuiteVersion base = target.baseVersion();
return base == null
|| !Objects.equals(base.getDisplayName(), presentation.displayName())
|| !Objects.equals(base.getSummary(), presentation.summary())
|| !Objects.equals(base.getOverview(), presentation.overview())
|| base.getVisibility() != manifest.spec().visibility();
}
private List<String> requestedVersions(
SkillSuiteBundlePackageAnalyzer.BundleAnalysis packageAnalysis
) {
LinkedHashSet<String> versions = new LinkedHashSet<>();
packageAnalysis.manifest().spec().members().stream()
.filter(member -> member.referenceSource() != null)
.map(member -> member.referenceSource().version())
.forEach(versions::add);
packageAnalysis.packageMembers().stream()
.map(SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis::metadata)
.filter(Objects::nonNull)
.map(metadata -> metadata.version())
.filter(Objects::nonNull)
.filter(version -> !version.isBlank())
.forEach(versions::add);
versions.add(AUTO_VERSION_FORMATTER.format(clock.instant()));
return List.copyOf(versions);
}
private Map<Long, List<SkillVersion>> groupVersions(List<SkillVersion> versions) {
return versions.stream().collect(Collectors.groupingBy(SkillVersion::getSkillId));
}
private String fingerprint(List<SkillFile> files) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
files.stream().sorted(Comparator.comparing(SkillFile::getFilePath)).forEach(file ->
digest.update((file.getFilePath() + ":" + file.getSha256() + "\n")
.getBytes(StandardCharsets.UTF_8)));
return "sha256:" + HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
private String warningDigest(List<String> warnings) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
warnings.stream().sorted().forEach(warning -> {
digest.update(warning.getBytes(StandardCharsets.UTF_8));
digest.update((byte) '\n');
});
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
private boolean audienceCompatible(
SkillVisibility suiteVisibility,
String suiteNamespace,
SkillVisibility memberVisibility,
String memberNamespace
) {
if (memberVisibility == SkillVisibility.PUBLIC) {
return true;
}
if (suiteVisibility == SkillVisibility.PUBLIC || !suiteNamespace.equals(memberNamespace)) {
return false;
}
if (suiteVisibility == SkillVisibility.NAMESPACE_ONLY) {
return memberVisibility == SkillVisibility.NAMESPACE_ONLY;
}
return suiteVisibility == SkillVisibility.PRIVATE;
}
private record Target(
Long namespaceId,
Long suiteId,
Long baseVersionId,
SkillSuiteVersion baseVersion,
SkillSuite suite
) {
private static Target empty() {
return new Target(null, null, null, null, null);
}
private static Target empty(Long namespaceId) {
return new Target(namespaceId, null, null, null, null);
}
}
private record ReferenceCandidate(Skill skill, SkillVersion version) {
}
private record ResolvedPresentation(String displayName, String summary, String overview) {
}
public record PreviewPlan(
SkillSuiteBundleMode mode,
SkillSuiteBundleCoordinate target,
Long targetNamespaceId,
Long targetSuiteId,
Long baseSuiteVersionId,
String targetVersion,
String displayName,
String summary,
String overview,
SkillVisibility visibility,
List<MemberPlan> members,
List<RemovedMemberPlan> removedMembers,
List<String> errors,
List<String> warnings,
String warningDigest
) {
public boolean confirmable() {
return errors.isEmpty();
}
public boolean requiresWarningConfirmation() {
return !warnings.isEmpty();
}
}
public record MemberPlan(
SkillSuiteBundleCoordinate coordinate,
SkillSuiteBundleMemberSourceType sourceType,
SkillSuiteBundleRelationshipChange relationship,
SkillSuiteBundlePublishAction publishAction,
Long skillId,
Long skillVersionId,
SkillVisibility finalVisibility,
String resolvedVersion,
String fingerprint,
List<SkillSuiteBundlePackageAnalyzer.StagedMemberFile> files,
List<String> errors,
List<String> warnings
) {
private static MemberPlan invalidPackage(
SkillSuiteBundleCoordinate coordinate, List<String> errors
) {
return packageResult(
coordinate, null, null, null, null, null, null, List.of(), errors, List.of());
}
private static MemberPlan packageResult(
SkillSuiteBundleCoordinate coordinate,
Long skillId,
Long skillVersionId,
SkillSuiteBundlePublishAction action,
String fingerprint,
SkillVisibility visibility,
String version,
List<SkillSuiteBundlePackageAnalyzer.StagedMemberFile> files,
List<String> errors,
List<String> warnings
) {
return new MemberPlan(
coordinate, SkillSuiteBundleMemberSourceType.PACKAGE,
SkillSuiteBundleRelationshipChange.ADDED, action, skillId, skillVersionId,
visibility, version, fingerprint, files,
List.copyOf(errors), List.copyOf(warnings));
}
private MemberPlan withRelationship(SkillSuiteBundleRelationshipChange value) {
return new MemberPlan(
coordinate, sourceType, value, publishAction, skillId, skillVersionId,
finalVisibility, resolvedVersion, fingerprint, files, errors, warnings);
}
}
public record RemovedMemberPlan(
SkillSuiteBundleCoordinate coordinate,
Long skillId,
Long skillVersionId,
String version,
boolean entry,
SkillSuiteBundleRelationshipChange relationship,
SkillSuiteBundlePublishAction publishAction
) {
}
}

View file

@ -0,0 +1,92 @@
package com.iflytek.skillhub.service.bundle;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
import com.iflytek.skillhub.domain.skill.validation.ValidationResult;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifest;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMember;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
import com.iflytek.skillhub.storage.ObjectStorageService;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/** Rebuilds a PreviewSession against current authorization and lifecycle state without file extraction. */
@Service
public class SkillSuiteBundlePreviewRevalidationService {
private final SkillSuiteBundlePreviewPlanner planner;
private final ObjectStorageService objectStorageService;
private final ObjectMapper objectMapper;
public SkillSuiteBundlePreviewRevalidationService(
SkillSuiteBundlePreviewPlanner planner,
ObjectStorageService objectStorageService,
ObjectMapper objectMapper
) {
this.planner = planner;
this.objectStorageService = objectStorageService;
this.objectMapper = objectMapper;
}
public ValidatedPreview requireUnchanged(
SkillSuiteBundlePreviewSession preview,
String actorId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
if (!objectStorageService.exists(preview.getArchiveObjectKey())) {
throw stateChanged();
}
SkillSuiteBundlePreviewPlanner.PreviewPlan previewPlan = objectMapper.convertValue(
preview.getPlan(), SkillSuiteBundlePreviewPlanner.PreviewPlan.class);
SkillSuiteBundleManifest manifest = objectMapper.convertValue(
preview.getManifest(), SkillSuiteBundleManifest.class);
SkillSuiteBundlePreviewPlanner.PreviewPlan currentPlan = planner.plan(
rebuildAnalysis(manifest, previewPlan), actorId, namespaceRoles, platformRoles);
if (!currentPlan.confirmable() || !currentPlan.equals(previewPlan)) {
throw stateChanged();
}
return new ValidatedPreview(manifest, previewPlan);
}
private SkillSuiteBundlePackageAnalyzer.BundleAnalysis rebuildAnalysis(
SkillSuiteBundleManifest manifest,
SkillSuiteBundlePreviewPlanner.PreviewPlan plan
) {
Map<SkillSuiteBundleCoordinate, SkillSuiteBundleMember> manifestMembers =
manifest.spec().members().stream().collect(Collectors.toMap(
SkillSuiteBundleMember::coordinate, Function.identity()));
List<SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis> packages = plan.members().stream()
.filter(member -> member.sourceType() == SkillSuiteBundleMemberSourceType.PACKAGE)
.map(member -> {
var source = Objects.requireNonNull(manifestMembers.get(member.coordinate()).packageSource());
SkillMetadata metadata = new SkillMetadata(
member.coordinate().slug(), "", member.resolvedVersion(), "", Map.of());
return new SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis(
member.coordinate(), source.path(), metadata,
ValidationResult.of(List.of(), member.warnings()), member.files(), member.fingerprint());
})
.toList();
return new SkillSuiteBundlePackageAnalyzer.BundleAnalysis(manifest, packages, List.of());
}
private DomainBadRequestException stateChanged() {
return new DomainBadRequestException("error.suite.bundle.preview.stateChanged");
}
public record ValidatedPreview(
SkillSuiteBundleManifest manifest,
SkillSuiteBundlePreviewPlanner.PreviewPlan plan
) {
}
}

View file

@ -0,0 +1,81 @@
package com.iflytek.skillhub.service.bundle;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationResponse;
import com.iflytek.skillhub.dto.SkillSuiteBundlePreviewResponse;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** Maps internal Bundle plans to stable transport responses without exposing staged object keys. */
@Component
public class SkillSuiteBundleResponseMapper {
public SkillSuiteBundlePreviewResponse toResponse(
SkillSuiteBundlePreviewAppService.PreviewOutcome outcome
) {
SkillSuiteBundlePreviewPlanner.PreviewPlan plan = outcome.plan();
if (plan == null) {
var manifest = outcome.packageAnalysis().manifest();
List<SkillSuiteBundlePreviewResponse.PreviewMember> members = outcome.packageAnalysis()
.packageMembers().stream()
.map(member -> new SkillSuiteBundlePreviewResponse.PreviewMember(
member.coordinate().canonical(), SkillSuiteBundleMemberSourceType.PACKAGE,
member.directory(),
SkillSuiteBundleRelationshipChange.ADDED, null, null, null, null,
member.metadata() == null ? null : member.metadata().version(),
member.fingerprint(), member.validation().errors(), member.validation().warnings()))
.toList();
List<String> warnings = new ArrayList<>();
outcome.packageAnalysis().packageMembers().forEach(member -> member.validation().warnings()
.forEach(warning -> warnings.add(member.coordinate().canonical() + ": " + warning)));
return new SkillSuiteBundlePreviewResponse(
null, null, false,
new SkillSuiteBundlePreviewResponse.Target(
manifest.spec().mode(), manifest.metadata().coordinate().canonical(), null, null, null,
manifest.spec().version(), manifest.spec().displayName(), manifest.spec().summary(),
manifest.spec().overview(), manifest.spec().visibility()),
members, List.of(), outcome.errors(), List.copyOf(warnings), null);
}
var packagePaths = outcome.packageAnalysis() == null
? Map.<SkillSuiteBundleCoordinate, String>of()
: outcome.packageAnalysis().packageMembers().stream().collect(
Collectors.toMap(
SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis::coordinate,
SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis::directory));
List<SkillSuiteBundlePreviewResponse.PreviewMember> members = plan.members().stream()
.map(member -> new SkillSuiteBundlePreviewResponse.PreviewMember(
member.coordinate().canonical(), member.sourceType(), packagePaths.get(member.coordinate()),
member.relationship(),
member.publishAction(), member.skillId(), member.skillVersionId(),
member.finalVisibility(), member.resolvedVersion(), member.fingerprint(),
member.errors(), member.warnings()))
.toList();
List<SkillSuiteBundlePreviewResponse.RemovedMember> removed = plan.removedMembers().stream()
.map(member -> new SkillSuiteBundlePreviewResponse.RemovedMember(
member.coordinate().canonical(), member.skillId(), member.skillVersionId(),
member.version(), member.entry()))
.toList();
return new SkillSuiteBundlePreviewResponse(
outcome.previewToken(), outcome.expiresAt(), outcome.confirmable(),
new SkillSuiteBundlePreviewResponse.Target(
plan.mode(), plan.target().canonical(), plan.targetNamespaceId(), plan.targetSuiteId(),
plan.baseSuiteVersionId(), plan.targetVersion(), plan.displayName(), plan.summary(),
plan.overview(), plan.visibility()),
members, removed, plan.errors(), plan.warnings(), plan.warningDigest());
}
public SkillSuiteBundleOperationResponse toResponse(
SkillSuiteBundleConfirmationAppService.ConfirmationOutcome outcome
) {
return new SkillSuiteBundleOperationResponse(
outcome.operationId(), outcome.status(), outcome.replayed());
}
}

View file

@ -0,0 +1,145 @@
package com.iflytek.skillhub.service.bundle;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewStatus;
import com.iflytek.skillhub.storage.ObjectStorageService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/** Deletes only Bundle staging keys while retaining retryable cleanup evidence on failure. */
@Service
public class SkillSuiteBundleStagedCleanupService {
private static final Logger log = LoggerFactory.getLogger(SkillSuiteBundleStagedCleanupService.class);
private static final String STAGING_PREFIX = "temporary/suite-bundles/";
private final SkillSuiteBundlePreviewSessionRepository previewRepository;
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
private final ObjectStorageService objectStorageService;
private final ObjectMapper objectMapper;
private final Clock clock;
public SkillSuiteBundleStagedCleanupService(
SkillSuiteBundlePreviewSessionRepository previewRepository,
SkillSuiteBundleExecutionOperationRepository operationRepository,
ObjectStorageService objectStorageService,
ObjectMapper objectMapper,
Clock clock
) {
this.previewRepository = previewRepository;
this.operationRepository = operationRepository;
this.objectStorageService = objectStorageService;
this.objectMapper = objectMapper;
this.clock = clock;
}
@Transactional
public int expireReadyPreviews() {
return previewRepository.expireReadyBefore(clock.instant());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void cleanupExpiredPreview(String previewToken) {
SkillSuiteBundlePreviewSession preview = previewRepository.findByIdForUpdate(previewToken).orElse(null);
if (preview == null || preview.getStatus() != SkillSuiteBundlePreviewStatus.EXPIRED
|| preview.getStagedObjectsCleanedAt() != null
|| operationRepository.findByPreviewToken(previewToken).isPresent()) {
return;
}
Instant now = clock.instant();
CleanupKeys keys = cleanupKeys(preview.getArchiveObjectKey(), preview.getPlan());
if (!keys.valid()) {
preview.markStagedCleanupFailed("STAGED_KEY_SCOPE_INVALID", now);
previewRepository.save(preview);
previewRepository.flush();
return;
}
try {
objectStorageService.deleteObjects(keys.values());
preview.markStagedObjectsCleaned(now);
previewRepository.delete(preview);
previewRepository.flush();
} catch (RuntimeException exception) {
preview.markStagedCleanupFailed("STORAGE_DELETE_FAILED", now);
previewRepository.save(preview);
previewRepository.flush();
log.warn("Failed to clean expired Suite Bundle preview staging [previewToken={}]",
previewToken, exception);
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void cleanupTerminalOperation(String operationId) {
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
.orElse(null);
if (operation == null || !terminal(operation.getStatus())
|| operation.getStagedObjectsCleanedAt() != null) {
return;
}
Instant now = clock.instant();
CleanupKeys keys = cleanupKeys(operation.getArchiveObjectKey(), operation.getPlan());
if (!keys.valid()) {
operation.markStagedCleanupFailed("STAGED_KEY_SCOPE_INVALID", now);
operationRepository.save(operation);
operationRepository.flush();
return;
}
try {
objectStorageService.deleteObjects(keys.values());
operation.markStagedObjectsCleaned(now);
operationRepository.save(operation);
operationRepository.flush();
} catch (RuntimeException exception) {
operation.markStagedCleanupFailed("STORAGE_DELETE_FAILED", now);
operationRepository.save(operation);
operationRepository.flush();
log.warn("Failed to clean terminal Suite Bundle operation staging [operationId={}]",
operationId, exception);
}
}
private CleanupKeys cleanupKeys(String archiveObjectKey, java.util.Map<String, Object> planJson) {
try {
SkillSuiteBundlePreviewPlanner.PreviewPlan plan = objectMapper.convertValue(
planJson, SkillSuiteBundlePreviewPlanner.PreviewPlan.class);
Set<String> keys = new LinkedHashSet<>();
keys.add(archiveObjectKey);
plan.members().stream()
.flatMap(member -> member.files().stream())
.map(SkillSuiteBundlePackageAnalyzer.StagedMemberFile::objectKey)
.forEach(keys::add);
List<String> values = List.copyOf(keys);
return new CleanupKeys(
!values.isEmpty() && values.stream().allMatch(this::inStagingScope), values);
} catch (RuntimeException exception) {
return new CleanupKeys(false, List.of());
}
}
private boolean inStagingScope(String key) {
return key != null && key.startsWith(STAGING_PREFIX) && key.length() > STAGING_PREFIX.length();
}
private boolean terminal(SkillSuiteBundleOperationStatus status) {
return status == SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED
|| status == SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED
|| status == SkillSuiteBundleOperationStatus.CANCELLED;
}
private record CleanupKeys(boolean valid, List<String> values) {
}
}

View file

@ -0,0 +1,19 @@
package com.iflytek.skillhub.service.bundle;
import java.io.IOException;
import java.io.InputStream;
/** One normalized archive file already staged outside application memory. */
public record SkillSuiteBundleStagedEntry(
String path,
long size,
String contentType,
String sha256,
String objectKey,
InputStreamSupplier content
) {
@FunctionalInterface
public interface InputStreamSupplier {
InputStream open() throws IOException;
}
}

View file

@ -0,0 +1,2 @@
/** Suite Bundle upload analysis and orchestration application services. */
package com.iflytek.skillhub.service.bundle;

View file

@ -0,0 +1,36 @@
package com.iflytek.skillhub.task;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleCoordinator;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Set;
/** Bounded recovery for lost, delayed, or out-of-order Bundle lifecycle events. */
@Component
public class SkillSuiteBundleRecoveryTask {
private static final Set<SkillSuiteBundleOperationStatus> RECOVERABLE = Set.of(
SkillSuiteBundleOperationStatus.RUNNING,
SkillSuiteBundleOperationStatus.WAITING_FOR_MEMBERS);
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
private final SkillSuiteBundleCoordinator coordinator;
public SkillSuiteBundleRecoveryTask(
SkillSuiteBundleExecutionOperationRepository operationRepository,
SkillSuiteBundleCoordinator coordinator
) {
this.operationRepository = operationRepository;
this.coordinator = coordinator;
}
@Scheduled(fixedDelayString = "${skillhub.suite.bundle.recovery-interval-ms:5000}")
public void recover() {
operationRepository.findTop100ByStatusInOrderByUpdatedAtAsc(RECOVERABLE).stream()
.map(operation -> operation.getOperationId())
.forEach(coordinator::advance);
}
}

View file

@ -0,0 +1,51 @@
package com.iflytek.skillhub.task;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewStatus;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleStagedCleanupService;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Set;
/** Bounded cleanup of expired previews and terminal Bundle staging objects. */
@Component
public class SkillSuiteBundleStagedCleanupTask {
private static final Set<SkillSuiteBundleOperationStatus> TERMINAL = Set.of(
SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED,
SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED,
SkillSuiteBundleOperationStatus.CANCELLED);
private final SkillSuiteBundlePreviewSessionRepository previewRepository;
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
private final SkillSuiteBundleStagedCleanupService cleanupService;
public SkillSuiteBundleStagedCleanupTask(
SkillSuiteBundlePreviewSessionRepository previewRepository,
SkillSuiteBundleExecutionOperationRepository operationRepository,
SkillSuiteBundleStagedCleanupService cleanupService
) {
this.previewRepository = previewRepository;
this.operationRepository = operationRepository;
this.cleanupService = cleanupService;
}
@Scheduled(fixedDelayString = "${skillhub.suite.bundle.cleanup-interval-ms:60000}")
public void cleanup() {
cleanupService.expireReadyPreviews();
previewRepository
.findTop100ByStatusAndStagedObjectsCleanedAtIsNullOrderByExpiresAtAsc(
SkillSuiteBundlePreviewStatus.EXPIRED)
.stream()
.map(preview -> preview.getToken())
.forEach(cleanupService::cleanupExpiredPreview);
operationRepository
.findTop100ByStatusInAndStagedObjectsCleanedAtIsNullOrderByCompletedAtAsc(TERMINAL)
.stream()
.map(operation -> operation.getOperationId())
.forEach(cleanupService::cleanupTerminalOperation);
}
}

View file

@ -100,6 +100,9 @@ skillhub:
# Fail closed for deployment modes whose upgrade topology is unknown. Single-instance
# distributions explicitly enable this after ruling out mixed application versions.
review-writes-enabled: ${SKILLHUB_SUITE_REVIEW_WRITES_ENABLED:false}
bundle:
preview-ttl: ${SKILLHUB_SUITE_BUNDLE_PREVIEW_TTL:30m}
confirmation-enabled: ${SKILLHUB_SUITE_BUNDLE_CONFIRMATION_ENABLED:false}
observability:
tracing-mode: ${SKILLHUB_TRACING_MODE:none}
log-format: ${SKILLHUB_LOG_FORMAT:text}
@ -153,6 +156,7 @@ skillhub:
label:
max-definitions: ${SKILLHUB_LABEL_MAX_DEFINITIONS:100}
max-per-skill: ${SKILLHUB_LABEL_MAX_PER_SKILL:10}
max-per-suite: ${SKILLHUB_LABEL_MAX_PER_SUITE:10}
search:
engine: postgres
rebuild-on-startup: false

View file

@ -0,0 +1,97 @@
-- Durable two-stage Suite Bundle workflow. Preview rows never reserve a target; only active
-- execution operations participate in the partial unique reservation index.
CREATE TABLE skill_suite_bundle_preview (
token VARCHAR(64) PRIMARY KEY,
actor_id VARCHAR(128) NOT NULL,
mode VARCHAR(16) NOT NULL,
namespace_id BIGINT NOT NULL REFERENCES namespace(id),
target_suite_slug VARCHAR(128) NOT NULL,
target_suite_id BIGINT REFERENCES skill_suite(id) ON DELETE SET NULL,
base_suite_version_id BIGINT REFERENCES skill_suite_version(id) ON DELETE SET NULL,
target_version VARCHAR(64) NOT NULL,
archive_object_key VARCHAR(1024) NOT NULL,
archive_sha256 VARCHAR(64) NOT NULL,
manifest_json JSONB NOT NULL,
plan_json JSONB NOT NULL,
warning_digest VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
confirmed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
lock_version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_suite_bundle_preview_actor_created
ON skill_suite_bundle_preview(actor_id, created_at DESC);
CREATE INDEX idx_suite_bundle_preview_expiry
ON skill_suite_bundle_preview(expires_at)
WHERE status = 'PREVIEW_READY';
CREATE TABLE skill_suite_bundle_operation (
operation_id VARCHAR(64) PRIMARY KEY,
preview_token VARCHAR(64) NOT NULL UNIQUE REFERENCES skill_suite_bundle_preview(token),
client_request_id VARCHAR(64) NOT NULL,
actor_id VARCHAR(128) NOT NULL,
mode VARCHAR(16) NOT NULL,
namespace_id BIGINT NOT NULL REFERENCES namespace(id),
target_suite_slug VARCHAR(128) NOT NULL,
target_suite_id BIGINT REFERENCES skill_suite(id) ON DELETE SET NULL,
base_suite_version_id BIGINT REFERENCES skill_suite_version(id) ON DELETE SET NULL,
target_version VARCHAR(64) NOT NULL,
reservation_key VARCHAR(256) NOT NULL,
reservation_active BOOLEAN NOT NULL DEFAULT TRUE,
archive_object_key VARCHAR(1024) NOT NULL,
archive_sha256 VARCHAR(64) NOT NULL,
plan_json JSONB NOT NULL,
warning_digest VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
failure_code VARCHAR(128),
failure_detail TEXT,
result_suite_id BIGINT REFERENCES skill_suite(id) ON DELETE SET NULL,
result_suite_version_id BIGINT REFERENCES skill_suite_version(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMPTZ,
lock_version BIGINT NOT NULL DEFAULT 0,
CONSTRAINT uk_suite_bundle_operation_actor_request UNIQUE (actor_id, client_request_id)
);
CREATE UNIQUE INDEX uk_suite_bundle_operation_active_reservation
ON skill_suite_bundle_operation(reservation_key)
WHERE reservation_active = TRUE;
CREATE INDEX idx_suite_bundle_operation_actor_created
ON skill_suite_bundle_operation(actor_id, created_at DESC);
CREATE INDEX idx_suite_bundle_operation_recovery
ON skill_suite_bundle_operation(status, updated_at)
WHERE status IN ('RUNNING', 'WAITING_FOR_MEMBERS', 'BLOCKED_RETRYABLE');
CREATE TABLE skill_suite_bundle_member_result (
id BIGSERIAL PRIMARY KEY,
operation_id VARCHAR(64) NOT NULL REFERENCES skill_suite_bundle_operation(operation_id) ON DELETE CASCADE,
position INT NOT NULL CHECK (position >= 0),
namespace_slug VARCHAR(128) NOT NULL,
skill_slug VARCHAR(128) NOT NULL,
source_type VARCHAR(32) NOT NULL,
package_path VARCHAR(1024),
requested_visibility VARCHAR(32),
requested_version VARCHAR(64),
relationship_change VARCHAR(32) NOT NULL,
publish_action VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
fingerprint VARCHAR(255),
skill_id BIGINT REFERENCES skill(id) ON DELETE SET NULL,
skill_version_id BIGINT REFERENCES skill_version(id) ON DELETE SET NULL,
errors JSONB NOT NULL DEFAULT '[]'::jsonb,
warnings JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_suite_bundle_member_position UNIQUE (operation_id, position),
CONSTRAINT uk_suite_bundle_member_skill UNIQUE (operation_id, namespace_slug, skill_slug),
CONSTRAINT ck_suite_bundle_member_source CHECK (
(source_type = 'PACKAGE' AND package_path IS NOT NULL)
OR (source_type = 'REFERENCE' AND package_path IS NULL AND requested_version IS NOT NULL)
)
);
CREATE INDEX idx_suite_bundle_member_operation_status
ON skill_suite_bundle_member_result(operation_id, status);

View file

@ -0,0 +1,18 @@
ALTER TABLE skill_suite_bundle_preview
ADD COLUMN staged_objects_cleaned_at TIMESTAMPTZ,
ADD COLUMN staged_cleanup_failed_at TIMESTAMPTZ,
ADD COLUMN staged_cleanup_failure_code VARCHAR(128);
ALTER TABLE skill_suite_bundle_operation
ADD COLUMN staged_objects_cleaned_at TIMESTAMPTZ,
ADD COLUMN staged_cleanup_failed_at TIMESTAMPTZ,
ADD COLUMN staged_cleanup_failure_code VARCHAR(128);
CREATE INDEX idx_suite_bundle_preview_cleanup
ON skill_suite_bundle_preview(status, expires_at)
WHERE staged_objects_cleaned_at IS NULL AND status = 'EXPIRED';
CREATE INDEX idx_suite_bundle_operation_cleanup
ON skill_suite_bundle_operation(status, completed_at)
WHERE staged_objects_cleaned_at IS NULL
AND status IN ('REPREVIEW_REQUIRED', 'SUITE_DRAFT_CREATED', 'CANCELLED');

View file

@ -0,0 +1,4 @@
-- Reverse Skill-to-Suite discovery starts from any current snapshot member, not only the entry.
CREATE INDEX idx_skill_suite_member_skill
ON skill_suite_version_member(skill_id)
WHERE skill_id IS NOT NULL;

View file

@ -0,0 +1,11 @@
CREATE TABLE skill_suite_label (
id BIGSERIAL PRIMARY KEY,
suite_id BIGINT NOT NULL REFERENCES skill_suite(id) ON DELETE CASCADE,
label_id BIGINT NOT NULL REFERENCES label_definition(id) ON DELETE CASCADE,
created_by VARCHAR(128) REFERENCES user_account(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (suite_id, label_id)
);
CREATE INDEX idx_skill_suite_label_suite_id ON skill_suite_label(suite_id);
CREATE INDEX idx_skill_suite_label_label_id ON skill_suite_label(label_id);

View file

@ -0,0 +1,2 @@
CREATE INDEX idx_suite_bundle_operation_actor_status_updated
ON skill_suite_bundle_operation(actor_id, status, updated_at DESC, operation_id DESC);

View file

@ -0,0 +1,11 @@
CREATE INDEX idx_suite_bundle_operation_actor_priority_updated
ON skill_suite_bundle_operation(
actor_id,
(CASE
WHEN status IN ('BLOCKED_RETRYABLE', 'REPREVIEW_REQUIRED') THEN 0
WHEN status IN ('RUNNING', 'WAITING_FOR_MEMBERS') THEN 1
ELSE 2
END),
updated_at DESC,
operation_id DESC
);

View file

@ -47,6 +47,7 @@ error.auth.sessionBootstrap.disabled=Session bootstrap is disabled
error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap provider: {0}
error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found
error.badRequest=Invalid request
error.suite.workspace.invalidFilter=Invalid suite status filter
error.methodNotAllowed=HTTP method is not supported
error.unsupportedMediaType=Unsupported media type
error.notAcceptable=Requested response media type is not acceptable
@ -210,7 +211,7 @@ error.suite.members.limit=A Skill Suite cannot contain more than {0} Skills
error.suite.members.duplicate=A Skill Suite cannot contain multiple versions of the same Skill
error.suite.members.unavailable=One or more Suite members are unavailable: {0}
error.suite.members.selectionMismatch=The selected Skill version does not match its coordinate
error.suite.members.invalid=Invalid Suite members: {0}
error.suite.members.invalid=These Suite members are unavailable. Check their published version and your access: {0}
error.suite.entry.notMember=The Entry Skill must be one of the Suite members
error.suite.entry.required=An Entry Skill is required
error.suite.namespace.notWritable=The Suite namespace is not writable: {0}
@ -223,6 +224,11 @@ error.suite.review.notRejected=Skill Suite version {0} was not rejected
error.suite.review.subjectMismatch=The review task does not target a Skill Suite version
error.suite.publish.notPrivate=Only private Skill Suites can be published without review
error.suite.publish.notDraft=Skill Suite version {0} is not a draft
error.suite.summary.required=Add a Suite summary before submitting or publishing this version
error.suite.overview.required=Add a Suite overview before submitting or publishing this version
label.suite.too_many=Skill Suite {0} cannot have more than {1} labels
label.suite.not_found=Label {1} is not attached to Skill Suite {0}
label.suite.no_permission=You do not have permission to manage this Skill Suite label
error.suite.displayName.required=Skill Suite display name is required
error.suite.version.required=Skill Suite version is required
error.suite.version.invalid=Skill Suite version must use 1-64 portable characters: letters, numbers, dot, underscore, plus, or hyphen
@ -235,3 +241,17 @@ error.suite.version.renameNotAllowed=A Skill Suite version cannot be renamed; cr
error.suite.delete.pendingReview=Skill Suite cannot be deleted while a review is pending
error.suite.install.operationConflict=The idempotency key was already used for another Skill Suite version
error.suite.install.idempotencyKey.invalid=The Suite install idempotency key is invalid
error.suite.bundle.manifest.invalid=Invalid Suite Bundle manifest: {0}
error.suite.bundle.preview.ownerMismatch=This Suite Bundle preview belongs to another user
error.suite.bundle.preview.expired=This Suite Bundle preview is unavailable or expired; create a new preview
error.suite.bundle.preview.warningMismatch=The Suite Bundle warnings changed; review a new preview before confirming
error.suite.bundle.preview.notFound=The Suite Bundle preview does not exist
error.suite.bundle.preview.stateChanged=Suite, permission, package, or reference state changed; create a new preview
error.suite.bundle.confirmation.disabled=Suite Bundle confirmation is not enabled on this deployment
error.suite.bundle.confirmation.idempotencyKey.invalid=The Suite Bundle confirmation idempotency key is invalid
error.suite.bundle.confirmation.operationConflict=The idempotency key or target is already used by another Suite Bundle operation
error.suite.bundle.operation.notFound=Suite Bundle operation not found
error.suite.bundle.operation.cancel.notAllowed=This Suite Bundle operation can no longer be cancelled
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

View file

@ -45,6 +45,7 @@ error.auth.sessionBootstrap.disabled=Инициализация сессии о
error.auth.sessionBootstrap.providerUnsupported=Неподдерживаемый провайдер инициализации сессии: {0}
error.auth.sessionBootstrap.notAuthenticated=Внешняя аутентифицированная сессия не найдена
error.badRequest=Некорректный запрос
error.suite.workspace.invalidFilter=Некорректный фильтр статуса набора
error.methodNotAllowed=HTTP-метод не поддерживается
error.unsupportedMediaType=Неподдерживаемый тип медиа
error.notAcceptable=Запрошенный тип ответа не поддерживается
@ -188,3 +189,23 @@ error.skillReview.reason.tooLong=Причина модерации не долж
error.pagination.invalid=Номер страницы не может быть отрицательным, а размер должен быть от 1 до {0}
error.request.conflict=Данные изменились во время обработки запроса. Обновите страницу и повторите попытку.
error.skillReview.notInteractable=Отзывы доступны только для опубликованных навыков
error.suite.members.invalid=Эти участники набора недоступны. Проверьте опубликованную версию и права доступа: {0}
error.suite.summary.required=Перед отправкой или публикацией этой версии добавьте краткое описание набора
error.suite.overview.required=Перед отправкой или публикацией этой версии добавьте обзор набора
label.suite.too_many=Набор навыков {0} не может иметь более {1} меток
label.suite.not_found=Метка {1} не связана с набором навыков {0}
label.suite.no_permission=У вас нет прав на управление метками этого набора навыков
error.suite.bundle.manifest.invalid=Недопустимый манифест пакета Skill Suite: {0}
error.suite.bundle.preview.ownerMismatch=Этот предварительный просмотр пакета Skill Suite принадлежит другому пользователю
error.suite.bundle.preview.expired=Предварительный просмотр пакета Skill Suite недоступен или истёк; создайте новый
error.suite.bundle.preview.warningMismatch=Предупреждения пакета Skill Suite изменились; создайте и проверьте новый предварительный просмотр
error.suite.bundle.preview.notFound=Предварительный просмотр пакета Skill Suite не существует
error.suite.bundle.preview.stateChanged=Состояние набора, разрешений, пакета или ссылки изменилось; создайте новый предварительный просмотр
error.suite.bundle.confirmation.disabled=Подтверждение пакета Skill Suite не включено в этом развёртывании
error.suite.bundle.confirmation.idempotencyKey.invalid=Недопустимый ключ идемпотентности подтверждения пакета Skill Suite
error.suite.bundle.confirmation.operationConflict=Ключ идемпотентности или цель уже используются другой операцией пакета Skill Suite
error.suite.bundle.operation.notFound=Операция пакета Skill Suite не найдена
error.suite.bundle.operation.cancel.notAllowed=Эту операцию пакета Skill Suite больше нельзя отменить
error.suite.bundle.operation.retry.notAllowed=Повторить можно только заблокированную операцию Skill Suite, допускающую повтор
error.suite.bundle.member.stateChanged=Состояние связанного Skill или версии изменилось; создайте новый предварительный просмотр пакета Skill Suite
error.suite.bundle.actor.inactive=Пользователь операции Skill Suite Bundle больше не активен

View file

@ -47,6 +47,7 @@ error.auth.sessionBootstrap.disabled=会话引导能力未启用
error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供方:{0}
error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话
error.badRequest=请求参数不合法
error.suite.workspace.invalidFilter=套件状态筛选不合法
error.methodNotAllowed=不支持的请求方法
error.unsupportedMediaType=不支持的请求内容类型
error.notAcceptable=无法返回客户端接受的内容类型
@ -210,7 +211,7 @@ error.suite.members.limit=技能套件最多包含 {0} 个技能
error.suite.members.duplicate=技能套件不能包含同一技能的多个版本
error.suite.members.unavailable=一个或多个套件成员当前不可用:{0}
error.suite.members.selectionMismatch=所选技能版本与提交的坐标不匹配
error.suite.members.invalid=套件成员无效{0}
error.suite.members.invalid=以下套件成员当前不可用,请检查是否为已发布版本以及你是否有访问权限{0}
error.suite.entry.notMember=入口技能必须是套件成员
error.suite.entry.required=必须选择入口技能
error.suite.namespace.notWritable=套件所在命名空间不可写:{0}
@ -223,6 +224,11 @@ error.suite.review.notRejected=技能套件版本 {0} 未被驳回
error.suite.review.subjectMismatch=该审核任务不属于技能套件版本
error.suite.publish.notPrivate=只有私有技能套件可以免审核发布
error.suite.publish.notDraft=技能套件版本 {0} 不是草稿
error.suite.summary.required=提交或发布该版本前,请先填写套件摘要
error.suite.overview.required=提交或发布该版本前,请先填写套件概述
label.suite.too_many=技能套件 {0} 最多只能配置 {1} 个标签
label.suite.not_found=技能套件 {0} 未关联标签 {1}
label.suite.no_permission=无权管理该技能套件的标签
error.suite.displayName.required=技能套件显示名称不能为空
error.suite.version.required=技能套件版本不能为空
error.suite.version.invalid=技能套件版本须为 1-64 位,且只能包含字母、数字、点、下划线、加号或连字符
@ -235,3 +241,17 @@ error.suite.version.renameNotAllowed=不能修改技能套件版本号,请创
error.suite.delete.pendingReview=技能套件存在待审核版本,暂不能删除
error.suite.install.operationConflict=该幂等键已用于其他技能套件版本
error.suite.install.idempotencyKey.invalid=技能套件安装幂等键格式无效
error.suite.bundle.manifest.invalid=技能套件 Bundle Manifest 无效:{0}
error.suite.bundle.preview.ownerMismatch=该技能套件 Bundle 预览属于其他用户
error.suite.bundle.preview.expired=该技能套件 Bundle 预览不可用或已过期,请重新预览
error.suite.bundle.preview.warningMismatch=技能套件 Bundle 的警告已变化,请重新预览并确认
error.suite.bundle.preview.notFound=技能套件 Bundle 预览不存在
error.suite.bundle.preview.stateChanged=套件、权限、成员包或引用状态已经变化,请重新预览
error.suite.bundle.confirmation.disabled=当前部署尚未启用技能套件 Bundle 确认功能
error.suite.bundle.confirmation.idempotencyKey.invalid=技能套件 Bundle 确认幂等键格式无效
error.suite.bundle.confirmation.operationConflict=该幂等键或目标已被其他技能套件 Bundle 操作占用
error.suite.bundle.operation.notFound=技能套件 Bundle 操作不存在
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 的操作者已不再处于可用状态

View file

@ -201,7 +201,8 @@ class ReviewPortalControllerTest {
new SkillLifecycleVersionResponse(100L, "1.2.0", "PENDING_REVIEW"),
null,
"REVIEW_TASK",
List.of()
List.of(),
new com.iflytek.skillhub.dto.PageResponse<>(List.of(), 0, 0, 20)
),
List.of(new SkillVersionResponse(100L, "1.2.0", "PENDING_REVIEW", null, 1, 10L, null, true, null)),
List.of(new SkillFileResponse(1L, "README.md", 123L, "text/markdown", "sha")),
@ -299,7 +300,7 @@ class ReviewPortalControllerTest {
Instant.parse("2026-08-31T11:00:00Z"),
2L
);
given(reviewProgressQueryRepository.findMyProgress("author-1", null, "", 0, 20))
given(reviewProgressQueryRepository.findMyProgress("author-1", null, null, "", 0, 20))
.willReturn(new ReviewProgressPageResponse(
List.of(item),
1,
@ -316,7 +317,7 @@ class ReviewPortalControllerTest {
.andExpect(jsonPath("$.data.statusCounts.pending").value(0))
.andExpect(jsonPath("$.data.statusCounts.rejected").value(1));
verify(reviewProgressQueryRepository).findMyProgress("author-1", null, "", 0, 20);
verify(reviewProgressQueryRepository).findMyProgress("author-1", null, null, "", 0, 20);
}
@Test

View file

@ -188,10 +188,13 @@ class SkillControllerTest {
null,
"OWNER_PREVIEW"
));
when(skillSuiteAppService.findVisibleEntryReferences(
eq(1L), eq((String) null), eq(Map.of()), anySet()))
.thenReturn(List.of(new SkillSuiteReferenceResponse(
9L, "team", "demo-suite", "Demo Suite", "2.0.0", 3)));
SkillSuiteReferenceResponse suiteReference = new SkillSuiteReferenceResponse(
9L, "team", "demo-suite", "Demo Suite", "2.0.0", 3,
true, List.of(), 0, 0);
when(skillSuiteAppService.findVisibleMemberships(
eq(1L), eq((String) null), eq(Map.of()), anySet(), eq(0), eq(20)))
.thenReturn(new com.iflytek.skillhub.dto.PageResponse<>(
List.of(suiteReference), 1, 0, 20));
mockMvc.perform(get("/api/web/skills/team/demo"))
.andExpect(status().isOk())
@ -204,6 +207,8 @@ class SkillControllerTest {
.andExpect(jsonPath("$.data.entryForSuites[0].slug").value("demo-suite"))
.andExpect(jsonPath("$.data.entryForSuites[0].version").value("2.0.0"))
.andExpect(jsonPath("$.data.entryForSuites[0].memberCount").value(3))
.andExpect(jsonPath("$.data.memberOfSuites.total").value(1))
.andExpect(jsonPath("$.data.memberOfSuites.items[0].currentSkillEntry").value(true))
.andExpect(jsonPath("$.data.canInteract").value(false))
.andExpect(jsonPath("$.data.canReport").value(false));
}

View file

@ -0,0 +1,50 @@
package com.iflytek.skillhub.controller;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.service.SkillSuiteLabelAppService;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SkillSuiteLabelControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SkillSuiteLabelAppService skillSuiteLabelAppService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@Test
void listSuiteLabelsShouldBeReadableThroughWebContract() throws Exception {
when(skillSuiteLabelAppService.listLabels(
eq("team"), eq("workflow"), isNull(), eq(Map.of()), eq(Set.of())))
.thenReturn(List.of(new SkillLabelDto("healthcare", "RECOMMENDED", "医疗健康")));
mockMvc.perform(get("/api/web/suites/team/workflow/labels"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].slug").value("healthcare"))
.andExpect(jsonPath("$.data[0].displayName").value("医疗健康"));
}
}

View file

@ -0,0 +1,230 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationResponse;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationDetailResponse;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationPageResponse;
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationSummaryResponse;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
import com.iflytek.skillhub.dto.SkillSuiteBundlePreviewResponse;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleConfirmationAppService;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundlePreviewAppService;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleOperationQueryService;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleOperationCommandService;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleResponseMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import(TestRedisConfig.class)
class SkillSuiteBundleControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private SkillSuiteBundlePreviewAppService previewService;
@MockBean private SkillSuiteBundleConfirmationAppService confirmationService;
@MockBean private SkillSuiteBundleOperationQueryService operationQueryService;
@MockBean private SkillSuiteBundleOperationCommandService operationCommandService;
@MockBean private SkillSuiteBundleResponseMapper responseMapper;
@MockBean private NamespaceMemberRepository namespaceMemberRepository;
@MockBean private DeviceAuthService deviceAuthService;
@Test
void previewRequiresAuthentication() throws Exception {
mockMvc.perform(multipart("/api/v1/suite-bundles/preview")
.file(new MockMultipartFile(
"file", "bundle.zip", "application/zip", new byte[]{1}))
.with(csrf()))
.andExpect(status().isUnauthorized());
verify(previewService, never()).preview(any(), any(), any(), any());
}
@Test
void authenticatedPreviewReturnsStructuredPlanWithoutStorageLocations() throws Exception {
SkillSuiteBundlePreviewAppService.PreviewOutcome outcome =
new SkillSuiteBundlePreviewAppService.PreviewOutcome(
"preview-1", Instant.parse("2026-09-11T09:00:00Z"), null, null);
SkillSuiteBundlePreviewResponse response = new SkillSuiteBundlePreviewResponse(
"preview-1", Instant.parse("2026-09-11T09:00:00Z"), true, null,
List.of(), List.of(), List.of(), List.of(), "warning-digest");
when(previewService.preview(any(), eq("actor"), eq(Map.of()), eq(Set.of())))
.thenReturn(outcome);
when(responseMapper.toResponse(outcome)).thenReturn(response);
mockMvc.perform(multipart("/api/v1/suite-bundles/preview")
.file(new MockMultipartFile(
"file", "bundle.zip", "application/zip", new byte[]{1}))
.with(authentication(authToken("actor")))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.previewToken").value("preview-1"))
.andExpect(jsonPath("$.data.confirmable").value(true))
.andExpect(jsonPath("$.data.warningDigest").value("warning-digest"))
.andExpect(jsonPath("$.data.archiveObjectKey").doesNotExist());
}
@Test
void previewArchiveIsRequiredByTheHttpContract() throws Exception {
mockMvc.perform(multipart("/api/v1/suite-bundles/preview")
.with(authentication(authToken("actor")))
.with(csrf()))
.andExpect(status().isBadRequest());
verify(previewService, never()).preview(any(), any(), any(), any());
}
@Test
void confirmationRequiresWarningDigestAndPassesIdempotencyKey() throws Exception {
mockMvc.perform(post("/api/v1/suite-bundles/previews/preview-1/confirm")
.with(authentication(authToken("actor")))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
verify(confirmationService, never()).confirm(any(), any(), any(), any(), any(), any());
mockMvc.perform(post("/api/v1/suite-bundles/previews/preview-1/confirm")
.with(authentication(authToken("actor")))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"warningDigest\":\"warning-digest\"}"))
.andExpect(status().isBadRequest());
verify(confirmationService, never()).confirm(any(), any(), any(), any(), any(), any());
SkillSuiteBundleConfirmationAppService.ConfirmationOutcome outcome =
new SkillSuiteBundleConfirmationAppService.ConfirmationOutcome(
"operation-1", "RUNNING", false);
when(confirmationService.confirm(
"preview-1", "request-1", "warning-digest", "actor", Map.of(), Set.of()))
.thenReturn(outcome);
when(responseMapper.toResponse(outcome)).thenReturn(
new SkillSuiteBundleOperationResponse("operation-1", "RUNNING", false));
mockMvc.perform(post("/api/v1/suite-bundles/previews/preview-1/confirm")
.with(authentication(authToken("actor")))
.with(csrf())
.header("Idempotency-Key", "request-1")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"warningDigest\":\"warning-digest\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.operationId").value("operation-1"))
.andExpect(jsonPath("$.data.status").value("RUNNING"))
.andExpect(jsonPath("$.data.replayed").value(false));
}
@Test
void authenticatedCallerCanReadRedactedOperationStatus() throws Exception {
SkillSuiteBundleOperationDetailResponse response = new SkillSuiteBundleOperationDetailResponse(
"operation-1", SkillSuiteBundleOperationStatus.RUNNING, SkillSuiteBundleMode.UPDATE,
"@global/suite", 1L, 10L, "1.1.0", "1.0.0", null, null, null,
Instant.parse("2026-09-11T08:00:00Z"), Instant.parse("2026-09-11T08:00:00Z"),
null, List.of());
when(operationQueryService.get("operation-1", "actor", Map.of(), Set.of()))
.thenReturn(response);
mockMvc.perform(get("/api/v1/suite-bundles/operations/operation-1")
.with(authentication(authToken("actor"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.operationId").value("operation-1"))
.andExpect(jsonPath("$.data.targetCoordinate").value("@global/suite"))
.andExpect(jsonPath("$.data.baseVersion").value("1.0.0"))
.andExpect(jsonPath("$.data.actorId").doesNotExist())
.andExpect(jsonPath("$.data.archiveObjectKey").doesNotExist())
.andExpect(jsonPath("$.data.plan").doesNotExist());
}
@Test
void authenticatedCallerCanListTheirActiveOperations() throws Exception {
when(operationQueryService.listActive("actor", 0, 12)).thenReturn(new PageResponse<>(List.of(
new SkillSuiteBundleOperationSummaryResponse(
"operation-1", SkillSuiteBundleMode.CREATE, "@global/suite", "1.0.0",
SkillSuiteBundleOperationStatus.WAITING_FOR_MEMBERS, null,
null, 2, 1, 1, Instant.parse("2026-09-11T08:00:00Z"))), 1, 0, 12));
mockMvc.perform(get("/api/v1/suite-bundles/operations/active")
.with(authentication(authToken("actor"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].operationId").value("operation-1"))
.andExpect(jsonPath("$.data.items[0].targetCoordinate").value("@global/suite"))
.andExpect(jsonPath("$.data.items[0].completedMembers").value(1))
.andExpect(jsonPath("$.data.total").value(1));
}
@Test
void authenticatedCallerCanListTheirOperationHistory() throws Exception {
when(operationQueryService.listMine("actor", 0, 12)).thenReturn(new SkillSuiteBundleOperationPageResponse(List.of(
new SkillSuiteBundleOperationSummaryResponse(
"operation-cancelled", SkillSuiteBundleMode.CREATE, "@global/suite", "1.0.0",
SkillSuiteBundleOperationStatus.CANCELLED, null,
null, 2, 1, 0, Instant.parse("2026-09-11T08:00:00Z"))), 1, 0, 12, true));
mockMvc.perform(get("/api/v1/suite-bundles/operations/mine")
.with(authentication(authToken("actor"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.hasChangingOperations").value(true))
.andExpect(jsonPath("$.data.items[0].operationId").value("operation-cancelled"))
.andExpect(jsonPath("$.data.items[0].status").value("CANCELLED"));
}
@Test
void authenticatedCallerCanCancelAndRetryAnOperation() throws Exception {
when(operationCommandService.cancel("operation-1", "actor", Map.of(), Set.of()))
.thenReturn(new SkillSuiteBundleOperationResponse("operation-1", "CANCELLED", false));
when(operationCommandService.retry("operation-2", "actor", Map.of(), Set.of()))
.thenReturn(new SkillSuiteBundleOperationResponse("operation-2", "RUNNING", false));
mockMvc.perform(post("/api/v1/suite-bundles/operations/operation-1/cancel")
.with(authentication(authToken("actor")))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("CANCELLED"));
mockMvc.perform(post("/api/v1/suite-bundles/operations/operation-2/retry")
.with(authentication(authToken("actor")))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.operationId").value("operation-2"))
.andExpect(jsonPath("$.data.status").value("RUNNING"));
}
private UsernamePasswordAuthenticationToken authToken(String userId) {
PlatformPrincipal principal = new PlatformPrincipal(
userId, userId, userId + "@example.test", null, "local", Set.of());
return new UsernamePasswordAuthenticationToken(
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
}
}

View file

@ -0,0 +1,269 @@
package com.iflytek.skillhub.integration;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersion;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.repository.SkillSuiteBundleOperationQueryRepository;
import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.TestPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
@Import({SkillSuiteBundleOperationQueryRepository.class, MySkillSuiteQueryRepository.class})
@Testcontainers
@TestPropertySource(properties = {
"spring.flyway.enabled=true",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.show-sql=false",
"logging.level.org.hibernate.SQL=OFF"
})
class SkillSuiteBundleOperationQueryRepositoryTest {
private static final Instant NOW = Instant.parse("2026-09-14T08:00:00Z");
@Container
private static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configurePostgres(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
}
@Autowired
private TestEntityManager entityManager;
@Autowired
private SkillSuiteBundleOperationQueryRepository repository;
@Autowired
private MySkillSuiteQueryRepository workspaceRepository;
@Test
void workspacePagesTemporaryCreationsAndCountsAttentionAcrossAllPagesWithoutMemberQueries() {
entityManager.persist(new UserAccount("actor", "Actor", null, null));
Namespace namespace = entityManager.persistFlushFind(new Namespace("team-ai", "AI Team", "actor"));
for (int index = 0; index < 15; index++) {
var operation = createOperation("operation-" + Integer.toHexString(index), "actor", namespace, "temporary-" + index);
if (index < 3) operation.markBlockedRetryable("MEMBER_EXECUTION_FAILED", "retry", NOW.plusSeconds(index));
else operation.cancel(NOW.plusSeconds(index));
entityManager.persist(operation);
}
entityManager.flush();
var statistics = entityManager.getEntityManager().getEntityManagerFactory()
.unwrap(org.hibernate.engine.spi.SessionFactoryImplementor.class).getStatistics();
statistics.setStatisticsEnabled(true);
statistics.clear();
var first = workspaceRepository.findWorkspace("actor", java.util.Set.of(namespace.getId()), java.util.Set.of(), "", "", 0, 12);
assertThat(statistics.getPrepareStatementCount()).isEqualTo(2);
assertThat(first.items()).hasSize(12);
assertThat(first.total()).isEqualTo(15);
assertThat(first.attentionCount()).isEqualTo(3);
assertThat(first.hasChangingOperations()).isFalse();
assertThat(first.items().getFirst().state()).isEqualTo("ATTENTION");
var second = workspaceRepository.findWorkspace("actor", java.util.Set.of(namespace.getId()), java.util.Set.of(), "", "", 1, 12);
assertThat(second.items()).hasSize(3);
assertThat(second.attentionCount()).isEqualTo(3);
assertThat(workspaceRepository.findWorkspace("actor", java.util.Set.of(namespace.getId()), java.util.Set.of(), "", "ATTENTION", 0, 12).items()).hasSize(3);
assertThat(workspaceRepository.findWorkspace("other", java.util.Set.of(namespace.getId()), java.util.Set.of(), "", "", 0, 12).total()).isZero();
assertThat(workspaceRepository.findWorkspace("actor", java.util.Set.of(), java.util.Set.of(), "", "", 0, 12).total()).isZero();
assertThat(workspaceRepository.findWorkspace("actor", java.util.Set.of(namespace.getId()), java.util.Set.of(), "%", "", 0, 12).total()).isZero();
assertThat(workspaceRepository.findWorkspace("actor", java.util.Set.of(namespace.getId()), java.util.Set.of(), "team-ai/temporary-1", "", 0, 12).total()).isEqualTo(6);
}
@Test
void workspaceMergesCreatedSuiteAndKeepsSuiteReviewWithoutABundleOperation() {
entityManager.persist(new UserAccount("actor", "Actor", null, null));
Namespace namespace = entityManager.persistFlushFind(new Namespace("team-ai", "AI Team", "actor"));
var operation = createOperation("operation-1", "actor", namespace, "care-suite");
operation.cancel(NOW.plusSeconds(1));
entityManager.persist(operation);
SkillSuite suite = entityManager.persistFlushFind(new SkillSuite(namespace.getId(), "care-suite", "Care", "actor"));
SkillSuiteVersion version = new SkillSuiteVersion(suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "actor");
version.setDisplayName("Care");
version.setStatus(com.iflytek.skillhub.domain.suite.SkillSuiteVersionStatus.PENDING_REVIEW);
entityManager.persistAndFlush(version);
var result = workspaceRepository.findWorkspace("actor", java.util.Set.of(namespace.getId()), java.util.Set.of(), "", "", 0, 12);
assertThat(result.items()).singleElement().satisfies(item -> {
assertThat(item.suiteId()).isEqualTo(suite.getId());
assertThat(item.state()).isEqualTo("PENDING_REVIEW");
assertThat(item.operationId()).isNull();
});
assertThat(workspaceRepository.findWorkspace("admin", java.util.Set.of(namespace.getId()), java.util.Set.of(namespace.getId()), "", "PENDING_REVIEW", 0, 12).total()).isEqualTo(1);
assertThat(workspaceRepository.findWorkspace("other", java.util.Set.of(namespace.getId()), java.util.Set.of(), "", "", 0, 12).total()).isZero();
}
@Test
void pagesOnlyTheActorsActiveOperationsAndAggregatesMemberStatusesInPostgres() {
entityManager.persist(new UserAccount("actor", "Actor", null, null));
Namespace namespace = entityManager.persistFlushFind(
new Namespace("team-ai", "AI Team", "actor"));
SkillSuite suite = entityManager.persistFlushFind(
new SkillSuite(namespace.getId(), "care-suite", "Care Suite", "actor"));
SkillSuiteVersion baseVersion = entityManager.persistFlushFind(
new SkillSuiteVersion(suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "actor"));
SkillSuiteBundlePreviewSession preview = entityManager.persistFlushFind(
new SkillSuiteBundlePreviewSession(
"preview-1", "actor", SkillSuiteBundleMode.UPDATE, namespace.getId(),
suite.getSlug(), suite.getId(), baseVersion.getId(), "1.1.0",
"staging/archive.zip", "a".repeat(64), Map.of(), Map.of(),
"warning-digest", NOW.plusSeconds(600), NOW));
SkillSuiteBundleExecutionOperation operation = new SkillSuiteBundleExecutionOperation(
"operation-1", preview.getToken(), "request-1", "actor", SkillSuiteBundleMode.UPDATE,
namespace.getId(), suite.getSlug(), suite.getId(), baseVersion.getId(), "1.1.0",
"staging/archive.zip", "a".repeat(64), Map.of(), "warning-digest", NOW);
operation.markWaitingForMembers(NOW.plusSeconds(1));
entityManager.persist(operation);
entityManager.persist(member(0, "first", false));
entityManager.persist(member(1, "second", true));
SkillSuiteBundleExecutionOperation newest = createOperation(
"operation-2", "actor", namespace, "newer-suite");
newest.markWaitingForMembers(NOW.plusSeconds(2));
entityManager.persist(newest);
SkillSuiteBundleExecutionOperation terminal = createOperation(
"operation-3", "actor", namespace, "finished-suite");
terminal.cancel(NOW.plusSeconds(3));
entityManager.persist(terminal);
SkillSuiteBundleExecutionOperation anotherActor = createOperation(
"operation-4", "another-actor", namespace, "other-suite");
anotherActor.markWaitingForMembers(NOW.plusSeconds(4));
entityManager.persist(anotherActor);
entityManager.flush();
var firstPage = repository.findActive("actor", 0, 1);
var secondPage = repository.findActive("actor", 1, 1);
assertThat(firstPage.total()).isEqualTo(2);
assertThat(firstPage.items()).singleElement()
.extracting(summary -> summary.operationId())
.isEqualTo("operation-2");
assertThat(secondPage.items()).singleElement().satisfies(summary -> {
assertThat(summary.operationId()).isEqualTo("operation-1");
assertThat(summary.targetCoordinate()).isEqualTo("@team-ai/care-suite");
assertThat(summary.baseVersion()).isEqualTo("1.0.0");
assertThat(summary.totalMembers()).isEqualTo(2);
assertThat(summary.completedMembers()).isZero();
assertThat(summary.waitingMembers()).isEqualTo(1);
});
assertThat(repository.findActive("another-actor", 0, 1).items()).singleElement()
.extracting(summary -> summary.operationId())
.isEqualTo("operation-4");
var history = repository.findMine("actor", 0, 10);
assertThat(history.total()).isEqualTo(3);
assertThat(history.hasChangingOperations()).isTrue();
assertThat(history.items()).extracting(summary -> summary.operationId())
.containsExactly("operation-2", "operation-1", "operation-3");
assertThat(repository.findMine("another-actor", 0, 10).items()).singleElement()
.extracting(summary -> summary.operationId())
.isEqualTo("operation-4");
assertThat(entityManager.getEntityManager().createNativeQuery("""
SELECT indexdef FROM pg_indexes
WHERE indexname = 'idx_suite_bundle_operation_actor_status_updated'
""").getSingleResult().toString())
.contains("(actor_id, status, updated_at DESC, operation_id DESC)");
assertThat(entityManager.getEntityManager().createNativeQuery("""
SELECT indexdef FROM pg_indexes
WHERE indexname = 'idx_suite_bundle_operation_actor_priority_updated'
""").getSingleResult().toString())
.contains("actor_id", "CASE", "updated_at DESC", "operation_id DESC");
}
@Test
void prioritizesActionableAndChangingTasksAheadOfNewerTerminalHistory() {
entityManager.persist(new UserAccount("actor", "Actor", null, null));
Namespace namespace = entityManager.persistFlushFind(
new Namespace("team-ai", "AI Team", "actor"));
List<String> terminalTokens = List.of("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b");
for (int index = 0; index < terminalTokens.size(); index++) {
String token = terminalTokens.get(index);
SkillSuiteBundleExecutionOperation terminal = createOperation(
"operation-" + token, "actor", namespace, "terminal-" + token);
terminal.cancel(NOW.plusSeconds(100L + index));
entityManager.persist(terminal);
}
SkillSuiteBundleExecutionOperation running = createOperation(
"operation-c", "actor", namespace, "running-suite");
entityManager.persist(running);
SkillSuiteBundleExecutionOperation blocked = createOperation(
"operation-d", "actor", namespace, "blocked-suite");
blocked.markBlockedRetryable("MEMBER_FAILED", "retry", NOW.plusSeconds(1));
entityManager.persist(blocked);
entityManager.flush();
var page = repository.findMine("actor", 0, 12);
assertThat(page.total()).isEqualTo(14);
assertThat(page.hasChangingOperations()).isTrue();
assertThat(page.items()).hasSize(12);
assertThat(page.items()).extracting(summary -> summary.operationId())
.startsWith("operation-d", "operation-c");
assertThat(page.items()).extracting(summary -> summary.status())
.startsWith(SkillSuiteBundleOperationStatus.BLOCKED_RETRYABLE,
SkillSuiteBundleOperationStatus.RUNNING);
}
private SkillSuiteBundleMemberResult member(int position, String slug, boolean waiting) {
SkillSuiteBundleMemberResult member = new SkillSuiteBundleMemberResult(
"operation-1", position, new SkillSuiteBundleCoordinate("team-ai", slug),
SkillSuiteBundleMemberSourceType.PACKAGE, "members/" + slug,
SkillVisibility.PUBLIC, "1.0.0", SkillSuiteBundleRelationshipChange.ADDED,
SkillSuiteBundlePublishAction.CREATE_VERSION, "fingerprint-" + slug,
null, null, List.of(), List.of(), NOW);
if (waiting) member.markWaiting(NOW.plusSeconds(1));
return member;
}
private SkillSuiteBundleExecutionOperation createOperation(
String operationId,
String actorId,
Namespace namespace,
String suiteSlug
) {
String suffix = operationId.substring(operationId.lastIndexOf('-') + 1);
SkillSuiteBundlePreviewSession preview = entityManager.persistFlushFind(
new SkillSuiteBundlePreviewSession(
"preview-" + suffix, actorId, SkillSuiteBundleMode.CREATE, namespace.getId(),
suiteSlug, null, null, "1.0.0", "staging/" + suffix + ".zip",
suffix.repeat(64), Map.of(), Map.of(), "warning-" + suffix,
NOW.plusSeconds(600), NOW));
return new SkillSuiteBundleExecutionOperation(
operationId, preview.getToken(), "request-" + suffix, actorId,
SkillSuiteBundleMode.CREATE, namespace.getId(), suiteSlug, null, null, "1.0.0",
"staging/" + suffix + ".zip", suffix.repeat(64), Map.of(),
"warning-" + suffix, NOW);
}
}

View file

@ -0,0 +1,117 @@
package com.iflytek.skillhub.integration;
import static org.assertj.core.api.Assertions.assertThat;
import com.iflytek.skillhub.domain.label.LabelDefinition;
import com.iflytek.skillhub.domain.label.LabelType;
import com.iflytek.skillhub.domain.label.SkillLabel;
import com.iflytek.skillhub.domain.label.SkillLabelRepository;
import com.iflytek.skillhub.domain.label.SkillSuiteLabel;
import com.iflytek.skillhub.domain.label.SkillSuiteLabelRepository;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillTag;
import com.iflytek.skillhub.domain.skill.SkillTagRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.suite.SkillSuite;
import com.iflytek.skillhub.domain.user.UserAccount;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
@Testcontainers
class SkillSuiteLabelPersistenceTest {
@Container
private static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configurePostgres(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
registry.add("spring.flyway.enabled", () -> true);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
@Autowired private TestEntityManager entityManager;
@Autowired private SkillSuiteLabelRepository suiteLabelRepository;
@Autowired private SkillLabelRepository skillLabelRepository;
@Autowired private SkillTagRepository skillTagRepository;
@Test
void deletingSuiteRemovesOnlySuiteLabelsAndKeepsMemberSkillMetadata() {
Fixture fixture = persistFixture("suite-delete");
entityManager.remove(entityManager.find(SkillSuite.class, fixture.suite().getId()));
entityManager.flush();
entityManager.clear();
assertThat(suiteLabelRepository.findBySuiteId(fixture.suite().getId())).isEmpty();
assertThat(skillLabelRepository.findBySkillId(fixture.skill().getId()))
.extracting(SkillLabel::getLabelId)
.containsExactly(fixture.label().getId());
assertThat(skillTagRepository.findBySkillId(fixture.skill().getId()))
.extracting(SkillTag::getTagName)
.containsExactly("member-tag");
assertThat(entityManager.find(Skill.class, fixture.skill().getId())).isNotNull();
assertThat(entityManager.find(LabelDefinition.class, fixture.label().getId())).isNotNull();
}
@Test
void deletingSuiteLabelAssociationDoesNotPropagateToMemberSkill() {
Fixture fixture = persistFixture("association-delete");
SkillSuiteLabel association = suiteLabelRepository
.findBySuiteIdAndLabelId(fixture.suite().getId(), fixture.label().getId())
.orElseThrow();
suiteLabelRepository.delete(association);
entityManager.flush();
entityManager.clear();
assertThat(suiteLabelRepository.findBySuiteId(fixture.suite().getId())).isEmpty();
assertThat(skillLabelRepository.findBySkillId(fixture.skill().getId())).hasSize(1);
assertThat(skillTagRepository.findBySkillId(fixture.skill().getId())).hasSize(1);
}
private Fixture persistFixture(String suffix) {
String userId = "suite-label-" + suffix;
entityManager.persist(new UserAccount(userId, "Suite Label Owner", null, null));
Namespace namespace = entityManager.persistFlushFind(
new Namespace("suite-label-" + suffix, "Suite Label Namespace", userId));
Skill skill = entityManager.persistFlushFind(
new Skill(namespace.getId(), "member", userId, SkillVisibility.PUBLIC));
SkillVersion version = entityManager.persistFlushFind(
new SkillVersion(skill.getId(), "1.0.0", userId));
SkillSuite suite = entityManager.persistFlushFind(
new SkillSuite(namespace.getId(), "suite", "Suite", userId));
LabelDefinition label = entityManager.persistFlushFind(
new LabelDefinition("label-" + suffix, LabelType.RECOMMENDED, true, 0, userId));
entityManager.persist(new SkillLabel(skill.getId(), label.getId(), userId));
entityManager.persist(new SkillTag(skill.getId(), "member-tag", version.getId(), userId));
entityManager.persist(new SkillSuiteLabel(suite.getId(), label.getId(), userId));
entityManager.flush();
entityManager.clear();
return new Fixture(skill, suite, label);
}
private record Fixture(Skill skill, SkillSuite suite, LabelDefinition label) {
}
}

View file

@ -3,6 +3,10 @@ package com.iflytek.skillhub.integration;
import static org.assertj.core.api.Assertions.assertThat;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.label.LabelDefinition;
import com.iflytek.skillhub.domain.label.LabelType;
import com.iflytek.skillhub.domain.label.SkillLabel;
import com.iflytek.skillhub.domain.label.SkillSuiteLabel;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVersion;
@ -14,9 +18,11 @@ import com.iflytek.skillhub.domain.suite.SkillSuiteVersion;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMemberRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionStatus;
import com.iflytek.skillhub.domain.suite.SkillSuiteStatus;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.search.postgres.PostgresResourceDiscoveryQueryService;
import com.iflytek.skillhub.service.ResourceDiscoveryAppService;
import com.iflytek.skillhub.repository.SkillSuiteLabelQueryRepository;
import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository;
import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository;
import java.time.Instant;
@ -29,6 +35,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
@ -46,7 +53,9 @@ import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
@TestPropertySource(properties = {
"spring.flyway.enabled=true",
"spring.jpa.hibernate.ddl-auto=validate"
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.show-sql=false",
"logging.level.org.hibernate.SQL=OFF"
})
class SuiteDiscoveryIntegrationTest {
@ -69,6 +78,9 @@ class SuiteDiscoveryIntegrationTest {
@Autowired
private ResourceDiscoveryAppService appService;
@MockBean
private SkillSuiteLabelQueryRepository suiteLabelProjectionService;
@Autowired
private MySkillSuiteQueryRepository mySuiteRepository;
@ -80,6 +92,8 @@ class SuiteDiscoveryIntegrationTest {
@BeforeEach
void seedReferencedUsers() {
org.mockito.Mockito.when(suiteLabelProjectionService.labelsBySuiteIds(org.mockito.ArgumentMatchers.any()))
.thenReturn(Map.of());
entityManager.persist(new UserAccount("owner", "Owner", null, null));
entityManager.persist(new UserAccount("author", "Author", null, null));
entityManager.persist(new UserAccount("other-author", "Other Author", null, null));
@ -121,6 +135,13 @@ class SuiteDiscoveryIntegrationTest {
true));
suite.setLatestVersionId(suiteVersion.getId());
entityManager.persistAndFlush(suite);
LabelDefinition suiteLabel = entityManager.persistFlushFind(
new LabelDefinition("suite-label", LabelType.RECOMMENDED, true, 0, "owner"));
LabelDefinition memberLabel = entityManager.persistFlushFind(
new LabelDefinition("member-only", LabelType.RECOMMENDED, true, 1, "owner"));
entityManager.persist(new SkillSuiteLabel(suite.getId(), suiteLabel.getId(), "owner"));
entityManager.persist(new SkillLabel(skill.getId(), memberLabel.getId(), "owner"));
entityManager.flush();
entityManager.clear();
var result = appService.search("starter", "team-ai", "", "relevance", 0, 20, Set.of());
@ -141,6 +162,13 @@ class SuiteDiscoveryIntegrationTest {
assertThat(item.displayName()).isEqualTo("Published snapshot name");
assertThat(item.summary()).isEqualTo("Published snapshot summary");
});
assertThat(appService.search(
null, null, "SUITE", "newest", 0, 20, Set.of(), List.of("suite-label")).items())
.extracting(item -> item.slug())
.containsExactly("starter");
assertThat(appService.search(
null, null, "SUITE", "newest", 0, 20, Set.of(), List.of("member-only")).items())
.isEmpty();
assertThat(suiteReferenceRepository.findVisibleEntryReferences(
skill.getId(), null, Map.of(), Set.of()))
.singleElement()
@ -188,6 +216,157 @@ class SuiteDiscoveryIntegrationTest {
skill.getId(), "author", Map.of(namespace.getId(), NamespaceRole.MEMBER), Set.of()))
.singleElement()
.satisfies(reference -> assertThat(reference.slug()).isEqualTo("private-suite"));
suite = entityManager.find(SkillSuite.class, suite.getId());
suite.setHidden(true);
entityManager.persistAndFlush(suite);
entityManager.clear();
assertThat(suiteReferenceRepository.findVisibleMemberships(
skill.getId(), "author", Map.of(namespace.getId(), NamespaceRole.MEMBER),
Set.of(), 0, 20).items()).isEmpty();
suite = entityManager.find(SkillSuite.class, suite.getId());
suite.setHidden(false);
suite.setStatus(SkillSuiteStatus.ARCHIVED);
entityManager.persistAndFlush(suite);
entityManager.clear();
assertThat(suiteReferenceRepository.findVisibleMemberships(
skill.getId(), "author", Map.of(namespace.getId(), NamespaceRole.MEMBER),
Set.of(), 0, 20).items()).isEmpty();
}
@Test
void findsOrdinaryMembershipAndProtectsPrivateSiblingMetadata() {
Namespace namespace = entityManager.persistFlushFind(
new Namespace("member-team", "Member Team", "owner"));
PublishedSkill current = publishedSkill(
namespace, "ordinary", "owner", SkillVisibility.PUBLIC, "Ordinary");
PublishedSkill entry = publishedSkill(
namespace, "entry", "owner", SkillVisibility.PUBLIC, "Entry");
PublishedSkill restricted = publishedSkill(
namespace, "private-helper", "other-author", SkillVisibility.PRIVATE,
"Private Helper");
SkillSuite suite = entityManager.persistFlushFind(
new SkillSuite(namespace.getId(), "member-pack", "Member Pack", "author"));
SkillSuiteVersion suiteVersion = new SkillSuiteVersion(
suite.getId(), "2.0.0", "Member Pack", "Current members",
SkillVisibility.PUBLIC, "author");
suiteVersion.setStatus(SkillSuiteVersionStatus.PUBLISHED);
suiteVersion = entityManager.persistFlushFind(suiteVersion);
persistMember(suiteVersion, entry, 0, true);
persistMember(suiteVersion, current, 1, false);
persistMember(suiteVersion, restricted, 2, false);
for (int index = 0; index < 9; index++) {
persistMember(suiteVersion, publishedSkill(
namespace, "helper-" + index, "owner", SkillVisibility.PUBLIC,
"Helper " + index), index + 3, false);
}
suite.setLatestVersionId(suiteVersion.getId());
entityManager.persistAndFlush(suite);
entityManager.clear();
var page = suiteReferenceRepository.findVisibleMemberships(
current.skill().getId(), null, Map.of(), Set.of(), 0, 20);
assertThat(page.total()).isEqualTo(1);
assertThat(page.items()).singleElement().satisfies(reference -> {
assertThat(reference.slug()).isEqualTo("member-pack");
assertThat(reference.currentSkillEntry()).isFalse();
assertThat(reference.memberCount()).isEqualTo(12);
assertThat(reference.visibleSiblingMembers()).hasSize(8);
assertThat(reference.visibleSiblingMembers().getFirst()).satisfies(member -> {
assertThat(member.slug()).isEqualTo("entry");
assertThat(member.entry()).isTrue();
assertThat(member.available()).isTrue();
});
assertThat(reference.restrictedMemberCount()).isEqualTo(1);
assertThat(reference.omittedVisibleMemberCount()).isEqualTo(2);
});
entityManager.getEntityManager().createNativeQuery(
"UPDATE skill SET latest_version_id = NULL WHERE id = :id")
.setParameter("id", restricted.skill().getId())
.executeUpdate();
entityManager.getEntityManager().createNativeQuery("DELETE FROM skill_version WHERE id = :id")
.setParameter("id", restricted.version().getId())
.executeUpdate();
entityManager.getEntityManager().createNativeQuery("DELETE FROM skill WHERE id = :id")
.setParameter("id", restricted.skill().getId())
.executeUpdate();
entityManager.flush();
entityManager.clear();
assertThat(suiteReferenceRepository.findVisibleMemberships(
current.skill().getId(), null, Map.of(), Set.of(), 0, 20).items())
.singleElement()
.satisfies(reference -> {
assertThat(reference.restrictedMemberCount()).isEqualTo(1);
assertThat(reference.visibleSiblingMembers())
.noneMatch(member -> member.slug().equals("private-helper"));
});
}
@Test
void ignoresHistoricalMembershipOutsideLatestSuiteSnapshot() {
Namespace namespace = entityManager.persistFlushFind(
new Namespace("history-team", "History Team", "owner"));
PublishedSkill removed = publishedSkill(
namespace, "removed", "owner", SkillVisibility.PUBLIC, "Removed");
PublishedSkill replacement = publishedSkill(
namespace, "replacement", "owner", SkillVisibility.PUBLIC, "Replacement");
SkillSuite suite = entityManager.persistFlushFind(
new SkillSuite(namespace.getId(), "evolving-pack", "Evolving Pack", "owner"));
SkillSuiteVersion oldVersion = new SkillSuiteVersion(
suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "owner");
oldVersion.setStatus(SkillSuiteVersionStatus.PUBLISHED);
oldVersion = entityManager.persistFlushFind(oldVersion);
persistMember(oldVersion, removed, 0, true);
SkillSuiteVersion latestVersion = new SkillSuiteVersion(
suite.getId(), "2.0.0", SkillVisibility.PUBLIC, "owner");
latestVersion.setStatus(SkillSuiteVersionStatus.PUBLISHED);
latestVersion = entityManager.persistFlushFind(latestVersion);
persistMember(latestVersion, replacement, 0, true);
suite.setLatestVersionId(latestVersion.getId());
entityManager.persistAndFlush(suite);
entityManager.clear();
assertThat(suiteReferenceRepository.findVisibleMemberships(
removed.skill().getId(), null, Map.of(), Set.of(), 0, 20).items()).isEmpty();
}
@Test
void boundsMembershipPagesAndExposesTotalForContinuation() {
Namespace namespace = entityManager.persistFlushFind(
new Namespace("many-suite-team", "Many Suite Team", "owner"));
PublishedSkill current = publishedSkill(
namespace, "popular-member", "owner", SkillVisibility.PUBLIC, "Popular Member");
for (int index = 0; index < 21; index++) {
SkillSuite suite = new SkillSuite(
namespace.getId(), "pack-" + index, "Pack " + index, "owner");
entityManager.persist(suite);
SkillSuiteVersion version = new SkillSuiteVersion(
suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "owner");
version.setStatus(SkillSuiteVersionStatus.PUBLISHED);
entityManager.persist(version);
persistMember(version, current, 0, true);
suite.setLatestVersionId(version.getId());
}
entityManager.flush();
entityManager.clear();
var first = suiteReferenceRepository.findVisibleMemberships(
current.skill().getId(), null, Map.of(), Set.of(), 0, 100);
var second = suiteReferenceRepository.findVisibleMemberships(
current.skill().getId(), null, Map.of(), Set.of(), 1, 100);
assertThat(first.size()).isEqualTo(20);
assertThat(first.total()).isEqualTo(21);
assertThat(first.items()).hasSize(20);
assertThat(second.total()).isEqualTo(21);
assertThat(second.items()).hasSize(1);
}
@Test
@ -212,6 +391,32 @@ class SuiteDiscoveryIntegrationTest {
.satisfies(item -> assertThat(item.slug()).isEqualTo("internal"));
}
@Test
void exposesNamespaceOnlySuiteMembershipOnlyToNamespaceMembers() {
Namespace namespace = entityManager.persistFlushFind(
new Namespace("membership-team", "Membership Team", "owner"));
PublishedSkill current = publishedSkill(
namespace, "shared-member", "owner", SkillVisibility.PUBLIC, "Shared Member");
SkillSuite suite = entityManager.persistFlushFind(new SkillSuite(
namespace.getId(), "internal-pack", "Internal Pack", "owner"));
SkillSuiteVersion version = new SkillSuiteVersion(
suite.getId(), "1.0.0", SkillVisibility.NAMESPACE_ONLY, "owner");
version.setStatus(SkillSuiteVersionStatus.PUBLISHED);
version = entityManager.persistFlushFind(version);
persistMember(version, current, 0, true);
suite.setLatestVersionId(version.getId());
entityManager.persistAndFlush(suite);
entityManager.clear();
assertThat(suiteReferenceRepository.findVisibleMemberships(
current.skill().getId(), null, Map.of(), Set.of(), 0, 20).items()).isEmpty();
assertThat(suiteReferenceRepository.findVisibleMemberships(
current.skill().getId(), "author",
Map.of(namespace.getId(), NamespaceRole.MEMBER), Set.of(), 0, 20).items())
.singleElement()
.satisfies(reference -> assertThat(reference.slug()).isEqualTo("internal-pack"));
}
@Test
void dashboardReturnsTheLatestVersionTheCallerCanManage() {
Namespace namespace = entityManager.persistFlushFind(
@ -335,4 +540,42 @@ class SuiteDiscoveryIntegrationTest {
assertThat(member.getSkillVersionSnapshot()).isEqualTo("2.0.0");
});
}
private PublishedSkill publishedSkill(
Namespace namespace,
String slug,
String ownerId,
SkillVisibility visibility,
String displayName
) {
Skill skill = new Skill(namespace.getId(), slug, ownerId, visibility);
skill.setDisplayName(displayName);
entityManager.persist(skill);
SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", ownerId);
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setDownloadReady(true);
entityManager.persist(version);
skill.setLatestVersionId(version.getId());
return new PublishedSkill(skill, version, namespace.getSlug());
}
private void persistMember(
SkillSuiteVersion suiteVersion,
PublishedSkill publishedSkill,
int position,
boolean entry
) {
entityManager.persist(new SkillSuiteVersionMember(
suiteVersion.getId(),
new SkillSuiteMemberSelection(
publishedSkill.skill().getId(), publishedSkill.version().getId(),
publishedSkill.namespaceSlug(),
publishedSkill.skill().getSlug(), publishedSkill.version().getVersion(),
"sha256:" + Integer.toHexString(position).repeat(64).substring(0, 64)),
position,
entry));
}
private record PublishedSkill(Skill skill, SkillVersion version, String namespaceSlug) {
}
}

View file

@ -0,0 +1,39 @@
package com.iflytek.skillhub.listener;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.event.SkillSuiteBundleAdvanceRequestedEvent;
import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleCoordinator;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class SkillSuiteBundleEventListenerTest {
@Test
void directAndLifecycleEventsWakeEachBoundOperationOnce() {
SkillSuiteBundleMemberResultRepository repository = mock(SkillSuiteBundleMemberResultRepository.class);
SkillSuiteBundleCoordinator coordinator = mock(SkillSuiteBundleCoordinator.class);
SkillSuiteBundleEventListener listener = new SkillSuiteBundleEventListener(repository, coordinator);
SkillSuiteBundleMemberResult first = mock(SkillSuiteBundleMemberResult.class);
SkillSuiteBundleMemberResult duplicate = mock(SkillSuiteBundleMemberResult.class);
when(first.getOperationId()).thenReturn("operation-a");
when(duplicate.getOperationId()).thenReturn("operation-a");
when(repository.findBySkillVersionId(9L)).thenReturn(List.of(first, duplicate));
when(repository.findBySkillVersionId(10L)).thenReturn(List.of(first));
listener.onAdvanceRequested(new SkillSuiteBundleAdvanceRequestedEvent("operation-direct"));
listener.onSkillPublished(new SkillPublishedEvent(1L, 9L, "actor"));
listener.onSkillVersionYanked(new SkillVersionYankedEvent(1L, 10L, "actor", true));
verify(coordinator).advance("operation-direct");
verify(coordinator, org.mockito.Mockito.times(2)).advance("operation-a");
verify(repository).findBySkillVersionId(10L);
}
}

View file

@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewSubjectType;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersion;
@ -103,7 +104,7 @@ class JpaReviewProgressQueryRepositoryTest {
entityManager.flush();
entityManager.clear();
var firstPage = repository.findMyProgress("author-1", null, "", 0, 1);
var firstPage = repository.findMyProgress("author-1", null, null, "", 0, 1);
assertThat(firstPage.items()).hasSize(1);
assertThat(firstPage.total()).isEqualTo(3);
@ -116,23 +117,23 @@ class JpaReviewProgressQueryRepositoryTest {
assertThat(firstPage.statusCounts().approved()).isEqualTo(1);
assertThat(firstPage.statusCounts().rejected()).isEqualTo(1);
var emptyPage = repository.findMyProgress("author-1", null, "", 8, 1);
var emptyPage = repository.findMyProgress("author-1", null, null, "", 8, 1);
assertThat(emptyPage.items()).isEmpty();
assertThat(emptyPage.total()).isEqualTo(3);
var maximumPage = repository.findMyProgress(
"author-1", null, "", Integer.MAX_VALUE, 100);
"author-1", null, null, "", Integer.MAX_VALUE, 100);
assertThat(maximumPage.items()).isEmpty();
assertThat(maximumPage.total()).isEqualTo(3);
var searchedAndFiltered = repository.findMyProgress(
"author-1", ReviewTaskStatus.APPROVED, "BETA", 0, 20);
"author-1", null, ReviewTaskStatus.APPROVED, "BETA", 0, 20);
assertThat(searchedAndFiltered.items()).singleElement()
.satisfies(item -> assertThat(item.skillSlug()).isEqualTo("beta-skill"));
assertThat(searchedAndFiltered.total()).isEqualTo(1);
assertThat(searchedAndFiltered.statusCounts().approved()).isEqualTo(1);
var searchMiss = repository.findMyProgress("author-1", null, "missing", 0, 20);
var searchMiss = repository.findMyProgress("author-1", null, null, "missing", 0, 20);
assertThat(searchMiss.items()).isEmpty();
assertThat(searchMiss.total()).isZero();
assertThat(searchMiss.statusCounts().pending()).isZero();
@ -145,6 +146,8 @@ class JpaReviewProgressQueryRepositoryTest {
persistUsers("owner", "author-1");
Namespace namespace = entityManager.persistFlushFind(
new Namespace("team-suite-review", "Suite Review Team", "owner"));
Skill skill = entityManager.persistFlushFind(
new Skill(namespace.getId(), "starter-skill", "author-1", SkillVisibility.PUBLIC));
SkillSuite suite = entityManager.persistFlushFind(
new SkillSuite(namespace.getId(), "starter-pack", "Starter Pack", "author-1"));
SkillSuiteVersion suiteVersion = entityManager.persistFlushFind(
@ -152,12 +155,20 @@ class JpaReviewProgressQueryRepositoryTest {
ReviewTask task = ReviewTask.forSuiteVersion(
suiteVersion.getId(), suite.getId(), namespace.getId(), suiteVersion.getVersion(), "author-1");
entityManager.persist(task);
persistAttempt(
skill,
namespace,
"author-1",
"1.0.0",
ReviewTaskStatus.APPROVED,
Instant.parse("2026-08-30T10:00:00Z"));
entityManager.flush();
entityManager.clear();
var progress = repository.findMyProgress("author-1", null, "STARTER", 0, 20);
var progress = repository.findMyProgress("author-1", null, null, "STARTER", 0, 20);
assertThat(progress.items()).singleElement().satisfies(item -> {
assertThat(progress.items()).hasSize(2);
assertThat(progress.items()).anySatisfy(item -> {
assertThat(item.skillId()).isNull();
assertThat(item.skillSlug()).isNull();
assertThat(item.subjectType()).isEqualTo("SUITE_VERSION");
@ -166,6 +177,23 @@ class JpaReviewProgressQueryRepositoryTest {
assertThat(item.subjectSlug()).isEqualTo("starter-pack");
});
assertThat(progress.statusCounts().pending()).isEqualTo(1);
assertThat(progress.statusCounts().approved()).isEqualTo(1);
var suitesOnly = repository.findMyProgress(
"author-1", ReviewSubjectType.SUITE_VERSION, null, "STARTER", 0, 20);
assertThat(suitesOnly.items()).singleElement()
.satisfies(item -> assertThat(item.subjectType()).isEqualTo("SUITE_VERSION"));
assertThat(suitesOnly.total()).isEqualTo(1);
assertThat(suitesOnly.statusCounts().pending()).isEqualTo(1);
assertThat(suitesOnly.statusCounts().approved()).isZero();
var skillsOnly = repository.findMyProgress(
"author-1", ReviewSubjectType.SKILL_VERSION, null, "STARTER", 0, 20);
assertThat(skillsOnly.items()).singleElement()
.satisfies(item -> assertThat(item.subjectType()).isEqualTo("SKILL_VERSION"));
assertThat(skillsOnly.total()).isEqualTo(1);
assertThat(skillsOnly.statusCounts().pending()).isZero();
assertThat(skillsOnly.statusCounts().approved()).isEqualTo(1);
}
@Test

View file

@ -0,0 +1,544 @@
package com.iflytek.skillhub.repository;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.config.SkillSuiteBundleProperties;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersion;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewStatus;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleConfirmationAppService;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundlePreviewPlanner;
import com.iflytek.skillhub.service.bundle.SkillSuiteBundlePreviewRevalidationService;
import com.iflytek.skillhub.storage.ObjectStorageService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
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.mock;
import static org.mockito.Mockito.when;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
@Testcontainers
class SkillSuiteBundlePersistenceTest {
private static final TypeReference<Map<String, Object>> JSON_OBJECT = new TypeReference<>() { };
@Container
private static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configurePostgres(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
registry.add("spring.flyway.enabled", () -> true);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
@Autowired private jakarta.persistence.EntityManager entityManager;
@Autowired private SkillSuiteBundlePreviewSessionRepository previewRepository;
@Autowired private SkillSuiteBundleExecutionOperationRepository operationRepository;
@Autowired private SkillSuiteBundleMemberResultRepository memberRepository;
@Autowired private PlatformTransactionManager transactionManager;
@Test
void previewsForTheSameCreateTargetCanCoexistWithoutReservingIt() {
Namespace namespace = persistNamespace("bundle-preview");
previewRepository.save(preview("preview-a", "actor-a", namespace.getId(), "target", null, null));
previewRepository.save(preview("preview-b", "actor-b", namespace.getId(), "target", null, null));
entityManager.flush();
entityManager.clear();
assertThat(previewRepository.findById("preview-a")).isPresent();
assertThat(previewRepository.findById("preview-b")).isPresent();
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void activeCreateReservationIsUniqueAndAReleasedReservationCanBeReacquired() {
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
Long namespaceId = transactions.execute(status -> persistNamespace("bundle-create-lock").getId());
transactions.executeWithoutResult(status -> {
previewRepository.save(preview("create-preview-a", "create-actor-a", namespaceId, "target", null, null));
operationRepository.save(operation(
"create-op-a", "create-preview-a", "create-request-a", "create-actor-a",
SkillSuiteBundleMode.CREATE, namespaceId, "target", null, null));
});
assertThatThrownBy(() -> transactions.executeWithoutResult(status -> {
previewRepository.save(preview("create-preview-b", "create-actor-b", namespaceId, "target", null, null));
operationRepository.save(operation(
"create-op-b", "create-preview-b", "create-request-b", "create-actor-b",
SkillSuiteBundleMode.CREATE, namespaceId, "target", null, null));
})).isInstanceOf(DataIntegrityViolationException.class);
transactions.executeWithoutResult(status -> {
SkillSuiteBundleExecutionOperation first = operationRepository.findById("create-op-a").orElseThrow();
first.transition(SkillSuiteBundleOperationStatus.CANCELLED, now().plusSeconds(5));
operationRepository.save(first);
});
transactions.executeWithoutResult(status -> {
previewRepository.save(preview("create-preview-c", "create-actor-c", namespaceId, "target", null, null));
operationRepository.save(operation(
"create-op-c", "create-preview-c", "create-request-c", "create-actor-c",
SkillSuiteBundleMode.CREATE, namespaceId, "target", null, null));
});
assertThat(operationRepository.findById("create-op-c")).isPresent();
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void concurrentCreateConfirmationsAcquireExactlyOneReservation() throws Exception {
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
Long namespaceId = transactions.execute(status -> persistNamespace("bundle-concurrent-lock").getId());
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try (var executor = Executors.newFixedThreadPool(2)) {
var first = executor.submit(() -> attemptReservation(
transactions, ready, start, namespaceId, null, null, "concurrent-a"));
var second = executor.submit(() -> attemptReservation(
transactions, ready, start, namespaceId, null, null, "concurrent-b"));
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
assertThat(List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS)))
.containsExactlyInAnyOrder(true, false);
} finally {
start.countDown();
}
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void concurrentUpdateConfirmationsAcquireExactlyOneReservation() throws Exception {
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
SuiteFixture fixture = transactions.execute(status -> persistSuite("bundle-concurrent-update-lock"));
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try (var executor = Executors.newFixedThreadPool(2)) {
var first = executor.submit(() -> attemptReservation(
transactions, ready, start, fixture.namespaceId(), fixture.suiteId(),
fixture.baseVersionId(), "concurrent-update-a"));
var second = executor.submit(() -> attemptReservation(
transactions, ready, start, fixture.namespaceId(), fixture.suiteId(),
fixture.baseVersionId(), "concurrent-update-b"));
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
assertThat(List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS)))
.containsExactlyInAnyOrder(true, false);
} finally {
start.countDown();
}
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void confirmationServiceAtomicallyReservesTargetAndReplaysAfterResponseLoss() throws Exception {
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
Long namespaceId = transactions.execute(status -> persistNamespace("bundle-confirm-service").getId());
ObjectMapper objectMapper = new ObjectMapper();
SkillSuiteBundlePreviewPlanner.PreviewPlan plan = confirmationPlan(namespaceId);
var manifest = new com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifestParser().parse("""
apiVersion: skillhub.iflytek.com/v1alpha1
kind: SkillSuiteBundle
metadata:
namespace: bundle-confirm-service
slug: target
spec:
mode: CREATE
version: 1.1.0
displayName: Target
summary: Summary
overview: Overview
visibility: PUBLIC
entry: "@bundle-confirm-service/member"
members:
- skill: "@bundle-confirm-service/member"
package:
path: skills/member
visibility: PUBLIC
""");
transactions.executeWithoutResult(status -> {
previewRepository.save(confirmablePreview(
"confirm-service-a", "actor-a", namespaceId, manifest, plan, objectMapper));
previewRepository.save(confirmablePreview(
"confirm-service-b", "actor-b", namespaceId, manifest, plan, objectMapper));
});
SkillSuiteBundlePreviewPlanner planner = mock(SkillSuiteBundlePreviewPlanner.class);
when(planner.plan(any(), any(), any(), any())).thenReturn(plan);
ObjectStorageService storage = mock(ObjectStorageService.class);
when(storage.exists(any())).thenReturn(true);
SkillSuiteBundleProperties properties = new SkillSuiteBundleProperties();
properties.setConfirmationEnabled(true);
SkillSuiteBundlePreviewRevalidationService revalidation =
new SkillSuiteBundlePreviewRevalidationService(planner, storage, objectMapper);
SkillSuiteBundleConfirmationAppService confirmation = new SkillSuiteBundleConfirmationAppService(
previewRepository, operationRepository, memberRepository, revalidation, properties,
mock(org.springframework.context.ApplicationEventPublisher.class),
java.time.Clock.fixed(now(), java.time.ZoneOffset.UTC));
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try (var executor = Executors.newFixedThreadPool(2)) {
var first = executor.submit(() -> attemptConfirmation(
transactions, confirmation, ready, start,
"confirm-service-a", "request-a", "actor-a"));
var second = executor.submit(() -> attemptConfirmation(
transactions, confirmation, ready, start,
"confirm-service-b", "request-b", "actor-b"));
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
assertThat(List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS)))
.containsExactlyInAnyOrder(true, false);
} finally {
start.countDown();
}
SkillSuiteBundleExecutionOperation winner = transactions.execute(status -> operationRepository
.findByPreviewToken("confirm-service-a")
.or(() -> operationRepository.findByPreviewToken("confirm-service-b"))
.orElseThrow());
SkillSuiteBundleConfirmationAppService.ConfirmationOutcome replay = transactions.execute(status ->
confirmation.confirm(
winner.getPreviewToken(), winner.getClientRequestId(), winner.getWarningDigest(),
winner.getActorId(), Map.of(), java.util.Set.of()));
assertThat(replay.operationId()).isEqualTo(winner.getOperationId());
assertThat(replay.replayed()).isTrue();
}
@Test
void updateReservationRejectsMissingSuiteIdentity() {
assertThatThrownBy(() -> SkillSuiteBundleExecutionOperation.reservationKey(
SkillSuiteBundleMode.UPDATE, 1L, "target", null, "1.1.0"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("UPDATE reservation requires suite and target version");
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void updateReservationIsScopedToSuiteAndTargetVersion() {
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
SuiteFixture fixture = transactions.execute(status -> persistSuite("bundle-update-lock"));
transactions.executeWithoutResult(status -> {
previewRepository.save(preview(
"update-preview-a", "update-actor-a", fixture.namespaceId(), "target",
fixture.suiteId(), fixture.baseVersionId()));
operationRepository.save(operation(
"update-op-a", "update-preview-a", "update-request-a", "update-actor-a",
SkillSuiteBundleMode.UPDATE, fixture.namespaceId(), "target",
fixture.suiteId(), fixture.baseVersionId()));
});
assertThatThrownBy(() -> transactions.executeWithoutResult(status -> {
previewRepository.save(preview(
"update-preview-b", "update-actor-b", fixture.namespaceId(), "target",
fixture.suiteId(), fixture.baseVersionId()));
operationRepository.save(operation(
"update-op-b", "update-preview-b", "update-request-b", "update-actor-b",
SkillSuiteBundleMode.UPDATE, fixture.namespaceId(), "target",
fixture.suiteId(), fixture.baseVersionId()));
})).isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void deletingTheBaseSuiteVersionKeepsTheUpdateOperationAndClearsItsReference() {
SuiteFixture fixture = persistSuite("bundle-deleted-base-version");
previewRepository.save(preview(
"deleted-base-preview", "actor", fixture.namespaceId(), "target",
fixture.suiteId(), fixture.baseVersionId()));
operationRepository.save(operation(
"deleted-base-operation", "deleted-base-preview", "deleted-base-request", "actor",
SkillSuiteBundleMode.UPDATE, fixture.namespaceId(), "target",
fixture.suiteId(), fixture.baseVersionId()));
entityManager.flush();
entityManager.createNativeQuery("DELETE FROM skill_suite_version WHERE id = :id")
.setParameter("id", fixture.baseVersionId())
.executeUpdate();
entityManager.flush();
entityManager.clear();
assertThat(previewRepository.findById("deleted-base-preview"))
.get()
.extracting(SkillSuiteBundlePreviewSession::getBaseSuiteVersionId)
.isNull();
assertThat(operationRepository.findById("deleted-base-operation"))
.get()
.extracting(SkillSuiteBundleExecutionOperation::getBaseSuiteVersionId)
.isNull();
}
@Test
void operationAndMemberPlanRemainReadableAfterPersistenceContextIsCleared() {
Namespace namespace = persistNamespace("bundle-recovery");
previewRepository.save(preview("recovery-preview", "actor", namespace.getId(), "target", null, null));
operationRepository.save(operation(
"recovery-op", "recovery-preview", "request", "actor",
SkillSuiteBundleMode.CREATE, namespace.getId(), "target", null, null));
memberRepository.saveAll(List.of(new SkillSuiteBundleMemberResult(
"recovery-op", 0, new SkillSuiteBundleCoordinate("global", "member"),
SkillSuiteBundleMemberSourceType.PACKAGE, "members/member", SkillVisibility.PUBLIC,
"1.0.0", SkillSuiteBundleRelationshipChange.ADDED,
SkillSuiteBundlePublishAction.CREATE_SKILL, "sha256:member", null, null,
List.of(), List.of("review warning"), now())));
entityManager.flush();
entityManager.clear();
SkillSuiteBundleExecutionOperation operation = operationRepository.findById("recovery-op").orElseThrow();
assertThat(operation.getPlan()).containsEntry("memberCount", 1);
assertThat(operation.isReservationActive()).isTrue();
assertThat(memberRepository.findByOperationIdOrderByPosition("recovery-op"))
.singleElement()
.satisfies(member -> {
assertThat(member.getSkillSlug()).isEqualTo("member");
assertThat(member.getWarnings()).containsExactly("review warning");
});
}
@Test
void cleanupQueriesReturnOnlyExpiredPreviewsAndUncleanedTerminalOperations() {
Namespace namespace = persistNamespace("bundle-cleanup-query");
SkillSuiteBundlePreviewSession expired = preview(
"cleanup-expired", "actor", namespace.getId(), "expired", null, null);
expired.markExpired();
previewRepository.save(expired);
SkillSuiteBundlePreviewSession activePreview = preview(
"cleanup-active-preview", "actor", namespace.getId(), "active", null, null);
activePreview.markConfirmed(now());
previewRepository.save(activePreview);
operationRepository.save(operation(
"cleanup-active", "cleanup-active-preview", "cleanup-active-request", "actor",
SkillSuiteBundleMode.CREATE, namespace.getId(), "active", null, null));
SkillSuiteBundlePreviewSession terminalPreview = preview(
"cleanup-terminal-preview", "actor", namespace.getId(), "terminal", null, null);
terminalPreview.markConfirmed(now());
previewRepository.save(terminalPreview);
SkillSuiteBundleExecutionOperation terminal = operation(
"cleanup-terminal", "cleanup-terminal-preview", "cleanup-request", "actor",
SkillSuiteBundleMode.CREATE, namespace.getId(), "terminal", null, null);
terminal.cancel(now().plusSeconds(1));
operationRepository.save(terminal);
entityManager.flush();
entityManager.clear();
assertThat(previewRepository
.findTop100ByStatusAndStagedObjectsCleanedAtIsNullOrderByExpiresAtAsc(
SkillSuiteBundlePreviewStatus.EXPIRED))
.extracting(SkillSuiteBundlePreviewSession::getToken)
.containsExactly("cleanup-expired");
assertThat(operationRepository
.findTop100ByStatusInAndStagedObjectsCleanedAtIsNullOrderByCompletedAtAsc(Set.of(
SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED,
SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED,
SkillSuiteBundleOperationStatus.CANCELLED)))
.extracting(SkillSuiteBundleExecutionOperation::getOperationId)
.contains("cleanup-terminal")
.doesNotContain("cleanup-active");
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void rollingBackConfirmationLeavesNoOperationOrSuiteLifecycleRows() {
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
Long namespaceId = transactions.execute(status -> persistNamespace("bundle-rollback").getId());
assertThatThrownBy(() -> transactions.executeWithoutResult(status -> {
previewRepository.save(preview(
"rollback-preview", "rollback-actor", namespaceId, "target", null, null));
operationRepository.save(operation(
"rollback-op", "rollback-preview", "rollback-request", "rollback-actor",
SkillSuiteBundleMode.CREATE, namespaceId, "target", null, null));
entityManager.flush();
throw new IllegalStateException("force rollback");
})).isInstanceOf(IllegalStateException.class);
assertThat(operationRepository.findById("rollback-op")).isEmpty();
assertThat(previewRepository.findById("rollback-preview")).isEmpty();
Long suiteCount = transactions.execute(status -> entityManager.createQuery(
"SELECT COUNT(suite) FROM SkillSuite suite WHERE suite.namespaceId = :namespaceId", Long.class)
.setParameter("namespaceId", namespaceId)
.getSingleResult());
assertThat(suiteCount).isZero();
}
private boolean attemptReservation(
TransactionTemplate transactions, CountDownLatch ready, CountDownLatch start,
Long namespaceId, Long suiteId, Long baseVersionId, String suffix
) {
ready.countDown();
try {
if (!start.await(10, TimeUnit.SECONDS)) {
throw new AssertionError("Timed out waiting to start concurrent confirmation");
}
transactions.executeWithoutResult(status -> {
String previewToken = "preview-" + suffix;
SkillSuiteBundleMode mode = suiteId == null
? SkillSuiteBundleMode.CREATE
: SkillSuiteBundleMode.UPDATE;
previewRepository.save(preview(
previewToken, "actor-" + suffix, namespaceId, "target", suiteId, baseVersionId));
operationRepository.save(operation(
"operation-" + suffix, previewToken, "request-" + suffix, "actor-" + suffix,
mode, namespaceId, "target", suiteId, baseVersionId));
});
return true;
} catch (DataIntegrityViolationException exception) {
return false;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new AssertionError("Interrupted while waiting to confirm", exception);
}
}
private boolean attemptConfirmation(
TransactionTemplate transactions,
SkillSuiteBundleConfirmationAppService confirmation,
CountDownLatch ready,
CountDownLatch start,
String previewToken,
String requestId,
String actorId
) {
ready.countDown();
try {
if (!start.await(10, TimeUnit.SECONDS)) {
throw new AssertionError("Timed out waiting to start concurrent confirmation");
}
transactions.execute(status -> confirmation.confirm(
previewToken, requestId, "warning-digest", actorId, Map.of(), java.util.Set.of()));
return true;
} catch (DomainConflictException exception) {
return false;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new AssertionError("Interrupted while waiting to confirm", exception);
}
}
private SkillSuiteBundlePreviewSession confirmablePreview(
String token,
String actor,
Long namespaceId,
com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifest manifest,
SkillSuiteBundlePreviewPlanner.PreviewPlan plan,
ObjectMapper objectMapper
) {
return new SkillSuiteBundlePreviewSession(
token, actor, SkillSuiteBundleMode.CREATE, namespaceId, "target", null, null, "1.1.0",
"temporary/" + token + ".zip", "a".repeat(64),
objectMapper.convertValue(manifest, JSON_OBJECT), objectMapper.convertValue(plan, JSON_OBJECT),
"warning-digest", now().plus(30, ChronoUnit.MINUTES), now());
}
private SkillSuiteBundlePreviewPlanner.PreviewPlan confirmationPlan(Long namespaceId) {
SkillSuiteBundlePreviewPlanner.MemberPlan member = new SkillSuiteBundlePreviewPlanner.MemberPlan(
new SkillSuiteBundleCoordinate("bundle-confirm-service", "member"),
SkillSuiteBundleMemberSourceType.PACKAGE,
SkillSuiteBundleRelationshipChange.ADDED, SkillSuiteBundlePublishAction.CREATE_SKILL,
null, null, SkillVisibility.PUBLIC, "1.0.0", "sha256:member",
List.of(), List.of(), List.of());
return new SkillSuiteBundlePreviewPlanner.PreviewPlan(
SkillSuiteBundleMode.CREATE,
new SkillSuiteBundleCoordinate("bundle-confirm-service", "target"), namespaceId,
null, null, "1.1.0", "Target", "Summary", "Overview", SkillVisibility.PUBLIC,
List.of(member), List.of(), List.of(), List.of(), "warning-digest");
}
private Namespace persistNamespace(String slug) {
String ownerId = "owner-" + slug;
entityManager.persist(new UserAccount(ownerId, ownerId, null, null));
Namespace namespace = new Namespace(slug, slug, ownerId);
entityManager.persist(namespace);
entityManager.flush();
return namespace;
}
private SuiteFixture persistSuite(String namespaceSlug) {
Namespace namespace = persistNamespace(namespaceSlug);
String ownerId = "owner-" + namespaceSlug;
SkillSuite suite = new SkillSuite(namespace.getId(), "target", "Target", ownerId);
entityManager.persist(suite);
SkillSuiteVersion version = new SkillSuiteVersion(
suite.getId(), "1.0.0", "Target", "Summary", SkillVisibility.PUBLIC, ownerId);
entityManager.persist(version);
entityManager.flush();
return new SuiteFixture(namespace.getId(), suite.getId(), version.getId());
}
private SkillSuiteBundlePreviewSession preview(
String token, String actor, Long namespaceId, String slug, Long suiteId, Long baseVersionId
) {
SkillSuiteBundleMode mode = suiteId == null ? SkillSuiteBundleMode.CREATE : SkillSuiteBundleMode.UPDATE;
return new SkillSuiteBundlePreviewSession(
token, actor, mode, namespaceId, slug, suiteId, baseVersionId, "1.1.0",
"temporary/" + token + ".zip", "a".repeat(64), Map.of("kind", "SkillSuiteBundle"),
Map.of("memberCount", 1), "b".repeat(64), now().plus(30, ChronoUnit.MINUTES), now());
}
private SkillSuiteBundleExecutionOperation operation(
String operationId, String previewToken, String requestId, String actor,
SkillSuiteBundleMode mode, Long namespaceId, String slug, Long suiteId, Long baseVersionId
) {
return new SkillSuiteBundleExecutionOperation(
operationId, previewToken, requestId, actor, mode, namespaceId, slug, suiteId,
baseVersionId, "1.1.0", "temporary/" + previewToken + ".zip", "a".repeat(64),
Map.of("memberCount", 1), "b".repeat(64), now());
}
private Instant now() {
return Instant.parse("2026-09-11T04:00:00Z");
}
private record SuiteFixture(Long namespaceId, Long suiteId, Long baseVersionId) {
}
}

View file

@ -0,0 +1,56 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.repository.SkillSuiteLabelQueryRepository;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.search.ResourceDiscoveryQueryService;
import com.iflytek.skillhub.search.ResourceDiscoveryQueryService.ResourceHit;
import com.iflytek.skillhub.search.ResourceDiscoveryQueryService.ResourcePage;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
class ResourceDiscoveryAppServiceTest {
private final ResourceDiscoveryQueryService queryService = mock(ResourceDiscoveryQueryService.class);
private final SkillSuiteLabelQueryRepository projectionService =
mock(SkillSuiteLabelQueryRepository.class);
private final ResourceDiscoveryAppService service =
new ResourceDiscoveryAppService(queryService, projectionService);
@Test
void attachesLabelsToSuitesWithOneBatchAndNeverToSkills() {
Instant now = Instant.parse("2026-09-11T00:00:00Z");
ResourceHit suite = new ResourceHit(
"SUITE", 1L, "global", "suite", "Suite", "Summary", "1.0.0",
"PUBLIC", 1, true, now);
ResourceHit skill = new ResourceHit(
"SKILL", 2L, "global", "skill", "Skill", "Summary", "1.0.0",
"PUBLIC", 2, true, now);
when(queryService.search(anyQuery())).thenReturn(new ResourcePage(List.of(suite, skill), 2, 0, 20));
SkillLabelDto label = new SkillLabelDto("automation", "RECOMMENDED", "Automation");
when(projectionService.labelsBySuiteIds(List.of(1L))).thenReturn(Map.of(1L, List.of(label)));
var response = service.search(
null, null, "SUITE", "newest", 0, 20, Set.of(), List.of("Automation"));
assertThat(response.items()).filteredOn(item -> item.resourceType().equals("SUITE"))
.singleElement().extracting(item -> item.labels()).isEqualTo(List.of(label));
assertThat(response.items()).filteredOn(item -> item.resourceType().equals("SKILL"))
.singleElement().extracting(item -> item.labels()).isEqualTo(List.of());
verify(projectionService).labelsBySuiteIds(List.of(1L));
verify(queryService).search(new ResourceDiscoveryQueryService.ResourceQuery(
null, null, "SUITE", "newest", 0, 20, Set.of(), List.of("automation")));
}
private ResourceDiscoveryQueryService.ResourceQuery anyQuery() {
return org.mockito.ArgumentMatchers.any(ResourceDiscoveryQueryService.ResourceQuery.class);
}
}

View file

@ -25,6 +25,9 @@ import com.iflytek.skillhub.domain.suite.SkillSuiteMemberState;
import com.iflytek.skillhub.domain.suite.SkillSuiteQueryService;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersion;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionStatus;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.repository.SkillSuiteCandidateQueryRepository;
import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository;
import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository;
@ -38,8 +41,10 @@ import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Field;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@ -54,6 +59,21 @@ import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class SkillSuiteAppServiceTest {
@Test
void workspaceBoundsPaginationAndSeparatesMemberAndAdminPermissions() {
service.workspace("actor", Map.of(1L, NamespaceRole.MEMBER, 2L, NamespaceRole.ADMIN),
"care", "ATTENTION", -1, 200);
verify(mySkillSuiteQueryRepository).findWorkspace("actor", Set.of(1L, 2L), Set.of(2L),
"care", "ATTENTION", 0, 100);
}
@Test
void workspaceRejectsUnknownStateBeforeQuerying() {
assertThatThrownBy(() -> service.workspace("actor", Map.of(), "", "UNKNOWN", 0, 12))
.isInstanceOf(DomainBadRequestException.class);
org.mockito.Mockito.verifyNoInteractions(mySkillSuiteQueryRepository);
}
@Mock private NamespaceRepository namespaceRepository;
@Mock private SkillQueryService skillQueryService;
@Mock private SkillSuiteDraftService draftService;
@ -66,6 +86,7 @@ class SkillSuiteAppServiceTest {
@Mock private SkillSuiteCandidateQueryRepository candidateQueryRepository;
@Mock private MySkillSuiteQueryRepository mySkillSuiteQueryRepository;
@Mock private SkillSuiteReferenceQueryRepository referenceQueryRepository;
@Mock private UserAccountRepository userAccountRepository;
@Mock private HttpServletRequest request;
private SkillSuiteAppService service;
private Namespace namespace;
@ -80,7 +101,8 @@ class SkillSuiteAppServiceTest {
namespaceRepository, skillQueryService, draftService, lifecycleService,
queryService, installMetricsService, installOperationRepository, auditLogService,
requestIdAccessor,
candidateQueryRepository, mySkillSuiteQueryRepository, referenceQueryRepository);
candidateQueryRepository, mySkillSuiteQueryRepository, referenceQueryRepository,
userAccountRepository);
namespace = new Namespace("global", "Global", "admin");
setField(namespace, "id", 1L);
suite = new SkillSuite(1L, "starter", "Starter", "user-1");
@ -88,6 +110,7 @@ class SkillSuiteAppServiceTest {
version = new SkillSuiteVersion(7L, "1.0.0", SkillVisibility.PUBLIC, "user-1");
setField(version, "id", 70L);
version.setOverview("## Install in order");
version.setChangelog("Initial Suite workflow");
firstMember = member(11L, 101L, "first", "1.0.0", "sha256:first", 0);
secondMember = member(12L, 102L, "second", "2.0.0", "sha256:second", 1);
}
@ -120,6 +143,30 @@ class SkillSuiteAppServiceTest {
any(), any(), any(), any(), any(), any(), any(), any());
}
@Test
void createInstallPlan_keepsHistoricalEmptyDisplayMetadataInstallable() {
version.setOverview(null);
SkillSuiteQueryService.Detail detail = detail(true);
given(queryService.getDetail("global", "starter", null, "user-1", Map.of(), Set.of()))
.willReturn(detail);
given(skillQueryService.resolveVersionById(101L, "user-1", Map.of(), Set.of()))
.willReturn(resolved(11L, 101L, "first", "1.0.0", "sha256:first"));
given(skillQueryService.resolveVersionById(102L, "user-1", Map.of(), Set.of()))
.willReturn(resolved(12L, 102L, "second", "2.0.0", "sha256:second"));
given(installOperationRepository.insertIfAbsent(
any(), org.mockito.ArgumentMatchers.eq("historical-1"),
org.mockito.ArgumentMatchers.eq("user:user-1"),
org.mockito.ArgumentMatchers.eq(7L), org.mockito.ArgumentMatchers.eq(70L))).willReturn(1);
var result = service.createInstallPlan(
"global", "starter", null, "user-1", Map.of(), Set.of(), "historical-1", request);
assertThat(version.getSummary()).isNull();
assertThat(version.getOverview()).isNull();
assertThat(result.members()).hasSize(2);
verify(installMetricsService).recordIssuedPlan(7L);
}
@Test
void createInstallPlan_preservesSuperAdminAccessWhenResolvingPrivateMembers() {
Set<String> platformRoles = Set.of("SUPER_ADMIN");
@ -287,6 +334,8 @@ class SkillSuiteAppServiceTest {
org.mockito.ArgumentMatchers.eq(suite), org.mockito.ArgumentMatchers.eq(version),
org.mockito.ArgumentMatchers.eq(namespace), any()))
.willReturn(Set.of(SkillSuiteAllowedAction.EDIT, SkillSuiteAllowedAction.CREATE_VERSION));
given(userAccountRepository.findById("user-1"))
.willReturn(Optional.of(new UserAccount("user-1", "Suite Owner", null, null)));
var result = service.getDetail(
"global", "starter", null, "user-1", Map.of(1L, NamespaceRole.MEMBER), Set.of());
@ -296,10 +345,37 @@ class SkillSuiteAppServiceTest {
assertThat(result.suiteStatus()).isEqualTo("ACTIVE");
assertThat(result.hidden()).isFalse();
assertThat(result.overview()).isEqualTo("## Install in order");
assertThat(result.changelog()).isEqualTo("Initial Suite workflow");
assertThat(result.createdBy()).isEqualTo("user-1");
assertThat(result.createdByName()).isEqualTo("Suite Owner");
assertThat(result.members()).extracting(member -> member.displayName())
.containsExactly("First Skill", "Second Skill");
}
@Test
void listVersions_resolvesCreatorNamesInOneBatch() {
var createdAt = Instant.parse("2026-09-15T10:00:00Z");
given(queryService.listVersions("global", "starter", "user-1", Map.of(), Set.of()))
.willReturn(List.of(
new SkillSuiteQueryService.VersionSummary(
70L, "1.0.0", SkillSuiteVersionStatus.PUBLISHED,
SkillVisibility.PUBLIC, "Initial", "user-1", createdAt, null, createdAt),
new SkillSuiteQueryService.VersionSummary(
71L, "1.1.0", SkillSuiteVersionStatus.DRAFT,
SkillVisibility.PUBLIC, "Next", "user-2", null, null, createdAt)));
given(userAccountRepository.findByIdIn(List.of("user-1", "user-2")))
.willReturn(List.of(
new UserAccount("user-1", "Suite Owner", null, null),
new UserAccount("user-2", "Second Owner", null, null)));
var result = service.listVersions("global", "starter", "user-1", Map.of(), Set.of());
assertThat(result).extracting(item -> item.createdByName())
.containsExactly("Suite Owner", "Second Owner");
verify(userAccountRepository).findByIdIn(List.of("user-1", "user-2"));
verify(userAccountRepository, never()).findById(any());
}
@Test
void getDetail_hidesLiveMemberMetadataWhenTheViewerCannotReadThatSkill() {
SkillSuiteMemberState restricted = new SkillSuiteMemberState(
@ -341,13 +417,13 @@ class SkillSuiteAppServiceTest {
.isInstanceOfSatisfying(DomainBadRequestException.class, exception ->
assertThat(exception.messageArgs()[0].toString())
.contains("@global/selected@1.0.0")
.contains("error.suite.members.selectionMismatch"));
.doesNotContain("error.suite.members.selectionMismatch"));
verify(draftService, never()).create(any(), any());
}
@Test
void create_reportsEveryInvalidMemberCoordinateAndReason() {
void create_reportsEveryInvalidMemberCoordinateWithoutInternalReasonCodes() {
SkillSuiteMemberRequest first = new SkillSuiteMemberRequest(
101L, "global", "missing", "1.0.0");
SkillSuiteMemberRequest second = new SkillSuiteMemberRequest(
@ -367,8 +443,30 @@ class SkillSuiteAppServiceTest {
assertThat(exception.messageCode()).isEqualTo("error.suite.members.invalid");
assertThat((String) exception.messageArgs()[0])
.contains("@global/missing@1.0.0 (error.skill.version.notFound)")
.contains("@private-team/restricted@2.0.0 (error.skill.access.denied)");
.contains("@global/missing@1.0.0")
.contains("@private-team/restricted@2.0.0")
.doesNotContain("error.skill.version.notFound")
.doesNotContain("error.skill.access.denied");
verify(draftService, never()).create(any(), any());
}
@Test
void create_reportsADuplicateInvalidMemberOnlyOnce() {
SkillSuiteMemberRequest member = new SkillSuiteMemberRequest(
101L, "global", "unavailable", "1.0.0");
SkillSuiteCreateRequest createRequest = new SkillSuiteCreateRequest(
"global", "starter", "Starter", null, null, "1.0.0",
SkillVisibility.PRIVATE, null, member, List.of(member, member));
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillQueryService.resolveVersionById(101L, "user-1", Map.of(), Set.of()))
.willThrow(new DomainBadRequestException("error.skill.version.notDownloadable", "1.0.0"));
DomainBadRequestException exception = catchThrowableOfType(
() -> service.create(createRequest, "user-1", Map.of(), Set.of(), request),
DomainBadRequestException.class);
assertThat(exception.messageCode()).isEqualTo("error.suite.members.invalid");
assertThat(exception.messageArgs()[0]).isEqualTo("@global/unavailable@1.0.0");
verify(draftService, never()).create(any(), any());
}

View file

@ -0,0 +1,104 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.repository.SkillSuiteLabelQueryRepository;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.label.SkillSuiteLabel;
import com.iflytek.skillhub.domain.label.SkillSuiteLabelService;
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.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteQueryService;
import com.iflytek.skillhub.domain.suite.SkillSuiteRepository;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.util.List;
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.springframework.test.util.ReflectionTestUtils;
class SkillSuiteLabelAppServiceTest {
private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class);
private final SkillSuiteRepository suiteRepository = mock(SkillSuiteRepository.class);
private final SkillSuiteQueryService suiteQueryService = mock(SkillSuiteQueryService.class);
private final SkillSuiteLabelService suiteLabelService = mock(SkillSuiteLabelService.class);
private final SkillSuiteLabelQueryRepository projectionService =
mock(SkillSuiteLabelQueryRepository.class);
private final AuditLogService auditLogService = mock(AuditLogService.class);
private final RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
private final SkillSuiteLabelAppService service = new SkillSuiteLabelAppService(
namespaceRepository, suiteRepository, suiteQueryService, suiteLabelService,
projectionService, auditLogService, requestIdAccessor);
private Namespace namespace;
private SkillSuite suite;
@BeforeEach
void setUp() {
namespace = new Namespace("global", "Global", "owner");
suite = new SkillSuite(1L, "starter", "Starter", "author");
ReflectionTestUtils.setField(namespace, "id", 1L);
ReflectionTestUtils.setField(suite, "id", 10L);
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
when(suiteRepository.findByNamespaceIdAndSlug(1L, "starter")).thenReturn(Optional.of(suite));
}
@Test
void publicReaderMustPassExistingSuiteVisibilityCheck() {
SkillLabelDto label = new SkillLabelDto("automation", "RECOMMENDED", "Automation");
when(projectionService.labelsBySuiteIds(List.of(10L))).thenReturn(Map.of(10L, List.of(label)));
assertThat(service.listLabels("global", "starter", null, Map.of(), Set.of()))
.containsExactly(label);
verify(suiteQueryService).getDetail("global", "starter", null, null, Map.of(), Set.of());
}
@Test
void suiteManagerCanReadDraftContainerLabelsWithoutPublishedVersion() {
when(projectionService.labelsBySuiteIds(List.of(10L))).thenReturn(Map.of());
assertThat(service.listLabels(
"global", "starter", "owner", Map.of(1L, NamespaceRole.OWNER), Set.of()))
.isEmpty();
verify(suiteQueryService, never()).getDetail(any(), any(), any(), any(), any(), any());
}
@Test
void mutationRecordsSuiteScopedAuditWithRequestCorrelation() {
SkillSuiteLabel assignment = new SkillSuiteLabel(10L, 20L, "author");
SkillLabelDto label = new SkillLabelDto("automation", "RECOMMENDED", "Automation");
when(suiteLabelService.attachLabel(
10L, "automation", "author", Map.of(1L, NamespaceRole.MEMBER), Set.of()))
.thenReturn(assignment);
when(projectionService.labelsBySuiteIds(List.of(10L)))
.thenReturn(Map.of(10L, List.of(label)));
try (RequestIdAccessor.Scope ignored = requestIdAccessor.open("req-suite-label")) {
assertThat(service.attachLabel(
"global", "starter", "automation", "author",
Map.of(1L, NamespaceRole.MEMBER), Set.of(),
new AuditRequestContext("127.0.0.1", "test-agent")))
.isEqualTo(label);
}
verify(auditLogService).record(
eq("author"), eq("SKILL_SUITE_LABEL_ATTACH"), eq("SKILL_SUITE"), eq(10L),
eq("req-suite-label"), eq("127.0.0.1"), eq("test-agent"),
eq("{\"labelSlug\":\"automation\"}"));
}
}

View file

@ -0,0 +1,71 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.repository.SkillSuiteLabelQueryRepository;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.domain.label.LabelDefinition;
import com.iflytek.skillhub.domain.label.LabelDefinitionService;
import com.iflytek.skillhub.domain.label.LabelType;
import com.iflytek.skillhub.domain.label.SkillSuiteLabel;
import com.iflytek.skillhub.domain.label.SkillSuiteLabelService;
import com.iflytek.skillhub.dto.SkillLabelDto;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
class SkillSuiteLabelProjectionServiceTest {
private final SkillSuiteLabelService suiteLabelService = mock(SkillSuiteLabelService.class);
private final LabelDefinitionService labelDefinitionService = mock(LabelDefinitionService.class);
private final SkillSuiteLabelQueryRepository service = new SkillSuiteLabelQueryRepository(
suiteLabelService, labelDefinitionService, new LabelLocalizationService());
@Test
void projectsWholeSuitePageWithThreeBoundedLookups() {
LabelDefinition automation = definition(10L, "automation", LabelType.RECOMMENDED);
LabelDefinition verified = definition(11L, "verified", LabelType.PRIVILEGED);
when(suiteLabelService.listSuiteLabelsBySuiteIds(List.of(1L, 2L))).thenReturn(List.of(
new SkillSuiteLabel(1L, 10L, "owner"),
new SkillSuiteLabel(1L, 11L, "admin"),
new SkillSuiteLabel(2L, 10L, "owner")));
when(labelDefinitionService.listByIds(anyList())).thenReturn(List.of(automation, verified));
when(labelDefinitionService.listTranslationsByLabelIds(anyList())).thenReturn(Map.of());
Map<Long, List<SkillLabelDto>> result = service.labelsBySuiteIds(List.of(1L, 2L, 1L));
assertThat(result.get(1L)).extracting(SkillLabelDto::slug)
.containsExactly("verified", "automation");
assertThat(result.get(2L)).extracting(SkillLabelDto::slug)
.containsExactly("automation");
verify(suiteLabelService).listSuiteLabelsBySuiteIds(List.of(1L, 2L));
verify(labelDefinitionService).listByIds(anyList());
verify(labelDefinitionService).listTranslationsByLabelIds(anyList());
}
@Test
void skipsMissingDefinitionsAndDoesNotQueryForEmptyInput() {
when(suiteLabelService.listSuiteLabelsBySuiteIds(List.of(1L)))
.thenReturn(List.of(new SkillSuiteLabel(1L, 99L, "owner")));
when(labelDefinitionService.listByIds(anyList())).thenReturn(List.of());
when(labelDefinitionService.listTranslationsByLabelIds(anyList())).thenReturn(Map.of());
assertThat(service.labelsBySuiteIds(List.of(1L))).isEmpty();
assertThat(service.labelsBySuiteIds(List.of())).isEmpty();
assertThat(service.labelsBySuiteIds(null)).isEmpty();
verify(suiteLabelService, times(1)).listSuiteLabelsBySuiteIds(any());
}
private LabelDefinition definition(Long id, String slug, LabelType type) {
LabelDefinition definition = new LabelDefinition(slug, type, true, 0, "admin");
ReflectionTestUtils.setField(definition, "id", id);
return definition;
}
}

View file

@ -0,0 +1,320 @@
package com.iflytek.skillhub.service.bundle;
import com.iflytek.skillhub.config.SkillPublishProperties;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifestParser;
import com.iflytek.skillhub.storage.ObjectMetadata;
import com.iflytek.skillhub.storage.ObjectStorageService;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class SkillSuiteBundleArchiveServiceTest {
@Test
void stagesValidArchiveAndKeepsOnlyObjectLocationsInThePlan() throws Exception {
InMemoryObjectStorage storage = new InMemoryObjectStorage();
SkillSuiteBundleArchiveService service = service(storage, 1024 * 1024);
byte[] archive = zip(List.of(
file("outer/SUITE.yaml", manifest()),
file("outer/skills/member/SKILL.md", skillMd()),
file("outer/skills/member/notes.txt", "notes"),
file("__MACOSX/._notes.txt", "ignored")
));
SkillSuiteBundleArchiveService.StagedBundleAnalysis result = service.stageAndAnalyze(
new MockMultipartFile("file", "bundle.zip", "application/zip", archive));
assertThat(result.analysis().confirmable()).isTrue();
assertThat(result.archiveSha256()).hasSize(64);
assertThat(result.objectKeys()).hasSize(3);
assertThat(storage.objects).containsOnlyKeys(result.objectKeys().toArray(String[]::new));
assertThat(result.analysis().packageMembers()).singleElement().satisfies(member -> {
assertThat(member.fingerprint()).startsWith("sha256:");
assertThat(member.files()).extracting(
SkillSuiteBundlePackageAnalyzer.StagedMemberFile::relativePath)
.containsExactly("SKILL.md", "notes.txt");
assertThat(member.files()).allSatisfy(stagedFile ->
assertThat(storage.objects).containsKey(stagedFile.objectKey()));
});
assertThat(storage.putCounts.values()).allMatch(count -> count == 1);
}
@Test
void zipOrderAndCompressionMetadataDoNotChangeMemberFingerprint() throws Exception {
List<ArchiveFile> forward = List.of(
file("SUITE.yaml", manifest()),
file("skills/member/SKILL.md", skillMd()),
file("skills/member/notes.txt", "notes")
);
List<ArchiveFile> reverse = List.of(forward.get(2), forward.get(1), forward.get(0));
SkillSuiteBundleArchiveService.StagedBundleAnalysis first = service(
new InMemoryObjectStorage(), 1024 * 1024).stageAndAnalyze(
new MockMultipartFile("file", "first.zip", "application/zip", zip(forward)));
SkillSuiteBundleArchiveService.StagedBundleAnalysis second = service(
new InMemoryObjectStorage(), 1024 * 1024).stageAndAnalyze(
new MockMultipartFile("file", "second.zip", "application/zip", zip(reverse)));
assertThat(first.analysis().packageMembers().getFirst().fingerprint())
.isEqualTo(second.analysis().packageMembers().getFirst().fingerprint());
}
@Test
void zipAndNormalizedDirectoryTreesProduceTheSameMemberFingerprint() throws Exception {
List<ArchiveFile> files = List.of(
file("SUITE.yaml", manifest()),
file("skills/member/SKILL.md", skillMd()),
file("skills/member/notes.txt", "notes")
);
SkillSuiteBundleArchiveService.StagedBundleAnalysis zipped = service(
new InMemoryObjectStorage(), 1024 * 1024).stageAndAnalyze(
new MockMultipartFile("file", "bundle.zip", "application/zip", zip(files)));
SkillMetadataParser metadataParser = new SkillMetadataParser();
SkillSuiteBundlePackageAnalyzer directoryAnalyzer = new SkillSuiteBundlePackageAnalyzer(
new SkillSuiteBundleManifestParser(), metadataParser,
new SkillPackageValidator(metadataParser));
List<SkillSuiteBundleStagedEntry> directoryEntries = files.stream()
.map(this::stagedEntry)
.toList();
SkillSuiteBundlePackageAnalyzer.BundleAnalysis directory = directoryAnalyzer.analyze(directoryEntries);
assertThat(directory.packageMembers().getFirst().fingerprint())
.isEqualTo(zipped.analysis().packageMembers().getFirst().fingerprint());
}
@Test
void analyzesOneHundredPackagedMembersWithBoundedResultDescriptors() throws Exception {
StringBuilder members = new StringBuilder();
List<ArchiveFile> files = new java.util.ArrayList<>();
for (int index = 0; index < 100; index++) {
String slug = "member-" + index;
members.append(" - skill: \"@global/").append(slug).append("\"\n")
.append(" package:\n")
.append(" path: skills/").append(slug).append("\n")
.append(" visibility: PUBLIC\n");
files.add(file("skills/" + slug + "/SKILL.md", skillMd(slug)));
}
files.add(0, file("SUITE.yaml", manifest(members.toString(), "@global/member-0")));
SkillSuiteBundleArchiveService.StagedBundleAnalysis result = service(
new InMemoryObjectStorage(), 1024 * 1024).stageAndAnalyze(
new MockMultipartFile("file", "maximum.zip", "application/zip", zip(files)));
assertThat(result.analysis().confirmable()).isTrue();
assertThat(result.analysis().packageMembers()).hasSize(100);
assertThat(result.analysis().packageMembers())
.allSatisfy(member -> assertThat(member.files()).hasSize(1));
}
@Test
void rejectsExpandedArchiveLimitAndCleansUploadedObjects() throws Exception {
InMemoryObjectStorage storage = new InMemoryObjectStorage();
SkillSuiteBundleArchiveService service = service(storage, 2_000);
byte[] archive = zip(List.of(
file("SUITE.yaml", manifest()),
file("skills/member/SKILL.md", skillMd()),
file("skills/member/large.txt", "x".repeat(3_000))
));
assertThatThrownBy(() -> service.stageAndAnalyze(
new MockMultipartFile("file", "large.zip", "application/zip", archive)))
.isInstanceOf(DomainBadRequestException.class);
assertThat(storage.objects).isEmpty();
}
@Test
void rejectsUnixSymbolicLinksBeforeUploadingAnything() throws Exception {
InMemoryObjectStorage storage = new InMemoryObjectStorage();
byte[] archive = markFirstEntryAsUnixSymlink(zip(List.of(file("link", "target"))));
assertThatThrownBy(() -> service(storage, 1024 * 1024).stageAndAnalyze(
new MockMultipartFile("file", "link.zip", "application/zip", archive)))
.isInstanceOf(DomainBadRequestException.class);
assertThat(storage.objects).isEmpty();
}
@Test
void invalidBundleAnalysisDeletesTemporaryObjects() throws Exception {
InMemoryObjectStorage storage = new InMemoryObjectStorage();
byte[] archive = zip(List.of(
file("SUITE.yaml", manifest()),
file("skills/member/SKILL.md", skillMd()),
file("README.md", "not declared")
));
SkillSuiteBundleArchiveService.StagedBundleAnalysis result = service(
storage, 1024 * 1024).stageAndAnalyze(
new MockMultipartFile("file", "invalid.zip", "application/zip", archive));
assertThat(result.analysis().confirmable()).isFalse();
assertThat(result.archiveObjectKey()).isNull();
assertThat(result.objectKeys()).isEmpty();
assertThat(storage.objects).isEmpty();
}
private SkillSuiteBundleArchiveService service(InMemoryObjectStorage storage, long maxPackageSize) {
SkillMetadataParser metadataParser = new SkillMetadataParser();
SkillSuiteBundlePackageAnalyzer analyzer = new SkillSuiteBundlePackageAnalyzer(
new SkillSuiteBundleManifestParser(), metadataParser,
new SkillPackageValidator(metadataParser));
SkillPublishProperties properties = new SkillPublishProperties();
properties.setMaxPackageSize(maxPackageSize);
properties.setMaxSingleFileSize(maxPackageSize);
return new SkillSuiteBundleArchiveService(analyzer, storage, properties);
}
private byte[] zip(List<ArchiveFile> files) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes)) {
for (ArchiveFile file : files) {
zip.putNextEntry(new ZipEntry(file.path()));
zip.write(file.content());
zip.closeEntry();
}
}
return bytes.toByteArray();
}
private byte[] markFirstEntryAsUnixSymlink(byte[] archive) {
ByteBuffer bytes = ByteBuffer.wrap(archive).order(ByteOrder.LITTLE_ENDIAN);
for (int index = 0; index <= archive.length - 46; index++) {
if (bytes.getInt(index) == 0x02014b50) {
archive[index + 5] = 3;
bytes.putInt(index + 38, 0120777 << 16);
return archive;
}
}
throw new AssertionError("ZIP central directory not found");
}
private ArchiveFile file(String path, String content) {
return new ArchiveFile(path, content.getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
private SkillSuiteBundleStagedEntry stagedEntry(ArchiveFile file) {
try {
String sha = java.util.HexFormat.of().formatHex(
java.security.MessageDigest.getInstance("SHA-256").digest(file.content()));
return new SkillSuiteBundleStagedEntry(
file.path(), file.content().length, "text/plain", sha,
"directory/" + file.path(), () -> new ByteArrayInputStream(file.content()));
} catch (java.security.NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
private String manifest() {
return manifest("""
- skill: "@global/member"
package:
path: skills/member
visibility: PUBLIC
""", "@global/member");
}
private String manifest(String members, String entry) {
return """
apiVersion: skillhub.iflytek.com/v1alpha1
kind: SkillSuiteBundle
metadata:
namespace: global
slug: archive-test
spec:
mode: CREATE
version: 1.0.0
displayName: Archive Test
summary: Archive summary
overview: Archive overview
visibility: PUBLIC
entry: "%s"
members:
%s
""".formatted(entry, members);
}
private String skillMd() {
return skillMd("member");
}
private String skillMd(String name) {
return """
---
name: %s
description: Member skill
version: 1.0.0
---
Instructions.
""".formatted(name);
}
private record ArchiveFile(String path, byte[] content) {
}
private static final class InMemoryObjectStorage implements ObjectStorageService {
private final Map<String, byte[]> objects = new LinkedHashMap<>();
private final Map<String, Integer> putCounts = new LinkedHashMap<>();
@Override
public void putObject(String key, InputStream data, long size, String contentType) {
try {
byte[] bytes = data.readAllBytes();
if (bytes.length != size) {
throw new AssertionError("size mismatch");
}
objects.put(key, bytes);
putCounts.merge(key, 1, Integer::sum);
} catch (IOException exception) {
throw new IllegalStateException(exception);
}
}
@Override
public InputStream getObject(String key) {
return new ByteArrayInputStream(objects.get(key));
}
@Override
public void deleteObject(String key) {
objects.remove(key);
}
@Override
public void deleteObjects(List<String> keys) {
keys.forEach(objects::remove);
}
@Override
public boolean exists(String key) {
return objects.containsKey(key);
}
@Override
public ObjectMetadata getMetadata(String key) {
throw new UnsupportedOperationException();
}
@Override
public String generatePresignedUrl(String key, Duration expiry, String downloadFilename) {
throw new UnsupportedOperationException();
}
}
}

View file

@ -0,0 +1,232 @@
package com.iflytek.skillhub.service.bundle;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.config.SkillSuiteBundleProperties;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifest;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifestParser;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewStatus;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DataIntegrityViolationException;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
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.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class SkillSuiteBundleConfirmationAppServiceTest {
private static final Instant NOW = Instant.parse("2026-09-11T08:00:00Z");
private static final TypeReference<Map<String, Object>> JSON_OBJECT = new TypeReference<>() { };
private final ObjectMapper objectMapper = new ObjectMapper();
private SkillSuiteBundlePreviewSessionRepository previewRepository;
private SkillSuiteBundleExecutionOperationRepository operationRepository;
private SkillSuiteBundleMemberResultRepository memberRepository;
private SkillSuiteBundlePreviewRevalidationService revalidationService;
private SkillSuiteBundleProperties properties;
private SkillSuiteBundleConfirmationAppService service;
@BeforeEach
void setUp() {
previewRepository = mock(SkillSuiteBundlePreviewSessionRepository.class);
operationRepository = mock(SkillSuiteBundleExecutionOperationRepository.class);
memberRepository = mock(SkillSuiteBundleMemberResultRepository.class);
revalidationService = mock(SkillSuiteBundlePreviewRevalidationService.class);
properties = new SkillSuiteBundleProperties();
properties.setConfirmationEnabled(true);
service = new SkillSuiteBundleConfirmationAppService(
previewRepository, operationRepository, memberRepository, revalidationService, properties,
mock(org.springframework.context.ApplicationEventPublisher.class),
Clock.fixed(NOW, ZoneOffset.UTC));
}
@Test
void confirmsExactLivePlanAndCreatesReservationBeforeMemberResults() {
SkillSuiteBundleManifest manifest = manifest();
SkillSuiteBundlePreviewPlanner.PreviewPlan plan = plan();
SkillSuiteBundlePreviewSession preview = preview(manifest, plan);
when(operationRepository.findByActorIdAndClientRequestId("actor", "request-1"))
.thenReturn(Optional.empty());
when(previewRepository.findByIdForUpdate("preview-1")).thenReturn(Optional.of(preview));
when(revalidationService.requireUnchanged(any(), any(), any(), any()))
.thenReturn(new SkillSuiteBundlePreviewRevalidationService.ValidatedPreview(manifest, plan));
SkillSuiteBundleConfirmationAppService.ConfirmationOutcome outcome = service.confirm(
"preview-1", "request-1", "warning-digest", "actor", Map.of(), Set.of());
assertThat(outcome.status()).isEqualTo("RUNNING");
assertThat(outcome.replayed()).isFalse();
assertThat(preview.getStatus()).isEqualTo(SkillSuiteBundlePreviewStatus.CONFIRMED);
verify(operationRepository).flush();
verify(memberRepository).flush();
verify(previewRepository).flush();
ArgumentCaptor<List<SkillSuiteBundleMemberResult>> members = ArgumentCaptor.forClass(List.class);
verify(memberRepository).saveAll(members.capture());
assertThat(members.getValue()).singleElement().satisfies(member -> {
assertThat(member.getNamespaceSlug()).isEqualTo("global");
assertThat(member.getSkillSlug()).isEqualTo("member");
assertThat(member.getPackagePath()).isEqualTo("skills/member");
assertThat(member.getPublishAction()).isEqualTo(SkillSuiteBundlePublishAction.CREATE_SKILL);
});
}
@Test
void repeatsSameActorRequestWithoutLockingOrCreatingAnotherOperation() {
SkillSuiteBundleExecutionOperation existing = operation("preview-1", "request-1");
when(operationRepository.findByActorIdAndClientRequestId("actor", "request-1"))
.thenReturn(Optional.of(existing));
SkillSuiteBundleConfirmationAppService.ConfirmationOutcome outcome = service.confirm(
"preview-1", "request-1", "warning-digest", "actor", Map.of(), Set.of());
assertThat(outcome.operationId()).isEqualTo("operation-1");
assertThat(outcome.replayed()).isTrue();
verify(previewRepository, never()).findByIdForUpdate(any());
verify(operationRepository, never()).save(any());
}
@Test
void rejectsChangedLivePlanBeforeAcquiringReservation() {
SkillSuiteBundlePreviewPlanner.PreviewPlan original = plan();
when(operationRepository.findByActorIdAndClientRequestId(any(), any())).thenReturn(Optional.empty());
when(previewRepository.findByIdForUpdate("preview-1"))
.thenReturn(Optional.of(preview(manifest(), original)));
when(revalidationService.requireUnchanged(any(), any(), any(), any()))
.thenThrow(new DomainBadRequestException("error.suite.bundle.preview.stateChanged"));
assertThatThrownBy(() -> service.confirm(
"preview-1", "request-1", "warning-digest", "actor", Map.of(), Set.of()))
.isInstanceOf(DomainBadRequestException.class)
.extracting("messageCode")
.isEqualTo("error.suite.bundle.preview.stateChanged");
verify(operationRepository, never()).save(any());
verify(memberRepository, never()).saveAll(any());
}
@Test
void mapsReservationRaceToConflictAndTransactionDoesNotReachMembers() {
SkillSuiteBundlePreviewPlanner.PreviewPlan plan = plan();
when(operationRepository.findByActorIdAndClientRequestId(any(), any())).thenReturn(Optional.empty());
when(previewRepository.findByIdForUpdate("preview-1"))
.thenReturn(Optional.of(preview(manifest(), plan)));
when(revalidationService.requireUnchanged(any(), any(), any(), any()))
.thenReturn(new SkillSuiteBundlePreviewRevalidationService.ValidatedPreview(manifest(), plan));
doThrow(new DataIntegrityViolationException("reservation collision"))
.when(operationRepository).flush();
assertThatThrownBy(() -> service.confirm(
"preview-1", "request-1", "warning-digest", "actor", Map.of(), Set.of()))
.isInstanceOf(DomainConflictException.class)
.extracting("messageCode")
.isEqualTo("error.suite.bundle.confirmation.operationConflict");
verify(memberRepository, never()).saveAll(any());
}
@Test
void featureFlagAndIdempotencyKeyAreValidatedBeforeDatabaseWrites() {
properties.setConfirmationEnabled(false);
assertThatThrownBy(() -> service.confirm(
"preview-1", "request-1", "warning-digest", "actor", Map.of(), Set.of()))
.isInstanceOf(DomainBadRequestException.class)
.extracting("messageCode")
.isEqualTo("error.suite.bundle.confirmation.disabled");
properties.setConfirmationEnabled(true);
assertThatThrownBy(() -> service.confirm(
"preview-1", " ", "warning-digest", "actor", Map.of(), Set.of()))
.isInstanceOf(DomainBadRequestException.class)
.extracting("messageCode")
.isEqualTo("error.suite.bundle.confirmation.idempotencyKey.invalid");
assertThatThrownBy(() -> service.confirm(
"preview-1", "not valid!", "warning-digest", "actor", Map.of(), Set.of()))
.isInstanceOf(DomainBadRequestException.class)
.extracting("messageCode")
.isEqualTo("error.suite.bundle.confirmation.idempotencyKey.invalid");
verify(operationRepository, never()).save(any());
}
private SkillSuiteBundlePreviewSession preview(
SkillSuiteBundleManifest manifest,
SkillSuiteBundlePreviewPlanner.PreviewPlan plan
) {
return new SkillSuiteBundlePreviewSession(
"preview-1", "actor", SkillSuiteBundleMode.CREATE, 1L, "suite", null, null,
"1.0.0", "archive.zip", "a".repeat(64),
objectMapper.convertValue(manifest, JSON_OBJECT), objectMapper.convertValue(plan, JSON_OBJECT),
"warning-digest", NOW.plusSeconds(300), NOW.minusSeconds(60));
}
private SkillSuiteBundleExecutionOperation operation(String previewToken, String requestId) {
return new SkillSuiteBundleExecutionOperation(
"operation-1", previewToken, requestId, "actor", SkillSuiteBundleMode.CREATE,
1L, "suite", null, null, "1.0.0", "archive.zip", "a".repeat(64),
Map.of("plan", "value"), "warning-digest", NOW);
}
private SkillSuiteBundlePreviewPlanner.PreviewPlan plan() {
SkillSuiteBundlePreviewPlanner.MemberPlan member = new SkillSuiteBundlePreviewPlanner.MemberPlan(
new SkillSuiteBundleCoordinate("global", "member"), SkillSuiteBundleMemberSourceType.PACKAGE,
SkillSuiteBundleRelationshipChange.ADDED, SkillSuiteBundlePublishAction.CREATE_SKILL,
null, null, SkillVisibility.PUBLIC, "1.0.0", "sha256:member",
List.of(new SkillSuiteBundlePackageAnalyzer.StagedMemberFile(
"SKILL.md", 100, "text/markdown", "b".repeat(64), "staged/member/SKILL.md")),
List.of(), List.of());
return new SkillSuiteBundlePreviewPlanner.PreviewPlan(
SkillSuiteBundleMode.CREATE, new SkillSuiteBundleCoordinate("global", "suite"),
1L, null, null, "1.0.0", "Suite", "Summary", "Overview",
SkillVisibility.PUBLIC, List.of(member), List.of(), List.of(), List.of(), "warning-digest");
}
private SkillSuiteBundleManifest manifest() {
return new SkillSuiteBundleManifestParser().parse("""
apiVersion: skillhub.iflytek.com/v1alpha1
kind: SkillSuiteBundle
metadata:
namespace: global
slug: suite
spec:
mode: CREATE
version: 1.0.0
displayName: Suite
summary: Summary
overview: Overview
visibility: PUBLIC
entry: "@global/member"
members:
- skill: "@global/member"
package:
path: skills/member
visibility: PUBLIC
""");
}
}

View file

@ -0,0 +1,112 @@
package com.iflytek.skillhub.service.bundle;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class SkillSuiteBundleCoordinatorTest {
private SkillSuiteBundleMemberExecutionService executionService;
private SkillSuiteBundleMemberProgressService progressService;
private SkillSuiteBundleDraftCreationService draftCreationService;
private SkillSuiteBundleOperationStateService stateService;
private SkillSuiteBundleCoordinator coordinator;
@BeforeEach
void setUp() {
executionService = mock(SkillSuiteBundleMemberExecutionService.class);
progressService = mock(SkillSuiteBundleMemberProgressService.class);
draftCreationService = mock(SkillSuiteBundleDraftCreationService.class);
stateService = mock(SkillSuiteBundleOperationStateService.class);
coordinator = new SkillSuiteBundleCoordinator(
executionService, progressService, draftCreationService, stateService);
}
@Test
void createsDraftOnlyAfterAllMemberWorkConverges() {
when(executionService.executeNext("operation"))
.thenReturn(SkillSuiteBundleMemberExecutionService.ExecutionOutcome.PROGRESSED)
.thenReturn(SkillSuiteBundleMemberExecutionService.ExecutionOutcome.NONE);
when(progressService.reconcile("operation"))
.thenReturn(SkillSuiteBundleMemberProgressService.ProgressOutcome.READY_FOR_DRAFT);
coordinator.advance("operation");
verify(executionService, org.mockito.Mockito.times(2)).executeNext("operation");
verify(draftCreationService).create("operation");
verify(stateService, never()).markBlockedRetryable(
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.any());
}
@Test
void domainStateDriftRequiresFreshPreviewAndDoesNotCreateDraft() {
when(executionService.executeNext("operation"))
.thenThrow(new DomainBadRequestException("error.suite.bundle.member.stateChanged"));
coordinator.advance("operation");
verify(stateService).markRepreviewRequired("operation", "BUNDLE_PLAN_CHANGED");
verify(draftCreationService, never()).create("operation");
}
@Test
void infrastructureFailureIsRetryableAndDoesNotCreateDraft() {
doThrow(new IllegalStateException("storage unavailable"))
.when(executionService).executeNext("operation");
coordinator.advance("operation");
verify(stateService).markBlockedRetryable(
"operation", "MEMBER_EXECUTION_FAILED", "IllegalStateException");
verify(draftCreationService, never()).create("operation");
}
@Test
void frozenNamespaceKeepsTheOperationRetryable() {
when(executionService.executeNext("operation"))
.thenThrow(new DomainBadRequestException("error.namespace.frozen", "global"));
coordinator.advance("operation");
verify(stateService).markBlockedRetryable(
"operation", "AUTHORIZATION_OR_NAMESPACE_BLOCKED", "error.namespace.frozen");
verify(stateService, never()).markRepreviewRequired(
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
}
@Test
void revokedPermissionKeepsTheOperationRetryable() {
when(executionService.executeNext("operation"))
.thenThrow(new DomainForbiddenException("error.skill.lifecycle.noPermission"));
coordinator.advance("operation");
verify(stateService).markBlockedRetryable(
"operation", "AUTHORIZATION_OR_NAMESPACE_BLOCKED", "error.skill.lifecycle.noPermission");
verify(stateService, never()).markRepreviewRequired(
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
}
@Test
void removedNamespaceMemberKeepsTheOperationRetryable() {
when(executionService.executeNext("operation"))
.thenThrow(new DomainBadRequestException(
"error.skill.publish.publisher.notMember", "global"));
coordinator.advance("operation");
verify(stateService).markBlockedRetryable(
"operation", "AUTHORIZATION_OR_NAMESPACE_BLOCKED",
"error.skill.publish.publisher.notMember");
verify(stateService, never()).markRepreviewRequired(
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
}
}

View file

@ -0,0 +1,147 @@
package com.iflytek.skillhub.service.bundle;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteDraftService;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersion;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifest;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMember;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction;
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class SkillSuiteBundleDraftCreationServiceTest {
private static final Instant NOW = Instant.parse("2026-09-11T08:00:00Z");
private SkillSuiteBundleExecutionOperationRepository operationRepository;
private SkillSuiteBundleMemberResultRepository memberRepository;
private SkillSuiteBundlePreviewSessionRepository previewRepository;
private SkillSuiteDraftService draftService;
private ObjectMapper objectMapper;
private SkillSuiteBundleDraftCreationService service;
@BeforeEach
void setUp() {
operationRepository = mock(SkillSuiteBundleExecutionOperationRepository.class);
memberRepository = mock(SkillSuiteBundleMemberResultRepository.class);
previewRepository = mock(SkillSuiteBundlePreviewSessionRepository.class);
SkillSuiteBundleActorContextService actorContextService = mock(SkillSuiteBundleActorContextService.class);
draftService = mock(SkillSuiteDraftService.class);
objectMapper = mock(ObjectMapper.class);
when(actorContextService.requireCurrent("actor")).thenReturn(
new SkillSuiteBundleActorContextService.ActorContext(Map.of(), Set.of("SUPER_ADMIN")));
service = new SkillSuiteBundleDraftCreationService(
operationRepository, memberRepository, previewRepository, actorContextService,
draftService, objectMapper, Clock.fixed(NOW, ZoneOffset.UTC));
}
@Test
void doesNotCreatePartialSuiteWhileAnyMemberIsUnfinished() {
when(operationRepository.findByIdForUpdate("operation")).thenReturn(Optional.of(operation()));
when(memberRepository.findByOperationIdOrderByPositionForUpdate("operation"))
.thenReturn(List.of(member(false)));
assertThat(service.create("operation")).isFalse();
verify(draftService, never()).create(any(), any());
verify(draftService, never()).createVersion(any(), any(), any());
}
@Test
void atomicallyRecordsTheDraftCreatedFromExactCompletedMemberIds() {
SkillSuiteBundleExecutionOperation operation = operation();
SkillSuiteBundleMemberResult member = member(true);
SkillSuiteBundlePreviewSession preview = mock(SkillSuiteBundlePreviewSession.class);
Map<String, Object> manifestJson = Map.of("manifest", "value");
when(preview.getManifest()).thenReturn(manifestJson);
when(operationRepository.findByIdForUpdate("operation")).thenReturn(Optional.of(operation));
when(memberRepository.findByOperationIdOrderByPositionForUpdate("operation"))
.thenReturn(List.of(member));
when(previewRepository.findById("preview")).thenReturn(Optional.of(preview));
when(objectMapper.convertValue(manifestJson, SkillSuiteBundleManifest.class)).thenReturn(manifest());
when(objectMapper.convertValue(operation.getPlan(), SkillSuiteBundlePreviewPlanner.PreviewPlan.class))
.thenReturn(plan());
SkillSuite suite = mock(SkillSuite.class);
SkillSuiteVersion version = mock(SkillSuiteVersion.class);
when(suite.getId()).thenReturn(21L);
when(version.getId()).thenReturn(22L);
when(draftService.create(any(), any())).thenReturn(
new SkillSuiteDraftService.CreatedDraft(suite, version, List.of()));
assertThat(service.create("operation")).isTrue();
assertThat(operation.getStatus()).isEqualTo(SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED);
assertThat(operation.getResultSuiteId()).isEqualTo(21L);
assertThat(operation.getResultSuiteVersionId()).isEqualTo(22L);
assertThat(operation.isReservationActive()).isFalse();
verify(operationRepository).flush();
}
private SkillSuiteBundleExecutionOperation operation() {
Map<String, Object> planJson = Map.of("plan", "value");
return new SkillSuiteBundleExecutionOperation(
"operation", "preview", "request", "actor", SkillSuiteBundleMode.CREATE,
1L, "suite", null, null, "1.0.0", "archive", "a".repeat(64),
planJson, "digest", NOW.minusSeconds(1));
}
private SkillSuiteBundleMemberResult member(boolean completed) {
SkillSuiteBundleMemberResult member = new SkillSuiteBundleMemberResult(
"operation", 0, new SkillSuiteBundleCoordinate("global", "member"),
SkillSuiteBundleMemberSourceType.REFERENCE, null, SkillVisibility.PUBLIC, "1.0.0",
SkillSuiteBundleRelationshipChange.ADDED, SkillSuiteBundlePublishAction.REFERENCE_VERSION,
"sha256:fingerprint", 11L, 12L, List.of(), List.of(), NOW.minusSeconds(1));
if (completed) {
member.start(NOW.minusMillis(500));
member.markCompleted(NOW.minusMillis(400));
}
return member;
}
private SkillSuiteBundleManifest manifest() {
SkillSuiteBundleCoordinate suite = new SkillSuiteBundleCoordinate("global", "suite");
SkillSuiteBundleCoordinate entry = new SkillSuiteBundleCoordinate("global", "member");
return new SkillSuiteBundleManifest(
SkillSuiteBundleManifest.API_VERSION, SkillSuiteBundleManifest.KIND,
new SkillSuiteBundleManifest.Metadata(suite),
new SkillSuiteBundleManifest.Spec(
SkillSuiteBundleMode.CREATE, null, "1.0.0", "Suite", "Summary",
"Overview", SkillVisibility.PUBLIC, "Initial", entry,
List.of(new SkillSuiteBundleMember(
entry, null, new SkillSuiteBundleMember.ReferenceSource("1.0.0")))));
}
private SkillSuiteBundlePreviewPlanner.PreviewPlan plan() {
return new SkillSuiteBundlePreviewPlanner.PreviewPlan(
SkillSuiteBundleMode.CREATE, new SkillSuiteBundleCoordinate("global", "suite"),
1L, null, null, "1.0.0", "Suite", "Summary", "Overview",
SkillVisibility.PUBLIC, List.of(), List.of(), List.of(), List.of(), "digest");
}
}

Some files were not shown because too many files have changed in this diff Show more