diff --git a/docs/26-skill-avatars.md b/docs/26-skill-avatars.md new file mode 100644 index 00000000..196c2364 --- /dev/null +++ b/docs/26-skill-avatars.md @@ -0,0 +1,59 @@ +# 技能头像与审核 + +技能所有者可在新增/更新技能页面选择自定义 PNG/JPEG 头像,网页提供方形裁剪和缩放;不提供预设图标。更新默认保留头像,也可替换或移除。“我的技能”统一从“更新”入口维护,也可只改头像而不发布新版本;详情页仅在标题旁展示当前头像。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。所有者在“我的技能”和更新页可查看已提交头像的缩略图及审核状态,待审头像不会替代公开头像;加载失败会提示重试。 + +## 上传与提交 + +选择图片后先裁剪并上传到私有存储,上传成功后才允许提交技能或头像修改;未选择头像也可提交新技能。上传失败可重试,但不能把失败或尚未完成的上传随技能提交。上传成功仅表示文件已保存,不表示审核通过。 + +提交成功后图片才关联到技能并进入对应审核流程。更新时未修改头像会保留原头像;选择移除时先预览首字母。仅改头像时提交成功即恢复兜底;与新技能包一起提交时在同一申请审核通过后移除,驳回保留原头像。失效候选历史保留。取消编辑或提交失败不会改变原头像及已有待审申请。未关联的临时图片保持私有;上传引用 24 小时后过期,过期未关联图片由清理任务定期回收,提交时过期需要重新上传。 + +技能包与头像同次提交时只有一张技能审核申请:审核员在原技能审核详情同时检查内容与头像,一次批准或驳回,共同生效。机器结果作为同一申请的审批条件,不能提前发布头像。单改头像不创建新技能版本;需要人工决定时仍进入原技能审核队列,标明“头像修改”,不再有独立头像审核队列。 + +## 审核策略 + +超级管理员在“系统配置”中分别开关机器审核和人工审核。默认关闭机器审核、开启人工审核。单改头像时,两个开关都关闭直接发布;只开启机器时通过即发布、阻止或未知结果保持私有;只开启人工时进入技能审核队列;两者都开启时先机器后人工。若与技能包同次提交,则不论头像开关如何,仍等待原有技能内容审核共同批准。机器阻止后所有者可申请人工复核;机器服务失败时可重试,不会自动放行。当前云供应商不能提供可证明不可变的模型版本,不跨申请复用机器结果;新申请重新检测。每次调用记录实际服务/API版本和内部审核策略revision,未知模型版本留空,不把SDK或API版本作为模型版本。扩大可见范围时必须重新人工审核。`PRIVATE` 技能头像由所有者直接使用。 + +人工权限复用技能审核:团队管理员可审本团队,平台技能管理员/超级管理员可审团队和全局;自审限制也沿用原技能审核规则。头像只能由技能所有者提交、替换或删除;管理员可审核和撤下,不可代传。新申请使旧待审申请失效;旧审核决定不能覆盖新头像。被拒绝、替换、删除的候选图片私有保留 30 天后清理,文字审核记录继续保留。头像撤下后显示首字母兜底。 + +新提交若可见范围扩大,系统在同一技能申请中关联目标范围头像候选,审核完成后共同生效,不能先发布内容再要求另一次人工审核。遗留数据的范围补偿仍保证未经目标范围批准的头像不向新增受众公开。已有相同目标范围的待审申请不会被覆盖;新旧候选使用不同对象键,避免旧文件到期清理影响当前头像。自动建候选失败时旧头像仍保持隐藏;每五分钟分批检查并补建候选。若候选已建但机器读取或审核失败,状态记为 `ERROR`,一小时冷却后自动重试;同一候选自动审核最多调用三次(含首次),到达上限后由所有者在冷却结束后主动重试或重新提交。普通上传在机器调用前宕机留下的 `PENDING` 候选,以及机器处理中宕机留下的 `PROCESSING` 候选,十分钟后也由补偿任务重领。机器调用前后分别使用短事务核验候选指针,外部调用期间不持有数据库锁;过期尝试的迟到结果不会激活头像。当前发布版已驳回的自动申请不会反复入队,历史发布版的驳回不阻止新发布。服务日志仅记录技能或候选 ID 和异常类型,不记录可能包含凭证的供应商请求信息。`PRIVATE` 头像及候选图片只有技能所有者可以读取。 + +## 部署配置 + +### 机器结果与人工决定 + +机器审核结果与申请的最终决定是两个状态。以下针对非 `PRIVATE` 的单改头像;与技能包同时提交时,仍需等待原技能审核共同决定。 + +| 机器结果 | 开启人工审核 | 未开启人工审核 | +| --- | --- | --- | +| 通过(`PASSED`) | 进入统一技能审核,批准后生效 | 自动生效 | +| 阻止(`BLOCKED`) | 新头像保持私有,申请待审;审核员可拒绝,或填写明确理由后人工改判 | 不生效,不自动变为人工驳回;所有者可申请人工复核 | +| 异常(`ERROR`) | 保持私有,不允许直接批准;先重试得到有效结果 | 保持私有,等待重试 | + +文件上传成功不等于机器通过;机器阻止也不等于管理员已经拒绝。人工拒绝后,用户可查看提交图片及拒绝原因,公开头像保持旧图或首字母兜底。机器阻止没有自动驳回整个技能申请,避免把机器判断与人工决定混为一谈。 + +### 供应商与凭证 + +在部署密钥配置中指定唯一供应商及其凭证,然后才可从后台开启机器审核。Spring 属性如下;将值映射到部署 Secret/环境变量时请遵循当前部署平台的配置方式,不要写入仓库或后台数据库: + +| 供应商 | 属性 | +| --- | --- | +| 通用 | `skillhub.avatar.moderation.provider`:`xfyun` 或 `aliyun` | +| 讯飞 | `skillhub.avatar.moderation.xfyun.app-id`、`skillhub.avatar.moderation.xfyun.access-key-id`、`skillhub.avatar.moderation.xfyun.access-key-secret` | +| 阿里云 | `skillhub.avatar.moderation.aliyun.access-key-id`、`skillhub.avatar.moderation.aliyun.access-key-secret` | + +后台只显示两个启停开关,不回显供应商或密钥。阿里云适配器使用上海地域的 `profilePhotoCheck`,通过短期上传令牌把私有候选送到官方 OSS 临时对象,再调用同步审核;不会公开候选 URL。官方返回 `RiskLevel=none/low` 通过,`medium/high` 保持私有并允许人工复核。把 `medium` 归为阻止是本产品策略。响应非 200、空结果或未知值均不放行。机器接口可能按量计费,开启前由部署方确认供应商账号与费用。 + +阿里云须开通内容安全增强版,测试凭证至少需要 `yundun-greenweb:DescribeUploadToken` 和 `yundun-greenweb:ImageModeration` 权限。当前使用供应商临时上传令牌,无需部署方自建审核用 OSS Bucket 或向该凭证授予自有 OSS 全权限。[官方本地图片接入示例](https://help.aliyun.com/zh/document_detail/467828.html)即使用该流程。阿里云说明上传到其临时存储的本地图片保留 30 分钟;这与 SkillHub 私有候选图片保留 30 天是两个独立生命周期,见[官方接口说明](https://help.aliyun.com/document_detail/467829.html)。 + +切换部署供应商需要更新服务配置并重启应用。已有候选保留提交时的供应商与机器/人工开关快照,不自动迁移;尚需调用旧供应商的申请在切换后会得到 `ERROR`,不会偷偷改用新供应商。所有者应重新提交以使用新供应商;已经完成的机器结果不会因切换配置被重新检测。 + +讯飞官方图片审核接口要求把 `accessKeySecret` 放在请求 URL 参数中。应用不会记录完整 URL 或异常原文,但 Java HTTP 客户端调试日志、反向代理、出口代理、链路追踪及第三方日志仍可能记录该 URL。部署方使用讯飞适配器前,必须确认相关组件不采集请求 URL,限制凭证权限并建立轮换机制。若无法满足,先不要开启该适配器。 + +## 与原 Issue 的差异 + +原 Issue 提议自动图片审核;本期产品决策改为“上传、裁剪、机器审核可选、人工审核可选”的四种策略。默认使用人工审核,机器审核仅在部署好单一供应商且管理员开启后运行。个人头像不在本期范围。 + +## 验收要点 + +分别验证四种开关组合、候选与当前头像隔离、拥有者和审核员权限、审核通过/驳回/复核、相同图片的新申请重新检测及每次策略审计、未知机器结果不放行、连续替换后旧审核无效、删除后首字母兜底、30 天清理以及团队/全局可见范围变化。外部供应商真实计费调用需要部署方测试账号与授权;单元测试使用模拟响应。 diff --git a/docs/skillhub/.vitepress/config.ts b/docs/skillhub/.vitepress/config.ts index 10b17307..26aa7f7d 100644 --- a/docs/skillhub/.vitepress/config.ts +++ b/docs/skillhub/.vitepress/config.ts @@ -39,6 +39,7 @@ export default defineConfig({ text: '核心功能', items: [ { text: 'Skill 发布与版本管理', link: '/guide/skill-publish' }, + { text: '技能头像与图片审核接入', link: '/guide/skill-avatars' }, { text: 'Suite Bundle 批量导入', link: '/guide/suite-bundle' }, { text: 'Skill 搜索与发现', link: '/guide/skill-discovery' }, { text: '命名空间与团队管理', link: '/guide/namespace' }, diff --git a/docs/skillhub/en/guide/namespace.md b/docs/skillhub/en/guide/namespace.md index b693cc6e..9096323f 100644 --- a/docs/skillhub/en/guide/namespace.md +++ b/docs/skillhub/en/guide/namespace.md @@ -73,6 +73,15 @@ Discover a security issue in the namespace and temporarily freeze all publish op 5. Select a role (Owner / Admin / Member) 6. Confirm to add +**Invite with a link**: + +1. An Owner or Admin opens the Members page of an ACTIVE team space and selects "Invite member". +2. Share the copied link securely. It expires after 24 hours and can be redeemed once by one person who is not already a member. +3. The recipient must first pass the usual SkillHub login and account access rules. The page shows the space, current account, and MEMBER permissions. Joining happens only after explicit confirmation. +4. "Revoke invitation" invalidates the link immediately. Creating a new link asks for confirmation before replacing a still usable one; existing members remain members. + +Anyone who obtains the link can use it after normal account admission. Do not post it in a public channel. The login return URL does not contain the invitation token; the current browser tab stores it temporarily until joining or terminal invalidation. + **Manage Permissions**: 1. Find the target user in the member list @@ -167,6 +176,16 @@ Content-Type: application/json POST /api/v1/namespaces/{slug}/unfreeze ``` +## Invite Members and View History + +Team Owners and Admins can create a link from **Members → Invite members**. It expires after 24 hours and permits one new member to join. The recipient must sign in, meet existing admission rules, and confirm joining. Replacing an active link or revoking it requires confirmation and does not remove existing members. + +The dialog includes a read-only history (five records per page, up to 240px tall) showing the creator, creation and expiration times, status, and the account that successfully joined. Snapshot names are displayed first; full account IDs are available on hover, with abbreviated IDs used when names are missing. Each generated invitation has its own record. Replacement, revocation, expiration, or a member leaving does not erase history. History cannot restore expired links and does not indicate current membership. Only the space's Owner/Admin can view it; platform roles do not bypass this restriction. Frozen or archived spaces retain a history-only entry. + +An upgrade imports only the provable current invitation, marked as a legacy record; earlier links are not reconstructed from incomplete audit data. History remains while the space exists and is deleted when the space is permanently deleted. Existing audit records follow their original retention rules. + +The history endpoint requires a browser session: `GET /api/v1/namespaces/{slug}/invitations/history?page=0&size=10`. Page sizes are bounded to 1–50; tokens and hashes are never returned. + ## Notes > **Slug Uniqueness**: Namespace slugs must be globally unique and cannot be changed after creation. Use a short identifier for your team or project. diff --git a/docs/skillhub/guide/namespace.md b/docs/skillhub/guide/namespace.md index 8af0d2ec..b8928770 100644 --- a/docs/skillhub/guide/namespace.md +++ b/docs/skillhub/guide/namespace.md @@ -73,6 +73,15 @@ 5. 选择角色(Owner / Admin / Member) 6. 确认添加 +**通过链接邀请成员**: + +1. Owner 或 Admin 在处于 ACTIVE 状态的团队空间「成员」页点击「邀请成员」。 +2. 复制链接并安全地发给要加入的人。链接 24 小时内有效,只能由一名尚未加入的用户使用一次。 +3. 对方先按现有登录及账号准入规则进入 SkillHub。打开链接后会看到目标空间、当前账号和 MEMBER 权限;点击「确认加入」才会成为成员。 +4. 如需提前失效,点击「作废邀请」。重新生成链接时,页面会要求确认旧链接立即失效;已加入的成员不受影响。 + +邀请链接是持有者可用的加入凭证,不限定邮箱或账号。不要在公开频道分享。登录回跳不会携带邀请 Token;同一浏览器标签页会暂存链接直到确认、已加入或确认失效。 + **管理权限**: 1. 在成员列表中找到目标用户 @@ -167,6 +176,16 @@ Content-Type: application/json POST /api/v1/namespaces/{slug}/unfreeze ``` +## 邀请成员与邀请记录 + +团队空间的 Owner/Admin 可在“成员 → 邀请成员”生成24小时有效、仅一名新成员可使用的链接。对方须先登录并通过平台准入,再确认加入;仅打开链接不会自动成为成员。替换有效链接和作废须确认,已加入成员不受影响。 + +弹窗下方直接展示分页邀请记录(网页每页5条,列表最高240px):创建人及时间、到期时间、状态,以及实际加入账号与时间。人员优先显示记录时保存的姓名,完整账号ID放在悬停提示;无姓名时显示短标识。每次生成独立记录,替换、作废、过期和成员离开不会抹掉原历史。历史不能恢复失效链接,也不代表当前成员资格。普通成员和仅有平台角色的账号不能查看,冻结/归档空间的 Owner/Admin 可查看只读历史。 + +升级只迁入可以准确读取的当前邀请并标记“旧记录”,不推测更早链接的使用情况。空间存在期间保留历史,彻底删除空间时删除邀请业务记录,既有操作审计按原规则保留。 + +历史查询使用浏览器 Session:`GET /api/v1/namespaces/{slug}/invitations/history?page=0&size=10`,每页1–50条,不返回邀请码或哈希。 + ## 注意事项 > **Slug 唯一性**:命名空间 slug 在全局范围内必须唯一,且创建后不可修改。建议使用团队或项目的简短标识符。 diff --git a/docs/skillhub/guide/review.md b/docs/skillhub/guide/review.md index ef1d333b..6830e3cb 100644 --- a/docs/skillhub/guide/review.md +++ b/docs/skillhub/guide/review.md @@ -34,6 +34,8 @@ SkillHub 提供了完整的审核工作流,确保发布到注册中心的技 - **推广管理**:将命名空间技能包推���到全局 - **审计日志**:记录所有治理操作 +头像变更复用技能审核申请。机器阻止与人工拒绝是不同状态;开关组合、审核操作及云审核接入步骤详见 [技能头像与图片审核接入](/guide/skill-avatars)。 + ## 使用场景 **场景一:命名空间管理员审核** diff --git a/docs/skillhub/guide/skill-avatars.md b/docs/skillhub/guide/skill-avatars.md new file mode 100644 index 00000000..f8b6861a --- /dev/null +++ b/docs/skillhub/guide/skill-avatars.md @@ -0,0 +1,151 @@ +# 技能头像与图片审核接入 + +本页介绍如何上传技能头像,以及部署方如何接入讯飞或阿里云图片审核。这是**技能头像**功能,不涉及用户个人头像;图片审核与技能包安全扫描是两种不同的检查。 + +## 技能所有者怎么使用 + +1. 新增技能时,在发布页选择自定义 PNG/JPEG;已有技能从「我的技能 → 更新」进入,也可只改头像而不发布新版本。 +2. 在裁剪弹窗调整方形区域和缩放,确认后等待上传成功。图片上传中或失败时不能提交;未选择头像也可正常提交,显示技能名首字母。 +3. 提交后,在「我的技能」或更新页查看**本次提交的图片**及审核状态。审核通过前,其他人继续看到旧头像或首字母。 +4. 被拒绝后查看原因,修改图片再提交。新申请会替换旧待审申请,旧审核结果不会覆盖新头像。 + +上传输入限 PNG/JPEG、2 MB、边长不超过 1024 像素;服务器会再次校验并重新编码为 PNG。上传成功仅表示图片已存入私有存储,不代表审核通过。临时上传引用 24 小时后过期,过期需要重新上传。 + +更新默认保留原头像,也可移除:单改头像提交成功后恢复首字母;与新技能包一起提交时,审核通过后才移除。取消编辑或提交失败不改变现有头像。技能详情和列表展示当前生效头像,不设单独的头像管理入口。 + +## 管理员怎么审核 + +头像与技能包同时提交时,共用一张技能申请,审核员在原审核详情中对内容和头像一次批准或拒绝。只改头像且需要人工决定时,也进入这个技能审核队列,标明「头像修改」,不创建新技能版本。 + +团队管理员审核本团队;平台技能管理员和超级管理员可审核团队及全局,自审权限沿用原技能审核规则。管理员可撤下头像,但不能替所有者上传新头像。 + +### 两个开关与四种流程 + +超级管理员在「系统配置」(`/dashboard/admin/system-config`)中选择「启用机器审核」与「启用人工审核」。默认机器关闭、人工开启。后台只管理这两个开关,不配置供应商、不显示密钥。 + +| 机器审核 | 人工审核 | 非 PRIVATE 技能单改头像的流程 | +| --- | --- | --- | +| 关闭 | 关闭 | 提交成功后直接生效 | +| 关闭 | 开启 | 人工批准后生效 | +| 开启 | 关闭 | 机器通过后自动生效;阻止或异常时不生效 | +| 开启 | 开启 | 先机器审核,再由人工决定 | + +**边界:**`PRIVATE` 技能头像仅所有者可读,直接使用,不运行上述机器或人工审核;扩大可见范围时需要按目标范围重新审核。头像与技能包同次提交时,不论头像开关如何,仍须等待原技能审核共同批准,不会提前发布头像。 + +### 违规会直接失败吗? + +机器结果与申请最终决定不同: + +| 结果 | 处理 | +| --- | --- | +| `PASSED`(通过) | 有人工审核时继续待审;没有人工审核时单改头像自动生效 | +| `BLOCKED`(阻止) | 新图片保持私有、旧头像继续使用。开启人工审核时继续待审,审核员可拒绝;认为误判时必须填写明确理由才能批准。未开启人工审核时不生效,所有者可申请人工复核 | +| `ERROR`(异常) | 新图片保持私有,不能直接批准放行;先排查配置、网络或供应商服务,再重试 | + +因此,**机器判违规不等于文件上传失败,也不等于人工已拒绝申请**。人工拒绝后,所有者可查看提交图片与拒绝原因。异常自动重试有一小时冷却,同一候选最多自动调用三次(含首次);耗尽后可在冷却结束后主动重试或重新提交。 + +## 部署方如何接入机器审核 + +### 1. 选择一个供应商 + +每个服务部署只选择一个:`xfyun` 或 `aliyun`。供应商及凭证在应用启动时读取;后台仅能启停审核,不能切换供应商。不开启机器审核时无需提供云审核凭证。 + +| 供应商 | 需要开通的服务 | 所需凭证 | +| --- | --- | --- | +| 讯飞 | 图片合规审核 | APPID、APIKey、APISecret | +| 阿里云 | 内容安全增强版,上海地域的头像图片检测 `profilePhotoCheck` | RAM 用户的 AccessKey ID、AccessKey Secret | + +服务开通、额度及计费以供应商控制台为准;开启后真实调用可能产生费用。 + +- 讯飞:[官方图片合规服务接入与鉴权说明](https://www.xfyun.cn/doc/nlp/ImageModeration/API.html)。注意 APIKey 对应 `access-key-id`,APISecret 对应 `access-key-secret`。 +- 阿里云:[内容安全控制台](https://yundun.console.aliyun.com/?p=cts)、[开通服务与创建 RAM 用户的官方接入步骤](https://help.aliyun.com/zh/document_detail/467828.html)。按最小权限配置 `yundun-greenweb:DescribeUploadToken` 和 `yundun-greenweb:ImageModeration`,不需要给审核用户自有 OSS 全权限。 + +阿里云适配器通过官方临时上传令牌上传私有图片,再调用同步审核,**无需自建审核用 OSS Bucket**。它与 SkillHub 自己保存技能和头像的对象存储配置相互独立。当前将 `RiskLevel=none/low` 视为通过,`medium/high` 视为阻止,其中 `medium` 转人工复核是 SkillHub 的策略。空结果、未知值及接口失败均不放行。参考[官方接口说明](https://help.aliyun.com/document_detail/467829.html)。 + +### 2. 用私有环境文件提供凭证 + +下面使用仓库标准 `compose.release.yml` 的 `server` 服务。先按现有部署流程准备 `.env.release`、数据库及 SkillHub 对象存储,选用包含本功能的镜像版本。本节不会替换原部署设置。 + +在**仓库外**建立私有目录,将下面路径中的 `/etc/skillhub/private` 换成实际路径;目录权限建议 `700`,文件权限 `600`。不要把凭证文件提交到 Git。 + +标准 Compose 只透传它声明的环境变量;仅向 `.env.release` 添加新审核属性不会自动注入 `server`。以下通过额外 override 的 `env_file` 显式注入,原 Compose 模板没有内置这些凭证字段。 + +创建 `/etc/skillhub/private/avatar-moderation.env`,以下两种内容**二选一**。占位符需替换为实际凭证;`SPRING_APPLICATION_JSON` 必须是单行合法 JSON。 + +**讯飞:** + +```dotenv +SPRING_APPLICATION_JSON={"skillhub":{"avatar":{"moderation":{"provider":"xfyun","xfyun":{"app-id":"YOUR_APPID","access-key-id":"YOUR_APIKEY","access-key-secret":"YOUR_APISECRET"}}}}} +``` + +**阿里云:** + +```dotenv +SPRING_APPLICATION_JSON={"skillhub":{"avatar":{"moderation":{"provider":"aliyun","aliyun":{"access-key-id":"YOUR_ACCESS_KEY_ID","access-key-secret":"YOUR_ACCESS_KEY_SECRET"}}}}} +``` + +如果部署已有 `SPRING_APPLICATION_JSON`,**将上述 `skillhub.avatar.moderation` 合并进原 JSON,不要覆盖原配置或设置重复变量**;其他 override 若通过 `environment` 定义同名变量,会优先于 `env_file`,也需同步合并。特殊字符应按 JSON 格式转义。 + +在同一私有目录创建 `compose.avatar.yml`: + +```yaml +services: + server: + env_file: + - /etc/skillhub/private/avatar-moderation.env +``` + +然后在仓库根目录执行。已有部署若使用额外 Compose 文件、`-p` 项目名或其他启动参数,保留这些参数,再追加头像 override,确保更新原服务: + +```bash +make validate-release-config +docker compose --env-file .env.release \ + -f compose.release.yml \ + -f /etc/skillhub/private/compose.avatar.yml \ + up -d --no-deps server + +docker compose --env-file .env.release \ + -f compose.release.yml \ + -f /etc/skillhub/private/compose.avatar.yml \ + ps server +``` + +上述更新命令适用于已运行的完整栈。首次部署完整栈时,将 `up -d --no-deps server` 改为 `up -d`。只执行 `restart` 不会重新加载修改后的容器环境,配置更新需要重新创建服务。后续更新也要保留同一 override。 + +正式 Spring 属性是 `skillhub.avatar.moderation.provider`、`skillhub.avatar.moderation.xfyun.app-id`、`skillhub.avatar.moderation.xfyun.access-key-id`、`skillhub.avatar.moderation.xfyun.access-key-secret`,以及 `skillhub.avatar.moderation.aliyun.access-key-id`、`skillhub.avatar.moderation.aliyun.access-key-secret`。其他部署平台可通过 Secret 注入同等属性。 + +::: warning 讯飞出口日志 +讯飞官方接口将 `accessKeySecret` 放在请求 URL 参数中。应用不记录完整 URL 或异常原文,但部署方仍需关闭 HTTP 客户端调试日志,并确保出口代理、链路追踪及第三方日志不采集该请求 URL。不要把含凭证的 Compose 展开结果、容器环境或签名请求 URL 分享给其他人。 +::: + +### 3. 在后台开启,并验证真实调用 + +1. 确认 `server` 健康后,以超级管理员进入「系统配置」。 +2. 开启机器审核,建议初次接入时保留人工审核开启。 +3. 用技能所有者账号对一个非 `PRIVATE` 测试技能提交**新头像**。 +4. 在更新页查看机器结果;开启人工时,审核员在技能审核详情中确认图片、机器状态并做出决定。 +5. 对通过、阻止、异常分别检查:通过前旧公开头像不变,阻止或异常的新图片不可公开,人工拒绝后可见原因。不要为了测试把违规图片批准公开。 + +填写凭证、成功保存开关或应用健康,**都不能证明云服务调用成功**。后台检查的是配置是否齐全,真正的服务权限、网络、余额和接口响应必须通过新申请验证。已有申请不因开关开启而补跑机器审核。 + +接入验收现状:讯飞已通过真实接口验证通过(PASS)与阻止(BLOCK),并验证人工拒绝后保留旧公开头像;阿里云已进行接口契约、结果映射和异常处理的代码审查及模拟响应测试,**尚未真实账号联调**。这些结果不替代部署方对自己账号的连接验收。 + +## 修改开关与切换供应商 + +申请保存提交时的机器、人工开关和供应商快照,之后改配置不会回写已有申请。若要切换供应商,应更新私有配置并重新创建 `server`,再提交新申请: + +- 已完成的机器结果不会自动重新检测。 +- 旧候选仍需调用旧供应商时,切换后会得到 `ERROR`,不会偷偷改用新供应商;应重新提交以采用新供应商。 +- 配置切换前先处理待审申请,避免把旧任务留在不可继续审核的状态。 + +被拒绝、替换或删除的候选图片私有保留 30 天后清理,文字审核记录继续保留。云审核服务自己的临时图片生命周期与 SkillHub 私有保留规则不同。 + +## 常见问题 + +**开启失败提示供应商不可用?** 检查 `provider` 拼写、所选供应商凭证是否齐全,以及额外 override 是否实际用于启动 `server`。不要通过输出完整环境变量排查密钥。 + +**上传成功但一直异常?** 检查服务器能否访问对应云接口、凭证权限、服务是否开通及可用额度;阿里云还需要能访问其临时 OSS 上传端点。等待冷却后重试,不要把异常当通过。 + +**人工开关关闭就不审核技能包了吗?** 不是。这个开关只控制头像的人工策略,不改变原技能包审核流程。 + +**为什么其他人仍看到旧头像?** 新申请未通过时这是正常行为;所有者应到「我的技能」或更新页查看提交图片,公开详情只展示当前生效头像。 diff --git a/docs/skillhub/guide/skill-publish.md b/docs/skillhub/guide/skill-publish.md index 8d77fcf8..9aafcea7 100644 --- a/docs/skillhub/guide/skill-publish.md +++ b/docs/skillhub/guide/skill-publish.md @@ -91,6 +91,14 @@ npx @astron-team/skillhub@latest publish ./my-skill --namespace my-team 访问 `http://localhost:3000/dashboard/publish`,选择命名空间、上传 zip 文件、选择可见性后点击「发布」。 +可选上传自定义技能头像:选择 PNG/JPEG、裁剪并等待私有上传成功,再提交技能。头像上传中或失败时不能提交;没有选择头像则正常提交并使用技能名首字母。更新技能时默认保留原头像,可选择替换或移除;移除仅在提交成功后生效,取消或提交失败不影响原头像。 + +技能包与头像同次提交时,共用一张技能审核申请,审核员一次批准或拒绝;通过前显示旧头像或首字母。文件上传成功不代表头像审核通过。仅需修改头像时,可从“我的技能”的“更新”入口操作,不需要发布新的技能版本;需要人工审核时仍进入统一技能审核队列。 + +机器判定头像违规时,新图保持私有。若开启人工审核,申请继续等待审核员决定,并非机器自动驳回;审核员可拒绝,认为误判时须填写理由才能批准。人工拒绝后,可在“我的技能”和更新页查看提交图片及拒绝原因。机器调用异常与违规不同,异常时须先重试,不能直接批准放行。 + +头像上传、审核状态及讯飞/阿里云接入步骤详见 [技能头像与图片审核接入](/guide/skill-avatars)。 + 4. **使用 REST API 发布** ```bash diff --git a/openspec/changes/add-namespace-invite-links/.openspec.yaml b/openspec/changes/add-namespace-invite-links/.openspec.yaml new file mode 100644 index 00000000..b9ebfc72 --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/add-namespace-invite-links/design.md b/openspec/changes/add-namespace-invite-links/design.md new file mode 100644 index 00000000..1dc5337c --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/design.md @@ -0,0 +1,52 @@ +## Context + +现有 Namespace 成员只能由管理员直接添加。`NamespaceMemberService`、平台管理服务、生命周期服务和账号合并均会修改同一 Namespace 的状态或成员,但目前没有共同的 Namespace 行锁。前端已有 Session/CSRF、登录 returnTo 与 BASE_PATH 逻辑;当前 returnTo helper 会保留 fragment,所以 `/join` 必须先移除 Token 再触发登录跳转。 + +## Decisions + +1. 每个 Namespace 只保存一条当前邀请:Token SHA-256、签发人和时间、过期时间、消费人和时间。原文只在生成响应中返回一次。作废删除当前邀请记录或等价地使其不可匹配,写审计;不删除已加入成员。 +2. 涉及用户的写操作先按 userId 升序锁参与者用户行,再按 Namespace ID 升序锁目标 Namespace 行,最后读取或写入邀请与成员;账号合并沿用先锁用户的现有顺序,以免与成员或审计外键检查形成锁环。邀请创建、替换、作废以及带操作者审计的生命周期操作先锁操作者,再锁 Namespace;接受先锁接收者,再锁 Namespace。当前不写用户审计的 Namespace 删除只锁 Namespace。批量操作预先收集 ID、去重并排序。接受在锁内重检,成员创建、消费和审计原子提交。 +3. 前端在 `/join` 入口同步读取 fragment,放入当前标签页 sessionStorage,保留 path/query 清除 fragment,再应用原登录逻辑。登录 `returnTo=/join` 不传 Token。分享链接复用 `resolvePublicRegistryUrl` 构造并兼容 BASE_PATH。 +4. Preview/Accept 复用 Issue 的判定顺序:先验证 Token 为当前邀请,再查已有成员;已有成员返回 ALREADY_MEMBER。非成员必须面对 ACTIVE TEAM、未消费且未过期的邀请。失效统一 404,不区分随机、旧、作废、过期或已用 Token。Accept 锁前只按 Token 哈希查询标量 Namespace ID;取得 Namespace 锁后重新加载当前邀请并校验,不能沿用锁前已经进入 ORM 一级缓存的邀请实体,否则并发兑换可能都看到未消费状态。 +5. 只有可用浏览器 Session 能创建、预览、接受、作废;现有账号准入不能由邀请绕过。浏览器请求使用既有 CSRF。创建/预览/接受/作废与缓存或日志配置均不得泄露原文和哈希。 +6. 管理页面保留原直接添加成员入口,新增邀请按钮和单独作废按钮。仍可兑换的旧链接替换前先确认;剪贴板失败时展示本次生成的同一链接。 +7. 正式路径沿用 Issue #911 的复数合同:`POST /api/v1/namespaces/{slug}/invitations` 创建或替换,`POST /api/v1/namespace-invitations/preview|accept` 预览及确认。状态查询和作废使用同一 `/namespaces/{slug}/invitations` 路径的 GET/DELETE;保留现有 `/api/web` 别名。Preview 返回 `namespaceSlug`、`namespaceDisplayName`、`namespaceAvatarUrl`、`outcome`、`expiresAt`;Accept 仅返回 `namespaceSlug`、`outcome`。无效邀请的本地化键为 `error.namespace.invitation.invalid`。 + +## Risks and verification + +- 账号合并已先锁 UserAccount;邀请创建/替换/作废/接受、直接成员变更、平台成员管理及账号合并要按相同的用户后 Namespace 顺序加锁,避免与成员或审计外键检查形成环。 +- 并发兑换、直接添加与兑换、替换/作废与兑换、冻结/归档/删除与兑换均需事务级验证。 +- 根路径及子路径部署都要验证分享 URL、登录回跳和 fragment 清理。 +- API Token、跨团队管理员、平台角色和禁用账号的越权路径需明确拒绝。 + +## 管理交互与具名片段(2026-10-13,已确认,实施中) + +新分享地址为 `/join#invite=`,兼容原 `/join#`。进入页仍在登录跳转前同步捕获并清片段,Token不进入HTTP路径、查询、returnTo或日志。新片段无效时清旧暂存Token,无片段登录回跳才恢复暂存值。 + +命名空间卡片不平铺成员、邀请、作废、冻结、归档按钮;管理详情按技能/成员/审核/设置集中导航。成员页弹窗管理邀请,治理操作进入设置并保留旧权限与危险动作确认。公共空间技能浏览及旧成员/审核链接保持可用。平台审核权不等于成员管理权;GLOBAL不可冻结归档,但合法技能审核仍可用。 + +### 2026-10-14 人工核验交互修正 + +返回我的空间放在标题上方。普通成员不显示变更操作;角色编辑仍在角色列,保存/取消/移除使用操作列图标,提供可访问名称和悬停说明,保存成功或取消后恢复角色选择框焦点。审核状态用下拉筛选区分空间主导航。 + +邀请生成原文仍不存服务器;创建者以 userId+slug 隔离保存在当前标签页 sessionStorage。重开及刷新时先检查 active 再预览当前 Token,成功才显示;失效/替换时清除,网络失败不展示旧缓存但可重试。跨浏览器或标签页无法恢复时明确说明,替换须确认。存储不可用时仅保留当前弹窗的内存副本并提示立即复制。 + +邀请弹窗使用640px桌面宽度(手机为视口减32px),按邀请状态与链接、加入规则、管理操作分区。只在成功验证原邀请时展示实际到期时间;未知或失败不猜测到期时间,提供状态重试。复制为主要操作,替换和作废保留确认。 + +### 2026-10-14 邀请历史设计 + +已确认:邀请记录指一次成功生成链接及其生命周期结果;加入记录指账号通过该链接成功确认加入,非页面访问记录。每次生成独立记录;只有空间OWNER/ADMIN可以查看,不给平台角色增加旁路权限。REQ-INV-08/09/10已实现独立历史表及分页接口;前述当前邀请覆盖/作废删除只适用于当前链接凭证,业务历史不再被覆盖或删除。 + +旧版限制:升级前审计仅记录操作者、动作和空间ID,未携带邀请标识,因此无法可靠还原全部旧链接关联;新增审计关联独立邀请标识。已确认:空间存在期间保留,彻底删除空间时删除邀请业务历史而保留既有审计;仅迁入能确认的当前邀请并标注旧记录;保留曾经加入事实,账号变更不改写原邀请归属。已确认整体方案:保留当前邀请操作,并在弹窗下方直接展示只读分页历史,不加嵌套标签页。 + +#### 历史与当前邀请的技术边界 + +保留当前邀请哈希验证路径,新增独立业务历史及当前邀请关联标识。历史不存Token或哈希,每次生成写入独立记录;有效未消费的旧邀请被替换时标为已替换,已消费和已到期的旧记录保留原结果。成功加入、成员新增、邀请消费及相关审计必须同事务,不产生无成员的成功加入历史。自然过期由服务端时钟投影,业务流转校验在代码中,不新增SQL业务CHECK。 + +新迁移基于已实施V70–V73,不能为历史展示重写已应用迁移。只迁入当前邀请可证实字段并标记legacy,未知的过往姓名和关联不伪造;新记录以稳定账号ID关联,姓名以记录时快照辅助展示。空间彻底删除联动删除历史,冻结/归档保留历史。 + +新增历史查询沿用Session认证、禁止缓存及空间OWNER/ADMIN权限;每页数量有上限,按创建时间倒序和独立标识稳定排序。创建/预览/接受现有响应合同保持兼容。审计新增独立邀请标识关联,不包含任何凭证。 + +邀请历史列表网页每页5条并保持紧凑高度,数据保留规则不变。人员主要展示姓名快照,不以当前账号姓名替换历史;完整稳定账号ID作为悬停/可访问说明,未记录姓名时用短标识兜底,不增加邮箱采集或展示。 + +弹窗内采用紧凑操作尺寸:主要操作按钮32px、页码28px,收紧按钮与分页间距,日期和数量文字适当缩小;共用分页保留默认样式,仅邀请历史启用紧凑模式。640px弹窗宽度、每页5条及240px列表上限不变。 diff --git a/openspec/changes/add-namespace-invite-links/proposal.md b/openspec/changes/add-namespace-invite-links/proposal.md new file mode 100644 index 00000000..5d8db8be --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/proposal.md @@ -0,0 +1,30 @@ +## Why + +Namespace 管理员目前只能逐个选择现有用户添加成员。短期、单次邀请链接能让成员自行加入,同时让管理员控制链接失效和成员权限边界。 + +## What Changes + +- ACTIVE TEAM 的 OWNER/ADMIN 可以生成一条 24 小时内有效、仅一名非成员可兑换的邀请链接;替换未使用链接前须确认。 +- 管理员可以单独作废当前邀请,不需要生成新链接;不影响已经加入的成员。 +- 持链接者先按现有注册、登录和账号准入策略获得可用账号,再看到空间、当前账号和 MEMBER 权限,明确确认后加入。 +- Token 不存明文、不进 URL path/query 或登录回跳参数;创建、预览、兑换、作废均受权限及事务约束。 +- 邀请兑换与成员及 Namespace 生命周期操作并发时保持一致,不出现重复成员或部分消费。 + +## Capabilities + +### New Capabilities + +- `namespace-invitations`: 邀请创建、替换、作废、预览、确认加入、安全与并发规则。 + +### Modified Capabilities + +无。 + +## Impact + +- Namespace 领域、成员管理、账号合并事务、数据库迁移、审计、Session/CSRF 接口和 OpenAPI 类型。 +- Namespace 管理页面、邀请页、登录回跳与子路径部署、多语言文案及浏览器验证。 + +## 相对 Issue #911 的调整 + +原 Issue 把独立撤销入口列为范围外;本方案增加“作废邀请”按钮和相应后端动作。邀请不绕过现有账号准入策略。 diff --git a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md new file mode 100644 index 00000000..e79558b0 --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md @@ -0,0 +1,200 @@ +## Purpose + +让 Namespace 管理员使用可控的短期单次链接邀请新成员。 + +## ADDED Requirements + +### Requirement: REQ-INV-01 管理员 SHALL 创建、替换及作废邀请 + +有权管理员 SHALL 可创建、替换及作废邀请。 + +只有 ACTIVE TEAM 的 OWNER/ADMIN 可以创建邀请;平台角色不能绕过成员角色限制。每个 Namespace 仅有一条当前邀请,24 小时内仅一名非成员可兑换一次。替换仍可用的邀请前须确认,创建响应仅在旧链接当时仍可兑换时标记 `replaced=true`;已消费或过期后的新建不标记替换。管理员可以单独作废当前邀请,且已加入成员不受影响。 + +#### Scenario: 替换未使用链接 +- **WHEN** 管理员再次点击邀请且当前链接仍可兑换 +- **THEN** 页面先说明旧链接将失效并要求确认 +- **AND** 仅确认后生成新链接,旧链接立即失效 + +#### Scenario: 单独作废 +- **WHEN** 管理员点击作废并确认 +- **THEN** 当前链接立即失效,不会创建新链接或移除成员 + +### Requirement: REQ-INV-02 邀请 SHALL 服从现有账号准入 + +邀请 SHALL 服从现有账号准入规则。 + +邀请只授予目标 Namespace 的 MEMBER 权限,不使用户绕过现有注册、登录、外部身份准入和账号审批。接受者必须先拥有可用登录会话,看到目标 Namespace、当前账号和 MEMBER 可访问及发布资源的说明,再明确确认加入。页面加载不得自动兑换。 + +#### Scenario: 新用户未通过准入 +- **WHEN** 持链接者无法按既有策略获得可用账号 +- **THEN** 邀请不能使其进入目标 Namespace + +#### Scenario: 已登录非成员 +- **WHEN** 非成员打开有效链接 +- **THEN** 页面展示 Namespace、账号和权限,只有点击确认才新增 MEMBER + +### Requirement: REQ-INV-03 Token SHALL 避免意外泄露 + +Token SHALL 避免出现在日志、持久化明文和登录 URL 中。 + +Token 使用安全随机数,数据库仅保存哈希;原文只在生成响应中出现一次。创建者页面可将原文保存在按账号和空间隔离的当前标签页 sessionStorage,供重开弹窗或刷新后恢复;展示前须重新确认服务端 active 状态并预览该 Token,失效或替换时清除。不得存入 localStorage 或数据库明文;其他标签页或设备无法恢复时明确说明,并保留确认替换入口。分享 URL 使用 `/join#invite=`,页面立即移入当前标签页 sessionStorage 并清除 fragment;登录回跳只包含应用内 `/join`,不带 Token。创建、预览、兑换、作废响应禁止缓存;日志和审计不得包含原文或哈希。四类操作仅允许浏览器 Session,变更请求遵守 CSRF。 +Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码;数据库仅保存 SHA-256 哈希。 + +#### Scenario: 经过登录回跳 +- **WHEN** 未登录者打开邀请后登录 +- **THEN** Token 不出现在登录回跳 URL,回到原标签页后才从 sessionStorage 恢复邀请 + +#### Scenario: 子路径部署 +- **WHEN** 应用部署在 `/skillhub/` +- **THEN** 分享链接指向公开基地址下 `/skillhub/join#invite=`,应用内 returnTo 仍为 `/join` + +### Requirement: REQ-INV-04 服务端 SHALL 在接受时重新判断状态 + +服务端 SHALL 在接受邀请时重新判断全部状态。 + +预览不消费邀请。接受时重新检查 Token 是否仍为当前、用户是否已有成员关系、Namespace 是否 ACTIVE TEAM、邀请是否未消费且未过期。Token 匹配当前邀请且用户已是成员时返回 ALREADY_MEMBER,不再检查过期、消费或 Namespace 冻结归档状态,也不消费。未知、过期、替换、作废或已用 Token 对非成员返回统一失效响应。冻结或归档时非成员不能加入也不消费,恢复后仅在原有效期内可兑换。 +统一失效响应使用 HTTP 404、envelope `code=404` 与 `data=null`;前端只按状态处理,不解析本地化文案。 + +#### Scenario: 预览后链接被替换 +- **WHEN** 用户看到预览后管理员替换或作废了链接 +- **THEN** 接受请求失败且不新增成员 + +#### Scenario: 已有成员打开当前链接 +- **WHEN** Token 仍是当前邀请且当前用户已经是成员 +- **THEN** 返回 ALREADY_MEMBER,直接进入 Namespace,不消耗邀请 + +#### Scenario: 被移除者重用已消费链接 +- **WHEN** 曾兑换的成员后来被移除 +- **THEN** 原链接仍保持已消费,不能再次加入 + +### Requirement: REQ-INV-05 并发操作 SHALL 保持成员和邀请原子一致 + +并发操作 SHALL 保持成员和邀请原子一致。 + +邀请创建、替换、作废和接受与成员添加/删除/转交、Namespace 冻结/归档/恢复/删除、账号合并在相同 Namespace 上须串行化。涉及用户和 Namespace 的写操作先按用户 ID 升序、再按 Namespace ID 升序加锁;创建/替换/作废及带操作者审计的生命周期操作锁操作者,接受锁接收者;当前不写用户审计的 Namespace 删除只锁 Namespace。接受时成员创建、邀请消费和审计同一事务完成;并发兑换最多一名非成员成功,不能产生“已消费但无成员”。批量涉及多个 Namespace 的操作按去重 ID 升序锁定。 + +#### Scenario: 两人同时兑换 +- **WHEN** 两个非成员同时确认同一链接 +- **THEN** 最多一人新增 MEMBER,另一人收到统一失效结果 + +#### Scenario: 管理员直接添加与邀请并发 +- **WHEN** 管理员直接添加同一用户和该用户兑换邀请同时发生 +- **THEN** 不出现未处理的唯一约束异常、重复成员或错误消费 + +### Requirement: REQ-INV-06 前端 SHALL 完成链接分享和状态闭环 + +前端 SHALL 完成链接分享、登录回跳和邀请状态闭环。 + +空间卡片收拢为进入空间的主要入口;空间管理详情按技能、成员、审核、设置组织,邀请集中在成员页弹窗,提供生成、替换确认、复制失败后的手动复制以及单独作废;冻结、归档及恢复等原有治理动作集中在设置,保留权限及确认。接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token 并自动使用 replace 导航进入空间,避免重复导航。加载状态使用可访问的 status 提示,终态失效使用 alert 提示。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。 + +#### Scenario: 剪贴板写入失败 +- **WHEN** 创建成功但浏览器拒绝写剪贴板 +- **THEN** 页面显示同一链接供手动复制,不再次生成邀请 + +#### Scenario: 网络错误 +- **WHEN** 预览或接受遇到可重试网络错误 +- **THEN** 页面保留 Token,用户可重试而无需新邀请 + +### Requirement: REQ-INV-07 API SHALL 使用 Issue #911 的路径与响应合同 + +API SHALL 使用 Issue #911 的路径与响应合同。 + +创建或替换使用 `POST /api/v1/namespaces/{slug}/invitations`,首次创建允许无请求体;有仍可兑换的当前邀请时须按已批准的确认流程传入 `replace=true`。状态查询和单独作废分别使用同一路径的 GET 与 DELETE。预览和接受分别使用 `POST /api/v1/namespace-invitations/preview` 与 `POST /api/v1/namespace-invitations/accept`,请求体均为 `{ "token": "..." }`;可保留 `/api/web` 别名。 + +创建数据仅包含 `token`、`expiresAt`、`replaced`。预览数据包含 `namespaceSlug`、`namespaceDisplayName`、`namespaceAvatarUrl`、`outcome`(`CONFIRM_REQUIRED` 或 `ALREADY_MEMBER`)和 `expiresAt`。接受数据仅包含 `namespaceSlug` 和 `outcome`(`JOINED` 或 `ALREADY_MEMBER`)。无效邀请使用 `error.namespace.invitation.invalid` 本地化键,中文为“邀请链接无效或已失效”,响应为 HTTP 404、envelope `code=404`、`data=null`。 + +#### Scenario: 首次创建和预览 +- **WHEN** OWNER/ADMIN 首次创建邀请且不传请求体,非成员预览链接 +- **THEN** 创建返回三字段,预览返回五字段且 `outcome=CONFIRM_REQUIRED` + +#### Scenario: 接受与已有成员 +- **WHEN** 非成员确认加入,或已有成员访问当前邀请 +- **THEN** 接受分别返回 `JOINED` 或 `ALREADY_MEMBER`,且仅包含 `namespaceSlug` 和 `outcome` + +#### Scenario: 旧邀请与无效新邀请 +- **WHEN** 接收者打开旧的 `/join#` 链接 +- **THEN** 按原方式安全捕获并清除片段,保持兼容 +- **WHEN** 新的具名片段格式无效 +- **THEN** 清除先前暂存凭证,不能误用上一条邀请;无片段的登录回跳仍恢复合法暂存邀请 + +#### Scenario: 他人消费邀请后生成新链接 +- **WHEN** 他人已消费邀请,而管理员页面还缓存旧的可兑换状态 +- **THEN** 点击生成先刷新服务端状态,已消费或过期的邀请不误弹替换确认,仍可兑换的邀请必须确认 +- **AND** 状态刷新失败时不沿用旧缓存生成或替换,显示可重试错误 + +#### Scenario: 重开邀请弹窗与成员页布局 +- **WHEN** 同一账号在同一标签页重开弹窗或刷新成员页 +- **THEN** 重新校验有效的已保存链接后显示,并提供复制;失效或替换的链接不展示 +- **AND** 无保存链接时说明限制,不自动替换邀请 +- **AND** 返回我的空间位于空间标题上方;无管理权限不显示成员变更按钮;有权限的角色保存、取消和移除放在操作列,图标有可访问名称及悬停提示 +- **AND** 审核状态采用带标签的下拉筛选,与空间主导航区分 + +#### Scenario: 邀请弹窗提供完整状态及说明 +- **WHEN** 有权管理员打开邀请弹窗 +- **THEN** 显示目标空间及邀请有效/无有效邀请/加载/读取失败状态;可恢复原链接时显示实际到期时间及复制入口 +- **AND** 说明24小时内单次使用、MEMBER权限、先登录及现有准入规则;替换和作废操作独立于复制,并继续要求确认 +- **AND** 读取失败可重试;桌面弹窗适当增宽,手机仍可完整操作 + +### Requirement: REQ-INV-08 系统 SHALL 保留独立邀请记录 + +每次成功生成邀请 SHALL 创建一条具有独立标识的邀请记录,保存创建人、创建时间、到期时间及生命周期结果。再次生成、替换或作废 SHALL NOT 覆盖或删除之前的业务历史记录。记录可展示有效、已使用、已替换、已作废、已过期状态;过期状态按真实到期时间判定,不要求依赖定时任务才失效。 + +新成员通过邀请成功确认加入时 SHALL 将实际加入账号及加入时间准确关联至该次邀请记录。只打开或预览链接、不成功兑换或已有成员访问 SHALL NOT 被记录为新的加入成功。邀请记录 SHALL NOT 展示原始 Token 或 Token 哈希,历史记录不构成恢复已失效链接的入口。 + +本条为2026-10-14确认并实现的新增需求,执行验证以本次验收记录为准。空间存在期间持续保留邀请业务记录,不新增保留期配置;彻底删除空间时删除其邀请业务记录,操作审计按既有规则保留。升级仅迁入仍能准确读取的当前邀请并标注旧记录;不可可靠关联的更早审计不得拼造为邀请历史。用户退出或被移除后仍保留当时的成功加入事实;账号改名或合并不改写原邀请账号归属,记录不代表当前成员资格。 + +#### Scenario: 邀请已使用后再次生成 +- **WHEN** 邀请A被张三成功使用,管理员随后生成邀请B +- **THEN** A保留已使用状态及张三的加入时间,B为新的独立记录 + +#### Scenario: 替换或作废邀请 +- **WHEN** 管理员替换仍有效的邀请A,或作废邀请B +- **THEN** A或B保留各自已替换或已作废记录,不覆盖先前的加入记录 + +#### Scenario: 预览不记为加入 +- **WHEN** 用户仅打开邀请页或预览链接,未成功确认加入 +- **THEN** 不产生邀请加入成功记录 + +### Requirement: REQ-INV-09 邀请历史 SHALL 按空间管理权限限制访问 + +邀请历史页面和查询接口 SHALL 仅允许该空间OWNER或ADMIN访问。普通成员不得查看;平台角色不自动绕过空间成员角色规则。权限判定采用当前身份及当前空间角色,不能仅依赖前端隐藏入口。 + +#### Scenario: 普通成员或仅有平台角色的账号查看历史 +- **WHEN** 当前账号不是该空间OWNER或ADMIN +- **THEN** 页面不提供历史查看入口,直接调用查询接口也不能获得记录 + +#### Scenario: 空间管理员查看历史 +- **WHEN** 当前账号具有该空间OWNER或ADMIN角色 +- **THEN** 可查看该空间邀请记录,不可据此查看其他空间记录 + +#### Scenario: 删除空间与历史保留 +- **WHEN** 空间被彻底删除 +- **THEN** 删除该空间的邀请业务记录,现有操作审计按原规则保留;归档或冻结不等同于彻底删除 + +#### Scenario: 升级迁入旧邀请 +- **WHEN** 升级前存在可准确读取的当前邀请 +- **THEN** 将其已知创建及消费信息迁入并标注为旧记录,不伪造更早记录的链接关联 + +#### Scenario: 加入后退出或被移除 +- **WHEN** 通过邀请加入的用户后来退出或被管理员移除 +- **THEN** 历史仍显示当时的加入事实,不据此授予或恢复成员权限 + +### Requirement: REQ-INV-10 邀请弹窗 SHALL 提供只读分页历史 + +现有邀请弹窗 SHALL 在上方保留当前链接及生成/替换/作废操作,下方直接显示邀请记录,不增加嵌套标签页。历史最新在前并分页,网页默认每页5条并限制列表高度;人员优先显示记录时保存的姓名或显示名称,不平铺完整user_id,完整标识保留在悬停提示及可访问说明中;无姓名的旧记录以缩短标识兜底。展示创建人及时间、到期时间、有效/已使用/已替换/已作废/已过期状态、实际加入人及加入时间;旧迁入记录明确标注。历史不显示原始链接、Token或哈希,不提供复制或恢复失效链接操作。列表支持加载、空列表、失败及重试状态;不同空间记录不得串用,成功变更邀请后刷新列表。 + +#### Scenario: 管理员在同一弹窗查看历史 +- **WHEN** 空间OWNER/ADMIN打开邀请弹窗 +- **THEN** 当前邀请操作与只读历史同时可访问;列表按最新在前分页,手机端可阅读和翻页 + +#### Scenario: 成功变更邀请刷新历史 +- **WHEN** 管理员成功生成、替换或作废邀请 +- **THEN** 列表刷新并展示对应记录和状态;取消确认不新增记录 + +#### Scenario: 历史读取失败 +- **WHEN** 历史查询请求失败 +- **THEN** 显示失败及重试入口,不能将其他空间缓存记录展示为当前空间历史 + +#### Scenario: 冻结或归档空间查看历史 +- **WHEN** TEAM空间被冻结或归档,当前账号仍为该空间OWNER或ADMIN +- **THEN** 可打开只读历史入口,不开放当前邀请生成/替换/作废操作,历史读取不依赖变更权限 diff --git a/openspec/changes/add-namespace-invite-links/tasks.md b/openspec/changes/add-namespace-invite-links/tasks.md new file mode 100644 index 00000000..d14f5045 --- /dev/null +++ b/openspec/changes/add-namespace-invite-links/tasks.md @@ -0,0 +1,33 @@ +## 1. 持久化与事务 + +- [x] 1.1 增加当前邀请表、Token 哈希、时效和审计迁移。 +- [x] 1.2 实现 Namespace 根锁,并让成员、生命周期、删除和账号合并路径遵循统一锁顺序。 +- [x] 1.3 实现创建/替换/作废/预览/接受,接受时原子新增成员、消费邀请与写审计。 + +## 2. API 与页面 + +- [x] 2.1 新增仅 Session 可调用且受 CSRF 保护的邀请接口,重新生成 OpenAPI 类型。 +- [x] 2.2 在成员管理入口添加邀请和作废动作,替换前确认,复制失败展示原链接。 +- [x] 2.3 实现 `/join` 的 fragment 捕获、同标签页登录回跳、确认加入和失效状态;覆盖 BASE_PATH 与三语文案。 + +## 3. 验证与交付 + +- [x] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。 +- [x] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。 +- [x] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。 + +验证结果以 PR #947 的检查和交付验收记录为准。真实讯飞/阿里云计费审核接口未发起调用。 + +## 4. 人工反馈后的管理交互(2026-10-13,实施中) + +- [ ] 4.1 生成具名邀请片段并兼容旧链接,覆盖无效片段、暂存恢复和BASE_PATH。 +- [ ] 4.2 收拢空间卡片,提供管理详情导航,将邀请集中成员、治理集中设置。 +- [ ] 4.3 验证普通成员/团队管理/平台审核权限及邀请完整浏览器流程,更新唯一预览。 + + +## 5. 邀请历史(2026-10-14,方案已确认,实施中) + +- [x] 5.1 确认每次生成独立记录、真实加入关联和空间OWNER/ADMIN查看权限,写入REQ-INV-08/09。 +- [x] 5.2 确认保存期限、旧数据、成员/账号/空间变更边界及交互,补齐设计和REQ-INV-10验收标准。 +- [x] 5.3 实现历史持久化、生命周期事务关联、权限接口及弹窗历史展示(含冻结/归档只读入口)。 +- [x] 5.4 验证旧数据、邀请状态、并发加入、访问权限、分页及本地浏览器流程。 diff --git a/openspec/changes/add-skill-avatars-and-moderation/.openspec.yaml b/openspec/changes/add-skill-avatars-and-moderation/.openspec.yaml new file mode 100644 index 00000000..b9ebfc72 --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/add-skill-avatars-and-moderation/design.md b/openspec/changes/add-skill-avatars-and-moderation/design.md new file mode 100644 index 00000000..de0d28a3 --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/design.md @@ -0,0 +1,77 @@ +## Context + +当前 Skill 无头像字段。详情、搜索和“我的技能”由不同查询路径投影,Web 卡片与详情也没有统一头像组件。`ObjectStorageService` 支持写入、读取和删除,但本地实现不产生预签名 URL。现有 ReviewTask 只处理 SkillVersion/SuiteVersion;头像不能伪装成版本审核。V68 已提供 versioned `system_setting`。 + +## Decisions + +1. 为 Skill 保存当前已激活头像引用;另建头像候选及决定记录。候选包含所属 Skill、提交者、私有对象键、内容哈希、目标可见范围、提交时审核策略快照、机器结果、人工决定、状态和时间。所有状态转移按候选 ID 和当前候选 ID 条件更新,防止迟到结果覆盖。 +2. 头像人工决定复用现有 ReviewTask、技能审核队列与 ReviewPermissionChecker。技能包与头像同次提交时关联同一版本审核任务;单改头像使用元数据审核任务,不创建 SkillVersion。头像候选保留图片、机器结果和审计,不作为另一套人工审核流程。上传 API 显式校验 `skill.ownerId == actor`;审核权不授予代传权。 +3. 图片通过受控 GET 接口提供。私人和待审对象必须鉴权,当前公开头像可提供稳定、无时效的访问 URL;服务端不得将对象存储临时签名地址持久化。公开范围变更之前须重新判断头像是否已获目标范围许可,避免私人图片借 Skill 可见性变化直接泄露。 +4. 上传只接受最终裁剪图,服务端独立解码、校验、重新编码并去掉元数据。上限和格式以现有上传约定及测试验证后在实现中固定,避免前端校验成为唯一防线。 +5. `system_setting` 新增独立头像审核键,仅保存机器和人工开关。部署配置只允许一个供应商及其 Secret;后台不展示供应商或 Secret。适配器在服务端主动调用,供应商异常保留候选和重试,不自动通过。供应商实现与 API 参数在接入时以官方文档验证。 +6. 开关在提交候选时快照。PRIVATE 且仅所有者可见直接激活;其他候选按照快照执行。机器通过且人工关闭可激活;机器阻止时允许例外申诉;人工开启时有权审核员可改判并留理由。 +7. 替换待审候选时旧候选变为 SUPERSEDED。删除和管理员撤下立即取消当前头像并恢复兜底。失效图片保留私有 30 天,再由有界清理任务删除;审计记录不随图片删除。 +8. `SkillSummaryResponse` 的所有映射路径、详情 DTO 与生成类型包含当前头像 URL;Web 使用一个头像/兜底组件,所有者操作和审核队列分别展示其可执行动作。 + +## Risks and verification + +- 私人候选和已发布头像的读取权限用非所有者、匿名和跨团队请求验证。 +- 上传、更换、审核和撤下并发时验证当前候选不被旧结果激活。 +- V68 后迁移在已有数据上通过;旧 Skill 无头像时无需回填。 +- 服务端图片解码处理需设置资源上限;机器供应商凭证不得进入日志、数据库或前端。 +- 原 Issue 的“机器不得直接拒绝”与本轮方案不同。机器单开模式的质量和误判处理需在运行验证中单列,不以外部 API 返回成功代替有效审核验证。 + + +## 已确认的发布交互调整(2026-10-13,已实施,浏览器验收通过) + +本节补充原实现,需求以 `specs/skill-avatars/spec.md` 的 REQ-AV-09/10/11 为准。 + +- 新增和更新页面使用可选自定义上传,不提供预设图标;更新默认保留,支持替换及移除。 +- “我的技能”提供单独修改头像入口,不要求新版本;详情页保留展示与审核状态,移除完整上传表单。统一复用裁剪和上传组件。 +- 先私有上传、后业务提交:新增技能尚无 Skill ID,临时上传不能提前创建已绑定的头像申请。上传引用绑定提交者并校验可用性,临时阶段不改变当前头像或待审申请。 +- 技能提交成功与头像引用关联须共同成功。失败不改变当前头像或已有候选;数据库关联与业务状态共同提交,外部对象写入提前完成,不把对象存储或供应商请求放入长事务。重复提交不重复创建申请。 +- 头像审核在关联提交后执行,遵循原可见范围和策略规则;机器和人工开关在候选创建时快照。上传成功不意味着审核通过,技能与头像分别展示审核状态。 +- 更新页移除仅在提交成功后执行,立即恢复兜底并使待审候选失效,申请历史和失效图片保留策略不变。 +- 未关联临时图片始终私有,按有界任务定期清理;关联与清理并发须保证不会删除已绑定图片。临时引用使用独立 `skill_avatar_upload` 表,24 小时过期;上传接口为 `/api/web/skills/avatar-uploads`,提交技能携带 `avatarAction`(默认 KEEP,可选 REPLACE/REMOVE)和替换时的 `avatarUploadId`。单独维护使用现有头像路径的 JSON 提交;更新携带 `expectedSlug` 校验技能包目标。清理和引用消费均锁定临时上传记录,发布与独立修改均采用技能→上传记录的锁顺序。不引入业务状态 SQL CHECK 约束。 +- 唯一机器供应商的部署选择、后台两个审核开关、PRIVATE 直接使用规则保持不变。 + +原验收只覆盖详情页独立提交流程,不能证明上述新增/更新流程;本轮新增/更新及独立维护流程按对应测试和浏览器验收重新验证。真实供应商调用仍未验证。 + +同浏览器的发布、独立维护和审核决定成功后,须等待刷新技能展示、对应所有者头像状态、头像审核队列及技能内容审核队列缓存;不以等待缓存过期替代状态更新。回归以真实 QueryClient 下旧状态变为提交后的状态为 Oracle,并验证无关技能的所有者缓存不被失效。 + +## 裁剪组件选型调整(2026-10-13) + +头像文件选择复用已有 `react-dropzone`,裁剪交互采用 `react-image-crop`(ISC,兼容项目 React 版本)。方形选区的拖动、缩放选区、触屏和键盘行为交由开源组件处理,移除自写居中裁剪与缩放交互。仅保留选区图像导出及私有上传/提交状态衔接,不改变审核、引用消费、保留/替换/移除规则。组件适配须验证非居中选区的实际导出结果、取消与迟到响应隔离,以及新增/更新/独立维护入口。 + +## 头像入口与弹窗简化(2026-10-13,用户人工反馈) + +- 详情页仅保留标题旁头像,删除专门头像状态卡片、重复候选图、供应商开关说明及已批准审核理由。审核治理能力不因展示简化而移除。 +- 我的技能自绘列表补齐名称旁头像,删除独立“管理头像”按钮与入口;统一“更新”页同时支持仅修改头像或上传新技能包。无技能包时只提交既有头像维护接口,不新增版本;有包时保留原子发布/头像关联流程。新建及驳回重提仍必须提供技能包。 +- 使用已有 Tailwind CSS 视觉样式及 Radix Dialog 封装承载 React Image Crop,不引入新的整体 UI 框架。父表单为64像素头像和简短操作;裁剪弹窗画布有界、自适应,错误和重试在弹窗内。 +- 弹窗取消/关闭/ESC恢复打开前草稿,上传确认成功后关闭弹窗并展示小预览;成功上传仍需父页面最终提交。更新页仅展示所有者需要处理的待审/失败/驳回信息及原有重试、申诉动作,保持治理闭环。 + +组件接入方式:仓库已有 `web/components.json`,本地 `shared/ui/Button`、`Dialog` 沿用 shadcn 的源码分发/组件组合方式,底层为 Radix 与 Tailwind。当前为 Tailwind 3 和旧 `default` 风格;本轮复用现有封装与主题,不重新执行 init,也不覆盖已有 Portal/焦点/主题定制。头像选择由本地预览、Button、Dialog 和 React Image Crop 组合,shadcn Avatar 的展示职责与图片裁剪/上传业务分开。 + +删除头像后的展示闭环:移除成功时,除当前头像状态外,还须等待已缓存但未挂载的技能列表/详情更新,避免返回列表短暂读取已删除的头像地址。本轮仅 REMOVE 使用 `refetchType: all`,KEEP/REPLACE沿用 active 刷新;回归用无 observer 的真实 QueryClient 数据验证刷新完成后再导航。弹窗高度约束设在 ReactCrop 根元素,以使库的图片继承明确的 `min(320px,45vh)`,并用实际浏览器测量验证。 + +## 统一技能审核与单改头像(2026-10-13,已确认,实施中) + +本节取代以上历史实现中的“技能与头像独立人工审核”决定。 + +- 包与头像替换同次提交只生成一张技能审核申请;详情同时显示技能内容、当前头像与候选图。机器结果是该申请的审批条件,不是另一条人工审核流程。批准在同一事务发布版本及激活头像;驳回两者均不发布。 +- 包与移除头像同次提交也在批准时共同生效;驳回保留原头像。单独移除仍无需审核,提交成功即生效。 +- 单改头像不创建版本;需人工审核时以 SKILL_METADATA 进入同一技能审核队列,复用详情和批准/驳回动作。无人工审核时继续按头像策略直接使用或机器判定;PRIVATE规则保持。 +- 权限完全复用技能审核规则:平台技能管理员/超级管理员可审核团队和全局申请,团队 OWNER/ADMIN 只能审核其空间;自审限制保持原技能审核语义。 +- 新候选替换旧候选仍保留历史。关联旧候选的任务不能跳过头像单独批准内容;若候选已失效,整条批准失败,使用现有撤回/重提流程处理。机器未完成、异常或旧结果不能发布候选。 +- 扩大可见范围所需的头像决定在同一技能申请中完成,不在批准内容后另建人工头像申请。部署供应商、密钥、审核开关及30天私有图片保留策略不变。 +- 迁移保留旧数据和审计,兼容原版本/套件审核任务。业务状态约束放代码与测试,不增加 SQL CHECK。 + +申请留存边界:被替换/移除的候选及提交、决定、失效文字审计继续保留,关联的待审头像元数据任务可按既有撤回方式从活动队列删除,审计保留原任务关联。不得将撤回伪装为人工驳回。本期不扩展 ReviewTask 状态枚举或改变原技能包撤回语义。 + +## PR Review 的机器审计收敛(2026-10-13,实施中) + +现有云审核不能证明供应商模型版本不可变,取消不同候选之间的通过/阻止缓存复用。保留单候选的尝试租约及迟到结果保护;候选记录最新实际调用上下文,各次审计保留当次上下文与结果。记录供应商服务/API版本和内部风险映射策略revision,模型版本未知明确留空。历史候选只标未知,不用当前配置补造历史证据。不引入新后台开关或供应商调用协议。 + +原Issue的本地Scanner、模型评测及不可变模型制品供应链继续在原Issue跟踪。本PR只引用两条Issue,不自动关闭;四种头像审核开关和邀请确认/作废是维护者已确认的范围变化,PR说明须明确,并在合并前取得一致验收约定。 + +迁移合并顺序:本分支未发布迁移编号统一为 V70 头像、V71 邀请、V72 临时上传、V73 统一审核及机器证据,给PR #935 的 V69 authoring 留号。合并前须先合 #935 或与其维护者协调重新编号;不能在 #947 已应用 V73 后假设默认 Flyway 会补跑未来 V69。本机旧预览保留数据的编号映射仅是未发布测试迁移兼容,不替代目标主线更新后的空库/升级验证。 diff --git a/openspec/changes/add-skill-avatars-and-moderation/proposal.md b/openspec/changes/add-skill-avatars-and-moderation/proposal.md new file mode 100644 index 00000000..4f9f17ed --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/proposal.md @@ -0,0 +1,34 @@ +## Why + +Skill 目前没有稳定的图像标识,卡片和详情难以快速区分。上传图片后还需要保证文件安全、访问权限和可配置的内容审核,不让待审图片提前公开。 + +## What Changes + +- 为个人、团队和全局 Skill 增加由 Skill 所有者维护的可选头像;所有展示位置使用一致的首字兜底。 +- 在新增/更新技能页面支持可选自定义头像与裁剪,先私有上传成功后随业务提交关联;更新默认保留,可替换或移除,不提供预设图标。 +- “我的技能”支持单独修改头像,无需新版本;详情页仅在标题旁展示头像。头像属于 Skill,未明确替换或移除时不随版本更新重置。 +- 临时图片与正式申请分离,未关联图片定期清理;技能包与头像同次提交只生成一张技能审核申请,单改头像保留且无需新版本。 +- 按实际可见范围处理审核:仅本人可见的 PRIVATE 头像可直接使用;团队可见或公开头像按部署方的机器、人工审核开关处理;全局头像由平台管理员审核。 +- 部署时选择唯一机器审核供应商并配置凭证,提供讯飞和阿里云适配;后台仅管理审核开关。机器失败不放行,机器误判可申请人工复核。 +- 仅 Skill 所有者可提交、更换或主动删除;管理员可以审核、复核和撤下违规头像。旧候选失效后不能被迟到的审核结果激活。 +- 被驳回、被替换或主动删除的图片私有保留 30 天后清理,文字审计记录保留。 + +## Capabilities + +### New Capabilities + +- `skill-avatars`: Skill 头像、权限、候选生命周期、审核策略、访问和清理。 + +### Modified Capabilities + +无。 + +## Impact + +- Skill 领域、数据库迁移、对象存储、公开摘要和详情 API、OpenAPI 生成类型。 +- 系统配置、机器审核供应商配置、团队及平台审核入口、审计和清理任务。 +- Skill 卡片、详情、所有者操作和审核页面;后端、前端与浏览器验证。 + +## 相对 Issue #910 的调整 + +原 Issue 建议机器审核首版仅作风险分流。本方案允许部署方单独开启机器审核并据此自动发布或阻止发布,同时提供误判申诉。实现和验收必须明确这一差异、供应商故障时不放行,以及机器判定质量的回归验证。 diff --git a/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md new file mode 100644 index 00000000..e06670f3 --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md @@ -0,0 +1,212 @@ +## Purpose + +为 Skill 提供安全、一致、可治理的自定义头像。 + +## ADDED Requirements + +### Requirement: REQ-AV-01 Skill SHALL 有可选头像和统一兜底 + +Skill SHALL 支持可选头像和统一兜底。 + +头像属于 Skill 容器而非 SkillVersion。摘要与详情只返回当前已激活的稳定头像访问地址;无头像时所有 Skill 展示位置使用展示名称首字符的大写兜底。新版本发布未明确选择替换或移除头像时不得重置头像。 + +#### Scenario: 旧 Skill 没有头像 +- **WHEN** 客户端读取无头像的 Skill +- **THEN** 不需数据回填即可展示一致的首字兜底 + +#### Scenario: 发布新版本 +- **WHEN** 已有头像的 Skill 发布新版本,且未选择替换或移除头像 +- **THEN** 原头像保持不变 + +### Requirement: REQ-AV-02 仅 Skill 所有者 SHALL 维护头像 + +头像维护接口 SHALL 仅允许 Skill 所有者调用。 + +只有当前 Skill 所有者可提交、更换或主动删除头像。团队及平台管理员的审核权不授予代传权;有权管理员可以撤下违规头像并记录原因。所有者停用时本期不新增所有权交接或代传机制。 + +#### Scenario: 管理员不是所有者 +- **WHEN** 团队或平台管理员尝试上传、替换或主动删除别人的 Skill 头像 +- **THEN** 服务端拒绝,不依赖前端隐藏按钮 + +#### Scenario: 所有者停用 +- **WHEN** 头像已发布而所有者无法操作 +- **THEN** 有权管理员仍可撤下该头像并恢复兜底,但不能代传新头像 + +### Requirement: REQ-AV-03 服务端 SHALL 验证并私存最终裁剪图片 + +服务端 SHALL 验证并私存最终裁剪图片。 + +Web 端提供正方形裁剪且只上传最终裁剪图。服务端独立校验文件签名、尺寸、字节与像素限制,解码、重新编码并去除元数据。待审候选只在私有存储中,且只能由提交者和有权审核员读取;不得持久化临时预签名 URL 作为头像地址。 + +#### Scenario: 伪造图片或畸形文件 +- **WHEN** 上传的文件签名、解码结果或大小不符合限制 +- **THEN** 服务端拒绝创建候选,不公开文件 + +#### Scenario: 候选待审 +- **WHEN** 新候选尚未通过所需审核 +- **THEN** 公众继续看到原头像或兜底,无法读取候选图片 + +### Requirement: REQ-AV-04 审核 SHALL 依据实际可见范围 + +头像审核 SHALL 依据实际可见范围执行。 + +PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使用。NAMESPACE_ONLY 或 PUBLIC 的团队 Skill 使用团队范围策略,团队管理员或有技能审核权限的平台管理员可审核;全局 Skill 使用全局范围策略和平台管理员人工审核。所有人工权限及自审限制沿用现有技能审核规则。个人 Namespace 当前按 TEAM 处理,不能仅凭名称免审。可见范围扩大时,未经过目标范围审核的头像不得直接对新受众公开。 + +#### Scenario: 个人私有 Skill 扩大可见范围 +- **WHEN** 原来仅本人可见的 Skill 变为团队可见或公开 +- **THEN** 原头像进入目标范围审核,审核完成前新受众只能看到兜底或先前已获目标范围批准的头像 + +#### Scenario: 团队 Skill 提升至全局 +- **WHEN** 团队 Skill 派生全局 Skill +- **THEN** 团队头像不会自动成为全局头像;全局候选按全局策略审核 + +### Requirement: REQ-AV-05 部署方 SHALL 控制两项审核开关 + +部署方 SHALL 可独立控制机器和人工审核开关。 + +机器与人工审核开关保存于独立的系统设置键,后台仅显示和修改这两个开关;唯一机器供应商及凭证由部署配置确定,凭证不得写入系统设置或回显。机器开关开启而供应商未正确配置时应拒绝设置。 + +#### Scenario: 四种策略 +- **WHEN** 机器/人工为关/开、开/开、开/关、关/关 +- **THEN** 单改头像分别执行人工通过、机器后人工、机器通过自动发布且判违规不发布、直接发布;带技能包时无论头像开关如何,均须等待原有内容审核共同发布 +- **AND** 关闭全部审核时后台显示风险提示 + +### Requirement: REQ-AV-06 机器审核 SHALL 失败关闭并支持复核 + +机器审核 SHALL 在失败时保持候选私有,并支持例外复核。 + +服务端主动调用部署时确定的讯飞或阿里云适配器,首版不接受外部结果回调。机器调用失败、超时或结果不明不能视为通过;候选保持私有并可重试。机器判违规且人工审核开启时审核员可记理由改判;仅机器开启时提交者可申请例外人工复核,不把全部申请放入人工队列。 +本期云审核提供方未提供可证明不可变的模型版本,不跨候选复用机器通过或阻止结果;每条新候选重新检测。单候选调用的尝试锁、幂等及迟到结果保护保持。候选和每次机器尝试须记录实际供应商、服务标识、API版本、内部判定策略 revision 及结果;未知模型版本明确为空,不能以 SDK 或 API 版本替代。重试时策略变化必须记录该次真实上下文,不能标记成原策略结果;新目标范围的人工决定也不能复用。 + +#### Scenario: 供应商超时 +- **WHEN** 机器审核超时 +- **THEN** 候选不公开,系统保留可重试状态 + +#### Scenario: 机器误判 +- **WHEN** 提交者对机器阻止的候选申请复核 +- **THEN** 相应范围的管理员可以批准或驳回,记录决定人和理由 + +### Requirement: REQ-AV-07 候选状态 SHALL 防止旧结果激活 + +候选状态 SHALL 防止已失效申请被迟到结果激活。 + +提交候选时固定当时的审核策略。一个 Skill 的新申请替换待审旧申请,旧申请保留为已被替换状态;迟到的机器结果或审核动作不得激活它。自审沿用既有技能审核规则,团队 OWNER/ADMIN 和超级管理员按原权限处理,平台 SKILL_ADMIN 不因头像申请而获得额外自审权;提交和决定均须留痕。 + +#### Scenario: 旧机器结果晚到 +- **WHEN** 新候选已经替换旧候选,旧候选的机器结果才返回 +- **THEN** 旧候选保持失效,当前头像不受其影响 + +### Requirement: REQ-AV-08 删除与撤下 SHALL 恢复兜底并保留审计 + +删除与撤下 SHALL 立即恢复兜底并保留审计。 + +所有者主动删除当前头像无需审核;管理员违规撤下时须记录操作者和原因。两种动作均立即恢复兜底。被驳回、被替换或主动删除的图片私有保留 30 天后清理,文字申请与审核记录继续保留;确认违法的图片可提前清理。 + +#### Scenario: 管理员撤下 +- **WHEN** 有权管理员撤下违规的已发布头像 +- **THEN** 对外立即显示兜底,审计保留原因与操作者 + +### Requirement: REQ-AV-09 前后端 SHALL 提供可理解的操作闭环 + +前后端 SHALL 提供可理解的头像提交与审核闭环。 + +新增和更新 Skill 页面提供可选自定义头像、方形裁剪、上传预览及上传状态,不提供预设图标选择;未上传时使用首字母兜底。更新页面展示当前头像并提供保留、替换、移除三种动作,默认保留。“我的技能”仅保留统一的“更新”入口,列表在技能名称旁显示头像。更新页复用同一套自定义头像组件,可只提交头像修改而不要求上传技能包或发布新的 SkillVersion;上传技能包时仍按原流程发布版本。选择图片后在项目已有弹窗内裁剪,父表单仅保留小头像预览与必要操作,不展开大图。关闭、取消或按 ESC 退出裁剪时恢复打开前的头像草稿。详情页仅在标题旁显示当前头像,不再显示单独的头像管理面板、重复图片、机器开关说明或已批准理由;所有者需要处理的头像待审状态和驳回原因集中在更新页。技能包与头像同次提交时只展示一张技能审核申请及其内容、头像检查状态,一次批准或驳回共同生效。单独修改头像仍支持,需人工审核时进入相同技能审核队列,不另设头像审核入口。审核员可以读取其管辖范围候选并处理,机器单开时可以处理例外申诉。所有展示 Skill 身份的位置使用同一头像与兜底组件。 + +#### Scenario: 申请等待审核 +- **WHEN** 所有者上传候选后需人工决定 +- **THEN** 页面展示待审状态且已发布头像维持不变 + + + +#### Scenario: 裁剪取消 +- **WHEN** 所有者已选择头像草稿,再选择另一张图并取消裁剪弹窗 +- **THEN** 恢复弹窗打开前的草稿,包括原临时上传引用或移除选择 +- **AND** 迟到的上传结果不得替换恢复后的草稿 + +### Requirement: REQ-AV-10 头像 SHALL 先私有上传再随提交关联 + +新增、更新或单独修改头像时,客户端 SHALL 先裁剪并上传图片到私有存储,上传成功后取得可用于提交的临时引用。选择了新图片但仍在上传或上传失败时,客户端不得提交该次操作;服务端亦须校验引用对应的文件上传成功、未过期、属于当前提交者且不可越权关联。未选择头像的新技能可正常提交。 + +上传成功不等于审核通过。临时上传不改变 Skill 当前头像、待审申请或公开状态;技能提交成功时才关联上传引用并创建头像候选,按当时实际可见范围及审核配置执行;带包的候选绑定同一技能审核,单改头像才按策略创建元数据任务或直接执行,PRIVATE 仍沿用直接使用规则。需要的机器或人工审核在提交后进行,不要求用户停留在发布表单等待审核。关联失败不得留下已成功提交技能但丢失本次头像操作的部分结果;重复提交同一引用不得产生重复申请。 + +未关联、过期或用户放弃提交的临时图片保持私有并定期清理;清理不得删除已成功关联的候选或当前头像。临时图片仅供上传者预览,不因已取得引用而允许公众读取。 + +#### Scenario: 上传失败时提交 +- **WHEN** 所有者选择了新头像,但图片尚未上传成功 +- **THEN** 页面明确显示上传状态或失败原因,并阻止提交,允许重试 +- **AND** 服务端拒绝不存在、未就绪或无权使用的临时引用 + +#### Scenario: 新技能同时提交头像 +- **WHEN** 所有者上传成功并提交有效技能包和头像引用 +- **THEN** 技能提交与头像关联共同成功,头像进入其所需审核流程 +- **AND** 页面显示同一审核申请,内容与头像均在统一批准前保持原发布状态 + +#### Scenario: 取消编辑或提交失败 +- **WHEN** 图片已上传,但所有者取消编辑或技能提交失败 +- **THEN** 当前头像与现有待审申请不变,未关联临时图片可由清理任务回收 + +#### Scenario: 使用他人的上传引用 +- **WHEN** 提交者尝试关联他人的临时图片引用 +- **THEN** 服务端拒绝提交,不更改技能或头像状态 + +#### Scenario: 单独修改头像 +- **WHEN** 所有者从“我的技能”的“更新”入口上传并提交新头像 +- **THEN** 复用相同上传、关联及审核流程,不创建新的 SkillVersion + +### Requirement: REQ-AV-11 更新页移除 SHALL 在提交成功后生效 + +更新页的移除操作 SHALL 先提供首字母兜底预览。单独移除在提交成功后立即执行;与技能包同次提交则在同一技能审核批准后执行,驳回不改变原头像。取消编辑或提交失败时,原头像及待审申请不变。执行移除无需头像审核,立即恢复兜底,并取消尚未完成的头像申请;旧机器结果或审核动作不得恢复已移除头像。图片私有保留与申请历史沿用 REQ-AV-08。 + +#### Scenario: 成功提交移除 +- **WHEN** 所有者只选择移除头像并成功提交更新 +- **THEN** Skill 立即恢复首字母兜底,现有待审头像申请失效且历史保留 +- **WHEN** 所有者将移除与新技能包同次提交 +- **THEN** 审核批准后共同生效,驳回保留旧头像 + +#### Scenario: 移除后旧结果到达 +- **WHEN** 移除已成功执行,而旧申请的机器结果或人工审核动作随后到达 +- **THEN** 已移除头像不被恢复 + +#### Scenario: 放弃移除或提交失败 +- **WHEN** 所有者选择移除,但取消编辑或提交失败 +- **THEN** 原头像及待审申请保持原状 + +### Requirement: REQ-AV-12 人工审核 SHALL 复用统一技能申请 + +技能包与头像替换/移除同次提交 SHALL 只生成一张现有技能审核申请。批准原子发布本次版本和头像操作,驳回两者均不发布;绑定任务的候选不得因人工开关关闭或机器通过而提前生效。机器未完成或异常禁止批准,机器阻止仍按既有人工改判/例外申诉政策处理并留理由。 + +单独修改头像 SHALL 不新增 SkillVersion;需要人工决定时使用元数据审核任务进入原技能审核队列、详情和批准/驳回入口,标明“头像修改”。审核可见性、平台跨团队权限及自审规则沿用 ReviewPermissionChecker,不引入第二套头像人工权限。无须人工审核的单改头像仍按现有策略执行。 + +#### Scenario: 同次提交包和头像 +- **WHEN** 所有者提交有效新版本和头像 +- **THEN** 只有一张审核申请,批准共同生效,驳回保留原版本发布指针及头像 + +#### Scenario: 单改头像 +- **WHEN** 所有者只提交头像且策略需要人工决定 +- **THEN** 统一技能队列显示头像修改申请,版本数量不变 + +#### Scenario: 候选被替换后批准旧任务 +- **WHEN** 关联头像已被新申请替换或移除,旧任务随后被批准 +- **THEN** 整条批准失败,既不发布旧内容也不激活旧头像,失效候选历史仍保留 + +#### Scenario: 平台审核团队头像 +- **WHEN** 平台技能管理员审核团队的统一申请 +- **THEN** 与团队技能审核使用同一权限,不要求其成为团队成员 + +#### Scenario: 相同图片的新申请或策略变化 +- **WHEN** 已有相同图片的机器通过/阻止记录,所有者提交新申请或审核策略已改变 +- **THEN** 新申请重新调用配置的供应商,不命中跨候选缓存 +- **AND** 每次调用及结果记录实际服务/API版本和策略revision,未知模型版本不虚假回填 + +#### Scenario: 跨浏览器提交后重新进入审核列表 +- **WHEN** 审核员曾看到空列表,其他登录会话随后提交新申请,审核员重新进入列表 +- **THEN** 重新获取当前申请,不能仅以此前缓存的空列表显示“暂无审核记录” +- **AND** 命名空间、状态、分页和排序仍各自隔离,不新增轮询或扩大权限 + +### Requirement: 提交者 SHALL 可查看已提交头像 + +技能所有者在我的技能及更新页 SHALL 可查看已提交头像的紧凑缩略图及当前审核状态。待审候选与当前公开头像须明确区分;通过前公开展示保持旧头像。候选图片只使用现有所有者受保护接口,不公开未审核内容;无法加载候选时显示明确提示,不冒充现用头像。 + +#### Scenario: 头像申请等待审核 +- **WHEN** 所有者提交新头像并返回我的技能或重开更新页 +- **THEN** 可看到所提交的新头像及审核状态,而公共列表和详情继续展示旧头像 diff --git a/openspec/changes/add-skill-avatars-and-moderation/tasks.md b/openspec/changes/add-skill-avatars-and-moderation/tasks.md new file mode 100644 index 00000000..124795a0 --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/tasks.md @@ -0,0 +1,83 @@ +## 1. 领域与存储 + +- [x] 1.1 增加 Skill 头像指针、候选/审计持久化和向后兼容迁移。 +- [x] 1.2 实现图片解码、大小及像素上限、规范化、私有写入、受控读取与 30 天清理。 +- [x] 1.3 实现所有者提交/替换/删除、管理员审核/复核/撤下和旧候选失效保护。 + +## 2. 审核配置与供应商 + +- [x] 2.1 在 `system_setting` 增加独立头像审核开关,验证四种策略及未配置供应商时拒绝开启。 +- [x] 2.2 依据官方接口实现讯飞和阿里云适配器,部署只选择一家,凭证仅使用部署 Secret。 +- [x] 2.3 处理超时、未知结果、重试、人工改判和例外申诉。 + +## 3. API 与 Web + +- [x] 3.1 扩展 Skill 摘要/详情和头像操作/审核接口,重新生成 OpenAPI 类型。 +- [x] 3.2 实现统一头像兜底、所有者裁剪/提交/状态及审核员队列。 +- [x] 3.3 更新中文、英文、俄文文案和操作说明。 + +## 4. 验证与交付 + +- [x] 4.1 覆盖权限、文件安全、状态并发、策略矩阵、可见范围变化和清理边界。 +- [x] 4.2 运行相关后端、前端、API 类型检查、容器化本地预览及浏览器操作。 +- [x] 4.3 独立 Review、Draft PR、CI 与验收报告。 + +验证结果以 PR #947 的检查和交付验收记录为准。本节原验收时未发起真实供应商调用;2026-10-14 的讯飞增量联调见下节,阿里云仍未真实调用。 + + +## 5. 发布交互调整(2026-10-13,已实施并验证) + +本节已完成实现、独立 Review、源码检查与真实浏览器验证。新增/更新页使用临时私有上传,“我的技能”保留独立维护,详情页只显示头像与审核状态。原验收保留为历史基线,本节对应流程已重新验证。 + +- [x] 5.1 实现所有者临时私有上传、预览、引用校验及未关联文件清理,覆盖越权引用与关联/清理并发。 +- [x] 5.2 扩展技能提交以处理保留、关联新头像和移除;保证技能提交与头像操作共同成功、失败不改变旧状态、重复提交不重复申请。 +- [x] 5.3 在新增/更新页接入统一自定义上传裁剪组件,上传未成功禁止提交,默认保留旧头像,支持移除预览。 +- [x] 5.4 在“我的技能”增加无需新版本的头像维护入口;详情页改为展示及审核状态,技能与头像状态分别呈现,更新三种语言文案。 +- [x] 5.5 覆盖新增、更新保留/替换/移除、取消、上传失败、提交失败、单独维护、旧审核失效、临时清理及原审核策略回归。 +- [x] 5.6 更新相关用户文档、OpenAPI 类型及独立 Review,构建当前 SHA 的预览并进行真实浏览器验收,更新同一个 PR #947 的验收记录。 + +真实讯飞/阿里云接入是独立的部署联调项,需要已开通能力及部署凭证;模拟测试不得标记为真实调用通过。 + +本轮浏览器验收使用 Chromium 145 与 Playwright 1.58.2,分别通过新增/更新/独立维护头像生命周期及无头像正常发布与下载流程;前端 958 项测试、类型检查、Lint、生产构建、真实 PostgreSQL 临时上传与原子提交回归通过。CI 状态及完整验收证据随 PR #947 更新,未自动合并。 + +## 6. 复用开源头像裁剪组件(2026-10-13,已实施并验证) + +前述验收对应原居中裁剪实现;本节完成后重新验证受影响的交互及预览,不把旧结果视为新组件验证。 + +- [x] 6.1 复用 react-dropzone 文件选择,并用 react-image-crop 替换自写裁剪交互,保留既有私有上传与头像提交状态。 +- [x] 6.2 验证可移动方形选区、键盘、实际导出位置、文件错误、取消/迟到响应及上传失败重试,完成独立复审。 +- [x] 6.3 构建当前提交的唯一预览,实际验证新增/更新/独立维护并更新 PR #947 验收记录。 + +开源组件替换的验收:真实组件专项14项、全量前端965项及类型/Lint/构建通过;独立Review无阻塞。Chromium实际拖动红蓝测试图到左半,上传512 PNG三个采样点均为红色;中俄键盘操作断言选区位置改变;头像生命周期和无头像发布/下载两个轨道通过。机器供应商真实调用未执行,唯一预览继续保留供人工验收。 + +## 7. 人工验收后的入口与展示优化(2026-10-13,已实现并本地验证) + +- [x] 7.1 简化详情展示,补齐我的技能列表头像,合并为唯一更新入口,保留仅改头像不新增版本能力。 +- [x] 7.2 复用 Radix 弹窗承载开源裁剪组件,父表单紧凑展示,取消恢复草稿并隔离迟到结果。 +- [x] 7.3 验证更新页头像单独提交/带技能包提交、审核恢复、列表/详情、桌面/移动弹窗和键盘焦点,独立Review后更新同一预览供人工验收。 + +本轮本地验收:984项前端测试及类型/Lint/构建通过,独立Review无阻塞;正常发布/下载与统一更新/弹窗两个真实浏览器轨通过。375px视口弹窗343px、图像309px,取消/三语/焦点/两条删除后缓存均通过,最终未知console/page错误、404及requestfailed为0。首轮四项问题已集中修复,一次网络变更失败保留后同源码确认成功;新预览保持唯一环境,待用户人工验收,未合并。 + +## 8. 统一技能审核(2026-10-13,已确认,实施中) + +以上已完成项为历史验收,不代表本节完成。 + +- [x] 8.1 统一 ReviewTask 的包/头像关联及头像元数据任务,保留旧审核兼容,不新增伪版本。 +- [x] 8.2 统一批准/驳回、权限、机器门禁、可见范围扩大及旧候选并发防护,保留审计。 +- [x] 8.3 删除独立头像审核入口,将候选展示和动作整合进原技能审核队列/详情。 +- [x] 8.4 更新 API 契约及类型,完成针对性回归、独立 Review 与唯一预览的真实浏览器验收。 + +- [x] 8.5 取消缺少可信版本依据的跨候选机器缓存,补策略/服务/API版本及每次尝试审计,覆盖策略改变不复用回归。 +- [x] 8.6 协调PR #935与本分支迁移编号/合并顺序,验证迁移并明确原Issue未交付范围。 + +8.6 验证:#935 已于 2026-10-14 合入主线,本分支已同步包含 V69 的 main。真实 PostgreSQL 验证 V68 → V69 → V74 顺序升级(默认 outOfOrder=false),原有全局命名空间记录保持;标准 Compose 组合启动、登录及真实浏览器业务验收通过。最终 CI 与合并门禁由 PR 状态记录。原 Issue 的本地 Scanner 模型评测及模型供应链范围未交付,不将两家云审核接入视为该范围完成。 + +## 9. 供应商增量验证与文档(2026-10-14) + +- [x] 9.1 真实讯飞接口验证正常图片返回 PASS、测试图片返回 BLOCK;确认审计结果生效、候选保持私有。 +- [x] 9.2 浏览器验证 BLOCK 申请仍待人工审核、无理由批准禁用、有理由可人工复核;本次只执行人工拒绝,用户可见原因,公开指针未切到违规候选。 +- [x] 9.3 独立检查阿里云官方临时上传/同步审核契约、风险映射、失败处理及供应商切换,供应商定向测试 4 项通过;补充用户与部署文档。 +- [ ] 9.4 阿里云真实账号联调:用户决定本期暂不执行,不能将模拟响应测试标为真实接入通过。 +- [x] 9.5 新增文档站「技能头像与图片审核接入」指南及侧栏/发布/审核入口,覆盖凭证、标准 Compose 接入、开关及排障;两家 JSON 与 Compose 注入示例纯解析、文档站构建和独立复核通过,未发起真实云调用。 + +本节不替代完整 PR 门禁,不表示已合并。未实际发布被机器阻止后人工改判通过的图片。唯一预览继续保留供用户验收。 diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index 876d9ddc..856939bd 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -18,6 +18,23 @@ + + + com.aliyun + green20220302 + 3.3.3 + + + com.aliyun.oss + aliyun-sdk-oss + 3.16.3 + + + commons-logging + commons-logging + + + org.springframework.boot spring-boot-starter-web diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AvatarReviewSettingsController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AvatarReviewSettingsController.java new file mode 100644 index 00000000..939bfae5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AvatarReviewSettingsController.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse; +import com.iflytek.skillhub.dto.AvatarReviewSettingsUpdateRequest; +import com.iflytek.skillhub.service.AvatarReviewSettingsService; +import com.iflytek.skillhub.service.AuditRequestContext; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/system-config/avatar-review") +@PreAuthorize("hasRole('SUPER_ADMIN')") +public class AvatarReviewSettingsController extends BaseApiController { + private final AvatarReviewSettingsService service; + + public AvatarReviewSettingsController(AvatarReviewSettingsService service, ApiResponseFactory responses) { + super(responses); + this.service = service; + } + + @GetMapping + public ApiResponse current() { + return ok("response.success.read", service.current()); + } + + @PutMapping + public ApiResponse update(@Valid @RequestBody AvatarReviewSettingsUpdateRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest httpRequest) { + return ok("response.success.updated", service.update(request, principal.userId(), + AuditRequestContext.from(httpRequest))); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java new file mode 100644 index 00000000..5b396929 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java @@ -0,0 +1,113 @@ +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.NamespaceInvitationService; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.MessageResponse; +import com.iflytek.skillhub.dto.NamespaceInvitationTokenRequest; +import com.iflytek.skillhub.dto.NamespaceInvitationCreateRequest; +import com.iflytek.skillhub.exception.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestParam; +import java.time.Instant; +import java.util.List; + +@RestController +@RequestMapping({"/api/v1", "/api/web"}) +public class NamespaceInvitationController extends BaseApiController { + private final NamespaceInvitationService invitations; + + public NamespaceInvitationController(NamespaceInvitationService invitations, ApiResponseFactory responseFactory) { + super(responseFactory); + this.invitations = invitations; + } + + @GetMapping("/namespaces/{slug}/invitations") + public ApiResponse status(@PathVariable String slug, @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + return ok("response.success.read", new InvitationStatus(invitations.hasActiveInvitation(slug, principal.userId()))); + } + + @GetMapping("/namespaces/{slug}/invitations/history") + public ApiResponse history(@PathVariable String slug, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + var rows = invitations.history(slug, principal.userId(), page, size); + var now = invitations.currentTime(); + return ok("response.success.read", new InvitationHistoryPage(rows.getContent().stream() + .map(row -> new InvitationHistoryItem(row.getId(), row.getIssuedBy(), row.getIssuedByName(), + row.getIssuedAt(), row.getExpiresAt(), row.status(now), row.getConsumedBy(), row.getConsumedByName(), + row.getConsumedAt(), row.getEndedAt(), row.isLegacy())).toList(), + rows.getNumber(), rows.getSize(), rows.getTotalElements(), rows.getTotalPages())); + } + + public record InvitationHistoryItem(String invitationId, String issuedBy, String issuedByName, + Instant issuedAt, Instant expiresAt, String status, String consumedBy, + String consumedByName, Instant consumedAt, Instant endedAt, boolean legacy) {} + public record InvitationHistoryPage(List items, int page, int size, + long total, int totalPages) {} + + @PostMapping("/namespaces/{slug}/invitations") + public ApiResponse create(@PathVariable String slug, + @RequestBody(required = false) NamespaceInvitationCreateRequest body, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + return ok("response.success.created", invitations.create(slug, principal.userId(), body != null && body.replace())); + } + + @DeleteMapping("/namespaces/{slug}/invitations") + public ApiResponse revoke(@PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + invitations.revoke(slug, principal.userId()); + return ok("response.success.deleted", new MessageResponse("Invitation revoked")); + } + + @PostMapping("/namespace-invitations/preview") + public ApiResponse preview(@Valid @RequestBody NamespaceInvitationTokenRequest body, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + return ok("response.success.read", invitations.preview(body.token(), principal.userId())); + } + + @PostMapping("/namespace-invitations/accept") + public ApiResponse accept(@Valid @RequestBody NamespaceInvitationTokenRequest body, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + return ok("response.success.updated", invitations.accept(body.token(), principal.userId())); + } + + private void requireSession(PlatformPrincipal principal, HttpServletRequest request) { + if (principal == null || "api_token".equals(principal.oauthProvider()) || request.getSession(false) == null) { + throw new UnauthorizedException("error.auth.required"); + } + } + + private void noStore(HttpServletResponse response) { + response.setHeader("Cache-Control", "no-store, private"); + response.setHeader("Pragma", "no-cache"); + } + + public record InvitationStatus(boolean active) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java new file mode 100644 index 00000000..9aec69ac --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java @@ -0,0 +1,179 @@ +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.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.SkillAvatarDecisionRequest; +import com.iflytek.skillhub.dto.SkillAvatarReasonRequest; +import com.iflytek.skillhub.dto.SkillAvatarStatusResponse; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.SkillAvatarAppService; +import com.iflytek.skillhub.service.SkillAvatarUploadService; +import com.iflytek.skillhub.dto.SkillAvatarUploadResponse; +import com.iflytek.skillhub.dto.SkillAvatarSubmitRequest; +import com.iflytek.skillhub.ratelimit.RateLimit; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping({"/api/v1/skills", "/api/web/skills"}) +public class SkillAvatarController extends BaseApiController { + private final SkillAvatarAppService service; + private final SkillAvatarUploadService uploads; + + public SkillAvatarController(SkillAvatarAppService service, SkillAvatarUploadService uploads, ApiResponseFactory responses) { + super(responses); + this.service = service; + this.uploads = uploads; + } + + @PostMapping(value = "/avatar-uploads", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @RateLimit(category = "publish", authenticated = 10, anonymous = 0) + public ApiResponse upload(@RequestParam("file") MultipartFile file, + @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { + if (file.getSize() < 1 || file.getSize() > com.iflytek.skillhub.service.SkillAvatarImageProcessor.MAX_BYTES) { + throw new DomainBadRequestException("error.skill.avatar.invalidImage"); + } + return ok("response.success.created", uploads.upload(userId(principal), file.getBytes())); + } + + @GetMapping("/avatar-uploads/{uploadId}/image") + public ResponseEntity uploadPreview(@PathVariable String uploadId, + @AuthenticationPrincipal PlatformPrincipal principal) { + return png(uploads.preview(uploadId, userId(principal))); + } + + @PostMapping(value = "/{namespace}/{slug}/avatar", consumes = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse submitUpload(@PathVariable String namespace, @PathVariable String slug, + @Valid @RequestBody SkillAvatarSubmitRequest input, @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) { + return ok("response.success.created", uploads.submit(namespace, slug, userId(principal), input.uploadId(), + AuditRequestContext.from(request))); + } + + @GetMapping("/{namespace}/{slug}/avatar") + public ResponseEntity current(@PathVariable String namespace, @PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles) { + InputStream image = service.readActive(namespace, slug, userId(principal), roles, platformRoles(principal)); + return png(image); + } + + @PostMapping(value = "/{namespace}/{slug}/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse submit(@PathVariable String namespace, @PathVariable String slug, + @RequestParam("file") MultipartFile file, @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) throws IOException { + if (file.getSize() < 1 || file.getSize() > com.iflytek.skillhub.service.SkillAvatarImageProcessor.MAX_BYTES) { + throw new DomainBadRequestException("error.skill.avatar.invalidImage"); + } + AuditRequestContext context = AuditRequestContext.from(httpRequest); + SkillAvatarStatusResponse created = service.submit(namespace, slug, userId(principal), file.getBytes(), context); + if ("PENDING".equals(created.machineStatus()) && "PENDING".equals(created.candidateStatus())) { + return ok("response.success.created", service.processMachine(created.candidateId(), context)); + } + return ok("response.success.created", created); + } + + @GetMapping("/{namespace}/{slug}/avatar/status") + public ApiResponse status(@PathVariable String namespace, @PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal) { + return ok("response.success.read", service.ownerStatus(namespace, slug, userId(principal))); + } + + @GetMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/image") + public ResponseEntity candidate(@PathVariable String namespace, @PathVariable String slug, + @PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles) { + return png(service.readCandidate(namespace, slug, candidateId, userId(principal), roles, platformRoles(principal))); + } + + @PostMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/appeal") + public ApiResponse appeal(@PathVariable String namespace, @PathVariable String slug, + @PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) { + return ok("response.success.updated", service.appeal(namespace, slug, candidateId, + userId(principal), AuditRequestContext.from(request))); + } + + @PostMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/retry") + public ApiResponse retry(@PathVariable String namespace, @PathVariable String slug, + @PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) { + service.requireCurrentOwnerCandidate(namespace, slug, candidateId, userId(principal)); + return ok("response.success.updated", service.processMachine(candidateId, AuditRequestContext.from(request))); + } + + @Deprecated + public ApiResponse> queue( + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles) { + return ok("response.success.read", service.reviewQueue(userId(principal), roles, platformRoles(principal))); + } + + @Deprecated + public ApiResponse decide(@PathVariable String namespace, @PathVariable String slug, + @PathVariable long candidateId, @Valid @RequestBody SkillAvatarDecisionRequest decision, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + HttpServletRequest request) { + return ok("response.success.updated", service.decide(namespace, slug, candidateId, + decision.approved(), decision.reason(), userId(principal), roles, platformRoles(principal), + AuditRequestContext.from(request))); + } + + @DeleteMapping("/{namespace}/{slug}/avatar") + public ApiResponse delete(@PathVariable String namespace, @PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + HttpServletRequest request) { + service.remove(namespace, slug, userId(principal), false, null, roles, platformRoles(principal), + AuditRequestContext.from(request)); + return ok("response.success.updated", null); + } + + @PostMapping("/{namespace}/{slug}/avatar/takedown") + public ApiResponse takedown(@PathVariable String namespace, @PathVariable String slug, + @Valid @RequestBody SkillAvatarReasonRequest input, @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + HttpServletRequest request) { + service.remove(namespace, slug, userId(principal), true, input.reason(), roles, platformRoles(principal), + AuditRequestContext.from(request)); + return ok("response.success.updated", null); + } + + private ResponseEntity png(InputStream input) { + return ResponseEntity.ok().contentType(MediaType.IMAGE_PNG).cacheControl(CacheControl.noStore()) + .body(new InputStreamResource(input)); + } + + private String userId(PlatformPrincipal principal) { + return principal == null ? null : principal.userId(); + } + + private Set platformRoles(PlatformPrincipal principal) { + return principal == null || principal.platformRoles() == null ? Set.of() : principal.platformRoles(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 213f78d9..d81e1637 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -25,6 +25,8 @@ import com.iflytek.skillhub.dto.SkillVersionResponse; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.SkillLabelAppService; +import com.iflytek.skillhub.service.SkillAvatarUrls; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.service.ComplianceSnapshotProjectionService; import com.iflytek.skillhub.service.SkillSuiteAppService; import org.springframework.core.io.InputStreamResource; @@ -127,7 +129,11 @@ public class SkillController extends BaseApiController { memberOfSuites.items().stream() .filter(SkillSuiteReferenceResponse::currentSkillEntry) .toList(), - memberOfSuites + memberOfSuites, + SkillVisibility.PRIVATE == SkillVisibility.valueOf(detail.visibility()) + && !detail.ownerId().equals(userId) ? null + : SkillAvatarUrls.current(detail.activeAvatarId(), detail.activeAvatarAudience(), + SkillVisibility.valueOf(detail.visibility()), namespace, detail.slug()) ); return ok("response.success.read", response); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index a061afc1..9b9235e5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -11,6 +11,9 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PublishResponse; import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.service.SkillPublishAppService; +import com.iflytek.skillhub.service.AuditRequestContext; +import jakarta.servlet.http.HttpServletRequest; import com.iflytek.skillhub.ratelimit.RateLimit; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -29,11 +32,11 @@ import java.util.List; @RequestMapping({"/api/v1/skills", "/api/web/skills"}) public class SkillPublishController extends BaseApiController { - private final SkillPublishService skillPublishService; + private final SkillPublishAppService skillPublishService; private final SkillPackageArchiveExtractor skillPackageArchiveExtractor; private final SkillHubMetrics skillHubMetrics; - public SkillPublishController(SkillPublishService skillPublishService, + public SkillPublishController(SkillPublishAppService skillPublishService, SkillPackageArchiveExtractor skillPackageArchiveExtractor, ApiResponseFactory responseFactory, SkillHubMetrics skillHubMetrics) { @@ -54,7 +57,10 @@ public class SkillPublishController extends BaseApiController { @RequestParam("file") MultipartFile file, @RequestParam("visibility") String visibility, @RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings, - @AuthenticationPrincipal PlatformPrincipal principal) throws IOException { + @RequestParam(value = "avatarAction", defaultValue = "KEEP") SkillPublishAppService.AvatarAction avatarAction, + @RequestParam(value = "avatarUploadId", required = false) String avatarUploadId, + @RequestParam(value = "expectedSlug", required = false) String expectedSlug, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request) throws IOException { SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); @@ -75,13 +81,13 @@ public class SkillPublishController extends BaseApiController { String.join("\n", extractionWarnings)); } - SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( + SkillPublishService.PublishResult publishResult = skillPublishService.publish( namespace, entries, principal.userId(), skillVisibility, principal.platformRoles(), - confirmWarnings + confirmWarnings, avatarAction, avatarUploadId, expectedSlug, AuditRequestContext.from(request) ); PublishResponse response = new PublishResponse( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsResponse.java new file mode 100644 index 00000000..80c46b8f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsResponse.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +public record AvatarReviewSettingsResponse(boolean machineEnabled, boolean manualEnabled, + long version, Instant updatedAt) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsUpdateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsUpdateRequest.java new file mode 100644 index 00000000..7ef4eb24 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsUpdateRequest.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Min; + +public record AvatarReviewSettingsUpdateRequest(boolean machineEnabled, boolean manualEnabled, + @Min(0) long version) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationCreateRequest.java new file mode 100644 index 00000000..ed247002 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationCreateRequest.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.dto; + +public record NamespaceInvitationCreateRequest(boolean replace) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationTokenRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationTokenRequest.java new file mode 100644 index 00000000..3f87ff6f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceInvitationTokenRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record NamespaceInvitationTokenRequest(@NotBlank String token) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewSkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewSkillDetailResponse.java index 17488bdf..bbe77691 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewSkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewSkillDetailResponse.java @@ -9,5 +9,14 @@ public record ReviewSkillDetailResponse( String documentationPath, String documentationContent, String downloadUrl, - String activeVersion -) {} + String activeVersion, + AvatarChange avatarChange +) { + public record AvatarChange(Long candidateId, String currentAvatarUrl, String candidatePreviewUrl, + String machineStatus, String reviewStatus, boolean readyForApproval, boolean removal) {} + public ReviewSkillDetailResponse(SkillDetailResponse skill, List versions, + List files, String documentationPath, String documentationContent, + String downloadUrl, String activeVersion) { + this(skill, versions, files, documentationPath, documentationContent, downloadUrl, activeVersion, null); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java index b1c7f685..a3dc437a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewTaskResponse.java @@ -19,8 +19,19 @@ public record ReviewTaskResponse( String subjectType, Long subjectId, Long subjectVersionId, - String subjectSlug + String subjectSlug, + Long avatarCandidateId, + boolean avatarRemoval ) { + public ReviewTaskResponse(Long id, Long skillVersionId, String namespace, String skillSlug, String version, + String status, String submittedBy, String submittedByName, String reviewedBy, String reviewedByName, + String reviewComment, Instant submittedAt, Instant reviewedAt, String subjectType, Long subjectId, + Long subjectVersionId, String subjectSlug) { + this(id, skillVersionId, namespace, skillSlug, version, status, submittedBy, submittedByName, + reviewedBy, reviewedByName, reviewComment, submittedAt, reviewedAt, subjectType, subjectId, + subjectVersionId, subjectSlug, null, false); + } + /** Backward-compatible constructor for Skill-only callers and tests. */ public ReviewTaskResponse( Long id, @@ -40,6 +51,6 @@ public record ReviewTaskResponse( this(id, skillVersionId, namespace, skillSlug, version, status, submittedBy, submittedByName, reviewedBy, reviewedByName, reviewComment, submittedAt, reviewedAt, - "SKILL_VERSION", null, skillVersionId, skillSlug); + "SKILL_VERSION", null, skillVersionId, skillSlug, null, false); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarDecisionRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarDecisionRequest.java new file mode 100644 index 00000000..4f2d4c02 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarDecisionRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SkillAvatarDecisionRequest(boolean approved, @NotBlank String reason) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarReasonRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarReasonRequest.java new file mode 100644 index 00000000..423781e9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarReasonRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SkillAvatarReasonRequest(@NotBlank String reason) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java new file mode 100644 index 00000000..4d62d69b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +public record SkillAvatarStatusResponse(String avatarUrl, Long candidateId, String candidateStatus, + String machineStatus, String reviewStatus, String reviewReason, + String candidateUrl, String namespace, String slug, + java.time.Instant machineNextRetryAt, boolean automaticRetryExhausted) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarSubmitRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarSubmitRequest.java new file mode 100644 index 00000000..73a40968 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarSubmitRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SkillAvatarSubmitRequest(@NotBlank String uploadId) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarUploadResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarUploadResponse.java new file mode 100644 index 00000000..6adaa66e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarUploadResponse.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; +import jakarta.validation.constraints.NotNull; + +public record SkillAvatarUploadResponse(@NotNull String uploadId, @NotNull String previewUrl, @NotNull Instant expiresAt) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index a3825624..9f0665ba 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -30,5 +30,6 @@ public record SkillDetailResponse( String ownerPreviewReviewComment, String resolutionMode, List entryForSuites, - PageResponse memberOfSuites + PageResponse memberOfSuites, + String avatarUrl ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index fdabac73..1773bec4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -32,9 +32,23 @@ public record SkillSummaryResponse( * that do not opt in. */ @JsonInclude(JsonInclude.Include.NON_NULL) - List labels + List labels, + String avatarUrl ) { + public SkillSummaryResponse( + Long id, String slug, String displayName, String summary, String visibility, String status, + Long downloadCount, Integer starCount, BigDecimal ratingAvg, Integer ratingCount, + String namespace, Instant updatedAt, String ownerId, String ownerDisplayName, + boolean canSubmitPromotion, SkillLifecycleVersionResponse headlineVersion, + SkillLifecycleVersionResponse publishedVersion, SkillLifecycleVersionResponse ownerPreviewVersion, + String resolutionMode, ComplianceSnapshotResponse complianceSnapshot, List labels) { + this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg, + ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion, + headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, + labels, null); + } + /** * Summary without label projection. */ @@ -93,6 +107,6 @@ public record SkillSummaryResponse( return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion, headlineVersion, - publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels); + publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels, avatarUrl); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillAvatarScopeReviewListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillAvatarScopeReviewListener.java new file mode 100644 index 00000000..1781bce2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillAvatarScopeReviewListener.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.listener; + +import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.dto.SkillAvatarStatusResponse; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.SkillAvatarAppService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class SkillAvatarScopeReviewListener { + private static final Logger log = LoggerFactory.getLogger(SkillAvatarScopeReviewListener.class); + private final SkillAvatarAppService avatars; + + public SkillAvatarScopeReviewListener(SkillAvatarAppService avatars) { + this.avatars = avatars; + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onSkillPublished(SkillPublishedEvent event) { + reviewScope(event.skillId(), event.publisherId()); + } + + public void reviewScope(Long skillId, String publisherId) { + try { + SkillAvatarStatusResponse candidate = avatars.prepareScopeReview(skillId, publisherId); + if (candidate != null && ("PENDING".equals(candidate.machineStatus()) + || "PROCESSING".equals(candidate.machineStatus()) + || "ERROR".equals(candidate.machineStatus()))) { + avatars.processMachine(candidate.candidateId(), new AuditRequestContext(null, null)); + } + } catch (Exception failure) { + // Provider errors can include credential-bearing request URLs. Never log their message or stack here. + log.warn("Avatar scope review failed for skill {}, failureType={}", + skillId, failure.getClass().getSimpleName()); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillAvatarSubmissionListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillAvatarSubmissionListener.java new file mode 100644 index 00000000..fd379115 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillAvatarSubmissionListener.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.listener; + +import com.iflytek.skillhub.service.SkillAvatarAppService; +import com.iflytek.skillhub.service.SkillAvatarUploadService.AvatarSubmittedEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class SkillAvatarSubmissionListener { + private static final Logger log = LoggerFactory.getLogger(SkillAvatarSubmissionListener.class); + private final SkillAvatarAppService avatars; + public SkillAvatarSubmissionListener(SkillAvatarAppService avatars) { this.avatars = avatars; } + + @Async("skillhubEventExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void submitted(AvatarSubmittedEvent event) { + try { + avatars.processMachine(event.candidateId(), event.context()); + } catch (Exception failure) { + log.warn("Avatar submission moderation failed for candidate {}, failureType={}", + event.candidateId(), failure.getClass().getSimpleName()); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteBundleEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteBundleEventListener.java index dfaf2ec5..0e9c3013 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteBundleEventListener.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteBundleEventListener.java @@ -51,6 +51,7 @@ public class SkillSuiteBundleEventListener { } private void advanceBoundOperations(Long skillVersionId) { + if (skillVersionId == null) { return; } memberRepository.findBySkillVersionId(skillVersionId).stream() .map(member -> member.getOperationId()) .distinct() diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java index aaeaca1f..b47e5722 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java @@ -230,7 +230,7 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { : require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getSkillId(); Skill skill = require(bundle.skillsById(), skillId, "skill.not_found"); Namespace namespace = require(bundle.namespacesById(), task.getNamespaceId(), "namespace.not_found"); - String skillVersion = task.getSkillVersion() != null + String skillVersion = task.getSubjectType() == ReviewSubjectType.SKILL_METADATA ? null : task.getSkillVersion() != null ? task.getSkillVersion() : require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getVersion(); UserAccount submittedBy = bundle.usersById().get(task.getSubmittedBy()); @@ -251,7 +251,7 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { task.getReviewedAt(), subjectType(task), task.getSubjectId() != null ? task.getSubjectId() : skillId, task.getSubjectVersionId() != null ? task.getSubjectVersionId() : task.getSkillVersionId(), - skill.getSlug() + skill.getSlug(), task.getAvatarCandidateId(), task.isAvatarRemoval() ); } @@ -311,8 +311,11 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { task.getSubmittedAt() != null ? task.getSubmittedAt().toString() : null, namespaceSlug, null, "SUITE", suiteSlug); } - SkillVersion version = bundle.versionsById().get(task.getSkillVersionId()); - Skill skill = version != null ? bundle.skillsById().get(version.getSkillId()) : null; + SkillVersion version = task.getSkillVersionId() != null + ? bundle.versionsById().get(task.getSkillVersionId()) : null; + Long skillId = task.getSkillId() != null ? task.getSkillId() + : version != null ? version.getSkillId() : null; + Skill skill = skillId != null ? bundle.skillsById().get(skillId) : null; Namespace namespace = skill != null ? bundle.namespacesById().get(skill.getNamespaceId()) : null; String namespaceSlug = namespace != null ? namespace.getSlug() : null; String skillSlug = skill != null ? skill.getSlug() : null; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java index e7a3acd7..7ad9e013 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.service.SkillAvatarUrls; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -145,7 +146,8 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository { toLifecycleVersion(ownerPreviewVersion), projection.resolutionMode().name(), null, - null + null, + SkillAvatarUrls.current(skill, namespace != null ? namespace.getSlug() : "") ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java index c5ce1dc5..7a84ca69 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java @@ -70,7 +70,7 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo COALESCE(skill.slug, suite.slug) AS subject_slug FROM latest LEFT JOIN skill - ON latest.subject_type = 'SKILL_VERSION' AND skill.id = latest.subject_id + ON latest.subject_type IN ('SKILL_VERSION', 'SKILL_METADATA') AND skill.id = latest.subject_id LEFT JOIN skill_suite suite ON latest.subject_type = 'SUITE_VERSION' AND suite.id = latest.subject_id JOIN namespace ON namespace.id = latest.namespace_id @@ -80,23 +80,23 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo OR LOWER(namespace.slug) LIKE :queryPattern ) AND (:status = '' OR latest.status = :status) - AND (:subjectType = '' OR latest.subject_type = :subjectType) + AND (:subjectType = '' OR latest.subject_type = :subjectType OR :subjectType = 'SKILL_VERSION' AND latest.subject_type = 'SKILL_METADATA') 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) - AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS filtered_total, + AND (:subjectType = '' OR latest.subject_type = :subjectType OR :subjectType = 'SKILL_VERSION' AND latest.subject_type = 'SKILL_METADATA')) AS filtered_total, COUNT(*) FILTER (WHERE latest.status = 'PENDING' - AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS pending_count, + AND (:subjectType = '' OR latest.subject_type = :subjectType OR :subjectType = 'SKILL_VERSION' AND latest.subject_type = 'SKILL_METADATA')) AS pending_count, COUNT(*) FILTER (WHERE latest.status = 'APPROVED' - AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS approved_count, + AND (:subjectType = '' OR latest.subject_type = :subjectType OR :subjectType = 'SKILL_VERSION' AND latest.subject_type = 'SKILL_METADATA')) AS approved_count, COUNT(*) FILTER (WHERE latest.status = 'REJECTED' - AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS rejected_count + AND (:subjectType = '' OR latest.subject_type = :subjectType OR :subjectType = 'SKILL_VERSION' AND latest.subject_type = 'SKILL_METADATA')) AS rejected_count FROM latest LEFT JOIN skill - ON latest.subject_type = 'SKILL_VERSION' AND skill.id = latest.subject_id + ON latest.subject_type IN ('SKILL_VERSION', 'SKILL_METADATA') AND skill.id = latest.subject_id LEFT JOIN skill_suite suite ON latest.subject_type = 'SUITE_VERSION' AND suite.id = latest.subject_id JOIN namespace ON namespace.id = latest.namespace_id diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java index e67cd87f..1286096d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; @@ -48,6 +49,7 @@ public class AdminNamespaceAppService { private final NamespaceGovernanceService namespaceGovernanceService; private final NamespaceMemberService namespaceMemberService; private final NamespaceMemberRepository namespaceMemberRepository; + private final NamespaceRepository namespaceRepository; private final NamespaceMemberCandidateService namespaceMemberCandidateService; private final NamespaceAccessPolicy namespaceAccessPolicy; private final UserAccountRepository userAccountRepository; @@ -57,6 +59,7 @@ public class AdminNamespaceAppService { NamespaceGovernanceService namespaceGovernanceService, NamespaceMemberService namespaceMemberService, NamespaceMemberRepository namespaceMemberRepository, + NamespaceRepository namespaceRepository, NamespaceMemberCandidateService namespaceMemberCandidateService, NamespaceAccessPolicy namespaceAccessPolicy, UserAccountRepository userAccountRepository) { @@ -65,6 +68,7 @@ public class AdminNamespaceAppService { this.namespaceGovernanceService = namespaceGovernanceService; this.namespaceMemberService = namespaceMemberService; this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceRepository = namespaceRepository; this.namespaceMemberCandidateService = namespaceMemberCandidateService; this.namespaceAccessPolicy = namespaceAccessPolicy; this.userAccountRepository = userAccountRepository; @@ -139,7 +143,7 @@ public class AdminNamespaceAppService { @Transactional public MemberResponse addMember(String slug, MemberRequest request, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Namespace namespace = lockedMutableTeamNamespace(slug, List.of(request.userId())); if (request.role() == NamespaceRole.OWNER) { throw new DomainBadRequestException("error.namespace.member.owner.assignDirect"); } @@ -152,7 +156,7 @@ public class AdminNamespaceAppService { @Transactional public BatchMemberResponse batchAddMembers(String slug, BatchMemberRequest request, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Namespace namespace = lockedMutableTeamNamespace(slug, request.members().stream().map(MemberRequest::userId).toList()); List results = new ArrayList<>(); int successCount = 0; int failureCount = 0; @@ -178,7 +182,7 @@ public class AdminNamespaceAppService { @Transactional public MemberResponse updateMemberRole(String slug, String userId, UpdateMemberRoleRequest request, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Namespace namespace = lockedMutableTeamNamespace(slug, List.of(userId)); if (request.role() == NamespaceRole.OWNER) { throw new DomainBadRequestException("error.namespace.member.owner.setDirect"); } @@ -194,7 +198,7 @@ public class AdminNamespaceAppService { @Transactional public MessageResponse removeMember(String slug, String userId, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Namespace namespace = lockedMutableTeamNamespace(slug, List.of(userId)); NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), userId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.member.notFound")); if (member.getRole() == NamespaceRole.OWNER) { @@ -206,7 +210,12 @@ public class AdminNamespaceAppService { @Transactional public MessageResponse transferOwnership(String slug, String newOwnerId, String actorUserId) { - Namespace namespace = requireMutableTeamNamespace(slug); + Long namespaceId = namespaceRepository.findIdBySlug(slug) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + String oldOwnerId = namespaceMemberRepository.findByNamespaceIdAndRoleIn(namespaceId, List.of(NamespaceRole.OWNER)) + .stream().findFirst().map(NamespaceMember::getUserId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.current.notFound")); + Namespace namespace = lockedMutableTeamNamespace(slug, List.of(oldOwnerId, newOwnerId)); NamespaceMember currentOwner = namespaceMemberRepository.findByNamespaceIdAndRoleIn(namespace.getId(), List.of(NamespaceRole.OWNER)) .stream() .findFirst() @@ -295,15 +304,19 @@ public class AdminNamespaceAppService { .collect(Collectors.toMap(NamespaceMember::getNamespaceId, NamespaceMember::getRole)); } - private Namespace requireMutableTeamNamespace(String slug) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - if (namespaceAccessPolicy.isImmutable(namespace)) { - throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug()); + private Namespace lockedMutableTeamNamespace(String slug, List userIds) { + Long namespaceId = namespaceRepository.findIdBySlug(slug) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + userIds.stream().distinct().sorted().forEach(userAccountRepository::findLockedById); + Namespace locked = namespaceRepository.findLockedById(namespaceId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + if (namespaceAccessPolicy.isImmutable(locked)) { + throw new DomainBadRequestException("error.namespace.system.immutable", slug); } - if (!namespaceAccessPolicy.canManageMembers(namespace)) { - throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + if (!namespaceAccessPolicy.canManageMembers(locked)) { + throw new DomainBadRequestException("error.namespace.readonly", slug); } - return namespace; + return locked; } private NamespaceStatus parseStatus(String status) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java new file mode 100644 index 00000000..9fdb570e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java @@ -0,0 +1,120 @@ +package com.iflytek.skillhub.service; + +import com.aliyun.green20220302.Client; +import com.aliyun.green20220302.models.DescribeUploadTokenResponse; +import com.aliyun.green20220302.models.DescribeUploadTokenResponseBody.DescribeUploadTokenResponseBodyData; +import com.aliyun.green20220302.models.ImageModerationRequest; +import com.aliyun.green20220302.models.ImageModerationResponse; +import com.aliyun.green20220302.models.ImageModerationResponseBody; +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.ClientBuilderConfiguration; +import com.aliyun.oss.model.PutObjectRequest; +import com.aliyun.teaopenapi.models.Config; +import com.aliyun.teautil.models.RuntimeOptions; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayInputStream; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** Uses Alibaba Cloud's short-lived private upload token; never exposes a public candidate URL. */ +@Component +public class AliyunAvatarModerationProvider implements AvatarModerationProvider { + private static final String ENDPOINT = "green-cip.cn-shanghai.aliyuncs.com"; + private static final String SERVICE_ID = "profilePhotoCheck"; + private final String accessKeyId; + private final String accessKeySecret; + private final ObjectMapper mapper; + + public AliyunAvatarModerationProvider( + @Value("${skillhub.avatar.moderation.aliyun.access-key-id:}") String accessKeyId, + @Value("${skillhub.avatar.moderation.aliyun.access-key-secret:}") String accessKeySecret, + ObjectMapper mapper) { + this.accessKeyId = accessKeyId; + this.accessKeySecret = accessKeySecret; + this.mapper = mapper; + } + + @Override public Context context() { + return new Context(SERVICE_ID, "2022-03-02", + "aliyun-profile-photo/1:none,low=PASS;medium,high=BLOCK;other=ERROR", null); + } + + @Override public String code() { return "aliyun"; } + + @Override public boolean configured() { return !accessKeyId.isBlank() && !accessKeySecret.isBlank(); } + + @Override public Verdict moderate(byte[] normalizedPng) { + if (!configured()) { + return Verdict.ERROR; + } + OSS oss = null; + try { + Config config = new Config(); + config.setAccessKeyId(accessKeyId); + config.setAccessKeySecret(accessKeySecret); + config.setEndpoint(ENDPOINT); + Client client = new Client(config); + RuntimeOptions runtime = new RuntimeOptions().setConnectTimeout(3000).setReadTimeout(8000) + .setAutoretry(false); + DescribeUploadTokenResponse tokenResponse = client.describeUploadTokenWithOptions(runtime); + if (tokenResponse == null || tokenResponse.getBody() == null + || !Objects.equals(tokenResponse.getBody().getCode(), 200)) { + return Verdict.ERROR; + } + DescribeUploadTokenResponseBodyData token = tokenResponse.getBody().getData(); + if (token == null || token.getBucketName() == null || token.getFileNamePrefix() == null + || token.getOssInternetEndPoint() == null || token.getSecurityToken() == null) { + return Verdict.ERROR; + } + ClientBuilderConfiguration ossConfig = new ClientBuilderConfiguration(); + ossConfig.setConnectionTimeout(3000); + ossConfig.setSocketTimeout(8000); + oss = new OSSClientBuilder().build(token.getOssInternetEndPoint(), token.getAccessKeyId(), + token.getAccessKeySecret(), token.getSecurityToken(), ossConfig); + String objectName = token.getFileNamePrefix() + UUID.randomUUID() + ".png"; + oss.putObject(new PutObjectRequest(token.getBucketName(), objectName, + new ByteArrayInputStream(normalizedPng))); + + ImageModerationRequest request = new ImageModerationRequest(); + request.setService(SERVICE_ID); + request.setServiceParameters(mapper.writeValueAsString(Map.of( + "ossBucketName", token.getBucketName(), + "ossObjectName", objectName, + "dataId", UUID.randomUUID().toString()))); + ImageModerationResponse response = client.imageModerationWithOptions(request, runtime); + return classify(response); + } catch (Exception failure) { + // SDK exception text may include credential or temporary upload details. + return Verdict.ERROR; + } finally { + if (oss != null) { + oss.shutdown(); + } + } + } + + Verdict classify(ImageModerationResponse response) { + if (response == null || !Objects.equals(response.getStatusCode(), 200)) { + return Verdict.ERROR; + } + ImageModerationResponseBody body = response.getBody(); + if (body == null || !Objects.equals(body.getCode(), 200) || body.getData() == null + || body.getData().getResult() == null || body.getData().getResult().isEmpty()) { + return Verdict.ERROR; + } + if (body.getData().getRiskLevel() == null) { + return Verdict.ERROR; + } + // Official guidance treats medium as requiring human review. BLOCK keeps the image + // private and lets the owner request the platform's exceptional manual review. + return switch (body.getData().getRiskLevel()) { + case "none", "low" -> Verdict.PASS; + case "medium", "high" -> Verdict.BLOCK; + default -> Verdict.ERROR; + }; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationGateway.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationGateway.java new file mode 100644 index 00000000..8ee22363 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationGateway.java @@ -0,0 +1,45 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class AvatarModerationGateway { + private final String selectedProvider; + private final List providers; + + public AvatarModerationGateway(@Value("${skillhub.avatar.moderation.provider:}") String selectedProvider, + List providers) { + this.selectedProvider = selectedProvider; + this.providers = providers; + } + + public String requireConfiguredProvider() { + return providers.stream() + .filter(provider -> provider.code().equals(selectedProvider) && provider.configured()) + .findFirst() + .map(AvatarModerationProvider::code) + .orElseThrow(() -> new DomainBadRequestException("error.skill.avatar.providerUnavailable")); + } + + public AvatarModerationProvider.Context context(String providerCode) { + return providers.stream() + .filter(provider -> provider.code().equals(providerCode) && providerCode.equals(selectedProvider) + && provider.configured()) + .findFirst().map(AvatarModerationProvider::context) + .orElse(AvatarModerationProvider.Context.UNKNOWN); + } + + public AvatarModerationProvider.Verdict moderate(String providerCode, byte[] image) { + if (!providerCode.equals(selectedProvider)) { + return AvatarModerationProvider.Verdict.ERROR; + } + return providers.stream() + .filter(provider -> provider.code().equals(providerCode) && provider.configured()) + .findFirst() + .map(provider -> provider.moderate(image)) + .orElse(AvatarModerationProvider.Verdict.ERROR); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationProvider.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationProvider.java new file mode 100644 index 00000000..9e4e63d1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationProvider.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.service; + +/** A deployment-selected image reviewer; implementations must never log credentials or raw URLs. */ +public interface AvatarModerationProvider { + enum Verdict { PASS, BLOCK, ERROR } + + /** API/policy evidence is not a claim that the supplier's underlying model is immutable. */ + record Context(String serviceId, String apiVersion, String policyRevision, String modelVersion) { + public static final Context UNKNOWN = new Context("UNKNOWN", "UNKNOWN", "UNKNOWN", null); + } + + default Context context() { return Context.UNKNOWN; } + + String code(); + boolean configured(); + Verdict moderate(byte[] normalizedPng); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarReviewSettingsService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarReviewSettingsService.java new file mode 100644 index 00000000..86e7bba5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarReviewSettingsService.java @@ -0,0 +1,64 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.settings.SystemSetting; +import com.iflytek.skillhub.auth.settings.SystemSettingRepository; +import com.iflytek.skillhub.domain.audit.AuditDetail; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse; +import com.iflytek.skillhub.dto.AvatarReviewSettingsUpdateRequest; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AvatarReviewSettingsService { + public static final String SETTING_KEY = "skill.avatar-review"; + + private final SystemSettingRepository settings; + private final AvatarModerationGateway gateway; + private final AuditLogService audit; + private final RequestIdAccessor requestIds; + + public AvatarReviewSettingsService(SystemSettingRepository settings, AvatarModerationGateway gateway, + AuditLogService audit, RequestIdAccessor requestIds) { + this.settings = settings; + this.gateway = gateway; + this.audit = audit; + this.requestIds = requestIds; + } + + public AvatarReviewSettingsResponse current() { + SystemSetting setting = load(); + return new AvatarReviewSettingsResponse( + Boolean.TRUE.equals(setting.getValue().get("machineEnabled")), + !Boolean.FALSE.equals(setting.getValue().get("manualEnabled")), + setting.getVersion(), setting.getUpdatedAt()); + } + + @Transactional + public AvatarReviewSettingsResponse update(AvatarReviewSettingsUpdateRequest request, String actor, + AuditRequestContext context) { + if (request.machineEnabled()) { + gateway.requireConfiguredProvider(); + } + SystemSetting setting = load(); + if (setting.getVersion() != request.version()) { + throw new DomainConflictException("error.system.settings.version.conflict"); + } + Map previous = setting.getValue(); + setting.update(Map.of("machineEnabled", request.machineEnabled(), + "manualEnabled", request.manualEnabled()), actor); + settings.saveAndFlush(setting); + audit.record(actor, "SKILL_AVATAR_SETTINGS_UPDATE", "SYSTEM_SETTING", setting.getId(), + requestIds.current(), context.clientIp(), context.userAgent(), + AuditDetail.builder().put("previous", previous).put("current", setting.getValue()).build()); + return current(); + } + + private SystemSetting load() { + return settings.findBySettingKey(SETTING_KEY) + .orElseThrow(() -> new IllegalStateException("Missing required skill.avatar-review setting")); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java index 4bf8af91..b5ef6468 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java @@ -84,8 +84,7 @@ public class NamespacePortalCommandAppService { @Transactional public MessageResponse deleteNamespace(String slug, String userId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - namespaceService.deleteNamespace(namespace.getId(), userId); + namespaceService.deleteNamespace(namespaceId(slug), userId); return new MessageResponse("Namespace deleted successfully"); } @@ -147,9 +146,8 @@ public class NamespacePortalCommandAppService { @Transactional public MemberResponse addMember(String slug, String memberUserId, com.iflytek.skillhub.domain.namespace.NamespaceRole role, String operatorUserId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); NamespaceMember member = namespaceMemberService.addMember( - namespace.getId(), + namespaceId(slug), memberUserId, role, operatorUserId @@ -161,8 +159,7 @@ public class NamespacePortalCommandAppService { // Intentionally not @Transactional: each addMember runs in its own transaction // so partial success is possible (some members added even if others fail). public BatchMemberResponse batchAddMembers(String slug, List members, String operatorUserId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - Long namespaceId = namespace.getId(); + Long namespaceId = namespaceId(slug); List results = new ArrayList<>(); int successCount = 0; @@ -195,8 +192,7 @@ public class NamespacePortalCommandAppService { @Transactional public MessageResponse removeMember(String slug, String memberUserId, String operatorUserId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - namespaceMemberService.removeMember(namespace.getId(), memberUserId, operatorUserId); + namespaceMemberService.removeMember(namespaceId(slug), memberUserId, operatorUserId); return new MessageResponse("Member removed successfully"); } @@ -205,9 +201,8 @@ public class NamespacePortalCommandAppService { String userId, UpdateMemberRoleRequest request, String operatorUserId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); NamespaceMember member = namespaceMemberService.updateMemberRole( - namespace.getId(), + namespaceId(slug), userId, request.role(), operatorUserId @@ -217,8 +212,7 @@ public class NamespacePortalCommandAppService { @Transactional public MessageResponse transferOwnership(String slug, String newOwnerId, String currentOwnerId) { - Namespace namespace = namespaceService.getNamespaceBySlug(slug); - namespaceMemberService.transferOwnership(namespace.getId(), currentOwnerId, newOwnerId); + namespaceMemberService.transferOwnership(namespaceId(slug), currentOwnerId, newOwnerId); return new MessageResponse("Ownership transferred successfully"); } @@ -226,4 +220,10 @@ public class NamespacePortalCommandAppService { return principal.platformRoles().contains("SKILL_ADMIN") || principal.platformRoles().contains("SUPER_ADMIN"); } + + private Long namespaceId(String slug) { + return namespaceRepository.findIdBySlug(slug) + .orElseThrow(() -> new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException( + "error.namespace.slug.notFound", slug)); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java index dfaf2f8b..a8cf12f2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java @@ -137,7 +137,9 @@ public class ReviewPortalAppService { reviewTaskId, suiteContext(userId, userNsRoles, auditContext)); return; } - reviewService.withdrawReview(task.getSkillVersionId(), userId); + if (task.getSubjectType() == ReviewSubjectType.SKILL_METADATA) { + reviewService.withdrawMetadata(task, userId); + } else { reviewService.withdrawReview(task.getSkillVersionId(), userId); } recordAudit( "REVIEW_WITHDRAW", userId, @@ -272,10 +274,10 @@ public class ReviewPortalAppService { throw new DomainForbiddenException("review.no_permission"); } - List attempts = anchor.getSubjectType() == ReviewSubjectType.SUITE_VERSION + List attempts = anchor.getSubjectType() != null && anchor.getSubjectType() != ReviewSubjectType.SKILL_VERSION ? reviewTaskRepository .findBySubmittedByAndSubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc( - userId, ReviewSubjectType.SUITE_VERSION, + userId, anchor.getSubjectType(), anchor.getSubjectId(), anchor.getSubjectVersion()) : reviewTaskRepository .findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( @@ -301,10 +303,10 @@ public class ReviewPortalAppService { throw new DomainForbiddenException("review.no_permission"); } - List attempts = anchor.getSubjectType() == ReviewSubjectType.SUITE_VERSION + List attempts = anchor.getSubjectType() != null && anchor.getSubjectType() != ReviewSubjectType.SKILL_VERSION ? reviewTaskRepository .findBySubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc( - ReviewSubjectType.SUITE_VERSION, + anchor.getSubjectType(), anchor.getSubjectId(), anchor.getSubjectVersion()) : reviewTaskRepository .findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java index 49a629ab..f4a57d0f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java @@ -1,5 +1,11 @@ package com.iflytek.skillhub.service; +import com.iflytek.skillhub.domain.review.ReviewSubjectType; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository; +import org.springframework.beans.factory.annotation.Autowired; import com.iflytek.skillhub.auth.rbac.RbacService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; @@ -36,6 +42,15 @@ public class ReviewSkillDetailAppService { private final SkillQueryService skillQueryService; private final SkillDownloadService skillDownloadService; private final ComplianceSnapshotProjectionService complianceSnapshotProjectionService; + private SkillRepository skills; + private SkillAvatarCandidateJpaRepository candidates; + + @Autowired + public void configureAvatars(SkillRepository skills, + SkillAvatarCandidateJpaRepository candidates) { + this.skills = skills; this.candidates = candidates; + } + public ReviewSkillDetailAppService(ReviewTaskRepository reviewTaskRepository, NamespaceRepository namespaceRepository, @@ -57,6 +72,9 @@ public class ReviewSkillDetailAppService { String userId, Map userNsRoles) { ReviewAccessContext context = loadAuthorizedContext(reviewId, userId, userNsRoles); + if (context.task().getSubjectType() == ReviewSubjectType.SKILL_METADATA) { + return metadataDetail(context); + } SkillQueryService.ReviewSkillSnapshotDTO snapshot = skillQueryService.getReviewSkillSnapshot(context.task().getSkillVersionId()); @@ -87,7 +105,8 @@ public class ReviewSkillDetailAppService { null, "REVIEW_TASK", List.of(), - new PageResponse<>(List.of(), 0, 0, 20) + new PageResponse<>(List.of(), 0, 0, 20), + SkillAvatarUrls.current(snapshot.skill(), context.namespace().getSlug()) ); List versions = snapshot.versions().stream() @@ -116,7 +135,8 @@ public class ReviewSkillDetailAppService { snapshot.documentationPath(), snapshot.documentationContent(), "/api/v1/reviews/" + reviewId + "/download", - snapshot.activeVersion().getVersion() + snapshot.activeVersion().getVersion(), + avatarChange(context) ); } @@ -124,6 +144,9 @@ public class ReviewSkillDetailAppService { String userId, Map userNsRoles) { ReviewAccessContext context = loadAuthorizedContext(reviewId, userId, userNsRoles); + if (context.task().getSkillVersionId() == null) { + throw new DomainBadRequestException("review.metadata.no_package"); + } SkillQueryService.ReviewSkillSnapshotDTO snapshot = skillQueryService.getReviewSkillSnapshot(context.task().getSkillVersionId()); return skillDownloadService.downloadReviewVersion(snapshot.skill(), snapshot.activeVersion()); @@ -139,12 +162,47 @@ public class ReviewSkillDetailAppService { String userId, Map userNsRoles) { ReviewAccessContext context = loadAuthorizedContext(reviewId, userId, userNsRoles); + if (context.task().getSkillVersionId() == null) { + throw new DomainBadRequestException("review.metadata.no_package"); + } return skillQueryService.getFileContentByVersionId( context.task().getSkillVersionId(), filePath ); } + private ReviewSkillDetailResponse.AvatarChange avatarChange(ReviewAccessContext context) { + ReviewTask task = context.task(); + if (task.getAvatarCandidateId() == null && !task.isAvatarRemoval()) { return null; } + var skill = skills.findById(task.getSkillId()).orElseThrow(() -> new DomainNotFoundException("skill.not_found", task.getSkillId())); + var candidate = task.getAvatarCandidateId() == null ? null : candidates.findById(task.getAvatarCandidateId()).orElse(null); + boolean current = candidate != null && candidate.getStatus() == SkillAvatarCandidate.Status.PENDING + && java.util.Objects.equals(skill.getPendingAvatarId(), candidate.getId()); + boolean removalReady = task.isAvatarRemoval() + && java.util.Objects.equals(skill.getActiveAvatarId(), task.getAvatarRemovalActiveId()) + && java.util.Objects.equals(skill.getPendingAvatarId(), task.getAvatarRemovalPendingId()); + boolean ready = removalReady || !task.isAvatarRemoval() && current && (!candidate.isMachineEnabled() + || candidate.getMachineStatus() == SkillAvatarCandidate.MachineStatus.PASSED + || candidate.getMachineStatus() == SkillAvatarCandidate.MachineStatus.BLOCKED + && (candidate.isManualEnabled() || candidate.getReviewStatus() == SkillAvatarCandidate.ReviewStatus.APPEAL_PENDING)); + return new ReviewSkillDetailResponse.AvatarChange(task.getAvatarCandidateId(), + SkillAvatarUrls.current(skill, context.namespace().getSlug()), + candidate == null || candidate.getObjectKey() == null ? null : SkillAvatarUrls.candidate(context.namespace().getSlug(), skill, candidate.getId()), + candidate == null ? null : candidate.getMachineStatus().name(), + candidate == null ? null : candidate.getReviewStatus().name(), ready, task.isAvatarRemoval()); + } + + private ReviewSkillDetailResponse metadataDetail(ReviewAccessContext context) { + var skill = skills.findById(context.task().getSkillId()).orElseThrow(() -> new DomainNotFoundException("skill.not_found", context.task().getSkillId())); + SkillDetailResponse detail = new SkillDetailResponse(skill.getId(), skill.getSlug(), skill.getDisplayName(), + skill.getOwnerId(), null, skill.getSummary(), skill.getVisibility().name(), skill.getStatus().name(), + skill.getDownloadCount(), skill.getStarCount(), skill.getSubscriptionCount(), skill.getRatingAvg(), + skill.getRatingCount(), skill.isHidden(), context.namespace().getSlug(), List.of(), false, false, + false, false, null, null, null, null, "REVIEW_TASK", List.of(), new PageResponse<>(List.of(), 0, 0, 20), + SkillAvatarUrls.current(skill, context.namespace().getSlug())); + return new ReviewSkillDetailResponse(detail, List.of(), List.of(), null, null, null, null, avatarChange(context)); + } + private ReviewAccessContext loadAuthorizedContext(Long reviewId, String userId, Map userNsRoles) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java new file mode 100644 index 00000000..fc81b0c1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java @@ -0,0 +1,713 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.review.ReviewAvatarParticipant; +import com.iflytek.skillhub.domain.review.ReviewPermissionChecker; +import com.iflytek.skillhub.domain.review.ReviewSubjectType; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import org.springframework.beans.factory.annotation.Autowired; +import com.iflytek.skillhub.domain.audit.AuditDetail; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse; +import com.iflytek.skillhub.dto.SkillAvatarStatusResponse; +import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository; +import com.iflytek.skillhub.infra.jpa.SkillJpaRepository; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.storage.ObjectStorageService; +import jakarta.persistence.EntityManager; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Service +public class SkillAvatarAppService implements ReviewAvatarParticipant { + private final SkillJpaRepository skills; + private final NamespaceRepository namespaces; + private final SkillAvatarCandidateJpaRepository candidates; + private final ObjectStorageService storage; + private final SkillAvatarImageProcessor imageProcessor; + private final AvatarReviewSettingsService settings; + private final AvatarModerationGateway moderation; + private final VisibilityChecker visibility; + private final SkillQueryService skillQueries; + private final AuditLogService audit; + private final RequestIdAccessor requestIds; + private final EntityManager entityManager; + private final TransactionTemplate machineTransactions; + private ReviewTaskRepository reviews; + private ReviewPermissionChecker reviewPermissions; + private org.springframework.context.ApplicationEventPublisher events; + + @Autowired + public void setEvents(org.springframework.context.ApplicationEventPublisher events) { this.events = events; } + + + @Autowired + public void configureUnifiedReviews(ReviewTaskRepository reviews, + ReviewPermissionChecker permissions) { + this.reviews = reviews; + this.reviewPermissions = permissions; + } + + + public SkillAvatarAppService(SkillJpaRepository skills, NamespaceRepository namespaces, + SkillAvatarCandidateJpaRepository candidates, ObjectStorageService storage, + SkillAvatarImageProcessor imageProcessor, AvatarReviewSettingsService settings, + AvatarModerationGateway moderation, VisibilityChecker visibility, + SkillQueryService skillQueries, AuditLogService audit, RequestIdAccessor requestIds, + EntityManager entityManager, PlatformTransactionManager transactionManager) { + this.skills = skills; + this.namespaces = namespaces; + this.candidates = candidates; + this.storage = storage; + this.imageProcessor = imageProcessor; + this.settings = settings; + this.moderation = moderation; + this.visibility = visibility; + this.skillQueries = skillQueries; + this.audit = audit; + this.requestIds = requestIds; + this.entityManager = entityManager; + this.machineTransactions = new TransactionTemplate(transactionManager); + this.machineTransactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + @Transactional + public SkillAvatarStatusResponse submit(String namespaceSlug, String slug, String actor, byte[] image, + AuditRequestContext context) { + Skill skill = lockedOwnedSkill(namespaceSlug, slug, actor); + requireOwner(skill, actor); + byte[] normalized = imageProcessor.normalize(image); + return createCandidate(skill, namespaceSlug, actor, actor, "SKILL_AVATAR_SUBMIT", normalized, context); + } + + /** Reassesses the existing image when publication expands its audience. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public SkillAvatarStatusResponse prepareScopeReview(Long skillId, String publisher) { + Skill skill = skills.findLockedById(skillId).orElseThrow(() -> notFound()); + if (skill.getActiveAvatarId() == null || skill.getActiveAvatarAudience() == null + || SkillAvatarUrls.current(skill, namespaceSlug(skill)) != null) { + return null; + } + if (skill.getPendingAvatarId() != null) { + SkillAvatarCandidate pending = candidates.findById(skill.getPendingAvatarId()).orElse(null); + if (pending != null && pending.getStatus() == SkillAvatarCandidate.Status.PENDING) { + Instant now = Instant.now(); + boolean abandonedBeforeClaim = pending.getMachineStatus() == SkillAvatarCandidate.MachineStatus.PENDING + && pending.getCreatedAt() != null + && !pending.getCreatedAt().plusSeconds(600).isAfter(now); + return pending.getAudience() == skill.getVisibility() && ((pending.canRetryMachine(now) && pending.getMachineAttemptCount() < 3) || abandonedBeforeClaim) + ? status(skill, pending, namespaceSlug(skill)) : null; + } + } + if (candidates.existsBySkillIdAndAudienceAndScopeReviewVersionIdAndStatus(skill.getId(), + skill.getVisibility(), skill.getLatestVersionId(), SkillAvatarCandidate.Status.REJECTED)) { + return null; + } + SkillAvatarCandidate active = findCandidate(skill.getActiveAvatarId()); + if (active.getStatus() != SkillAvatarCandidate.Status.ACTIVE || active.getObjectKey() == null) { + return null; + } + byte[] image; + try (InputStream input = storage.getObject(active.getObjectKey())) { + image = input.readAllBytes(); + } catch (IOException failure) { + throw new IllegalStateException("Cannot read private avatar candidate", failure); + } + // A fresh object key keeps 30-day candidate cleanup from deleting the active image. + return createCandidate(skill, namespaceSlug(skill), skill.getOwnerId(), + publisher == null ? skill.getOwnerId() : publisher, "SKILL_AVATAR_SCOPE_REVIEW", image, + new AuditRequestContext(null, null)); + } + + private SkillAvatarStatusResponse createCandidate(Skill skill, String namespaceSlug, String submittedBy, + String auditActor, String action, byte[] normalized, + AuditRequestContext context) { + String sha256 = sha256(normalized); + String key = "skill-avatars/" + skill.getId() + "/" + UUID.randomUUID() + ".png"; + storage.putObject(key, new ByteArrayInputStream(normalized), normalized.length, "image/png"); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCompletion(int result) { + if (result != STATUS_COMMITTED) { + storage.deleteObject(key); + } + } + }); + + return createStoredCandidate(skill, namespaceSlug, submittedBy, auditActor, action, key, sha256, skill.getVisibility(), context); + } + + @Transactional + public void lockOwnedSkill(String namespaceSlug, String slug, String actor) { + requireOwner(lockedOwnedSkill(namespaceSlug, slug, actor), actor); + } + + @Transactional + public SkillAvatarStatusResponse submitStored(String namespaceSlug, String slug, String actor, + String objectKey, String sha256, SkillVisibility audience, AuditRequestContext context) { + Skill skill = lockedOwnedSkill(namespaceSlug, slug, actor); + requireOwner(skill, actor); + return submitStoredForReview(namespaceSlug, slug, actor, objectKey, sha256, audience, context, null); + } + + @Transactional + public SkillAvatarStatusResponse submitStoredForReview(String namespaceSlug, String slug, String actor, + String objectKey, String sha256, SkillVisibility audience, AuditRequestContext context, + ReviewTask review) { + Skill skill = lockedOwnedSkill(namespaceSlug, slug, actor); + requireOwner(skill, actor); + return createStoredCandidate(skill, namespaceSlug, actor, actor, "SKILL_AVATAR_SUBMIT", objectKey, sha256, + audience == null ? skill.getVisibility() : audience, context, review); + } + + private SkillAvatarStatusResponse createStoredCandidate(Skill skill, String namespaceSlug, String submittedBy, + String auditActor, String action, String key, String sha256, SkillVisibility audience, AuditRequestContext context) { + return createStoredCandidate(skill, namespaceSlug, submittedBy, auditActor, action, key, sha256, audience, context, null); + } + + private SkillAvatarStatusResponse createStoredCandidate(Skill skill, String namespaceSlug, String submittedBy, + String auditActor, String action, String key, String sha256, SkillVisibility audience, + AuditRequestContext context, ReviewTask review) { + AvatarReviewSettingsResponse policy = settings.current(); + boolean privateOnly = audience == SkillVisibility.PRIVATE; + boolean machineEnabled = !privateOnly && policy.machineEnabled(); + boolean manualEnabled = !privateOnly && policy.manualEnabled(); + String provider = machineEnabled ? moderation.requireConfiguredProvider() : null; + if (skill.getPendingAvatarId() != null) { + candidates.findById(skill.getPendingAvatarId()).ifPresent(old -> { + retireMetadataTask(old); + old.supersede(); + }); + } + SkillAvatarCandidate candidate = new SkillAvatarCandidate(skill.getId(), submittedBy, key, sha256, + audience, machineEnabled, manualEnabled, provider); + if ("SKILL_AVATAR_SCOPE_REVIEW".equals(action)) { + candidate.markScopeReviewVersion(skill.getLatestVersionId()); + } + if (machineEnabled) { + // A cloud API version does not pin its model or policy: never reuse another candidate's verdict. + recordMachineContext(candidate, moderation.context(provider)); + } + if (review != null || manualEnabled) { candidate.bindReview(); } + candidate = candidates.saveAndFlush(candidate); + if (review != null) { + review.setAvatarCandidateId(candidate.getId()); + reviews.save(review); + } else if (manualEnabled) { + reviews.save(ReviewTask.forSkillMetadata( + skill.getId(), skill.getNamespaceId(), submittedBy, candidate.getId())); + } + skill.setPendingAvatarId(candidate.getId()); + if (!candidate.isReviewBound() && (!machineEnabled && !manualEnabled + || candidate.getMachineStatus() == SkillAvatarCandidate.MachineStatus.PASSED && !manualEnabled)) { + activate(skill, candidate); + } + log(auditActor, action, candidate.getId(), context, + AuditDetail.builder().put("skillId", skill.getId()).put("audience", candidate.getAudience().name()).build()); + return status(skill, candidate, namespaceSlug); + } + + @Transactional(readOnly = true) + public SkillAvatarStatusResponse submittedStatus(String namespaceSlug, String slug, String actor, long candidateId) { + Skill skill = findOwnedSkill(namespaceSlug, slug, actor); + requireOwner(skill, actor); + SkillAvatarCandidate candidate = findCandidate(candidateId); + if (!skill.getId().equals(candidate.getSkillId())) { + throw notFound(); + } + return status(skill, candidate, namespaceSlug); + } + + /** Claims and finishes in short transactions; the external call holds no database lock. */ + public SkillAvatarStatusResponse processMachine(long candidateId, AuditRequestContext context) { + MachineClaim claim = machineTransactions.execute(ignored -> { + SkillAvatarCandidate candidate = findCandidate(candidateId); + Skill skill = skills.findLockedById(candidate.getSkillId()).orElseThrow(() -> notFound()); + entityManager.refresh(candidate); + String attemptId = UUID.randomUUID().toString(); + Instant now = Instant.now(); + if (!Objects.equals(skill.getPendingAvatarId(), candidateId) + || !candidate.claimMachineAttempt(attemptId, now, now.plusSeconds(600))) { + throw new DomainConflictException("error.skill.avatar.notPending"); + } + AvatarModerationProvider.Context actualContext = moderation.context(candidate.getMachineProvider()); + recordMachineContext(candidate, actualContext); + log(candidate.getSubmittedBy(), "SKILL_AVATAR_MACHINE_CLAIM", candidate.getId(), context, + machineAudit(attemptId, candidate.getMachineProvider(), actualContext).build()); + return new MachineClaim(candidate.getObjectKey(), candidate.getMachineProvider(), attemptId, actualContext); + }); + AvatarModerationProvider.Verdict verdict; + boolean contextMatched = claim.moderationContext().equals(moderation.context(claim.provider())); + try (InputStream input = storage.getObject(claim.objectKey())) { + verdict = contextMatched ? moderation.moderate(claim.provider(), input.readAllBytes()) + : AvatarModerationProvider.Verdict.ERROR; + } catch (IOException | RuntimeException failure) { + // Leave an explicit retriable state. Storage and provider exceptions can contain secret URLs. + verdict = AvatarModerationProvider.Verdict.ERROR; + } + AvatarModerationProvider.Verdict result = verdict; + return machineTransactions.execute(ignored -> { + SkillAvatarCandidate candidate = findCandidate(candidateId); + Skill skill = skills.findLockedById(candidate.getSkillId()).orElseThrow(() -> notFound()); + entityManager.refresh(candidate); + boolean applied = Objects.equals(skill.getPendingAvatarId(), candidateId) + && candidate.completeMachineAttempt(claim.attemptId(), switch (result) { + case PASS -> SkillAvatarCandidate.MachineStatus.PASSED; + case BLOCK -> SkillAvatarCandidate.MachineStatus.BLOCKED; + case ERROR -> SkillAvatarCandidate.MachineStatus.ERROR; + }); + if (applied && result == AvatarModerationProvider.Verdict.PASS && !candidate.isManualEnabled() && !candidate.isReviewBound()) { + activate(skill, candidate); + } + log(candidate.getSubmittedBy(), "SKILL_AVATAR_MACHINE_RESULT", candidate.getId(), context, + machineAudit(claim.attemptId(), claim.provider(), claim.moderationContext()) + .put("verdict", result.name()).put("contextMatched", contextMatched).put("applied", applied).build()); + return status(skill, candidate, namespaceSlug(skill)); + }); + } + + private record MachineClaim(String objectKey, String provider, String attemptId, + AvatarModerationProvider.Context moderationContext) {} + + private void recordMachineContext(SkillAvatarCandidate candidate, AvatarModerationProvider.Context context) { + if (context == null) { throw new IllegalStateException("Missing machine moderation context"); } + candidate.recordMachineContext(context.serviceId(), context.apiVersion(), context.policyRevision(), context.modelVersion()); + } + + private AuditDetail.Builder machineAudit(String attemptId, String provider, AvatarModerationProvider.Context context) { + return AuditDetail.builder().put("attemptId", attemptId).put("provider", provider) + .put("serviceId", context.serviceId()).put("apiVersion", context.apiVersion()) + .put("policyRevision", context.policyRevision()) + .put("modelVersion", context.modelVersion() == null ? "UNKNOWN" : context.modelVersion()); + } + + @Transactional(readOnly = true) + public SkillAvatarStatusResponse ownerStatus(String namespaceSlug, String slug, String actor) { + Skill skill = findOwnedSkill(namespaceSlug, slug, actor); + requireOwner(skill, actor); + return status(skill, candidates.findBySkillIdOrderByCreatedAtDesc(skill.getId()).stream().findFirst().orElse(null), + namespaceSlug); + } + + @Transactional(readOnly = true) + public InputStream readActive(String namespaceSlug, String slug, String actor, + Map namespaceRoles, Set platformRoles) { + SkillQueryService.SkillDetailDTO detail = skillQueries.getSkillDetail(namespaceSlug, slug, actor, + namespaceRoles == null ? Map.of() : namespaceRoles); + Skill skill = ((SkillRepository) skills).findById(detail.id()).orElseThrow(() -> notFound()); + if (skill.getStatus() != SkillStatus.ACTIVE) { + throw notFound(); + } + if (!visibility.canAccess(skill, actor, namespaceRoles, platformRoles) + || skill.getVisibility() == SkillVisibility.PRIVATE && !skill.getOwnerId().equals(actor) + || SkillAvatarUrls.current(skill, namespaceSlug) == null) { + throw notFound(); + } + SkillAvatarCandidate candidate = findCandidate(skill.getActiveAvatarId()); + if (candidate.getStatus() != SkillAvatarCandidate.Status.ACTIVE || candidate.getObjectKey() == null) { + throw notFound(); + } + return storage.getObject(candidate.getObjectKey()); + } + + @Transactional(readOnly = true) + public InputStream readCandidate(String namespaceSlug, String slug, long candidateId, String actor, + Map namespaceRoles, Set platformRoles) { + SkillAvatarCandidate candidate = findCandidate(candidateId); + Skill skill = findCandidateSkill(namespaceSlug, slug, candidate); + if (!skill.getId().equals(candidate.getSkillId()) || candidate.getObjectKey() == null + || candidate.getAudience() == SkillVisibility.PRIVATE && !skill.getOwnerId().equals(actor) + || !skill.getOwnerId().equals(actor) && !canReview(skill, actor, namespaceRoles, platformRoles)) { + throw notFound(); + } + return storage.getObject(candidate.getObjectKey()); + } + + @Transactional + public SkillAvatarStatusResponse appeal(String namespaceSlug, String slug, long candidateId, String actor, + AuditRequestContext context) { + Skill skill = lockedOwnedSkill(namespaceSlug, slug, actor); + requireOwner(skill, actor); + SkillAvatarCandidate candidate = currentPending(skill, candidateId); + candidate.appeal(); + if (!candidate.isReviewBound() && reviews != null) { + candidate.bindReview(); + reviews.save(ReviewTask.forSkillMetadata( + skill.getId(), skill.getNamespaceId(), actor, candidateId)); + } + log(actor, "SKILL_AVATAR_APPEAL", candidateId, context, AuditDetail.of("skillId", skill.getId())); + return status(skill, candidate, namespaceSlug); + } + + @Transactional(readOnly = true) + public void requireCurrentOwnerCandidate(String namespaceSlug, String slug, long candidateId, String actor) { + Skill skill = findOwnedSkill(namespaceSlug, slug, actor); + requireOwner(skill, actor); + if (!Objects.equals(skill.getPendingAvatarId(), candidateId)) { + throw new DomainConflictException("error.skill.avatar.notPending"); + } + } + + @Transactional + public SkillAvatarStatusResponse decide(String namespaceSlug, String slug, long candidateId, boolean approved, + String reason, String actor, Map namespaceRoles, + Set platformRoles, AuditRequestContext context) { + Skill skill = lockAndRefresh(findCandidateSkill(namespaceSlug, slug, findCandidate(candidateId))); + requireReviewer(skill, actor, namespaceRoles, platformRoles); + SkillAvatarCandidate candidate = currentPending(skill, candidateId); + if (candidate.isReviewBound()) { throw new DomainConflictException("error.skill.avatar.unifiedReviewRequired"); } + if (candidate.isMachineEnabled() && candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PASSED + && candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.BLOCKED) { + throw new DomainConflictException("error.skill.avatar.machineIncomplete"); + } + if (reason == null || reason.isBlank()) { + throw new DomainBadRequestException("error.skill.avatar.reasonRequired"); + } + if (approved) { + candidate.approve(actor, reason.trim()); + activate(skill, candidate); + } else { + candidate.reject(actor, reason.trim()); + skill.setPendingAvatarId(null); + } + log(actor, approved ? "SKILL_AVATAR_APPROVE" : "SKILL_AVATAR_REJECT", candidateId, context, + AuditDetail.builder().put("skillId", skill.getId()).put("reason", reason.trim()).build()); + return status(skill, candidate, namespaceSlug); + } + + @Transactional + public void remove(String namespaceSlug, String slug, String actor, boolean byAdmin, String reason, + Map namespaceRoles, Set platformRoles, + AuditRequestContext context) { + Skill skill = byAdmin ? lockedSkill(namespaceSlug, slug) : lockedOwnedSkill(namespaceSlug, slug, actor); + if (byAdmin) { + requireReviewer(skill, actor, namespaceRoles, platformRoles); + if (reason == null || reason.isBlank()) { + throw new DomainBadRequestException("error.skill.avatar.reasonRequired"); + } + } else { + requireOwner(skill, actor); + } + if (skill.getActiveAvatarId() == null && skill.getPendingAvatarId() == null) { + throw notFound(); + } + if (skill.getActiveAvatarId() != null) { + candidates.findById(skill.getActiveAvatarId()).ifPresent(SkillAvatarCandidate::remove); + } + if (skill.getPendingAvatarId() != null) { + candidates.findById(skill.getPendingAvatarId()).ifPresent(old -> { + retireMetadataTask(old); + old.remove(); + }); + } + skill.clearAvatar(); + skill.setPendingAvatarId(null); + log(actor, byAdmin ? "SKILL_AVATAR_TAKEDOWN" : "SKILL_AVATAR_DELETE", skill.getId(), context, + AuditDetail.of("reason", reason == null ? "" : reason.trim())); + } + + @Transactional(readOnly = true) + public List reviewQueue(String actor, Map namespaceRoles, + Set platformRoles) { + return java.util.stream.Stream.concat( + candidates.findByStatusAndReviewStatusOrderByCreatedAtAsc( + SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.ReviewStatus.PENDING).stream(), + candidates.findByStatusAndReviewStatusOrderByCreatedAtAsc( + SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.ReviewStatus.APPEAL_PENDING).stream()) + .filter(candidate -> candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.ERROR) + .filter(candidate -> candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PENDING) + .filter(candidate -> candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PROCESSING) + .filter(candidate -> ((SkillRepository) skills).findById(candidate.getSkillId()) + .filter(skill -> canReview(skill, actor, namespaceRoles, platformRoles)).isPresent()) + .map(candidate -> { + Skill skill = ((SkillRepository) skills).findById(candidate.getSkillId()).orElseThrow(() -> notFound()); + return status(skill, candidate, namespaceSlug(skill)); + }).toList(); + } + + private void retireMetadataTask(SkillAvatarCandidate candidate) { + if (reviews == null) { return; } + for (var task : reviews.findByAvatarCandidateIdAndStatus(candidate.getId(), + ReviewTaskStatus.PENDING)) { + if (task.getSubjectType() == ReviewSubjectType.SKILL_METADATA) { + log(task.getSubmittedBy(), "SKILL_AVATAR_SUPERSEDE", candidate.getId(), new AuditRequestContext(null, null), + AuditDetail.builder().put("reviewTaskId", task.getId()).put("candidateId", candidate.getId()) + .put("reason", "replaced-or-removed").put("submittedBy", task.getSubmittedBy()) + .put("submittedAt", task.getSubmittedAt().toString()).build()); + reviews.delete(task); + } + } + } + + @Override + public void lock(ReviewTask task) { + Long skillId = task.getSkillId() != null ? task.getSkillId() : task.getSubjectId(); + if (skillId != null) { + Skill skill = skills.findLockedById(skillId).orElseThrow(() -> notFound()); + skills.flush(); + entityManager.refresh(skill); + } + } + + @Override + public void validateApproval(ReviewTask task, String comment) { + if (task.getAvatarCandidateId() == null && !task.isAvatarRemoval()) { return; } + Skill skill = skills.findLockedById(task.getSkillId()).orElseThrow(() -> notFound()); + if (task.isAvatarRemoval()) { + if (!Objects.equals(skill.getActiveAvatarId(), task.getAvatarRemovalActiveId()) + || !Objects.equals(skill.getPendingAvatarId(), task.getAvatarRemovalPendingId())) { + throw new DomainConflictException("error.skill.avatar.notPending"); + } + return; + } + if (task.getAvatarCandidateId() == null) { return; } + SkillAvatarCandidate candidate = currentPending(skill, task.getAvatarCandidateId()); + if (!skill.getId().equals(candidate.getSkillId())) { throw notFound(); } + if (candidate.isMachineEnabled() && candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PASSED + && candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.BLOCKED) { + throw new DomainConflictException("error.skill.avatar.machineIncomplete"); + } + if (candidate.getMachineStatus() == SkillAvatarCandidate.MachineStatus.BLOCKED + && (!candidate.isManualEnabled() && candidate.getReviewStatus() != SkillAvatarCandidate.ReviewStatus.APPEAL_PENDING + || comment == null || comment.isBlank())) { + throw new DomainConflictException("error.skill.avatar.reasonRequired"); + } + } + + @Override + public void decide(ReviewTask task, boolean approved, String actor, String comment) { + if (task.getAvatarCandidateId() == null && !task.isAvatarRemoval()) { return; } + Skill skill = skills.findLockedById(task.getSkillId()).orElseThrow(() -> notFound()); + // Review CAS clears the persistence context; obtain fresh managed instances under the same row lock. + skills.flush(); + entityManager.refresh(skill); + if (task.isAvatarRemoval()) { + if (approved) { + if (skill.getActiveAvatarId() != null) { findCandidate(skill.getActiveAvatarId()).remove(); } + if (skill.getPendingAvatarId() != null) { findCandidate(skill.getPendingAvatarId()).remove(); } + skill.clearAvatar(); + skill.setPendingAvatarId(null); + } + } else { + SkillAvatarCandidate candidate = approved ? currentPending(skill, task.getAvatarCandidateId()) : findCandidate(task.getAvatarCandidateId()); + if (!skill.getId().equals(candidate.getSkillId())) { throw notFound(); } + if (candidate.getStatus() == SkillAvatarCandidate.Status.PENDING) { + candidate.unifiedDecision(approved, actor, comment); + if (approved) { activate(skill, candidate); } + else if (Objects.equals(skill.getPendingAvatarId(), candidate.getId())) { skill.setPendingAvatarId(null); } + } else if (approved) { throw new DomainConflictException("error.skill.avatar.notPending"); } + } + ((SkillRepository) skills).save(skill); + log(actor, approved ? "SKILL_AVATAR_APPROVE" : "SKILL_AVATAR_REJECT", skill.getId(), + new AuditRequestContext(null, null), AuditDetail.of("reviewTaskId", task.getId())); + } + + @Override + public void withdraw(ReviewTask task) { + entityManager.refresh(task); + if (task.getStatus() != ReviewTaskStatus.PENDING) { + throw new DomainConflictException("error.skill.avatar.notPending"); + } + if (task.getAvatarCandidateId() == null) { return; } + Skill skill = skills.findLockedById(task.getSkillId()).orElseThrow(() -> notFound()); + SkillAvatarCandidate candidate = findCandidate(task.getAvatarCandidateId()); + candidate.supersede(); + if (Objects.equals(skill.getPendingAvatarId(), candidate.getId())) { skill.setPendingAvatarId(null); } + log(task.getSubmittedBy(), "SKILL_AVATAR_WITHDRAW", candidate.getId(), new AuditRequestContext(null, null), + AuditDetail.of("reviewTaskId", task.getId())); + } + + @Transactional + public void prepareRemoval(ReviewTask task, String namespace, String slug, String actor) { + Skill skill = lockedOwnedSkill(namespace, slug, actor); + task.prepareAvatarRemoval(skill.getActiveAvatarId(), skill.getPendingAvatarId()); + reviews.save(task); + } + + @Transactional + public void attachScopeToReview(Long skillId, SkillVisibility audience, ReviewTask task, + String actor, AuditRequestContext context) { + Skill skill = skills.findLockedById(skillId).orElseThrow(() -> notFound()); + if (skill.getActiveAvatarId() == null || skill.getActiveAvatarAudience() == audience) { return; } + SkillAvatarCandidate active = findCandidate(skill.getActiveAvatarId()); + if (active.getObjectKey() == null) { return; } + try (InputStream input = storage.getObject(active.getObjectKey())) { + byte[] image = input.readAllBytes(); + String key = "skill-avatars/" + skillId + "/" + UUID.randomUUID() + ".png"; + storage.putObject(key, new ByteArrayInputStream(image), image.length, "image/png"); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCompletion(int result) { if (result != STATUS_COMMITTED) { storage.deleteObject(key); } } + }); + SkillAvatarStatusResponse status = createStoredCandidate(skill, namespaceSlug(skill), skill.getOwnerId(), actor, + "SKILL_AVATAR_SCOPE_REVIEW", key, active.getContentSha256(), audience, context, task); + if ("PENDING".equals(status.machineStatus())) { + events.publishEvent(new SkillAvatarUploadService.AvatarSubmittedEvent(status.candidateId(), context)); + } + } catch (IOException failure) { throw new IllegalStateException("Cannot read private avatar candidate", failure); } + } + + private SkillAvatarCandidate currentPending(Skill skill, long candidateId) { + if (!Objects.equals(skill.getPendingAvatarId(), candidateId)) { + throw new DomainConflictException("error.skill.avatar.notPending"); + } + SkillAvatarCandidate candidate = findCandidate(candidateId); + if (candidate.getStatus() != SkillAvatarCandidate.Status.PENDING) { + throw new DomainConflictException("error.skill.avatar.notPending"); + } + return candidate; + } + + private void activate(Skill skill, SkillAvatarCandidate candidate) { + if (skill.getActiveAvatarId() != null) { + candidates.findById(skill.getActiveAvatarId()).ifPresent(SkillAvatarCandidate::supersede); + } + candidate.activate(); + skill.activateAvatar(candidate.getId(), candidate.getAudience()); + skill.setPendingAvatarId(null); + } + + private Skill findSkill(String namespaceSlug, String slug) { + Namespace namespace = namespaces.findBySlug(namespaceSlug).orElseThrow(() -> notFound()); + return skills.findByNamespaceIdAndSlug(namespace.getId(), slug).stream() + .filter(skill -> skill.getStatus() == SkillStatus.ACTIVE).findFirst() + .orElseThrow(() -> notFound()); + } + + private Skill findCandidateSkill(String namespaceSlug, String slug, SkillAvatarCandidate candidate) { + Namespace namespace = namespaces.findBySlug(namespaceSlug).orElseThrow(() -> notFound()); + Skill skill = ((SkillRepository) skills).findById(candidate.getSkillId()).orElseThrow(() -> notFound()); + if (!skill.getNamespaceId().equals(namespace.getId()) || !skill.getSlug().equals(slug) + || skill.getStatus() != SkillStatus.ACTIVE) { + throw notFound(); + } + return skill; + } + + private Skill findOwnedSkill(String namespaceSlug, String slug, String actor) { + if (actor == null) { + throw new DomainForbiddenException("error.skill.avatar.ownerOnly"); + } + Namespace namespace = namespaces.findBySlug(namespaceSlug).orElseThrow(() -> notFound()); + return skills.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), slug, actor) + .filter(skill -> skill.getStatus() == SkillStatus.ACTIVE) + .orElseThrow(() -> new DomainForbiddenException("error.skill.avatar.ownerOnly")); + } + + private Skill lockedOwnedSkill(String namespaceSlug, String slug, String actor) { + return lockAndRefresh(findOwnedSkill(namespaceSlug, slug, actor)); + } + + private Skill lockedSkill(String namespaceSlug, String slug) { + return lockAndRefresh(findSkill(namespaceSlug, slug)); + } + + private Skill lockAndRefresh(Skill skill) { + Skill locked = skills.findLockedById(skill.getId()).orElseThrow(() -> notFound()); + // Publication may have local changes; flush them before refreshing a previously loaded row after the lock. + skills.flush(); + entityManager.refresh(locked); + return locked; + } + + private SkillAvatarCandidate findCandidate(Long candidateId) { + return candidates.findById(candidateId).orElseThrow(() -> notFound()); + } + + private String namespaceSlug(Skill skill) { + return namespaces.findById(skill.getNamespaceId()).orElseThrow(() -> notFound()).getSlug(); + } + + private void requireOwner(Skill skill, String actor) { + if (actor == null || !skill.getOwnerId().equals(actor)) { + throw new DomainForbiddenException("error.skill.avatar.ownerOnly"); + } + } + + private void requireReviewer(Skill skill, String actor, Map roles, + Set platformRoles) { + if (actor == null || !canReview(skill, actor, roles, platformRoles)) { + throw new DomainForbiddenException("error.skill.avatar.reviewerOnly"); + } + } + + private boolean canReview(Skill skill, String actor, Map roles, + Set platformRoles) { + if (actor == null) { + return false; + } + Namespace namespace = namespaces.findById(skill.getNamespaceId()).orElseThrow(() -> notFound()); + if (reviewPermissions != null) { + return reviewPermissions.canReviewNamespace(skill.getNamespaceId(), namespace.getType(), + roles == null ? Map.of() : roles, platformRoles == null ? Set.of() : platformRoles); + } + if (namespace.getType() == NamespaceType.GLOBAL) { + return platformRoles != null && (platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN")); + } + NamespaceRole role = roles == null ? null : roles.get(skill.getNamespaceId()); + return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; + } + + private SkillAvatarStatusResponse status(Skill skill, SkillAvatarCandidate candidate, String namespaceSlug) { + return new SkillAvatarStatusResponse(SkillAvatarUrls.current(skill, namespaceSlug), + candidate == null ? null : candidate.getId(), + candidate == null ? null : candidate.getStatus().name(), + candidate == null ? null : candidate.getMachineStatus().name(), + candidate == null ? null : candidate.getReviewStatus().name(), + candidate == null ? null : candidate.getReviewReason(), + candidate == null || candidate.getObjectKey() == null ? null + : SkillAvatarUrls.candidate(namespaceSlug, skill, candidate.getId()), + namespaceSlug, skill.getSlug(), + candidate == null ? null : candidate.getMachineNextRetryAt(), + candidate != null && candidate.getMachineAttemptCount() >= 3); + } + + private String sha256(byte[] image) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(image)); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } + + private void log(String actor, String action, Long targetId, AuditRequestContext context, String detail) { + audit.record(actor, action, "SKILL_AVATAR", targetId, requestIds.current(), + context.clientIp(), context.userAgent(), detail); + } + + private DomainNotFoundException notFound() { + return new DomainNotFoundException("error.skill.avatar.notFound"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarImageProcessor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarImageProcessor.java new file mode 100644 index 00000000..076f80c2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarImageProcessor.java @@ -0,0 +1,79 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import org.springframework.stereotype.Component; + +/** Decodes a final crop and writes fresh pixels, without client-supplied metadata. */ +@Component +public class SkillAvatarImageProcessor { + public static final int MAX_BYTES = 2 * 1024 * 1024; + public static final int MAX_SIDE = 1024; + + public byte[] normalize(byte[] input) { + if (input == null || input.length == 0 || input.length > MAX_BYTES) { + throw invalidImage(); + } + boolean png = input.length >= 8 && (input[0] & 0xff) == 0x89 + && input[1] == 'P' && input[2] == 'N' && input[3] == 'G' + && (input[4] & 0xff) == 13 && input[5] == 10 && (input[6] & 0xff) == 26 && input[7] == 10; + boolean jpeg = input.length >= 3 && (input[0] & 0xff) == 0xff + && (input[1] & 0xff) == 0xd8 && (input[2] & 0xff) == 0xff; + if (!png && !jpeg) { + throw invalidImage(); + } + try (ImageInputStream stream = ImageIO.createImageInputStream(new ByteArrayInputStream(input))) { + if (stream == null) { + throw invalidImage(); + } + var readers = ImageIO.getImageReaders(stream); + if (!readers.hasNext()) { + throw invalidImage(); + } + ImageReader reader = readers.next(); + try { + if (!((png && "png".equalsIgnoreCase(reader.getFormatName())) + || (jpeg && "jpeg".equalsIgnoreCase(reader.getFormatName())))) { + throw invalidImage(); + } + reader.setInput(stream, true, true); + int width = reader.getWidth(0); + int height = reader.getHeight(0); + if (width < 1 || height < 1 || width != height || width > MAX_SIDE) { + throw invalidImage(); + } + BufferedImage decoded = reader.read(0); + if (decoded == null) { + throw invalidImage(); + } + BufferedImage clean = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = clean.createGraphics(); + try { + graphics.drawImage(decoded, 0, 0, null); + } finally { + graphics.dispose(); + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + if (!ImageIO.write(clean, "png", output) || output.size() > MAX_BYTES) { + throw invalidImage(); + } + return output.toByteArray(); + } finally { + reader.dispose(); + } + } catch (IOException | IllegalArgumentException failure) { + throw invalidImage(); + } + } + + private DomainBadRequestException invalidImage() { + return new DomainBadRequestException("error.skill.avatar.invalidImage"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUploadService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUploadService.java new file mode 100644 index 00000000..7d0360a8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUploadService.java @@ -0,0 +1,159 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import org.springframework.beans.factory.annotation.Autowired; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillAvatarUpload; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.dto.SkillAvatarStatusResponse; +import com.iflytek.skillhub.dto.SkillAvatarUploadResponse; +import com.iflytek.skillhub.infra.jpa.SkillAvatarUploadJpaRepository; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.UUID; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Service +public class SkillAvatarUploadService { + private final SkillAvatarUploadJpaRepository uploads; + private final SkillAvatarImageProcessor images; + private final ObjectStorageService storage; + private final SkillAvatarAppService avatars; + private final ApplicationEventPublisher events; + private ReviewTaskRepository reviews; + + @Autowired + public void setReviews(ReviewTaskRepository reviews) { this.reviews = reviews; } + + + public SkillAvatarUploadService(SkillAvatarUploadJpaRepository uploads, SkillAvatarImageProcessor images, + ObjectStorageService storage, SkillAvatarAppService avatars, ApplicationEventPublisher events) { + this.uploads = uploads; + this.images = images; + this.storage = storage; + this.avatars = avatars; + this.events = events; + } + + @Transactional + public SkillAvatarUploadResponse upload(String actor, byte[] image) { + requireAuthenticated(actor); + byte[] normalized = images.normalize(image); + String id = UUID.randomUUID().toString(); + String key = "skill-avatars/uploads/" + id + ".png"; + storage.putObject(key, new ByteArrayInputStream(normalized), normalized.length, "image/png"); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCompletion(int result) { + if (result != STATUS_COMMITTED) { + storage.deleteObject(key); + } + } + }); + Instant expiresAt = Instant.now().plusSeconds(24 * 60 * 60); + uploads.saveAndFlush(new SkillAvatarUpload(id, actor, key, sha256(normalized), expiresAt)); + return new SkillAvatarUploadResponse(id, "/api/web/skills/avatar-uploads/" + id + "/image", expiresAt); + } + + @Transactional(readOnly = true) + public InputStream preview(String id, String actor) { + SkillAvatarUpload upload = uploads.findById(id).orElseThrow(() -> notFound()); + requireUploader(upload, actor); + if (upload.getObjectKey() == null || !upload.getExpiresAt().isAfter(Instant.now())) { + throw notFound(); + } + return storage.getObject(upload.getObjectKey()); + } + + @Transactional(readOnly = true) + public void validateReference(String namespace, String slug, String actor, String id) { + SkillAvatarUpload upload = uploads.findById(id).orElseThrow(() -> notFound()); + requireUploader(upload, actor); + if (upload.getCandidateId() != null) { + avatars.submittedStatus(namespace, slug, actor, upload.getCandidateId()); + } else if (upload.getObjectKey() == null || !upload.getExpiresAt().isAfter(Instant.now())) { + throw new DomainBadRequestException("error.skill.avatar.uploadExpired"); + } else if (!storage.exists(upload.getObjectKey())) { + throw new DomainBadRequestException("error.skill.avatar.invalidUpload"); + } + } + + @Transactional + public SkillAvatarStatusResponse submit(String namespace, String slug, String actor, String id, + AuditRequestContext context) { + return submitForAudience(namespace, slug, actor, id, null, context); + } + + @Transactional + public SkillAvatarStatusResponse submitForAudience(String namespace, String slug, String actor, String id, + SkillVisibility audience, AuditRequestContext context) { + return submitForReview(namespace, slug, actor, id, audience, context, null); + } + + @Transactional + public SkillAvatarStatusResponse submitForReview(String namespace, String slug, String actor, String id, + SkillVisibility audience, AuditRequestContext context, ReviewTask review) { + // Publication already holds this Skill lock; standalone submission follows the same lock order. + avatars.lockOwnedSkill(namespace, slug, actor); + SkillAvatarUpload upload = uploads.findLockedById(id).orElseThrow(() -> notFound()); + requireUploader(upload, actor); + if (upload.getCandidateId() != null) { + if (review != null && reviews.findByAvatarCandidateIdAndStatus(upload.getCandidateId(), review.getStatus()) + .stream().noneMatch(bound -> bound.getId().equals(review.getId()))) { + throw new DomainConflictException("error.skill.avatar.invalidUpload"); + } + // Owner and exact Skill binding are rechecked even for a repeated successful submission. + return avatars.submittedStatus(namespace, slug, actor, upload.getCandidateId()); + } + if (upload.getObjectKey() == null || !upload.getExpiresAt().isAfter(Instant.now())) { + throw new DomainBadRequestException("error.skill.avatar.uploadExpired"); + } + if (!storage.exists(upload.getObjectKey())) { + throw new DomainBadRequestException("error.skill.avatar.invalidUpload"); + } + SkillAvatarStatusResponse status = review == null + ? avatars.submitStored(namespace, slug, actor, upload.getObjectKey(), upload.getContentSha256(), audience, context) + : avatars.submitStoredForReview(namespace, slug, actor, upload.getObjectKey(), upload.getContentSha256(), audience, context, review); + upload.consume(status.candidateId()); + if ("PENDING".equals(status.machineStatus()) && "PENDING".equals(status.candidateStatus())) { + events.publishEvent(new AvatarSubmittedEvent(status.candidateId(), context)); + } + return status; + } + + public record AvatarSubmittedEvent(long candidateId, AuditRequestContext context) {} + + private void requireAuthenticated(String actor) { + if (actor == null) { + throw new DomainForbiddenException("error.skill.avatar.ownerOnly"); + } + } + private void requireUploader(SkillAvatarUpload upload, String actor) { + requireAuthenticated(actor); + if (!upload.getUploadedBy().equals(actor)) { + throw new DomainForbiddenException("error.skill.avatar.ownerOnly"); + } + } + private String sha256(byte[] image) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(image)); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } + private DomainNotFoundException notFound() { + return new DomainNotFoundException("error.skill.avatar.notFound"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUrls.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUrls.java new file mode 100644 index 00000000..1f980765 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUrls.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +public final class SkillAvatarUrls { + private SkillAvatarUrls() {} + + public static String current(Skill skill, String namespaceSlug) { + return current(skill.getActiveAvatarId(), skill.getActiveAvatarAudience(), skill.getVisibility(), + namespaceSlug, skill.getSlug()); + } + + public static String current(Long activeAvatarId, SkillVisibility approved, SkillVisibility current, + String namespaceSlug, String skillSlug) { + if (activeAvatarId == null || approved == null) { + return null; + } + if (current == SkillVisibility.PUBLIC && approved != SkillVisibility.PUBLIC + || current == SkillVisibility.NAMESPACE_ONLY && approved == SkillVisibility.PRIVATE) { + return null; + } + return "/api/v1/skills/" + segment(namespaceSlug) + "/" + segment(skillSlug) + "/avatar"; + } + + public static String candidate(String namespaceSlug, Skill skill, long candidateId) { + return "/api/v1/skills/" + segment(namespaceSlug) + "/" + segment(skill.getSlug()) + + "/avatar/candidates/" + candidateId + "/image"; + } + + private static String segment(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java new file mode 100644 index 00000000..ff4061e9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java @@ -0,0 +1,75 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import org.springframework.beans.factory.annotation.Autowired; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import java.nio.charset.StandardCharsets; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Commits the skill version and the requested avatar action as one database operation. */ +@Service +public class SkillPublishAppService { + public enum AvatarAction { KEEP, REPLACE, REMOVE } + private final SkillPublishService publications; + private final SkillAvatarUploadService uploads; + private final SkillAvatarAppService avatars; + private final SkillMetadataParser metadata; + private ReviewTaskRepository reviews; + @Autowired + public void setReviews(ReviewTaskRepository reviews) { this.reviews = reviews; } + + + public SkillPublishAppService(SkillPublishService publications, SkillAvatarUploadService uploads, + SkillAvatarAppService avatars, SkillMetadataParser metadata) { + this.publications = publications; + this.uploads = uploads; + this.avatars = avatars; + this.metadata = metadata; + } + + @Transactional + public SkillPublishService.PublishResult publish(String namespace, List entries, String actor, + SkillVisibility visibility, Set roles, boolean confirmWarnings, AvatarAction action, + String uploadId, String expectedSlug, AuditRequestContext context) { + if (action == AvatarAction.REPLACE && (uploadId == null || uploadId.isBlank()) + || action != AvatarAction.REPLACE && uploadId != null && !uploadId.isBlank()) { + throw new DomainBadRequestException("error.skill.avatar.invalidUpload"); + } + if (action == AvatarAction.REPLACE || expectedSlug != null && !expectedSlug.isBlank()) { + PackageEntry skillMd = entries.stream().filter(entry -> "SKILL.md".equals(entry.path())).findFirst() + .orElseThrow(() -> new DomainBadRequestException("error.skill.publish.skillMd.notFound")); + String targetSlug = SlugValidator.slugify(metadata.parse(new String(skillMd.content(), StandardCharsets.UTF_8)).name()); + if (expectedSlug != null && !expectedSlug.isBlank() && !expectedSlug.equals(targetSlug)) { + throw new DomainBadRequestException("error.skill.avatar.skillMismatch"); + } + if (action == AvatarAction.REPLACE) { + uploads.validateReference(namespace, targetSlug, actor, uploadId); + } + } + SkillPublishService.PublishResult result = publications.publishFromEntries( + namespace, entries, actor, visibility, roles, confirmWarnings); + var review = reviews.findBySkillVersionIdAndStatus( + result.version().getId(), ReviewTaskStatus.PENDING).orElse(null); + if (action == AvatarAction.REPLACE) { + uploads.submitForReview(namespace, result.slug(), actor, uploadId, visibility, context, review); + } else if (action == AvatarAction.REMOVE && review != null) { + avatars.prepareRemoval(review, namespace, result.slug(), actor); + } else if (action == AvatarAction.REMOVE) { + avatars.remove(namespace, result.slug(), actor, false, null, Map.of(), roles, context); + } + if (action == AvatarAction.KEEP && review != null) { + avatars.attachScopeToReview(review.getSkillId(), visibility, review, actor, context); + } + return result; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 458a30ee..60bb9cdd 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -187,7 +187,7 @@ public class SkillSearchAppService { normalizeLabelSlugs(labelSlugs), requireInstallableLatest )); - List pageItems = mapVisibleSkillSummaries(result.skillIds()); + List pageItems = mapVisibleSkillSummaries(result.skillIds(), scope.userId()); return new SearchResponse(pageItems, result.total(), page, size); } @@ -202,7 +202,7 @@ public class SkillSearchAppService { .toList(); } - private List mapVisibleSkillSummaries(List skillIds) { + private List mapVisibleSkillSummaries(List skillIds, String userId) { if (skillIds.isEmpty()) { return List.of(); } @@ -235,7 +235,8 @@ public class SkillSearchAppService { skill, namespaceSlugsById, ownersById, - projectionsBySkillId.get(skill.getId()) + projectionsBySkillId.get(skill.getId()), + userId )) .toList(); } @@ -244,7 +245,8 @@ public class SkillSearchAppService { Skill skill, Map namespaceSlugsById, Map ownersById, - SkillLifecycleProjectionService.Projection projection) { + SkillLifecycleProjectionService.Projection projection, + String userId) { String namespaceSlug = namespaceSlugsById.get(skill.getNamespaceId()); UserAccount owner = ownersById.get(skill.getOwnerId()); SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion(); @@ -274,7 +276,9 @@ public class SkillSearchAppService { headlineVersion != null ? complianceSnapshotProjectionService.fromParsedMetadataJson(headlineVersion.parsedMetadataJson()) : null, - null + null, + skill.getVisibility() == com.iflytek.skillhub.domain.skill.SkillVisibility.PRIVATE + && !skill.getOwnerId().equals(userId) ? null : SkillAvatarUrls.current(skill, namespaceSlug) ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/XfyunAvatarModerationProvider.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/XfyunAvatarModerationProvider.java new file mode 100644 index 00000000..ff6586db --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/XfyunAvatarModerationProvider.java @@ -0,0 +1,118 @@ +package com.iflytek.skillhub.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** Xfyun image compliance API v2. Its documented query contains a secret: never log URI or exceptions. */ +@Component +public class XfyunAvatarModerationProvider implements AvatarModerationProvider { + private static final String ENDPOINT = "https://audit.iflyaisol.com/audit/v2/image"; + private static final DateTimeFormatter UTC_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ"); + + private final String appId; + private final String accessKeyId; + private final String accessKeySecret; + private final ObjectMapper mapper; + private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build(); + + public XfyunAvatarModerationProvider( + @Value("${skillhub.avatar.moderation.xfyun.app-id:}") String appId, + @Value("${skillhub.avatar.moderation.xfyun.access-key-id:}") String accessKeyId, + @Value("${skillhub.avatar.moderation.xfyun.access-key-secret:}") String accessKeySecret, + ObjectMapper mapper) { + this.appId = appId; + this.accessKeyId = accessKeyId; + this.accessKeySecret = accessKeySecret; + this.mapper = mapper; + } + + @Override public Context context() { + return new Context("image", "v2", "xfyun-image/1:code=000000;pass=PASS;block=BLOCK;other=ERROR", null); + } + + @Override public String code() { return "xfyun"; } + + @Override public boolean configured() { + return !appId.isBlank() && !accessKeyId.isBlank() && !accessKeySecret.isBlank(); + } + + @Override public Verdict moderate(byte[] normalizedPng) { + if (!configured()) { + return Verdict.ERROR; + } + try { + TreeMap params = new TreeMap<>(Map.of( + "accessKeyId", accessKeyId, + "accessKeySecret", accessKeySecret, + "appId", appId, + "modeType", "base64", + "utc", OffsetDateTime.now(ZoneOffset.UTC).format(UTC_FORMAT), + "uuid", UUID.randomUUID().toString())); + String base = encode(params); + params.put("signature", sign(base)); + String payload = mapper.writeValueAsString(Map.of("content", Base64.getEncoder().encodeToString(normalizedPng))); + HttpRequest request = HttpRequest.newBuilder(URI.create(ENDPOINT + "?" + encode(params))) + .timeout(Duration.ofSeconds(8)) + .header("Content-Type", "application/json;charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(payload)).build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 200 ? parse(response.body()) : Verdict.ERROR; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return Verdict.ERROR; + } catch (IOException | IllegalArgumentException | InvalidKeyException | NoSuchAlgorithmException failure) { + // The request URI includes accessKeySecret. Do not log or propagate exception text. + return Verdict.ERROR; + } + } + + Verdict parse(String response) throws IOException { + JsonNode body = mapper.readTree(response); + if (!"000000".equals(body.path("code").asText())) { + return Verdict.ERROR; + } + return switch (body.path("data").path("result").path("suggest").asText()) { + case "pass" -> Verdict.PASS; + case "block" -> Verdict.BLOCK; + default -> Verdict.ERROR; + }; + } + + private String sign(String base) throws NoSuchAlgorithmException, InvalidKeyException { + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(accessKeySecret.getBytes(StandardCharsets.UTF_8), "HmacSHA1")); + return Base64.getEncoder().encodeToString(mac.doFinal(base.getBytes(StandardCharsets.UTF_8))); + } + + private String encode(Map params) { + StringBuilder query = new StringBuilder(); + params.forEach((key, value) -> { + if (value != null && !value.isEmpty()) { + if (!query.isEmpty()) query.append('&'); + query.append(key).append('=').append(URLEncoder.encode(value, StandardCharsets.UTF_8)); + } + }); + return query.toString(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarCleanupTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarCleanupTask.java new file mode 100644 index 00000000..73c62c96 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarCleanupTask.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.task; + +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository; +import com.iflytek.skillhub.infra.jpa.SkillAvatarUploadJpaRepository; +import org.springframework.data.domain.PageRequest; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.time.Instant; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class SkillAvatarCleanupTask { + private final SkillAvatarCandidateJpaRepository candidates; + private final ObjectStorageService storage; + private final SkillAvatarUploadJpaRepository uploads; + + public SkillAvatarCleanupTask(SkillAvatarCandidateJpaRepository candidates, ObjectStorageService storage, SkillAvatarUploadJpaRepository uploads) { + this.candidates = candidates; + this.storage = storage; + this.uploads = uploads; + } + + @Scheduled(cron = "0 35 2 * * ?") + @Transactional + public void removeExpiredImages() { + for (var upload : uploads.findExpired(Instant.now(), PageRequest.of(0, 100))) { + storage.deleteObject(upload.getObjectKey()); + upload.clearObjectKey(); + } + for (SkillAvatarCandidate candidate : candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(Instant.now())) { + storage.deleteObject(candidate.getObjectKey()); + candidate.clearObjectKey(); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarMachineRecoveryTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarMachineRecoveryTask.java new file mode 100644 index 00000000..ddc40e18 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarMachineRecoveryTask.java @@ -0,0 +1,54 @@ +package com.iflytek.skillhub.task; + +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.SkillAvatarAppService; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** Recovers ordinary uploads abandoned before or during machine moderation. */ +@Component +public class SkillAvatarMachineRecoveryTask { + private static final Logger log = LoggerFactory.getLogger(SkillAvatarMachineRecoveryTask.class); + private final SkillAvatarCandidateJpaRepository candidates; + private final SkillAvatarAppService avatars; + private final AtomicLong lastScannedId = new AtomicLong(); + + public SkillAvatarMachineRecoveryTask(SkillAvatarCandidateJpaRepository candidates, SkillAvatarAppService avatars) { + this.candidates = candidates; + this.avatars = avatars; + } + + @Scheduled(fixedDelay = 300000) + public void recover() { + List batch = findAfter(lastScannedId.get()); + if (batch.isEmpty() && lastScannedId.get() != 0L) { + lastScannedId.set(0L); + batch = findAfter(0L); + } + for (SkillAvatarCandidate candidate : batch) { + lastScannedId.set(candidate.getId()); + try { + avatars.processMachine(candidate.getId(), new AuditRequestContext(null, null)); + } catch (Exception failure) { + // Provider exceptions may contain credential-bearing URLs; log no message or stack. + log.warn("Avatar machine recovery failed for candidate {}, failureType={}", + candidate.getId(), failure.getClass().getSimpleName()); + } + } + } + + private List findAfter(Long afterId) { + Instant now = Instant.now(); + return candidates.findAbandonedMachineCandidates(afterId, SkillAvatarCandidate.Status.PENDING, + SkillAvatarCandidate.MachineStatus.PENDING, SkillAvatarCandidate.MachineStatus.PROCESSING, + now.minusSeconds(600), now, PageRequest.of(0, 100)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarScopeReviewRecoveryTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarScopeReviewRecoveryTask.java new file mode 100644 index 00000000..cc2721e7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarScopeReviewRecoveryTask.java @@ -0,0 +1,50 @@ +package com.iflytek.skillhub.task; + +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.infra.jpa.SkillJpaRepository; +import com.iflytek.skillhub.listener.SkillAvatarScopeReviewListener; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import java.time.Instant; + +/** Recovers scope reviews lost after publication because private storage was unavailable. */ +@Component +public class SkillAvatarScopeReviewRecoveryTask { + private final SkillJpaRepository skills; + private final SkillAvatarScopeReviewListener listener; + private final AtomicLong lastScannedId = new AtomicLong(); + + public SkillAvatarScopeReviewRecoveryTask(SkillJpaRepository skills, SkillAvatarScopeReviewListener listener) { + this.skills = skills; + this.listener = listener; + } + + @Scheduled(fixedDelay = 300000) + public void recover() { + List batch = findAfter(lastScannedId.get()); + if (batch.isEmpty() && lastScannedId.get() != 0L) { + lastScannedId.set(0L); + batch = findAfter(0L); + } + for (var skill : batch) { + lastScannedId.set(skill.getId()); + listener.reviewScope(skill.getId(), null); + } + } + + private List findAfter(Long afterId) { + Instant now = Instant.now(); + return skills.findScopeReviewRecoveryCandidates(afterId, SkillStatus.ACTIVE, SkillVisibility.PRIVATE, + SkillVisibility.NAMESPACE_ONLY, SkillVisibility.PUBLIC, + SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.MachineStatus.PENDING, + SkillAvatarCandidate.MachineStatus.PROCESSING, SkillAvatarCandidate.MachineStatus.ERROR, + 3, SkillAvatarCandidate.Status.REJECTED, now.minusSeconds(600), now, PageRequest.of(0, 100)); + } +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V70__skill_avatars.sql b/server/skillhub-app/src/main/resources/db/migration/V70__skill_avatars.sql new file mode 100644 index 00000000..19171b2a --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V70__skill_avatars.sql @@ -0,0 +1,37 @@ +ALTER TABLE skill ADD COLUMN active_avatar_id BIGINT; +ALTER TABLE skill ADD COLUMN active_avatar_audience VARCHAR(32); +ALTER TABLE skill ADD COLUMN pending_avatar_id BIGINT; + +CREATE TABLE skill_avatar_candidate ( + id BIGSERIAL PRIMARY KEY, + skill_id BIGINT NOT NULL REFERENCES skill(id) ON DELETE CASCADE, + submitted_by VARCHAR(128) NOT NULL, + object_key VARCHAR(512), + content_sha256 VARCHAR(64) NOT NULL, + audience VARCHAR(32) NOT NULL, + machine_enabled BOOLEAN NOT NULL, + manual_enabled BOOLEAN NOT NULL, + machine_provider VARCHAR(32), + scope_review_version_id BIGINT, + scope_review BOOLEAN NOT NULL DEFAULT FALSE, + machine_attempt_id VARCHAR(36), + machine_lease_until TIMESTAMP, + machine_next_retry_at TIMESTAMP, + machine_attempt_count INTEGER NOT NULL DEFAULT 0, + machine_status VARCHAR(32) NOT NULL, + review_status VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + review_reason TEXT, + reviewed_by VARCHAR(128), + reviewed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + discard_at TIMESTAMP +); + +CREATE INDEX idx_avatar_candidate_skill ON skill_avatar_candidate(skill_id, created_at DESC); +CREATE INDEX idx_avatar_candidate_review ON skill_avatar_candidate(status, review_status, created_at); +CREATE INDEX idx_avatar_candidate_discard ON skill_avatar_candidate(discard_at) WHERE object_key IS NOT NULL; + +INSERT INTO system_setting(setting_key, value_json) +VALUES ('skill.avatar-review', '{"machineEnabled":false,"manualEnabled":true}'::jsonb); diff --git a/server/skillhub-app/src/main/resources/db/migration/V71__namespace_invitation.sql b/server/skillhub-app/src/main/resources/db/migration/V71__namespace_invitation.sql new file mode 100644 index 00000000..ed42c0be --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V71__namespace_invitation.sql @@ -0,0 +1,9 @@ +CREATE TABLE namespace_invitation ( + namespace_id BIGINT PRIMARY KEY REFERENCES namespace(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, + issued_by VARCHAR(128) NOT NULL, + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_by VARCHAR(128), + consumed_at TIMESTAMPTZ +); diff --git a/server/skillhub-app/src/main/resources/db/migration/V72__staged_skill_avatar_uploads.sql b/server/skillhub-app/src/main/resources/db/migration/V72__staged_skill_avatar_uploads.sql new file mode 100644 index 00000000..21946d18 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V72__staged_skill_avatar_uploads.sql @@ -0,0 +1,9 @@ +CREATE TABLE skill_avatar_upload ( + id VARCHAR(36) PRIMARY KEY, + uploaded_by VARCHAR(128) NOT NULL, + object_key VARCHAR(512), + content_sha256 VARCHAR(64) NOT NULL, + expires_at TIMESTAMP NOT NULL, + candidate_id BIGINT +); +CREATE INDEX idx_avatar_upload_expiry ON skill_avatar_upload(expires_at) WHERE candidate_id IS NULL AND object_key IS NOT NULL; diff --git a/server/skillhub-app/src/main/resources/db/migration/V73__unified_skill_avatar_review.sql b/server/skillhub-app/src/main/resources/db/migration/V73__unified_skill_avatar_review.sql new file mode 100644 index 00000000..c7083fc6 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V73__unified_skill_avatar_review.sql @@ -0,0 +1,21 @@ +ALTER TABLE review_task ADD COLUMN avatar_candidate_id BIGINT REFERENCES skill_avatar_candidate(id); +ALTER TABLE review_task ADD COLUMN avatar_removal BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE review_task ADD COLUMN avatar_removal_active_id BIGINT; +ALTER TABLE review_task ADD COLUMN avatar_removal_pending_id BIGINT; +ALTER TABLE skill_avatar_candidate ADD COLUMN review_bound BOOLEAN NOT NULL DEFAULT FALSE; +CREATE INDEX idx_review_avatar_candidate ON review_task(avatar_candidate_id); +-- Retain existing pending image applications when upgrading to the shared queue. +INSERT INTO review_task (skill_id, namespace_id, subject_type, subject_id, subject_version, + status, version, submitted_by, submitted_at, avatar_candidate_id) +SELECT s.id, s.namespace_id, 'SKILL_METADATA', s.id, 'avatar:' || c.id, + 'PENDING', 1, c.submitted_by, c.created_at, c.id +FROM skill_avatar_candidate c JOIN skill s ON s.id = c.skill_id AND s.pending_avatar_id = c.id +WHERE c.status = 'PENDING' AND c.review_status IN ('PENDING', 'APPEAL_PENDING'); +UPDATE skill_avatar_candidate SET review_bound = TRUE WHERE id IN + (SELECT avatar_candidate_id FROM review_task WHERE avatar_candidate_id IS NOT NULL); + +-- Cloud model versions are not supplied by the current APIs; historical evidence stays unknown. +ALTER TABLE skill_avatar_candidate ADD COLUMN machine_service_id VARCHAR(128); +ALTER TABLE skill_avatar_candidate ADD COLUMN machine_api_version VARCHAR(64); +ALTER TABLE skill_avatar_candidate ADD COLUMN machine_policy_revision VARCHAR(256); +ALTER TABLE skill_avatar_candidate ADD COLUMN machine_model_version VARCHAR(128); diff --git a/server/skillhub-app/src/main/resources/db/migration/V74__namespace_invitation_history.sql b/server/skillhub-app/src/main/resources/db/migration/V74__namespace_invitation_history.sql new file mode 100644 index 00000000..433b1ada --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V74__namespace_invitation_history.sql @@ -0,0 +1,25 @@ +CREATE TABLE namespace_invitation_history ( + id VARCHAR(36) PRIMARY KEY, + namespace_id BIGINT NOT NULL REFERENCES namespace(id) ON DELETE CASCADE, + issued_by VARCHAR(128) NOT NULL, + issued_by_name VARCHAR(255), + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_by VARCHAR(128), + consumed_by_name VARCHAR(255), + consumed_at TIMESTAMPTZ, + ended_reason VARCHAR(16), + ended_at TIMESTAMPTZ, + legacy BOOLEAN NOT NULL DEFAULT FALSE +); +CREATE INDEX idx_namespace_invitation_history_page + ON namespace_invitation_history(namespace_id, issued_at DESC, id DESC); +ALTER TABLE namespace_invitation ADD COLUMN history_id VARCHAR(36); +UPDATE namespace_invitation SET history_id = gen_random_uuid()::text; +INSERT INTO namespace_invitation_history + (id, namespace_id, issued_by, issued_at, expires_at, consumed_by, consumed_at, legacy) +SELECT history_id, namespace_id, issued_by, issued_at, expires_at, consumed_by, consumed_at, TRUE +FROM namespace_invitation; +ALTER TABLE namespace_invitation ALTER COLUMN history_id SET NOT NULL; +ALTER TABLE namespace_invitation ADD CONSTRAINT fk_namespace_invitation_history + FOREIGN KEY (history_id) REFERENCES namespace_invitation_history(id); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 4302f540..7bcf1b9e 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -88,6 +88,8 @@ label.translation.locale.conflict=Duplicate label translation locale error.namespace.slug.exists=Namespace slug ''{0}'' already exists error.namespace.id.notFound=Namespace not found: {0} error.namespace.slug.notFound=Namespace not found: {0} +error.namespace.invitation.invalid=Invitation link is invalid or expired +error.namespace.invite.replaceConfirmationRequired=Confirm replacement of the current invitation error.namespace.membership.required=Namespace membership required error.namespace.global.members.platformAdmin.required=Only platform user administrators can list global namespace members error.namespace.admin.required=Namespace owner or admin role required @@ -129,6 +131,14 @@ review_task.not_found_for_version=No pending review submission found for version error.skill.publish.summary.tooLong=Skill description must not exceed {0} characters error.skill.notFound=Skill not found: {0} error.skill.access.denied=Access denied to skill: {0} +error.skill.avatar.invalidImage=Upload a square PNG or JPEG image up to 1024 pixels and 2 MB. +error.skill.avatar.providerUnavailable=The configured image moderation provider is unavailable. +error.skill.avatar.notPending=This avatar request is no longer pending. +error.skill.avatar.machineIncomplete=Wait for image moderation before reviewing this avatar. +error.skill.avatar.reasonRequired=Enter a reason for this decision. +error.skill.avatar.ownerOnly=Only the skill owner can change its avatar. +error.skill.avatar.reviewerOnly=You cannot review this skill avatar. +error.skill.avatar.notFound=Skill avatar not found. error.skill.status.notActive=Skill is not active error.skill.lifecycle.noPermission=Only the skill owner or namespace admin can manage this skill error.skill.version.exists=Version already exists: {0} @@ -321,3 +331,7 @@ error.authoring.finding.applied=The fix for finding {0} was already applied error.authoring.finding.suggestionUnreadable=The fix suggestion of finding {0} could not be read error.authoring.stream.tooManySubscribers=Too many validation event subscribers. Close an existing connection and retry. + +error.skill.avatar.uploadExpired=The uploaded avatar expired. Please upload it again. +error.skill.avatar.invalidUpload=Select a valid uploaded avatar before submitting. +error.skill.avatar.skillMismatch=The uploaded package does not match the skill being updated. diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 17927428..69ebe259 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -85,6 +85,8 @@ label.translation.locale.conflict=Дублирующаяся локаль пер error.namespace.slug.exists=Slug пространства имён ''{0}'' уже существует error.namespace.id.notFound=Пространство имён не найдено: {0} error.namespace.slug.notFound=Пространство имён не найдено: {0} +error.namespace.invitation.invalid=Ссылка-приглашение недействительна или срок её действия истёк +error.namespace.invite.replaceConfirmationRequired=Подтвердите замену текущего приглашения error.namespace.membership.required=Требуется членство в пространстве имён error.namespace.global.members.platformAdmin.required=Только администраторы пользователей платформы могут просматривать участников глобального пространства имён error.namespace.admin.required=Требуется роль владельца или администратора пространства имён @@ -126,6 +128,14 @@ review_task.not_found_for_version=Не найдена ожидающая зая error.skill.publish.summary.tooLong=Описание скилла не должно превышать {0} символов error.skill.notFound=Скилл не найден: {0} error.skill.access.denied=Нет доступа к скиллу: {0} +error.skill.avatar.invalidImage=Загрузите квадратное изображение PNG или JPEG до 2 МБ и 1024 пикселей. +error.skill.avatar.providerUnavailable=Сервис автоматической проверки изображений недоступен. +error.skill.avatar.notPending=Эта заявка на аватар больше не ожидает рассмотрения. +error.skill.avatar.machineIncomplete=Дождитесь автоматической проверки изображения. +error.skill.avatar.reasonRequired=Укажите причину решения. +error.skill.avatar.ownerOnly=Изменять аватар может только владелец скилла. +error.skill.avatar.reviewerOnly=У вас нет прав на проверку этого аватара. +error.skill.avatar.notFound=Аватар скилла не найден. error.skill.status.notActive=Скилл не активен error.skill.lifecycle.noPermission=Управлять этим скиллом может только владелец скилла или администратор пространства имён error.skill.version.exists=Версия уже существует: {0} @@ -264,3 +274,7 @@ error.authoring.finding.applied=Исправление результата {0} error.authoring.finding.suggestionUnreadable=Не удалось прочитать рекомендацию для результата {0} error.authoring.stream.tooManySubscribers=Достигнут предел подключений к событиям проверки. Закройте существующее подключение и повторите попытку. + +error.skill.avatar.uploadExpired=Срок действия загрузки аватара истёк. Загрузите изображение снова. +error.skill.avatar.invalidUpload=Перед отправкой выберите успешно загруженный аватар. +error.skill.avatar.skillMismatch=Загруженный пакет не соответствует обновляемому навыку. diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 1e8b144e..66c93f16 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -88,6 +88,8 @@ label.translation.locale.conflict=标签翻译 locale 重复 error.namespace.slug.exists=命名空间 slug ''{0}'' 已存在 error.namespace.id.notFound=未找到命名空间:{0} error.namespace.slug.notFound=未找到命名空间:{0} +error.namespace.invitation.invalid=邀请链接无效或已失效 +error.namespace.invite.replaceConfirmationRequired=请先确认替换当前邀请 error.namespace.membership.required=需要先加入该命名空间 error.namespace.global.members.platformAdmin.required=只有平台用户管理员可以查看 global 命名空间成员 error.namespace.admin.required=需要命名空间管理员或所有者权限 @@ -129,6 +131,14 @@ review_task.not_found_for_version=未找到该版本对应的待审核记录:{ error.skill.publish.summary.tooLong=技能描述长度不能超过 {0} 个字符 error.skill.notFound=未找到技能:{0} error.skill.access.denied=没有权限访问技能:{0} +error.skill.avatar.invalidImage=请上传不超过 2 MB、边长不超过 1024 像素的正方形 PNG 或 JPEG 图片。 +error.skill.avatar.providerUnavailable=部署的图片机器审核服务不可用。 +error.skill.avatar.notPending=这条头像申请已失效,不能继续处理。 +error.skill.avatar.machineIncomplete=请等待图片机器审核完成后再处理。 +error.skill.avatar.reasonRequired=请填写处理原因。 +error.skill.avatar.ownerOnly=只有技能所有者可以维护头像。 +error.skill.avatar.reviewerOnly=你没有审核此技能头像的权限。 +error.skill.avatar.notFound=未找到技能头像。 error.skill.status.notActive=技能未处于 ACTIVE 状态 error.skill.lifecycle.noPermission=只有技能所有者或命名空间管理员可以管理该技能 error.skill.version.exists=版本已存在:{0} @@ -323,3 +333,7 @@ error.authoring.finding.applied=问题项 {0} 的修复已被应用 error.authoring.finding.suggestionUnreadable=问题项 {0} 的修复建议无法解析 error.authoring.stream.tooManySubscribers=校验事件连接数量已达上限,请关闭已有连接后重试。 + +error.skill.avatar.uploadExpired=头像上传引用已过期,请重新上传。 +error.skill.avatar.invalidUpload=请选择已上传成功的头像后再提交。 +error.skill.avatar.skillMismatch=上传的技能包与正在更新的技能不一致。 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java index 8ac33168..8a8145af 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java @@ -17,6 +17,7 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; import com.iflytek.skillhub.service.NamespacePortalCommandAppService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -31,6 +32,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.RequestPostProcessor; import java.util.List; +import java.util.Optional; import java.util.Set; import static org.mockito.ArgumentMatchers.any; @@ -80,6 +82,11 @@ class NamespaceBatchMemberControllerTest { @MockBean private UserAccountRepository userAccountRepository; + @BeforeEach + void setUpNamespaceLookup() { + given(namespaceRepository.findIdBySlug("team-a")).willReturn(Optional.of(1L)); + } + @Test void batchAddMembers_partialSuccess_returnsResultsPerRow() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java new file mode 100644 index 00000000..f1d28d9a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java @@ -0,0 +1,182 @@ +package com.iflytek.skillhub.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.portal.NamespaceInvitationController; +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.NamespaceInvitationCreateRequest; +import com.iflytek.skillhub.dto.NamespaceInvitationTokenRequest; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.exception.UnauthorizedException; +import com.iflytek.skillhub.exception.GlobalExceptionHandler; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.security.SensitiveLogSanitizer; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.Test; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Locale; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +class NamespaceInvitationControllerTest { + private final NamespaceInvitationService invitations = mock(NamespaceInvitationService.class); + private final ApiResponseFactory responses = mock(ApiResponseFactory.class); + private final NamespaceInvitationController controller = new NamespaceInvitationController(invitations, responses); + private final HttpServletRequest request = mock(HttpServletRequest.class); + private final HttpServletResponse response = mock(HttpServletResponse.class); + + @Test + void refusesApiTokenEvenWhenItCarriesAnAdminPlatformRole() { + when(request.getSession(false)).thenReturn(mock(HttpSession.class)); + PlatformPrincipal token = new PlatformPrincipal("admin", "Admin", null, null, "api_token", Set.of("SUPER_ADMIN")); + assertThatThrownBy(() -> controller.create("team", new NamespaceInvitationCreateRequest(true), token, request, response)) + .isInstanceOf(UnauthorizedException.class); + verifyNoInteractions(invitations); + } + + @Test + void refusesApiTokenForInvitationStatusAndRevoke() { + when(request.getSession(false)).thenReturn(mock(HttpSession.class)); + PlatformPrincipal token = new PlatformPrincipal("admin", "Admin", null, null, "api_token", Set.of("SUPER_ADMIN")); + + assertThatThrownBy(() -> controller.status("team", token, request, response)) + .isInstanceOf(UnauthorizedException.class); + verifyNoInteractions(invitations); + assertThatThrownBy(() -> controller.revoke("team", token, request, response)) + .isInstanceOf(UnauthorizedException.class); + verifyNoInteractions(invitations); + } + + @Test + void refusesAuthenticationWithoutAnExistingBrowserSession() { + PlatformPrincipal principal = new PlatformPrincipal("owner", "Owner", null, null, "local", Set.of()); + assertThatThrownBy(() -> controller.preview(new com.iflytek.skillhub.dto.NamespaceInvitationTokenRequest("secret"), + principal, request, response)).isInstanceOf(UnauthorizedException.class); + verifyNoInteractions(invitations); + } + + @Test + void sendsNoStoreHeadersBeforeReturningAnInvitation() { + when(request.getSession(false)).thenReturn(mock(HttpSession.class)); + PlatformPrincipal principal = new PlatformPrincipal("owner", "Owner", null, null, "local", Set.of()); + controller.create("team", new NamespaceInvitationCreateRequest(false), principal, request, response); + verify(response).setHeader("Cache-Control", "no-store, private"); + verify(response).setHeader("Pragma", "no-cache"); + verify(invitations).create("team", "owner", false); + } + + @Test + void issueRoutesUsePluralPathsForBothApiPrefixes() throws NoSuchMethodException { + assertThat(NamespaceInvitationController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/v1", "/api/web"); + assertThat(NamespaceInvitationController.class.getMethod("create", String.class, + NamespaceInvitationCreateRequest.class, PlatformPrincipal.class, HttpServletRequest.class, + HttpServletResponse.class).getAnnotation(PostMapping.class).value()) + .containsExactly("/namespaces/{slug}/invitations"); + assertThat(NamespaceInvitationController.class.getMethod("status", String.class, PlatformPrincipal.class, + HttpServletRequest.class, HttpServletResponse.class).getAnnotation(GetMapping.class).value()) + .containsExactly("/namespaces/{slug}/invitations"); + assertThat(NamespaceInvitationController.class.getMethod("revoke", String.class, PlatformPrincipal.class, + HttpServletRequest.class, HttpServletResponse.class).getAnnotation(DeleteMapping.class).value()) + .containsExactly("/namespaces/{slug}/invitations"); + assertThat(NamespaceInvitationController.class.getMethod("preview", NamespaceInvitationTokenRequest.class, + PlatformPrincipal.class, HttpServletRequest.class, HttpServletResponse.class) + .getAnnotation(PostMapping.class).value()).containsExactly("/namespace-invitations/preview"); + assertThat(NamespaceInvitationController.class.getMethod("accept", NamespaceInvitationTokenRequest.class, + PlatformPrincipal.class, HttpServletRequest.class, HttpServletResponse.class) + .getAnnotation(PostMapping.class).value()).containsExactly("/namespace-invitations/accept"); + } + + @Test + void historyRequiresSessionAndNeverReturnsTokensOrHashes() throws Exception { + PlatformPrincipal owner = new PlatformPrincipal("owner", "Owner", null, null, "local", Set.of()); + assertThatThrownBy(() -> controller.history("team", 0, 10, owner, request, response)) + .isInstanceOf(UnauthorizedException.class); + when(request.getSession(false)).thenReturn(mock(HttpSession.class)); + PlatformPrincipal token = new PlatformPrincipal("owner", "Owner", null, null, "api_token", Set.of("SUPER_ADMIN")); + assertThatThrownBy(() -> controller.history("team", 0, 10, token, request, response)) + .isInstanceOf(UnauthorizedException.class); + Instant now = Instant.parse("2026-10-13T00:00:00Z"); + var row = new com.iflytek.skillhub.domain.namespace.NamespaceInvitationHistory(7L, "owner", "Original name", now, now.plusSeconds(86400)); + when(invitations.history("team", "owner", 0, 10)).thenReturn(new org.springframework.data.domain.PageImpl<>(java.util.List.of(row), org.springframework.data.domain.PageRequest.of(0, 10), 1)); + when(invitations.currentTime()).thenReturn(now); + when(responses.ok(anyString(), any())).thenAnswer(call -> new ApiResponse<>(0, "ok", call.getArgument(1), now, null)); + var page = controller.history("team", 0, 10, owner, request, response).data(); + assertThat(page.items().getFirst().status()).isEqualTo("ACTIVE"); + assertThat(page.total()).isEqualTo(1); + String json = new ObjectMapper().registerModule(new JavaTimeModule()).writeValueAsString(page); + assertThat(json).contains("invitationId", "Original name").doesNotContain("token", "hash"); + verify(response, times(3)).setHeader("Cache-Control", "no-store, private"); + } + + @Test + void firstCreateNeedsNoBodyAndSuccessDtosMatchIssueContract() { + when(request.getSession(false)).thenReturn(mock(HttpSession.class)); + PlatformPrincipal owner = new PlatformPrincipal("owner", "Owner", null, null, "local", Set.of()); + Instant expiry = Instant.parse("2026-10-13T00:00:00Z"); + NamespaceInvitationService.Created created = new NamespaceInvitationService.Created("raw-token", expiry, false); + when(invitations.create("team", "owner", false)).thenReturn(created); + when(responses.ok(anyString(), any())).thenAnswer(call -> new ApiResponse<>(0, "ok", call.getArgument(1), expiry, null)); + + assertThat(controller.create("team", null, owner, request, response).data()).isEqualTo(created); + verify(invitations).create("team", "owner", false); + + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + assertThat(mapper.valueToTree(created).fieldNames()).toIterable() + .containsExactlyInAnyOrder("token", "expiresAt", "replaced"); + for (NamespaceInvitationService.PreviewOutcome outcome : NamespaceInvitationService.PreviewOutcome.values()) { + NamespaceInvitationService.Preview preview = new NamespaceInvitationService.Preview( + "team", "Team", null, outcome, expiry); + assertThat(mapper.valueToTree(preview).fieldNames()).toIterable() + .containsExactlyInAnyOrder("namespaceSlug", "namespaceDisplayName", "namespaceAvatarUrl", "outcome", "expiresAt"); + assertThat(mapper.valueToTree(preview).get("outcome").asText()).isEqualTo(outcome.name()); + } + for (NamespaceInvitationService.AcceptOutcome outcome : NamespaceInvitationService.AcceptOutcome.values()) { + NamespaceInvitationService.Accepted accepted = new NamespaceInvitationService.Accepted("team", outcome); + assertThat(mapper.valueToTree(accepted).fieldNames()).toIterable() + .containsExactlyInAnyOrder("namespaceSlug", "outcome"); + assertThat(mapper.valueToTree(accepted).get("outcome").asText()).isEqualTo(outcome.name()); + } + } + + @Test + void invalidInvitationUsesIssueErrorKeyAndLocalized404Envelope() { + ResourceBundleMessageSource messages = new ResourceBundleMessageSource(); + messages.setBasename("messages"); + messages.setDefaultEncoding("UTF-8"); + ApiResponseFactory factory = new ApiResponseFactory(messages, Clock.fixed( + Instant.parse("2026-10-12T00:00:00Z"), ZoneOffset.UTC), new RequestIdAccessor()); + GlobalExceptionHandler handler = new GlobalExceptionHandler(factory, + mock(SensitiveLogSanitizer.class), mock(SkillHubMetrics.class), new RequestIdAccessor()); + LocaleContextHolder.setLocale(Locale.SIMPLIFIED_CHINESE); + try { + var result = handler.handleLocalizedDomainException( + new DomainNotFoundException("error.namespace.invitation.invalid"), request); + assertThat(result.getStatusCode().value()).isEqualTo(404); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().code()).isEqualTo(404); + assertThat(result.getBody().msg()).isEqualTo("邀请链接无效或已失效"); + assertThat(result.getBody().data()).isNull(); + } finally { + LocaleContextHolder.resetLocaleContext(); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index d7dc9142..ac60aad1 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -31,6 +32,7 @@ import org.springframework.test.web.servlet.request.RequestPostProcessor; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import static org.mockito.ArgumentMatchers.any; @@ -78,6 +80,11 @@ class NamespacePortalControllerTest { @MockBean private UserAccountRepository userAccountRepository; + @BeforeEach + void setUpNamespaceLookup() { + given(namespaceRepository.findIdBySlug("team-a")).willReturn(Optional.of(1L)); + } + @Test void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java index 3e35e561..a942b481 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java @@ -202,7 +202,8 @@ class ReviewPortalControllerTest { null, "REVIEW_TASK", List.of(), - new com.iflytek.skillhub.dto.PageResponse<>(List.of(), 0, 0, 20) + new com.iflytek.skillhub.dto.PageResponse<>(List.of(), 0, 0, 20), + null ), 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")), diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 60a6a64f..cb9eebf7 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -186,7 +186,9 @@ class SkillControllerTest { null, new com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService.VersionProjection(11L, "1.1.0", "PENDING_REVIEW"), null, - "OWNER_PREVIEW" + "OWNER_PREVIEW", + null, + null )); SkillSuiteReferenceResponse suiteReference = new SkillSuiteReferenceResponse( 9L, "team", "demo-suite", "Demo Suite", "2.0.0", 3, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java index ee467c27..1ca7599e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java @@ -12,6 +12,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; @@ -39,6 +41,11 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; 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; import org.springframework.test.web.servlet.MockMvc; import static org.assertj.core.api.Assertions.assertThat; @@ -55,14 +62,35 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @AutoConfigureMockMvc @ActiveProfiles("test") @Import(TestRedisConfig.class) +@Testcontainers class SkillApprovalVisibilityFlowIntegrationTest { + // Unified review takes a PostgreSQL row lock; H2 PostgreSQL mode cannot + // execute the generated FOR NO KEY UPDATE statement. + @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 MockMvc mockMvc; @Autowired private NamespaceRepository namespaceRepository; + @Autowired + private UserAccountRepository userAccountRepository; + @Autowired private SkillRepository skillRepository; @@ -92,6 +120,11 @@ class SkillApprovalVisibilityFlowIntegrationTest { @BeforeEach void setUp() { + for (String userId : List.of("system", "super-1", "local-user", "team-admin")) { + if (userAccountRepository.findById(userId).isEmpty()) { + userAccountRepository.save(new UserAccount(userId, userId, userId + "@example.test", null)); + } + } when(searchEmbeddingService.embed(anyString())).thenReturn(""); when(searchEmbeddingService.similarity(anyString(), anyString())).thenReturn(0.0d); when(rbacService.getUserRoleCodes("super-1")).thenReturn(Set.of("SUPER_ADMIN")); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillAvatarScopeReviewListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillAvatarScopeReviewListenerTest.java new file mode 100644 index 00000000..51b3fbca --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillAvatarScopeReviewListenerTest.java @@ -0,0 +1,45 @@ +package com.iflytek.skillhub.listener; + +import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.service.SkillAvatarAppService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.after; + +@SpringBootTest +@ActiveProfiles("test") +class SkillAvatarScopeReviewListenerTest { + @Autowired private ApplicationEventPublisher events; + @Autowired private PlatformTransactionManager transactions; + @MockBean private SkillAvatarAppService avatars; + + @Test + void committedPublicationTriggersScopeReview() { + new TransactionTemplate(transactions).executeWithoutResult(ignored -> { + events.publishEvent(new SkillPublishedEvent(912345L, 1L, "publisher")); + verify(avatars, never()).prepareScopeReview(912345L, "publisher"); + }); + + verify(avatars, timeout(5000)).prepareScopeReview(912345L, "publisher"); + } + + @Test + void rolledBackPublicationDoesNotTriggerScopeReview() { + new TransactionTemplate(transactions).executeWithoutResult(status -> { + events.publishEvent(new SkillPublishedEvent(912346L, 1L, "publisher")); + status.setRollbackOnly(); + }); + + verify(avatars, after(1000).never()).prepareScopeReview(912346L, "publisher"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java index 970114a0..1728c3d2 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java @@ -209,6 +209,26 @@ class JpaGovernanceQueryRepositoryTest { assertThat(responses.get(0).subtitle()).isEqualTo("Spam"); } + @Test + void getReviewInboxItems_supportsMetadataWithoutVersionContext() { + ReviewTask task = ReviewTask.forSkillMetadata(201L, 11L, "submitter", 7L); + setField(task, "id", 4L); + Skill skill = new Skill(11L, "avatar-only", "submitter", SkillVisibility.PUBLIC); + setField(skill, "id", 201L); + Namespace namespace = new Namespace("team-a", "Team A", "submitter"); + setField(namespace, "id", 11L); + given(skillRepository.findByIdIn(List.of(201L))).willReturn(List.of(skill)); + given(namespaceRepository.findByIdIn(List.of(11L))).willReturn(List.of(namespace)); + + var responses = repository.getReviewInboxItems(List.of(task)); + + assertThat(responses).hasSize(1); + assertThat(responses.get(0).type()).isEqualTo("REVIEW"); + assertThat(responses.get(0).title()).isEqualTo("team-a/avatar-only"); + assertThat(responses.get(0).namespace()).isEqualTo("team-a"); + assertThat(responses.get(0).skillSlug()).isEqualTo("avatar-only"); + } + @Test void getReviewInboxItems_toleratesMissingVersionContext() { ReviewTask task = new ReviewTask(101L, 11L, "submitter"); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java new file mode 100644 index 00000000..7fdd5932 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java @@ -0,0 +1,424 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.auth.merge.AccountMergeRequest; +import com.iflytek.skillhub.auth.merge.AccountMergeRequestRepository; +import com.iflytek.skillhub.auth.merge.AccountMergeService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.infra.jpa.NamespaceInvitationJpaRepository; +import com.iflytek.skillhub.infra.jpa.NamespaceJpaRepository; +import com.iflytek.skillhub.infra.jpa.NamespaceMemberJpaRepository; +import com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository; +import java.time.Clock; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +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.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +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.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Import({NamespaceInvitationService.class, NamespaceMemberService.class, NamespaceService.class, + AccountMergeService.class, + NamespaceAccessPolicy.class, NamespaceGovernanceService.class, AuditLogService.class, + NamespaceInvitationPostgresTest.ClockConfig.class}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class NamespaceInvitationPostgresTest { + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void postgres(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"); + } + + @TestConfiguration + static class ClockConfig { + @Bean Clock clock() { return Clock.systemUTC(); } + } + + @Autowired private NamespaceInvitationService invitations; + @Autowired private NamespaceMemberService memberService; + @Autowired private NamespaceGovernanceService governance; + @Autowired private NamespaceService namespaceService; + @Autowired private AccountMergeService accountMerge; + @Autowired private AccountMergeRequestRepository mergeRequests; + @Autowired private NamespaceInvitationJpaRepository invitationRows; + @Autowired private com.iflytek.skillhub.infra.jpa.NamespaceInvitationHistoryJpaRepository historyRows; + @Autowired private NamespaceJpaRepository namespaces; + @Autowired private NamespaceMemberJpaRepository members; + @Autowired private UserAccountJpaRepository users; + @Autowired private PlatformTransactionManager transactionManager; + @Autowired private org.springframework.jdbc.core.JdbcTemplate jdbc; + @MockBean private SkillRepository skills; + @MockBean private ReviewTaskRepository reviewTasks; + @MockBean private PromotionRequestRepository promotions; + + @Test + void twoConcurrentAcceptsCreateOnlyOneMemberAndOneConsumption() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String first = "inv-a-" + suffix; + String second = "inv-b-" + suffix; + Long namespaceId = new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(first, first, null, null)); + users.saveAndFlush(new UserAccount(second, second, null, null)); + Namespace namespace = namespaces.saveAndFlush(new Namespace("inv-" + suffix, "Invitation test", owner)); + members.saveAndFlush(new NamespaceMember(namespace.getId(), owner, NamespaceRole.OWNER)); + return namespace.getId(); + }); + String token = invitations.create("inv-" + suffix, owner).token(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var one = pool.submit(() -> attempt(start, token, first)); + var two = pool.submit(() -> attempt(start, token, second)); + start.countDown(); + assertThat(one.get(20, TimeUnit.SECONDS) + two.get(20, TimeUnit.SECONDS)).isEqualTo(1); + } + assertThat(members.findByNamespaceId(namespaceId, org.springframework.data.domain.Pageable.unpaged()) + .getContent().stream().filter(member -> member.getRole() == NamespaceRole.MEMBER)).hasSize(1); + assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedBy()).isIn(first, second); + var history = invitations.history("inv-" + suffix, owner, 0, 10).getContent(); + assertThat(history).hasSize(1); + assertThat(history.getFirst().getConsumedBy()).isIn(first, second); + assertThat(history.getFirst().status(Instant.now())).isEqualTo("USED"); + } + + @Test + void revokeAndReplaceSerializeWithAcceptance() throws Exception { + for (boolean replace : new boolean[] {false, true}) { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(guest, guest, null, null)); + Namespace namespace = namespaces.saveAndFlush(new Namespace(slug, "Invitation race", owner)); + members.saveAndFlush(new NamespaceMember(namespace.getId(), owner, NamespaceRole.OWNER)); + return namespace.getId(); + }); + String oldToken = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> attempt(start, oldToken, guest)); + var change = pool.submit(() -> { + start.await(); + if (replace) invitations.create(slug, owner, true); + else invitations.revoke(slug, owner); + return true; + }); + start.countDown(); + int accepted = accept.get(20, TimeUnit.SECONDS); + assertThat(change.get(20, TimeUnit.SECONDS)).isTrue(); + assertThat(members.findByNamespaceIdAndUserId(namespaceId, guest).isPresent()) + .isEqualTo(accepted == 1); + if (replace) { + assertThat(invitationRows.findByNamespaceId(namespaceId)).isPresent(); + assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedAt()).isNull(); + } else { + assertThat(invitationRows.findByNamespaceId(namespaceId)).isEmpty(); + } + assertThat(attempt(new CountDownLatch(0), oldToken, guest)).isZero(); + } + } + } + + @Test + void directAddAndAcceptProduceOneMembershipWithoutConsumingUnusedLink() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = createTeam(slug, owner, guest); + String token = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + boolean acceptedAsExistingMember; + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> { + start.await(); + return invitations.accept(token, guest).outcome() == NamespaceInvitationService.AcceptOutcome.ALREADY_MEMBER; + }); + var direct = pool.submit(() -> { + start.await(); + try { + memberService.addMember(namespaceId, guest, NamespaceRole.MEMBER, owner); + return true; + } catch (DomainBadRequestException alreadyMember) { + assertThat(alreadyMember).hasMessageContaining("error.namespace.member.alreadyExists"); + return false; + } + }); + start.countDown(); + acceptedAsExistingMember = accept.get(20, TimeUnit.SECONDS); + boolean directAdded = direct.get(20, TimeUnit.SECONDS); + assertThat(acceptedAsExistingMember).isEqualTo(directAdded); + } + assertThat(members.findByNamespaceId(namespaceId, org.springframework.data.domain.Pageable.unpaged()) + .getContent().stream().filter(member -> member.getUserId().equals(guest))).hasSize(1); + assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedAt() != null) + .isEqualTo(!acceptedAsExistingMember); + } + + @Test + void freezeAndArchiveSerializeWithAcceptance() throws Exception { + for (boolean archive : new boolean[] {false, true}) { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = createTeam(slug, owner, guest); + String token = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> attempt(start, token, guest)); + var lifecycle = pool.submit(() -> { + start.await(); + if (archive) governance.archiveNamespace(slug, owner, "test", null, null, null); + else governance.freezeNamespace(slug, owner, "test", null, null, null); + return true; + }); + start.countDown(); + int accepted = accept.get(20, TimeUnit.SECONDS); + assertThat(lifecycle.get(20, TimeUnit.SECONDS)).isTrue(); + assertThat(members.findByNamespaceIdAndUserId(namespaceId, guest).isPresent()) + .isEqualTo(accepted == 1); + assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedAt() != null) + .isEqualTo(accepted == 1); + } + } + } + + @Test + void deletingNamespaceAndAcceptingInvitationLeaveNoOrphan() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = createTeam(slug, owner, guest); + String token = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> attempt(start, token, guest)); + var deletion = pool.submit(() -> { + start.await(); + namespaceService.deleteNamespace(namespaceId, owner); + return true; + }); + start.countDown(); + accept.get(20, TimeUnit.SECONDS); + assertThat(deletion.get(20, TimeUnit.SECONDS)).isTrue(); + } + assertThat(namespaces.findBySlug(slug)).isEmpty(); + assertThat(invitationRows.findByNamespaceId(namespaceId)).isEmpty(); + assertThat(historyRows.findByNamespaceId(namespaceId, org.springframework.data.domain.Pageable.unpaged())).isEmpty(); + assertThat(jdbc.queryForObject("SELECT count(*) FROM audit_log WHERE target_type='NAMESPACE' AND target_id=? AND action='NAMESPACE_INVITE_CREATED'", Long.class, namespaceId)).isEqualTo(1); + + assertThat(members.findByNamespaceIdAndUserId(namespaceId, guest)).isEmpty(); + } + + @Test + void overlappingNamespaceAccountMergeAndAcceptKeepOnePrimaryMembership() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String primary = "inv-a-" + suffix; + String secondary = "inv-z-" + suffix; + String inviteSlug = "inv-b-" + suffix; + Long[] ids = new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(primary, primary, null, null)); + users.saveAndFlush(new UserAccount(secondary, secondary, null, null)); + Namespace shared = namespaces.saveAndFlush(new Namespace("inv-a-" + suffix, "Shared", owner)); + Namespace invited = namespaces.saveAndFlush(new Namespace(inviteSlug, "Invited", owner)); + for (Namespace ns : new Namespace[] {shared, invited}) { + members.saveAndFlush(new NamespaceMember(ns.getId(), owner, NamespaceRole.OWNER)); + members.saveAndFlush(new NamespaceMember(ns.getId(), secondary, NamespaceRole.MEMBER)); + } + members.saveAndFlush(new NamespaceMember(shared.getId(), primary, NamespaceRole.MEMBER)); + AccountMergeRequest request = new AccountMergeRequest(primary, secondary, null, + Instant.now().plusSeconds(3600)); + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + return new Long[] {shared.getId(), invited.getId(), mergeRequests.saveAndFlush(request).getId()}; + }); + String token = invitations.create(inviteSlug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + boolean alreadyMember; + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> { + start.await(); + return invitations.accept(token, primary).outcome() == NamespaceInvitationService.AcceptOutcome.ALREADY_MEMBER; + }); + var merge = pool.submit(() -> { + start.await(); + accountMerge.confirm(primary, ids[2]); + return true; + }); + start.countDown(); + alreadyMember = accept.get(20, TimeUnit.SECONDS); + assertThat(merge.get(20, TimeUnit.SECONDS)).isTrue(); + } + assertThat(users.findAllById(java.util.List.of(secondary)).iterator().next().getStatus()).isEqualTo(UserStatus.MERGED); + assertThat(members.findByNamespaceIdAndUserId(ids[0], secondary)).isEmpty(); + assertThat(members.findByNamespaceIdAndUserId(ids[1], secondary)).isEmpty(); + assertThat(members.findByNamespaceIdAndUserId(ids[1], primary)).isPresent(); + assertThat(members.findByNamespaceId(ids[1], org.springframework.data.domain.Pageable.unpaged()) + .getContent().stream().filter(member -> member.getUserId().equals(primary))).hasSize(1); + assertThat(invitationRows.findByNamespaceId(ids[1]).orElseThrow().getConsumedAt() != null) + .isEqualTo(!alreadyMember); + } + + @Test + void migrationBackfillsOnlyProvableCurrentRowsAndNeverTokensOrInventedNames() throws Exception { + String schema = "invite_legacy_" + UUID.randomUUID().toString().replace("-", ""); + var flyway = org.flywaydb.core.Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .schemas(schema).defaultSchema(schema).target("73").load(); + flyway.migrate(); + try (var connection = java.sql.DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); + var statement = connection.createStatement()) { + statement.execute("SET search_path TO " + schema); + statement.execute("INSERT INTO user_account(id,display_name,status) VALUES ('legacy-owner','Owner','ACTIVE')"); + statement.execute("INSERT INTO namespace(slug,display_name,type,status,created_by) VALUES ('legacy-one','Legacy one','TEAM','ACTIVE','legacy-owner'),('legacy-two','Legacy two','TEAM','ACTIVE','legacy-owner')"); + statement.execute("INSERT INTO namespace_invitation(namespace_id,token_hash,issued_by,issued_at,expires_at,consumed_by,consumed_at) " + + "SELECT id, CASE slug WHEN 'legacy-one' THEN repeat('a',64) ELSE repeat('b',64) END,'legacy-owner',now()-interval '1 hour',now()+interval '23 hours'," + + "CASE slug WHEN 'legacy-two' THEN 'legacy-guest' END,CASE slug WHEN 'legacy-two' THEN now() END FROM namespace WHERE slug IN ('legacy-one','legacy-two')"); + org.flywaydb.core.Flyway.configure().dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .schemas(schema).defaultSchema(schema).load().migrate(); + try (var rows = statement.executeQuery("SELECT count(*), count(*) FILTER (WHERE legacy), count(*) FILTER (WHERE consumed_by='legacy-guest'), count(issued_by_name) FROM namespace_invitation_history")) { + rows.next(); + assertThat(rows.getInt(1)).isEqualTo(2); + assertThat(rows.getInt(2)).isEqualTo(2); + assertThat(rows.getInt(3)).isEqualTo(1); + assertThat(rows.getInt(4)).isZero(); + } + try (var rows = statement.executeQuery("SELECT count(*) FROM namespace_invitation i JOIN namespace_invitation_history h ON h.id=i.history_id AND h.namespace_id=i.namespace_id")) { + rows.next(); assertThat(rows.getInt(1)).isEqualTo(2); + } + statement.execute("DROP SCHEMA " + schema + " CASCADE"); + } + } + + @Test + void historyKeepsUsedAndRemovedMemberFactsAndIndependentReplacementRevocation() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "history-owner-" + suffix; + String guest = "history-guest-" + suffix; + String slug = "history-" + suffix; + Long namespaceId = createTeam(slug, owner, guest); + String firstToken = invitations.create(slug, owner).token(); + String firstId = invitationRows.findByNamespaceId(namespaceId).orElseThrow().getHistoryId(); + invitations.create(slug, owner, true); + assertThat(historyRows.findById(firstId).orElseThrow().status(Instant.now())).isEqualTo("REPLACED"); + String secondId = invitationRows.findByNamespaceId(namespaceId).orElseThrow().getHistoryId(); + invitations.revoke(slug, owner); + assertThat(historyRows.findById(secondId).orElseThrow().status(Instant.now())).isEqualTo("REVOKED"); + String thirdToken = invitations.create(slug, owner).token(); + String thirdId = invitationRows.findByNamespaceId(namespaceId).orElseThrow().getHistoryId(); + invitations.accept(thirdToken, guest); + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + members.delete(members.findByNamespaceIdAndUserId(namespaceId, guest).orElseThrow()); + users.findAllById(java.util.List.of(guest)).iterator().next().setDisplayName("Renamed later"); + }); + invitations.create(slug, owner); + var history = invitations.history(slug, owner, 0, 2); + assertThat(history.getTotalElements()).isEqualTo(4); + assertThat(jdbc.queryForObject("SELECT detail_json::jsonb->>'invitationId' FROM audit_log WHERE target_type='NAMESPACE' AND target_id=? AND action='NAMESPACE_INVITE_ACCEPTED'", String.class, namespaceId)).isEqualTo(thirdId); + assertThat(history.getTotalPages()).isEqualTo(2); + var used = historyRows.findById(thirdId).orElseThrow(); + assertThat(used.status(Instant.now())).isEqualTo("USED"); + assertThat(used.getConsumedBy()).isEqualTo(guest); + assertThat(used.getConsumedByName()).isNotEqualTo("Renamed later"); + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> invitations.preview(firstToken, guest))) + .isInstanceOf(DomainNotFoundException.class); + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> invitations.history(slug, guest, 0, 10))) + .isInstanceOf(DomainNotFoundException.class); + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> invitations.history(slug, owner, 0, 51))) + .isInstanceOf(DomainBadRequestException.class); + } + + @Test + void accountMergeDoesNotRewriteHistoricalRecipientIdentity() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "history-owner-" + suffix; + String secondary = "history-secondary-" + suffix; + String primary = "history-primary-" + suffix; + String slug = "history-merge-" + suffix; + Long namespaceId = createTeam(slug, owner, secondary); + String token = invitations.create(slug, owner).token(); + invitations.accept(token, secondary); + Long requestId = new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(primary, primary, null, null)); + AccountMergeRequest request = new AccountMergeRequest(primary, secondary, null, Instant.now().plusSeconds(3600)); + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + return mergeRequests.saveAndFlush(request).getId(); + }); + accountMerge.confirm(primary, requestId); + assertThat(members.findByNamespaceIdAndUserId(namespaceId, primary)).isPresent(); + var row = invitations.history(slug, owner, 0, 10).getContent().getFirst(); + assertThat(row.getConsumedBy()).isEqualTo(secondary); + assertThat(row.status(Instant.now())).isEqualTo("USED"); + } + + private Long createTeam(String slug, String owner, String guest) { + return new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(guest, guest, null, null)); + Namespace namespace = namespaces.saveAndFlush(new Namespace(slug, "Invitation race", owner)); + members.saveAndFlush(new NamespaceMember(namespace.getId(), owner, NamespaceRole.OWNER)); + return namespace.getId(); + }); + } + + private int attempt(CountDownLatch start, String token, String userId) throws InterruptedException { + start.await(); + try { + invitations.accept(token, userId); + return 1; + } catch (DomainNotFoundException expected) { + return 0; + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarMachineRecoveryQueryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarMachineRecoveryQueryTest.java new file mode 100644 index 00000000..36042c2a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarMachineRecoveryQueryTest.java @@ -0,0 +1,73 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class SkillAvatarMachineRecoveryQueryTest { + @Autowired private EntityManager entities; + @Autowired private SkillAvatarCandidateJpaRepository candidates; + + @Test + void selectsOnlyCurrentOrdinaryCandidatesAfterAbandonmentDeadline() { + Namespace namespace = new Namespace("machine-recovery", "Machine recovery", "owner"); + entities.persist(namespace); + SkillAvatarCandidate pending = candidate(namespace.getId(), "pending", false); + SkillAvatarCandidate processing = candidate(namespace.getId(), "processing", true); + SkillAvatarCandidate superseded = candidate(namespace.getId(), "superseded", false); + SkillAvatarCandidate scoped = candidate(namespace.getId(), "scope", false); + scoped.markScopeReviewVersion(null); + Skill supersededSkill = entities.createQuery("SELECT s FROM Skill s WHERE s.id = :id", Skill.class) + .setParameter("id", superseded.getSkillId()).getSingleResult(); + supersededSkill.setPendingAvatarId(null); + entities.flush(); + entities.clear(); + + Instant now = Instant.now(); + assertThat(recoverable(now)).isEmpty(); + assertThat(recoverable(now.plusSeconds(601))).extracting(SkillAvatarCandidate::getId) + .containsExactly(pending.getId(), processing.getId()); + assertThat(recoverable(now.plusSeconds(601), pending.getId())) + .extracting(SkillAvatarCandidate::getId).containsExactly(processing.getId()); + } + + private SkillAvatarCandidate candidate(Long namespaceId, String slug, boolean claim) { + Skill skill = new Skill(namespaceId, slug, "owner", SkillVisibility.PUBLIC); + entities.persist(skill); + entities.flush(); + SkillAvatarCandidate candidate = new SkillAvatarCandidate(skill.getId(), "owner", slug + ".png", + "sha", SkillVisibility.PUBLIC, true, false, "xfyun"); + if (claim) { + Instant now = Instant.now(); + candidate.claimMachineAttempt("attempt", now, now.plusSeconds(600)); + } + entities.persist(candidate); + entities.flush(); + skill.setPendingAvatarId(candidate.getId()); + entities.flush(); + return candidate; + } + + private java.util.List recoverable(Instant now) { + return recoverable(now, 0L); + } + + private java.util.List recoverable(Instant now, Long afterId) { + return candidates.findAbandonedMachineCandidates(afterId, SkillAvatarCandidate.Status.PENDING, + SkillAvatarCandidate.MachineStatus.PENDING, SkillAvatarCandidate.MachineStatus.PROCESSING, + now.minusSeconds(600), now, PageRequest.of(0, 100)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java new file mode 100644 index 00000000..116ccefa --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarRecoveryPostgresTest.java @@ -0,0 +1,642 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse; +import com.iflytek.skillhub.infra.jpa.NamespaceJpaRepository; +import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository; +import com.iflytek.skillhub.infra.jpa.SkillJpaRepository; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.AvatarModerationGateway; +import com.iflytek.skillhub.service.AvatarModerationProvider; +import com.iflytek.skillhub.service.AvatarReviewSettingsService; +import com.iflytek.skillhub.service.SkillAvatarAppService; +import com.iflytek.skillhub.service.SkillAvatarImageProcessor; +import com.iflytek.skillhub.service.SkillAvatarUrls; +import com.iflytek.skillhub.listener.SkillAvatarScopeReviewListener; +import com.iflytek.skillhub.task.SkillAvatarMachineRecoveryTask; +import com.iflytek.skillhub.task.SkillAvatarScopeReviewRecoveryTask; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.io.ByteArrayInputStream; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +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 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.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** Real PostgreSQL recovery transactions with controlled policy, storage and external provider. */ +@DataJpaTest(showSql = false) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Import({SkillAvatarRecoveryPostgresTest.ReviewConfiguration.class, com.iflytek.skillhub.domain.review.ReviewService.class, com.iflytek.skillhub.domain.review.ReviewPermissionChecker.class, SkillAvatarAppService.class, SkillAvatarMachineRecoveryTask.class, + SkillAvatarScopeReviewListener.class, SkillAvatarScopeReviewRecoveryTask.class, + com.iflytek.skillhub.task.SkillAvatarCleanupTask.class}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class SkillAvatarRecoveryPostgresTest { + @Container private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void postgres(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 SkillJpaRepository skills; + @Autowired private NamespaceJpaRepository namespaces; + @Autowired private com.iflytek.skillhub.infra.jpa.SkillVersionJpaRepository versions; + @Autowired private SkillAvatarCandidateJpaRepository candidates; + @Autowired private SkillAvatarAppService avatars; + @Autowired private SkillAvatarMachineRecoveryTask machineRecovery; + @Autowired private SkillAvatarScopeReviewRecoveryTask scopeRecovery; + @Autowired private PlatformTransactionManager transactions; + @Autowired private JdbcTemplate jdbc; + @Autowired private com.iflytek.skillhub.task.SkillAvatarCleanupTask cleanup; + @Autowired private com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository users; + @MockBean private ObjectStorageService storage; + @MockBean private SkillAvatarImageProcessor imageProcessor; + @MockBean private AvatarReviewSettingsService settings; + @MockBean private AvatarModerationGateway moderation; + @MockBean private VisibilityChecker visibility; + @MockBean private SkillQueryService skillQueries; + @MockBean private AuditLogService audit; + @MockBean private RequestIdAccessor requestIds; + @MockBean private com.iflytek.skillhub.domain.skill.service.SkillGovernanceService governance; + @MockBean private com.iflytek.skillhub.domain.governance.GovernanceNotificationService notifications; + @Autowired private com.iflytek.skillhub.domain.review.ReviewService reviews; + @Autowired private com.iflytek.skillhub.infra.jpa.ReviewTaskJpaRepository reviewRows; + + @org.springframework.boot.test.context.TestConfiguration + static class ReviewConfiguration { + @org.springframework.context.annotation.Bean java.time.Clock clock() { return java.time.Clock.systemUTC(); } + @org.springframework.context.annotation.Bean com.fasterxml.jackson.databind.ObjectMapper mapper() { return new com.fasterxml.jackson.databind.ObjectMapper(); } + } + + private com.iflytek.skillhub.dto.SkillAvatarStatusResponse unifiedDecision(String ns, String slug, long candidateId, + boolean approved, String reason, String actor, java.util.Map roles, + java.util.Set platformRoles, AuditRequestContext context) { + var tasks = reviewRows.findByAvatarCandidateIdAndStatus(candidateId, com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING); + if (tasks.isEmpty()) { return avatars.decide(ns, slug, candidateId, approved, reason, actor, roles, platformRoles, context); } + if (approved) { reviews.approveReview(tasks.getFirst().getId(), actor, reason, roles, platformRoles); } + else { reviews.rejectReview(tasks.getFirst().getId(), actor, reason, roles, platformRoles); } + return avatars.submittedStatus(ns, slug, "owner", candidateId); + } + + + @BeforeEach + void providerAndStorage() { + if (((com.iflytek.skillhub.domain.user.UserAccountRepository) users).findById("owner").isEmpty()) { + users.saveAndFlush(new com.iflytek.skillhub.domain.user.UserAccount("owner", "owner", null, null)); + } + byte[] image = UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + when(storage.getObject(anyString())).thenAnswer(ignored -> new ByteArrayInputStream(image)); + when(moderation.requireConfiguredProvider()).thenReturn("xfyun"); + org.mockito.Mockito.lenient().when(moderation.context(anyString())).thenReturn( + new AvatarModerationProvider.Context("fixture-image", "test-v1", "fixture-policy-v1", null)); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(AvatarModerationProvider.Verdict.PASS); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(true, false, 1, null)); + } + + @Test + void ordinaryCommittedPendingUploadIsRecoveredByScheduler() { + Long skillId = skill(false); + byte[] image = UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + when(imageProcessor.normalize(any())).thenAnswer(call -> call.getArgument(0)); + Long candidateId = avatars.submit(namespaceSlug(skillId), "skill", "owner", image, + new AuditRequestContext(null, null)).candidateId(); + machineRecovery.recover(); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineStatus()) + .isEqualTo(SkillAvatarCandidate.MachineStatus.PENDING); + assertThat(readSkill(skillId).getActiveAvatarId()).isNull(); + setTime(candidateId, "created_at", Instant.now().minusSeconds(660)); + Instant createdAt = candidates.findById(candidateId).orElseThrow().getCreatedAt(); + assertThat(createdAt).isBefore(Instant.now().minusSeconds(600)); + assertThat(abandoned(createdAt.minusSeconds(1), Instant.now())).extracting(SkillAvatarCandidate::getId).doesNotContain(candidateId); + assertThat(abandoned(createdAt, Instant.now())).extracting(SkillAvatarCandidate::getId).contains(candidateId); + machineRecovery.recover(); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineStatus()) + .isEqualTo(SkillAvatarCandidate.MachineStatus.PASSED); + assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(candidateId); + assertThat(readSkill(skillId).getPendingAvatarId()).isNull(); + } + + @Test + void expiredLeaseIsReclaimedAndLateFirstAttemptCannotOverwriteSecond() throws Exception { + Long skillId = skill(false); + Long candidateId = pending(skillId); + CountDownLatch providerEntered = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + AtomicInteger calls = new AtomicInteger(); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenAnswer(ignored -> { + if (calls.incrementAndGet() == 1) { + providerEntered.countDown(); + assertThat(releaseFirst.await(20, TimeUnit.SECONDS)).isTrue(); + return AvatarModerationProvider.Verdict.BLOCK; + } + return AvatarModerationProvider.Verdict.PASS; + }); + try (var pool = Executors.newFixedThreadPool(2)) { + var first = pool.submit(() -> avatars.processMachine(candidateId, new AuditRequestContext(null, null))); + try { + assertThat(providerEntered.await(10, TimeUnit.SECONDS)).isTrue(); + var otherSession = pool.submit(() -> new TransactionTemplate(transactions).execute(status -> { + jdbc.execute("SET LOCAL lock_timeout = '2s'"); + return skills.findLockedById(skillId).orElseThrow().getId(); + })); + assertThat(otherSession.get(5, TimeUnit.SECONDS)).isEqualTo(skillId); + setTime(candidateId, "machine_lease_until", Instant.now().minusSeconds(1)); + Instant lease = candidates.findById(candidateId).orElseThrow().getMachineLeaseUntil(); + assertThat(lease).isBefore(Instant.now()); + assertThat(abandoned(Instant.now().minusSeconds(600), lease.minusSeconds(1))) + .extracting(SkillAvatarCandidate::getId).doesNotContain(candidateId); + assertThat(abandoned(Instant.now().minusSeconds(600), lease)) + .extracting(SkillAvatarCandidate::getId).contains(candidateId); + machineRecovery.recover(); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(2); + } finally { + releaseFirst.countDown(); + } + assertThat(first.get(10, TimeUnit.SECONDS).machineStatus()).isEqualTo("PASSED"); + assertThat(calls.get()).isEqualTo(2); + } + assertThat(candidates.findById(candidateId).orElseThrow().getMachineStatus()) + .isEqualTo(SkillAvatarCandidate.MachineStatus.PASSED); + assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(candidateId); + } + + @Test + void missingScopeListenerIsCompensatedWithoutPublishingPrivateImage() { + Long skillId = skill(true); + Long oldAvatarId = readSkill(skillId).getActiveAvatarId(); + when(storage.getObject("old/" + skillId)).thenThrow(new IllegalStateException("temporary storage outage")); + scopeRecovery.recover(); + assertThat(readSkill(skillId).getPendingAvatarId()).isNull(); + assertThat(SkillAvatarUrls.current(readSkill(skillId), "test")).isNull(); + org.mockito.Mockito.doAnswer(ignored -> new ByteArrayInputStream(new byte[] {1, 2, 3})) + .when(storage).getObject("old/" + skillId); + scopeRecovery.recover(); + Skill recovered = readSkill(skillId); + assertThat(recovered.getActiveAvatarId()).isNotEqualTo(oldAvatarId); + assertThat(recovered.getActiveAvatarAudience()).isEqualTo(SkillVisibility.PUBLIC); + assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(skillId)).hasSize(2); + assertThat(candidates.findById(recovered.getActiveAvatarId()).orElseThrow().isScopeReview()).isTrue(); + } + + @Test + void committedScopeCandidateRecoversWithCooldownAndOwnerRetryAfterThreeAutomaticAttempts() { + // Independent candidates: a manually repeated ERROR cannot be applied to an already active PASS. + for (AvatarModerationProvider.Verdict manualVerdict : new AvatarModerationProvider.Verdict[] { + AvatarModerationProvider.Verdict.ERROR, AvatarModerationProvider.Verdict.PASS}) { + Long skillId = skill(true); + Long candidateId = avatars.prepareScopeReview(skillId, null).candidateId(); + assertThat(candidates.findById(candidateId).orElseThrow().isScopeReview()).isTrue(); + assertThat(candidates.findById(candidateId).orElseThrow().getScopeReviewVersionId()).isNull(); + assertThat(abandoned(Instant.now().plusSeconds(660), Instant.now().plusSeconds(660))) + .extracting(SkillAvatarCandidate::getId).doesNotContain(candidateId); + setTime(candidateId, "created_at", Instant.now().minusSeconds(660)); + assertThat(candidates.findById(candidateId).orElseThrow().getCreatedAt()).isBefore(Instant.now().minusSeconds(600)); + org.mockito.Mockito.clearInvocations(moderation); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(AvatarModerationProvider.Verdict.ERROR); + scopeRecovery.recover(); + SkillAvatarCandidate first = candidates.findById(candidateId).orElseThrow(); + assertThat(first.getMachineAttemptCount()).isEqualTo(1); + assertThat(first.getMachineNextRetryAt()).isBetween(Instant.now().plusSeconds(3500), Instant.now().plusSeconds(3601)); + assertThatThrownBy(() -> avatars.processMachine(candidateId, new AuditRequestContext(null, null))) + .isInstanceOf(DomainConflictException.class); + scopeRecovery.recover(); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(1); + for (int attempt = 2; attempt <= 3; attempt++) { + setTime(candidateId, "machine_next_retry_at", Instant.now().minusSeconds(1)); + scopeRecovery.recover(); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(attempt); + } + avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "owner"); + assertThatThrownBy(() -> avatars.processMachine(candidateId, new AuditRequestContext(null, null))) + .isInstanceOf(DomainConflictException.class); + assertThatThrownBy(() -> avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "stranger")) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(3)).moderate(eq("xfyun"), any(byte[].class)); + setTime(candidateId, "machine_next_retry_at", Instant.now().minusSeconds(1)); + scopeRecovery.recover(); + scopeRecovery.recover(); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(3); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(3)).moderate(eq("xfyun"), any(byte[].class)); + assertThat(readSkill(skillId).getPendingAvatarId()).isEqualTo(candidateId); + assertThat(SkillAvatarUrls.current(readSkill(skillId), "test")).isNull(); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(manualVerdict); + avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "owner"); + var retried = avatars.processMachine(candidateId, new AuditRequestContext(null, null)); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineAttemptCount()).isEqualTo(4); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(4)).moderate(eq("xfyun"), any(byte[].class)); + if (manualVerdict == AvatarModerationProvider.Verdict.PASS) { + assertThat(retried.avatarUrl()).isNotNull(); + assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(candidateId); + assertThat(readSkill(skillId).getPendingAvatarId()).isNull(); + assertThatThrownBy(() -> avatars.requireCurrentOwnerCandidate(namespaceSlug(skillId), "skill", candidateId, "owner")) + .isInstanceOf(DomainConflictException.class); + } else { + assertThat(retried.avatarUrl()).isNull(); + assertThat(readSkill(skillId).getPendingAvatarId()).isEqualTo(candidateId); + assertThat(candidates.findById(candidateId).orElseThrow().getMachineNextRetryAt()) + .isAfter(Instant.now().plusSeconds(3500)); + } + assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(skillId)).hasSize(2); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(value = AvatarModerationProvider.Verdict.class, names = {"PASS", "BLOCK"}) + void changedPolicyDoesNotReuseEitherPassOrBlockFromIdenticalImage(AvatarModerationProvider.Verdict previousVerdict) throws Exception { + byte[] image = UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + when(imageProcessor.normalize(any())).thenAnswer(call -> call.getArgument(0)); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(true, true, 1, null)); + var policyA = new AvatarModerationProvider.Context("image", "v2", "test-policy-A", null); + var policyB = new AvatarModerationProvider.Context("image", "v2", "test-policy-B", null); + when(moderation.context("xfyun")).thenReturn(policyA); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(previousVerdict); + Long sourceId = skill(false); + Long sourceCandidate = avatars.submit(namespaceSlug(sourceId), "skill", "owner", image, + new AuditRequestContext(null, null)).candidateId(); + avatars.processMachine(sourceCandidate, new AuditRequestContext(null, null)); + assertThat(candidates.findById(sourceCandidate).orElseThrow().getMachinePolicyRevision()).isEqualTo("test-policy-A"); + + when(moderation.context("xfyun")).thenReturn(policyB); + Long nextId = skill(false); + var next = avatars.submit(namespaceSlug(nextId), "skill", "owner", image, new AuditRequestContext(null, null)); + assertThat(next.machineStatus()).isEqualTo("PENDING"); + assertThat(next.avatarUrl()).isNull(); + var nextVerdict = previousVerdict == AvatarModerationProvider.Verdict.PASS + ? AvatarModerationProvider.Verdict.BLOCK : AvatarModerationProvider.Verdict.PASS; + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(nextVerdict); + var result = avatars.processMachine(next.candidateId(), new AuditRequestContext(null, null)); + assertThat(result.machineStatus()).isEqualTo(nextVerdict == AvatarModerationProvider.Verdict.PASS ? "PASSED" : "BLOCKED"); + var persisted = candidates.findById(next.candidateId()).orElseThrow(); + assertThat(persisted.getMachineServiceId()).isEqualTo("image"); + assertThat(persisted.getMachineApiVersion()).isEqualTo("v2"); + assertThat(persisted.getMachinePolicyRevision()).isEqualTo("test-policy-B"); + assertThat(persisted.getMachineModelVersion()).isNull(); + assertThat(candidates.findById(sourceCandidate).orElseThrow().getMachinePolicyRevision()).isEqualTo("test-policy-A"); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(2)).moderate(eq("xfyun"), any(byte[].class)); + var details = org.mockito.ArgumentCaptor.forClass(String.class); + org.mockito.Mockito.verify(audit, org.mockito.Mockito.times(2)).record(eq("owner"), eq("SKILL_AVATAR_MACHINE_RESULT"), + eq("SKILL_AVATAR"), any(), any(), any(), any(), details.capture()); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + assertThat(details.getAllValues().stream().map(detail -> { + try { return mapper.readTree(detail).path("policyRevision").asText(); } + catch (Exception failure) { throw new IllegalStateException(failure); } + }).toList()).containsExactly("test-policy-A", "test-policy-B"); + assertThat(mapper.readTree(details.getValue()).path("attemptId").asText()).isNotBlank(); + assertThat(mapper.readTree(details.getValue()).path("modelVersion").asText()).isEqualTo("UNKNOWN"); + } + + @Test + void changedContextBeforeCallFailsClosedAndRetryRecordsItsActualPolicy() throws Exception { + when(imageProcessor.normalize(any())).thenAnswer(call -> call.getArgument(0)); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(true, true, 1, null)); + Long skillId = skill(false); + Long candidateId = avatars.submit(namespaceSlug(skillId), "skill", "owner", new byte[]{1}, + new AuditRequestContext(null, null)).candidateId(); + var policyA = new AvatarModerationProvider.Context("image", "v2", "test-policy-A", null); + var policyB = new AvatarModerationProvider.Context("image", "v2", "test-policy-B", null); + when(moderation.context("xfyun")).thenReturn(policyA, policyB); + var failed = avatars.processMachine(candidateId, new AuditRequestContext(null, null)); + assertThat(failed.machineStatus()).isEqualTo("ERROR"); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.never()).moderate(anyString(), any(byte[].class)); + assertThat(candidates.findById(candidateId).orElseThrow().getMachinePolicyRevision()).isEqualTo("test-policy-A"); + jdbc.update("UPDATE skill_avatar_candidate SET machine_next_retry_at = TIMESTAMP '2000-01-01 00:00:00' WHERE id = ?", candidateId); + when(moderation.context("xfyun")).thenReturn(policyB); + var retried = avatars.processMachine(candidateId, new AuditRequestContext(null, null)); + assertThat(retried.machineStatus()).isEqualTo("PASSED"); + assertThat(candidates.findById(candidateId).orElseThrow().getMachinePolicyRevision()).isEqualTo("test-policy-B"); + var details = org.mockito.ArgumentCaptor.forClass(String.class); + org.mockito.Mockito.verify(audit, org.mockito.Mockito.times(2)).record(eq("owner"), eq("SKILL_AVATAR_MACHINE_RESULT"), + eq("SKILL_AVATAR"), eq(candidateId), any(), any(), any(), details.capture()); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + var first = mapper.readTree(details.getAllValues().getFirst()); + var second = mapper.readTree(details.getAllValues().getLast()); + assertThat(first.path("policyRevision").asText()).isEqualTo("test-policy-A"); + assertThat(first.path("contextMatched").asBoolean()).isFalse(); + assertThat(second.path("policyRevision").asText()).isEqualTo("test-policy-B"); + assertThat(second.path("contextMatched").asBoolean()).isTrue(); + assertThat(first.path("attemptId").asText()).isNotEqualTo(second.path("attemptId").asText()); + } + + @Test + void blockedAndProviderFailureNeverExposeAnActiveAvatar() { + for (AvatarModerationProvider.Verdict verdict : new AvatarModerationProvider.Verdict[] { + AvatarModerationProvider.Verdict.BLOCK, AvatarModerationProvider.Verdict.ERROR}) { + Long skillId = skill(false); + Long candidateId = pending(skillId); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(verdict); + var result = avatars.processMachine(candidateId, new AuditRequestContext(null, null)); + assertThat(result.machineStatus()).isEqualTo(verdict == AvatarModerationProvider.Verdict.BLOCK ? "BLOCKED" : "ERROR"); + assertThat(result.avatarUrl()).isNull(); + assertThat(readSkill(skillId).getActiveAvatarId()).isNull(); + assertThat(readSkill(skillId).getPendingAvatarId()).isEqualTo(candidateId); + } + Long skillId = skill(false); + Long candidateId = pending(skillId); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenThrow(new IllegalStateException("provider unavailable")); + assertThat(avatars.processMachine(candidateId, new AuditRequestContext(null, null)).machineStatus()).isEqualTo("ERROR"); + assertThat(readSkill(skillId).getActiveAvatarId()).isNull(); + } + + @Test + void globalReviewDoesNotAcceptTeamAdministratorRole() { + Long skillId = skill(false); + Skill skill = readSkill(skillId); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + Namespace ns = ((com.iflytek.skillhub.domain.namespace.NamespaceRepository) namespaces).findById(skill.getNamespaceId()).orElseThrow(); + ns.setType(com.iflytek.skillhub.domain.namespace.NamespaceType.GLOBAL); + }); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(false, true, 1, null)); + when(imageProcessor.normalize(any())).thenReturn(new byte[] {5, 6}); + Long candidateId = avatars.submit(namespaceSlug(skillId), "skill", "owner", new byte[] {5, 6}, new AuditRequestContext(null, null)).candidateId(); + assertThatThrownBy(() -> unifiedDecision(namespaceSlug(skillId), "skill", candidateId, true, "reviewed", "reviewer", + java.util.Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.ADMIN), + java.util.Set.of(), new AuditRequestContext(null, null))) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class); + assertThat(readSkill(skillId).getActiveAvatarId()).isNull(); + var approved = unifiedDecision(namespaceSlug(skillId), "skill", candidateId, true, "reviewed", "owner", + java.util.Map.of(), java.util.Set.of("SUPER_ADMIN"), new AuditRequestContext(null, null)); + assertThat(approved.avatarUrl()).isNotNull(); + } + + + @Test + void expiredImageCleanupRetriesFailureAndPreservesHistoryAndActiveObject() { + Long skillId = skill(true); + SkillAvatarCandidate active = candidates.findById(readSkill(skillId).getActiveAvatarId()).orElseThrow(); + Long rejectedId = new TransactionTemplate(transactions).execute(status -> { + SkillAvatarCandidate rejected = new SkillAvatarCandidate(skillId, "owner", "rejected/" + skillId, + "rejected-sha", SkillVisibility.PUBLIC, false, true, null); + rejected.reject("owner", "keep this textual review history"); + return candidates.saveAndFlush(rejected).getId(); + }); + SkillAvatarCandidate rejected = candidates.findById(rejectedId).orElseThrow(); + assertThat(candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(rejected.getDiscardAt().minusSeconds(1))) + .extracting(SkillAvatarCandidate::getId).doesNotContain(rejectedId); + assertThat(candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(rejected.getDiscardAt())) + .extracting(SkillAvatarCandidate::getId).doesNotContain(rejectedId); + assertThat(candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(rejected.getDiscardAt().plusSeconds(1))) + .extracting(SkillAvatarCandidate::getId).contains(rejectedId); + assertThat(rejected.getDiscardAt()).isBetween(Instant.now().plusSeconds(30L * 86400 - 60), + Instant.now().plusSeconds(30L * 86400 + 1)); + cleanup.removeExpiredImages(); + org.mockito.Mockito.verify(storage, org.mockito.Mockito.never()).deleteObject("rejected/" + skillId); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + SkillAvatarCandidate expired = candidates.findById(rejectedId).orElseThrow(); + org.springframework.test.util.ReflectionTestUtils.setField(expired, "discardAt", Instant.now().minusSeconds(1)); + }); + org.mockito.Mockito.doThrow(new IllegalStateException("temporary object store outage")) + .doNothing().when(storage).deleteObject("rejected/" + skillId); + assertThatThrownBy(cleanup::removeExpiredImages).isInstanceOf(IllegalStateException.class); + assertThat(candidates.findById(rejectedId).orElseThrow().getObjectKey()).isEqualTo("rejected/" + skillId); + cleanup.removeExpiredImages(); + SkillAvatarCandidate retained = candidates.findById(rejectedId).orElseThrow(); + assertThat(retained.getObjectKey()).isNull(); + assertThat(retained.getReviewReason()).isEqualTo("keep this textual review history"); + assertThat(retained.getReviewedBy()).isEqualTo("owner"); + assertThat(retained.getStatus()).isEqualTo(SkillAvatarCandidate.Status.REJECTED); + assertThat(candidates.findById(active.getId()).orElseThrow().getObjectKey()).isEqualTo(active.getObjectKey()); + org.mockito.Mockito.verify(storage, org.mockito.Mockito.never()).deleteObject(active.getObjectKey()); + } + + + + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"false,false", "false,true", "true,false", "true,true"}) + void reviewPolicyMatrixAppliesToPublicUploadsButPrivateUploadsBypassReview(boolean machineEnabled, boolean manualEnabled) { + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(machineEnabled, manualEnabled, 1, null)); + when(imageProcessor.normalize(any())).thenAnswer(call -> call.getArgument(0)); + Long publicId = skill(false); + Skill publicSkill = readSkill(publicId); + var ownerRole = java.util.Map.of(publicSkill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER); + byte[] publicImage = UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + var submitted = avatars.submit(namespaceSlug(publicId), "skill", "owner", publicImage, new AuditRequestContext(null, null)); + assertThat(submitted.machineStatus()).isEqualTo(machineEnabled ? "PENDING" : "SKIPPED"); + assertThat(submitted.reviewStatus()).isEqualTo(manualEnabled ? "PENDING" : "NOT_REQUIRED"); + if (machineEnabled || manualEnabled) { + assertThat(submitted.avatarUrl()).isNull(); + assertThat(readSkill(publicId).getPendingAvatarId()).isEqualTo(submitted.candidateId()); + } else { + assertThat(submitted.avatarUrl()).isNotNull(); + } + if (machineEnabled) { + var machineResult = avatars.processMachine(submitted.candidateId(), new AuditRequestContext(null, null)); + assertThat(machineResult.machineStatus()).isEqualTo("PASSED"); + assertThat(machineResult.avatarUrl() == null).isEqualTo(manualEnabled); + } + if (manualEnabled) { + assertThat(avatars.reviewQueue("owner", ownerRole, java.util.Set.of())) + .extracting(com.iflytek.skillhub.dto.SkillAvatarStatusResponse::candidateId).contains(submitted.candidateId()); + var reviewed = unifiedDecision(namespaceSlug(publicId), "skill", submitted.candidateId(), true, "reviewed", "owner", + ownerRole, java.util.Set.of(), new AuditRequestContext(null, null)); + assertThat(reviewed.avatarUrl()).isNotNull(); + } + assertThat(readSkill(publicId).getActiveAvatarId()).isEqualTo(submitted.candidateId()); + assertThat(readSkill(publicId).getPendingAvatarId()).isNull(); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(machineEnabled ? 1 : 0)) + .moderate(anyString(), any(byte[].class)); + + Long privateId = skill(false); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + Skill privateSkill = skills.findLockedById(privateId).orElseThrow(); + privateSkill.setVisibility(SkillVisibility.PRIVATE); + }); + var privateUploaded = avatars.submit(namespaceSlug(privateId), "skill", "owner", + UUID.randomUUID().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8), new AuditRequestContext(null, null)); + assertThat(privateUploaded.machineStatus()).isEqualTo("SKIPPED"); + assertThat(privateUploaded.reviewStatus()).isEqualTo("NOT_REQUIRED"); + assertThat(privateUploaded.avatarUrl()).isNotNull(); + assertThat(readSkill(privateId).getActiveAvatarId()).isEqualTo(privateUploaded.candidateId()); + assertThat(readSkill(privateId).getPendingAvatarId()).isNull(); + assertThat(avatars.reviewQueue("owner", java.util.Map.of(readSkill(privateId).getNamespaceId(), + com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), java.util.Set.of())) + .extracting(com.iflytek.skillhub.dto.SkillAvatarStatusResponse::candidateId).doesNotContain(privateUploaded.candidateId()); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.times(machineEnabled ? 1 : 0)) + .moderate(anyString(), any(byte[].class)); + } + + @Test + void teamAdministratorCannotActAsOwnerOrReadPrivateAvatar() { + Long skillId = skill(false); + Skill skill = readSkill(skillId); + var adminRole = java.util.Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.ADMIN); + when(imageProcessor.normalize(any())).thenAnswer(call -> call.getArgument(0)); + assertThatThrownBy(() -> avatars.submit(namespaceSlug(skillId), "skill", "admin", new byte[] {7}, + new AuditRequestContext(null, null))) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class); + org.mockito.Mockito.verify(storage, org.mockito.Mockito.never()) + .putObject(anyString(), any(), org.mockito.ArgumentMatchers.anyLong(), anyString()); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + Skill privateSkill = skills.findLockedById(skillId).orElseThrow(); + privateSkill.setVisibility(SkillVisibility.PRIVATE); + }); + var uploaded = avatars.submit(namespaceSlug(skillId), "skill", "owner", new byte[] {8}, new AuditRequestContext(null, null)); + SkillQueryService.SkillDetailDTO detail = new SkillQueryService.SkillDetailDTO( + skillId, null, null, null, null, null, null, null, null, null, null, null, null, + false, null, null, null, false, false, false, false, null, null, null, null, null, null, null); + when(skillQueries.getSkillDetail(anyString(), eq("skill"), eq("admin"), any())).thenReturn(detail); + when(visibility.canAccess(any(), eq("admin"), any(), any())).thenReturn(true); + assertThatThrownBy(() -> avatars.readActive(namespaceSlug(skillId), "skill", "admin", adminRole, java.util.Set.of())) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException.class); + assertThatThrownBy(() -> avatars.readCandidate(namespaceSlug(skillId), "skill", uploaded.candidateId(), "admin", adminRole, java.util.Set.of())) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException.class); + assertThatThrownBy(() -> avatars.remove(namespaceSlug(skillId), "skill", "admin", false, null, adminRole, + java.util.Set.of(), new AuditRequestContext(null, null))) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class); + org.mockito.Mockito.verify(storage, org.mockito.Mockito.never()).getObject(anyString()); + assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(uploaded.candidateId()); + } + + + @Test + void scopeRejectionSuppressesCurrentPublicationAndNewPublicationCreatesOneFreshReview() { + Long skillId = skill(true); + publishVersion(skillId, "1.0.0"); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(false, true, 1, null)); + Long rejectedId = avatars.prepareScopeReview(skillId, null).candidateId(); + var roles = java.util.Map.of(readSkill(skillId).getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER); + unifiedDecision(namespaceSlug(skillId), "skill", rejectedId, false, "rejected for this publication", "owner", + roles, java.util.Set.of(), new AuditRequestContext(null, null)); + scopeRecovery.recover(); + scopeRecovery.recover(); + assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(skillId)).hasSize(2); + assertThat(readSkill(skillId).getPendingAvatarId()).isNull(); + assertThat(SkillAvatarUrls.current(readSkill(skillId), "test")).isNull(); + publishVersion(skillId, "1.1.0"); + scopeRecovery.recover(); + scopeRecovery.recover(); + assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(skillId)).hasSize(3); + Long newCandidateId = readSkill(skillId).getPendingAvatarId(); + assertThat(newCandidateId).isNotNull().isNotEqualTo(rejectedId); + SkillAvatarCandidate newCandidate = candidates.findById(newCandidateId).orElseThrow(); + assertThat(newCandidate.getScopeReviewVersionId()).isEqualTo(readSkill(skillId).getLatestVersionId()); + assertThat(newCandidate.getReviewStatus()).isEqualTo(SkillAvatarCandidate.ReviewStatus.PENDING); + assertThat(newCandidate.getStatus()).isEqualTo(SkillAvatarCandidate.Status.PENDING); + assertThat(SkillAvatarUrls.current(readSkill(skillId), "test")).isNull(); + } + + @Test + void machineOnlyBlockCanBeAppealedAndExplicitlyApproved() { + Long skillId = skill(false); + Long candidateId = pending(skillId); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenReturn(AvatarModerationProvider.Verdict.BLOCK); + assertThat(avatars.processMachine(candidateId, new AuditRequestContext(null, null)).avatarUrl()).isNull(); + var appealed = avatars.appeal(namespaceSlug(skillId), "skill", candidateId, "owner", new AuditRequestContext(null, null)); + assertThat(appealed.reviewStatus()).isEqualTo("APPEAL_PENDING"); + assertThat(appealed.avatarUrl()).isNull(); + var approved = unifiedDecision(namespaceSlug(skillId), "skill", candidateId, true, "machine false positive", "owner", + java.util.Map.of(readSkill(skillId).getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), + java.util.Set.of(), new AuditRequestContext(null, null)); + assertThat(approved.avatarUrl()).isNotNull(); + SkillAvatarCandidate candidate = candidates.findById(candidateId).orElseThrow(); + assertThat(candidate.getReviewReason()).isEqualTo("machine false positive"); + assertThat(candidate.getReviewedBy()).isEqualTo("owner"); + assertThat(readSkill(skillId).getPendingAvatarId()).isNull(); + assertThat(readSkill(skillId).getActiveAvatarId()).isEqualTo(candidateId); + } + + private void publishVersion(Long skillId, String version) { + new TransactionTemplate(transactions).executeWithoutResult(status -> { + var published = new com.iflytek.skillhub.domain.skill.SkillVersion(skillId, version, "owner"); + published.setStatus(com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED); + versions.saveAndFlush(published); + skills.findLockedById(skillId).orElseThrow().setLatestVersionId(published.getId()); + }); + } + + private java.util.List abandoned(Instant staleBefore, Instant now) { + return candidates.findAbandonedMachineCandidates(0L, SkillAvatarCandidate.Status.PENDING, + SkillAvatarCandidate.MachineStatus.PENDING, SkillAvatarCandidate.MachineStatus.PROCESSING, + staleBefore, now, org.springframework.data.domain.PageRequest.of(0, 100)); + } + + // Hibernate binds these timestamp-without-zone columns in UTC; use the same calendar in JDBC fixtures. + private void setTime(Long candidateId, String column, Instant value) { + jdbc.update(connection -> { + var statement = connection.prepareStatement("UPDATE skill_avatar_candidate SET " + column + " = ? WHERE id = ?"); + statement.setTimestamp(1, java.sql.Timestamp.from(value), + java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))); + statement.setLong(2, candidateId); + return statement; + }); + } + + private Skill readSkill(Long id) { + return ((com.iflytek.skillhub.domain.skill.SkillRepository) skills).findById(id).orElseThrow(); + } + + private String namespaceSlug(Long skillId) { + return ((com.iflytek.skillhub.domain.namespace.NamespaceRepository) namespaces).findById(readSkill(skillId).getNamespaceId()).orElseThrow().getSlug(); + } + + private Long skill(boolean expanded) { + return new TransactionTemplate(transactions).execute(status -> { + String suffix = UUID.randomUUID().toString().substring(0, 8); + Namespace ns = namespaces.saveAndFlush(new Namespace("avatar-" + suffix, "Avatar recovery", "owner")); + Skill skill = skills.saveAndFlush(new Skill(ns.getId(), "skill", "owner", expanded ? SkillVisibility.PRIVATE : SkillVisibility.PUBLIC)); + if (expanded) { + SkillAvatarCandidate old = new SkillAvatarCandidate(skill.getId(), "owner", "old/" + skill.getId(), "old-sha-" + suffix, + SkillVisibility.PRIVATE, false, false, null); + old.activate(); + candidates.saveAndFlush(old); + skill.activateAvatar(old.getId(), SkillVisibility.PRIVATE); + skill.setVisibility(SkillVisibility.PUBLIC); + skills.saveAndFlush(skill); + } + return skill.getId(); + }); + } + + private Long pending(Long skillId) { + return new TransactionTemplate(transactions).execute(status -> { + SkillAvatarCandidate candidate = new SkillAvatarCandidate(skillId, "owner", "pending/" + skillId, + "sha-" + skillId, SkillVisibility.PUBLIC, true, false, "xfyun"); + candidates.saveAndFlush(candidate); + Skill skill = skills.findLockedById(skillId).orElseThrow(); + skill.setPendingAvatarId(candidate.getId()); + return candidate.getId(); + }); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarScopeRecoveryQueryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarScopeRecoveryQueryTest.java new file mode 100644 index 00000000..4b59a446 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarScopeRecoveryQueryTest.java @@ -0,0 +1,125 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.infra.jpa.SkillJpaRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class SkillAvatarScopeRecoveryQueryTest { + @Autowired private TestEntityManager entities; + @Autowired private SkillJpaRepository skills; + + @Test + void selectsMissingAndErroredReviewsButSkipsCurrentRejectedCandidate() { + Namespace namespace = entities.persistAndFlush(new Namespace("scope-recovery", "Scope recovery", "owner")); + Skill needsReview = expandedSkill(namespace.getId(), "needs-review"); + Skill rejected = expandedSkill(namespace.getId(), "rejected"); + Skill oldRejection = expandedSkill(namespace.getId(), "old-rejection"); + Skill retry = expandedSkill(namespace.getId(), "retry"); + Skill fresh = expandedSkill(namespace.getId(), "fresh"); + Skill processing = expandedSkill(namespace.getId(), "processing"); + SkillAvatarCandidate rejectedCandidate = new SkillAvatarCandidate(rejected.getId(), "owner", "old/rejected.png", + "sha", SkillVisibility.PUBLIC, false, true, null); + rejectedCandidate.markScopeReviewVersion(4L); + rejectedCandidate.reject("reviewer", "blocked"); + entities.persistAndFlush(rejectedCandidate); + SkillAvatarCandidate historical = new SkillAvatarCandidate(oldRejection.getId(), "owner", "old/historical.png", + "sha", SkillVisibility.PUBLIC, false, true, null); + historical.markScopeReviewVersion(3L); + historical.reject("reviewer", "blocked on older publication"); + entities.persistAndFlush(historical); + SkillAvatarCandidate errored = new SkillAvatarCandidate(retry.getId(), "owner", "old/error.png", "sha", + SkillVisibility.PUBLIC, true, false, "xfyun"); + errored.machineResult(SkillAvatarCandidate.MachineStatus.ERROR); + entities.persistAndFlush(errored); + retry.setPendingAvatarId(errored.getId()); + entities.persistAndFlush(retry); + SkillAvatarCandidate freshCandidate = new SkillAvatarCandidate(fresh.getId(), "owner", "old/fresh.png", "sha", + SkillVisibility.PUBLIC, true, false, "xfyun"); + entities.persistAndFlush(freshCandidate); + fresh.setPendingAvatarId(freshCandidate.getId()); + entities.persistAndFlush(fresh); + SkillAvatarCandidate processingCandidate = new SkillAvatarCandidate(processing.getId(), "owner", + "old/processing.png", "sha", SkillVisibility.PUBLIC, true, false, "xfyun"); + Instant claimTime = Instant.now(); + processingCandidate.claimMachineAttempt("first-attempt", claimTime, claimTime.plusSeconds(600)); + entities.persistAndFlush(processingCandidate); + processing.setPendingAvatarId(processingCandidate.getId()); + entities.persistAndFlush(processing); + entities.clear(); + + Instant now = Instant.now(); + var results = recoverable(now); + + assertThat(results).extracting(Skill::getId) + .containsExactly(needsReview.getId(), oldRejection.getId(), retry.getId()); + assertThat(recoverable(now.plusSeconds(601))).extracting(Skill::getId) + .containsExactly(needsReview.getId(), oldRejection.getId(), retry.getId(), + fresh.getId(), processing.getId()); + } + + @Test + void cooledDownErrorIsNotRetriedOnConsecutiveTicksAndStopsAfterThreeAttempts() { + Namespace namespace = entities.persistAndFlush(new Namespace("scope-cooldown", "Scope cooldown", "owner")); + Skill cooling = expandedSkill(namespace.getId(), "cooling"); + Skill exhausted = expandedSkill(namespace.getId(), "exhausted"); + SkillAvatarCandidate first = errorCandidate(cooling, 1); + SkillAvatarCandidate third = errorCandidate(exhausted, 3); + cooling.setPendingAvatarId(first.getId()); + exhausted.setPendingAvatarId(third.getId()); + entities.persistAndFlush(cooling); + entities.persistAndFlush(exhausted); + entities.clear(); + + Instant now = Instant.now(); + assertThat(recoverable(now)).isEmpty(); + assertThat(recoverable(now.plusSeconds(300))).isEmpty(); + assertThat(recoverable(now.plusSeconds(3601))).extracting(Skill::getId) + .containsExactly(cooling.getId()); + } + + private SkillAvatarCandidate errorCandidate(Skill skill, int attempts) { + SkillAvatarCandidate candidate = new SkillAvatarCandidate(skill.getId(), "owner", "old/error.png", + "sha", SkillVisibility.PUBLIC, true, false, "xfyun"); + for (int n = 0; n < attempts; n++) { + Instant at = Instant.now().plusSeconds(n * 3601L); + candidate.claimMachineAttempt("attempt-" + n, at, at.plusSeconds(600)); + candidate.completeMachineAttempt("attempt-" + n, SkillAvatarCandidate.MachineStatus.ERROR); + } + return entities.persistAndFlush(candidate); + } + + private java.util.List recoverable(Instant now) { + return skills.findScopeReviewRecoveryCandidates(0L, SkillStatus.ACTIVE, + SkillVisibility.PRIVATE, SkillVisibility.NAMESPACE_ONLY, SkillVisibility.PUBLIC, + SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.MachineStatus.PENDING, + SkillAvatarCandidate.MachineStatus.PROCESSING, SkillAvatarCandidate.MachineStatus.ERROR, 3, + SkillAvatarCandidate.Status.REJECTED, now.minusSeconds(600), now, PageRequest.of(0, 100)); + } + + private Skill expandedSkill(Long namespaceId, String slug) { + Skill skill = entities.persistAndFlush(new Skill(namespaceId, slug, "owner", SkillVisibility.PRIVATE)); + SkillAvatarCandidate active = new SkillAvatarCandidate(skill.getId(), "owner", "old/" + slug + ".png", + "sha", SkillVisibility.PRIVATE, false, false, null); + active.activate(); + entities.persistAndFlush(active); + skill.activateAvatar(active.getId(), SkillVisibility.PRIVATE); + skill.setLatestVersionId(4L); + skill.setVisibility(SkillVisibility.PUBLIC); + entities.persistAndFlush(skill); + return skill; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarUploadPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarUploadPostgresTest.java new file mode 100644 index 00000000..4f7635a5 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillAvatarUploadPostgresTest.java @@ -0,0 +1,500 @@ +package com.iflytek.skillhub.repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.skill.service.SkillStorageDeletionCompensationService; +import com.iflytek.skillhub.domain.skill.validation.*; +import com.iflytek.skillhub.domain.security.SecurityScanService; +import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse; +import com.iflytek.skillhub.infra.jpa.*; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.service.*; +import com.iflytek.skillhub.task.SkillAvatarCleanupTask; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +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 static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real publication and staged-image transactions, with controlled external dependencies. */ +@DataJpaTest(showSql = false) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Import({com.iflytek.skillhub.domain.review.ReviewService.class, com.iflytek.skillhub.domain.review.ReviewPermissionChecker.class, SkillAvatarAppService.class, SkillAvatarUploadService.class, SkillPublishAppService.class, + SkillPublishService.class, SkillStorageDeletionCompensationService.class, JpaSkillStorageDeletionCompensationRepositoryAdapter.class, SkillAvatarCleanupTask.class, + SkillAvatarUploadPostgresTest.Configuration.class}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class SkillAvatarUploadPostgresTest { + @Container static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + @DynamicPropertySource static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + @TestConfiguration static class Configuration { + @Bean SkillMetadataParser metadata() { return new SkillMetadataParser(); } + @Bean SkillPackageValidator validator(SkillMetadataParser metadata) { return new SkillPackageValidator(metadata); } + @Bean PrePublishValidator preValidator() { return new NoOpPrePublishValidator(); } + @Bean ObjectMapper mapper() { return new ObjectMapper().disable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); } + @Bean Clock clock() { return Clock.systemUTC(); } + } + @Autowired SkillAvatarUploadService uploads; + @Autowired SkillPublishAppService publications; + @Autowired SkillAvatarAppService avatars; + @Autowired SkillAvatarCleanupTask cleanup; + @Autowired SkillAvatarUploadJpaRepository uploadRows; + @Autowired SkillJpaRepository skills; + @Autowired SkillVersionJpaRepository versions; + @Autowired SkillAvatarCandidateJpaRepository candidates; + @Autowired NamespaceJpaRepository namespaces; + @Autowired NamespaceMemberJpaRepository members; + @Autowired UserAccountJpaRepository users; + @Autowired PlatformTransactionManager transactions; + @Autowired JdbcTemplate jdbc; + @MockBean ObjectStorageService storage; + @MockBean SkillAvatarImageProcessor images; + @MockBean AvatarReviewSettingsService settings; + @MockBean AvatarModerationGateway gateway; + @MockBean VisibilityChecker visibility; + @MockBean SkillQueryService queries; + @MockBean AuditLogService audit; + @MockBean RequestIdAccessor requestIds; + @MockBean SecurityScanService scanner; + @MockBean com.iflytek.skillhub.domain.skill.service.SkillGovernanceService governance; + @MockBean com.iflytek.skillhub.domain.governance.GovernanceNotificationService notifications; + @Autowired com.iflytek.skillhub.domain.review.ReviewService reviews; + @Autowired ReviewTaskJpaRepository reviewRows; + + private String namespace; + private final AuditRequestContext context = new AuditRequestContext(null, null); + + @BeforeEach void fixtures() { + for (String actor : List.of("owner", "stranger")) { + if (((com.iflytek.skillhub.domain.user.UserAccountRepository) users).findById(actor).isEmpty()) { + users.saveAndFlush(new com.iflytek.skillhub.domain.user.UserAccount(actor, actor, null, null)); + } + } + namespace = "upload-" + UUID.randomUUID().toString().substring(0, 8); + var createdNamespace = namespaces.saveAndFlush(new Namespace(namespace, "Upload testing", "owner")); + members.saveAndFlush(new com.iflytek.skillhub.domain.namespace.NamespaceMember(createdNamespace.getId(), "owner", com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER)); + when(images.normalize(any())).thenAnswer(call -> call.getArgument(0)); + when(storage.exists(anyString())).thenReturn(true); + when(storage.getObject(anyString())).thenAnswer(call -> new ByteArrayInputStream(new byte[]{1,2,3})); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(false, true, 1, null)); + when(scanner.isEnabled()).thenReturn(true); + org.mockito.Mockito.lenient().when(gateway.context(any())).thenReturn( + new AvatarModerationProvider.Context("fixture-image", "test-v1", "fixture-policy-v1", null)); + } + + @Test void sameSlugPrivateDraftOwnedByAnotherUserDoesNotHijackAvatarBindingOrRemoval() { + Long otherId = new TransactionTemplate(transactions).execute(ignored -> + skills.saveAndFlush(new Skill(namespaceId(), "skill", "stranger", SkillVisibility.PRIVATE)).getId()); + String id = upload(); + var mine = publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, id, "skill"); + Long myCandidate = uploadRows.findById(id).orElseThrow().getCandidateId(); + assertThat(candidates.findById(myCandidate).orElseThrow().getSkillId()).isEqualTo(mine.skillId()); + assertThat(avatars.ownerStatus(namespace, "skill", "owner").candidateId()).isEqualTo(myCandidate); + assertThat(avatars.ownerStatus(namespace, "skill", "stranger").candidateId()).isNull(); + assertThat(avatars.readCandidate(namespace, "skill", myCandidate, "owner", java.util.Map.of(), Set.of())).isNotNull(); + var metadataTask = reviewRows.findByAvatarCandidateIdAndStatus(myCandidate, + com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING).getFirst(); + reviews.approveReview(metadataTask.getId(), "owner", "approved", + java.util.Map.of(namespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), Set.of()); + SkillQueryService.SkillDetailDTO detail = new SkillQueryService.SkillDetailDTO( + mine.skillId(), null, null, null, null, null, null, null, null, null, null, null, null, + false, null, null, null, false, false, false, false, null, null, null, null, null, null, null); + when(queries.getSkillDetail(eq(namespace), eq("skill"), eq("owner"), any())).thenReturn(detail); + when(visibility.canAccess(any(Skill.class), eq("owner"), any(), any())).thenReturn(true); + assertThat(avatars.readActive(namespace, "skill", "owner", java.util.Map.of(), Set.of())).isNotNull(); + publish("1.0.1", SkillPublishAppService.AvatarAction.REMOVE, null, "skill"); + assertThat(read(mine.skillId()).getPendingAvatarId()).isNull(); + assertThat(read(otherId).getPendingAvatarId()).isNull(); + assertThat(read(otherId).getVisibility()).isEqualTo(SkillVisibility.PRIVATE); + } + + @Test void uploadDoesNotCreateSkillOrCandidateAndPreviewIsUploaderOnly() { + long initialCandidates = candidates.count(); + String id = upload(); + assertThat(candidates.count()).isEqualTo(initialCandidates); + assertThat(skills.findByNamespaceIdAndSlug(namespaceId(), "skill")).isEmpty(); + assertThat(uploads.preview(id, "owner")).isNotNull(); + assertThatThrownBy(() -> uploads.preview(id, "stranger")).isInstanceOf(DomainForbiddenException.class); + } + + @Test void publishConsumesUploadAndRepeatedReferenceDoesNotCreateAnotherCandidate() { + String id = upload(); + var published = publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, id, null); + var candidate = candidates.findById(uploadRows.findById(id).orElseThrow().getCandidateId()).orElseThrow(); + assertThat(candidate.getSkillId()).isEqualTo(published.skillId()); + assertThat(candidate.getReviewStatus()).isEqualTo(SkillAvatarCandidate.ReviewStatus.PENDING); + assertThat(candidate.getObjectKey()).isEqualTo(uploadRows.findById(id).orElseThrow().getObjectKey()); + assertThat(uploads.submit(namespace, "skill", "owner", id, context).candidateId()).isEqualTo(candidate.getId()); + assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(published.skillId())).hasSize(1); + assertThat(read(published.skillId()).getActiveAvatarId()).isNull(); + verify(storage, times(1)).putObject(startsWith("skill-avatars/"), any(), anyLong(), anyString()); + } + + @Test void invalidReferencesAndWrongTargetRejectBeforePackageWrites() { + String foreign = uploads.upload("stranger", new byte[]{5}).uploadId(); + assertThatThrownBy(() -> publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, foreign, null)) + .isInstanceOf(DomainForbiddenException.class); + String expired = upload(); + jdbc.update("UPDATE skill_avatar_upload SET expires_at = TIMESTAMP '2000-01-01 00:00:00' WHERE id = ?", expired); + assertThatThrownBy(() -> publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, expired, null)) + .isInstanceOf(DomainBadRequestException.class); + String missingObject = upload(); + when(storage.exists(uploadRows.findById(missingObject).orElseThrow().getObjectKey())).thenReturn(false); + assertThatThrownBy(() -> publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, missingObject, null)) + .isInstanceOf(DomainBadRequestException.class); + assertThatThrownBy(() -> publish("1.0.0", SkillPublishAppService.AvatarAction.KEEP, null, "different-skill")) + .isInstanceOf(DomainBadRequestException.class); + verify(storage, never()).putObject(startsWith("skills/"), any(), anyLong(), anyString()); + assertThat(skills.findByNamespaceIdAndSlug(namespaceId(), "skill")).isEmpty(); + } + + @Test void lateAvatarFailureRollsBackVersionReplacementAndPreservesOldCandidateAndObjects() { + var old = publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, upload(), null); + Long oldCandidate = read(old.skillId()).getPendingAvatarId(); + new TransactionTemplate(transactions).executeWithoutResult(ignored -> + ((SkillVersionRepository) versions).findById(old.version().getId()).orElseThrow().setStatus(SkillVersionStatus.UPLOADED)); + String fresh = upload(); + when(audit.record(anyString(), eq("SKILL_AVATAR_SUBMIT"), anyString(), anyLong(), any(), any(), any(), anyString())) + .thenThrow(new IllegalStateException("audit unavailable")); + clearInvocations(storage); + assertThatThrownBy(() -> publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, fresh, null)) + .isInstanceOf(IllegalStateException.class); + assertThat(versions.findBySkillIdAndVersion(old.skillId(), "1.0.0").orElseThrow().getId()).isEqualTo(old.version().getId()); + assertThat(read(old.skillId()).getPendingAvatarId()).isEqualTo(oldCandidate); + assertThat(candidates.findById(oldCandidate).orElseThrow().getStatus()).isEqualTo(SkillAvatarCandidate.Status.PENDING); + assertThat(uploadRows.findById(fresh).orElseThrow().getCandidateId()).isNull(); + @SuppressWarnings("unchecked") + var deleted = (org.mockito.ArgumentCaptor>) (org.mockito.ArgumentCaptor) org.mockito.ArgumentCaptor.forClass(List.class); + verify(storage).deleteObjects(deleted.capture()); + assertThat(deleted.getValue()).hasSize(2).allMatch(key -> !key.contains("/" + old.version().getId() + "/")); + verify(storage, never()).deleteObject(uploadRows.findById(fresh).orElseThrow().getObjectKey()); + } + + @Test void failedNewPublicationLeavesNoSkillVersionOrCandidate() { + String id = upload(); + when(settings.current()).thenThrow(new IllegalStateException("settings unavailable")); + assertThatThrownBy(() -> publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, id, null)) + .isInstanceOf(IllegalStateException.class); + assertThat(skills.findByNamespaceIdAndSlug(namespaceId(), "skill")).isEmpty(); + assertThat(uploadRows.findById(id).orElseThrow().getCandidateId()).isNull(); + assertThat(jdbc.queryForObject("SELECT count(*) FROM skill_avatar_candidate WHERE object_key = ?", Long.class, + uploadRows.findById(id).orElseThrow().getObjectKey())).isZero(); + verify(storage).deleteObjects(argThat(keys -> keys.size() == 2)); + } + + @Test void failedRollbackObjectDeletionIsPersistedForRetryWithoutOriginalErrorText() { + String id = upload(); + when(settings.current()).thenThrow(new IllegalStateException("settings unavailable")); + doThrow(new IllegalStateException("sensitive-storage-error")).when(storage).deleteObjects(anyList()); + assertThatThrownBy(() -> publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, id, null)) + .isInstanceOf(IllegalStateException.class); + assertThat(jdbc.queryForObject("SELECT count(*) FROM skill_storage_delete_compensation WHERE namespace = ? AND status = 'PENDING'", Long.class, namespace)) + .isEqualTo(1L); + assertThat(jdbc.queryForObject("SELECT last_error FROM skill_storage_delete_compensation WHERE namespace = ?", String.class, namespace)) + .isEqualTo("IllegalStateException"); + } + + @Test void removeOnlyTakesEffectOnSuccessfulCommitAndOldReviewCannotRestoreIt() { + var first = publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, upload(), null); + Long candidate = read(first.skillId()).getPendingAvatarId(); + assertThatThrownBy(() -> publish("1.0.1", SkillPublishAppService.AvatarAction.REMOVE, null, "wrong")) + .isInstanceOf(DomainBadRequestException.class); + assertThat(read(first.skillId()).getPendingAvatarId()).isEqualTo(candidate); + publish("1.0.1", SkillPublishAppService.AvatarAction.REMOVE, null, "skill"); + assertThat(read(first.skillId()).getPendingAvatarId()).isNull(); + assertThat(candidates.findById(candidate).orElseThrow().getStatus()).isEqualTo(SkillAvatarCandidate.Status.REMOVED); + assertThatThrownBy(() -> avatars.decide(namespace, "skill", candidate, true, "late", "owner", + java.util.Map.of(namespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), Set.of(), context)) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainConflictException.class); + } + + @Test void cleanupExcludesLinkedImagesAndWaitsForConsumptionLock() throws Exception { + String abandoned = upload(); + String linked = upload(); + var skill = publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, linked, null); + String abandonedKey = uploadRows.findById(abandoned).orElseThrow().getObjectKey(); + String linkedKey = uploadRows.findById(linked).orElseThrow().getObjectKey(); + jdbc.update("UPDATE skill_avatar_upload SET expires_at = TIMESTAMP '2000-01-01 00:00:00' WHERE id IN (?, ?)", abandoned, linked); + cleanup.removeExpiredImages(); + verify(storage).deleteObject(abandonedKey); + verify(storage, never()).deleteObject(linkedKey); + assertThat(uploadRows.findById(abandoned).orElseThrow().getObjectKey()).isNull(); + assertThat(candidates.findById(read(skill.skillId()).getPendingAvatarId()).orElseThrow().getObjectKey()).isEqualTo(linkedKey); + String racing = upload(); + CountDownLatch locked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var consuming = pool.submit(() -> new TransactionTemplate(transactions).execute(status -> { + uploads.submit(namespace, "skill", "owner", racing, context); + jdbc.update("UPDATE skill_avatar_upload SET expires_at = TIMESTAMP '2000-01-01 00:00:00' WHERE id = ?", racing); + locked.countDown(); + try { assertThat(release.await(10, TimeUnit.SECONDS)).isTrue(); } + catch (InterruptedException failure) { throw new RuntimeException(failure); } + return null; + })); + try { + assertThat(locked.await(10, TimeUnit.SECONDS)).isTrue(); + var scanning = pool.submit(cleanup::removeExpiredImages); + release.countDown(); + consuming.get(10, TimeUnit.SECONDS); + scanning.get(10, TimeUnit.SECONDS); + } finally { release.countDown(); } + } + assertThat(uploadRows.findById(racing).orElseThrow().getCandidateId()).isNotNull(); + verify(storage, never()).deleteObject(uploadRows.findById(racing).orElseThrow().getObjectKey()); + } + + @Test void expiredCleanupLocksExcludeConcurrentConsumption() throws Exception { + var published = publish("1.0.0", SkillPublishAppService.AvatarAction.KEEP, null, null); + String id = upload(); + String key = uploadRows.findById(id).orElseThrow().getObjectKey(); + jdbc.update("UPDATE skill_avatar_upload SET expires_at = TIMESTAMP '2000-01-01 00:00:00' WHERE id = ?", id); + CountDownLatch deleting = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + doAnswer(call -> { + deleting.countDown(); + assertThat(release.await(10, TimeUnit.SECONDS)).isTrue(); + return null; + }).when(storage).deleteObject(key); + try (var pool = Executors.newFixedThreadPool(2)) { + var cleaning = pool.submit(cleanup::removeExpiredImages); + try { + assertThat(deleting.await(10, TimeUnit.SECONDS)).isTrue(); + var consuming = pool.submit(() -> uploads.submit(namespace, "skill", "owner", id, context)); + assertThatThrownBy(() -> consuming.get(250, TimeUnit.MILLISECONDS)) + .isInstanceOf(java.util.concurrent.TimeoutException.class); + release.countDown(); + cleaning.get(10, TimeUnit.SECONDS); + assertThatThrownBy(() -> consuming.get(10, TimeUnit.SECONDS)) + .hasCauseInstanceOf(DomainBadRequestException.class); + } finally { release.countDown(); } + } + assertThat(read(published.skillId()).getPendingAvatarId()).isNull(); + assertThat(uploadRows.findById(id).orElseThrow().getCandidateId()).isNull(); + assertThat(uploadRows.findById(id).orElseThrow().getObjectKey()).isNull(); + } + + @Test void concurrentSameReferenceIsConsumedOnceWithoutDeadlock() throws Exception { + var published = publish("1.0.0", SkillPublishAppService.AvatarAction.KEEP, null, null); + String id = upload(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var first = pool.submit(() -> { start.await(); return uploads.submit(namespace, "skill", "owner", id, context); }); + var second = pool.submit(() -> { start.await(); return uploads.submit(namespace, "skill", "owner", id, context); }); + start.countDown(); + assertThat(first.get(10, TimeUnit.SECONDS).candidateId()).isEqualTo(second.get(10, TimeUnit.SECONDS).candidateId()); + } + assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(published.skillId())).hasSize(1); + } + + @Test void targetPublicAudienceCannotBypassReviewWhilePrivateVersionWaits() { + var first = publications.publish(namespace, entries("1.0.0"), "owner", SkillVisibility.PRIVATE, Set.of("SUPER_ADMIN"), + false, SkillPublishAppService.AvatarAction.REPLACE, upload(), null, context); + Long active = read(first.skillId()).getActiveAvatarId(); + // The non-admin publisher must have namespace membership for the real domain service. + var expanded = publications.publish(namespace, entries("1.0.1"), "owner", SkillVisibility.PUBLIC, Set.of(), + false, SkillPublishAppService.AvatarAction.REPLACE, upload(), null, context); + var pending = candidates.findById(read(expanded.skillId()).getPendingAvatarId()).orElseThrow(); + assertThat(pending.getAudience()).isEqualTo(SkillVisibility.PUBLIC); + assertThat(pending.isManualEnabled()).isTrue(); + assertThat(read(first.skillId()).getActiveAvatarId()).isEqualTo(active); + assertThat(avatars.prepareScopeReview(first.skillId(), "owner")).isNull(); + assertThat(read(first.skillId()).getPendingAvatarId()).isEqualTo(pending.getId()); + } + + private String upload() { return uploads.upload("owner", UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8)).uploadId(); } + private Long namespaceId() { return namespaces.findBySlug(namespace).orElseThrow().getId(); } + private Skill read(Long id) { return ((SkillRepository) skills).findById(id).orElseThrow(); } + @Test void consumedReferenceCannotAttachToAnotherPackageTaskAndPublicationRollsBack() { + String uploadId = upload(); + var first = publications.publish(namespace, entries("1.0.0"), "owner", SkillVisibility.PUBLIC, Set.of(), + true, SkillPublishAppService.AvatarAction.REPLACE, uploadId, "skill", context); + var originalTask = reviewRows.findBySkillVersionIdAndStatus(first.version().getId(), + com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING).orElseThrow(); + Long candidateId = originalTask.getAvatarCandidateId(); + assertThat(uploads.submit(namespace, "skill", "owner", uploadId, context).candidateId()).isEqualTo(candidateId); + assertThat(uploads.submitForReview(namespace, "skill", "owner", uploadId, SkillVisibility.PUBLIC, + context, originalTask).candidateId()).isEqualTo(candidateId); + assertThatThrownBy(() -> publications.publish(namespace, entries("1.0.1"), "owner", SkillVisibility.PUBLIC, Set.of(), + true, SkillPublishAppService.AvatarAction.REPLACE, uploadId, "skill", context)) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainConflictException.class); + assertThat(versions.findBySkillIdAndVersion(first.skillId(), "1.0.1")).isEmpty(); + assertThat(reviewRows.findBySkillVersionIdAndStatus(first.version().getId(), + com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING)).isPresent(); + assertThat(candidates.findBySkillIdOrderByCreatedAtDesc(first.skillId())).hasSize(1); + assertThat(read(first.skillId()).getPendingAvatarId()).isEqualTo(candidateId); + assertThat(candidates.findById(candidateId).orElseThrow().getStatus()).isEqualTo(SkillAvatarCandidate.Status.PENDING); + } + + @Test void withdrawalRefreshesTaskAfterWaitingForConcurrentApproval() throws Exception { + var first = publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, upload(), "skill"); + Long candidateId = read(first.skillId()).getPendingAvatarId(); + var task = reviewRows.findByAvatarCandidateIdAndStatus(candidateId, + com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING).getFirst(); + try (var executor = Executors.newSingleThreadExecutor()) { + assertThatThrownBy(() -> new TransactionTemplate(transactions).executeWithoutResult(ignored -> { + var stale = ((com.iflytek.skillhub.domain.review.ReviewTaskRepository) reviewRows).findById(task.getId()).orElseThrow(); + try { + executor.submit(() -> reviews.approveReview(task.getId(), "stranger", "approved", java.util.Map.of(), Set.of("SKILL_ADMIN"))) + .get(10, TimeUnit.SECONDS); + } catch (Exception failure) { throw new IllegalStateException(failure); } + avatars.lock(stale); + avatars.withdraw(stale); + })).isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainConflictException.class); + } + assertThat(read(first.skillId()).getActiveAvatarId()).isEqualTo(candidateId); + assertThat(candidates.findById(candidateId).orElseThrow().getStatus()).isEqualTo(SkillAvatarCandidate.Status.ACTIVE); + } + + @Test void machinePassDoesNotActivateImageBeforeCombinedReviewAndPlatformCanReviewTeam() { + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(true, false, 1, null)); + when(gateway.requireConfiguredProvider()).thenReturn("xfyun"); + when(gateway.moderate(eq("xfyun"), any())).thenReturn(AvatarModerationProvider.Verdict.PASS); + var result = publications.publish(namespace, entries("1.0.0"), "owner", SkillVisibility.PUBLIC, Set.of(), + true, SkillPublishAppService.AvatarAction.REPLACE, upload(), "skill", context); + var task = reviewRows.findBySkillVersionIdAndStatus(result.version().getId(), + com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING).orElseThrow(); + assertThatThrownBy(() -> reviews.approveReview(task.getId(), "stranger", "approved", java.util.Map.of(), Set.of("SKILL_ADMIN"))) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainConflictException.class); + avatars.processMachine(task.getAvatarCandidateId(), context); + assertThat(read(result.skillId()).getActiveAvatarId()).isNull(); + reviews.approveReview(task.getId(), "stranger", "approved", java.util.Map.of(), Set.of("SKILL_ADMIN")); + assertThat(read(result.skillId()).getActiveAvatarId()).isEqualTo(task.getAvatarCandidateId()); + } + + @Test void removalWaitsForDecisionAndRejectKeepsActiveAvatar() { + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(false, false, 1, null)); + var first = publish("1.0.0", SkillPublishAppService.AvatarAction.REPLACE, upload(), "skill"); + Long active = read(first.skillId()).getActiveAvatarId(); + var removal = publications.publish(namespace, entries("1.0.1"), "owner", SkillVisibility.PUBLIC, Set.of(), + true, SkillPublishAppService.AvatarAction.REMOVE, null, "skill", context); + var task = reviewRows.findBySkillVersionIdAndStatus(removal.version().getId(), + com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING).orElseThrow(); + assertThat(read(first.skillId()).getActiveAvatarId()).isEqualTo(active); + reviews.rejectReview(task.getId(), "stranger", "keep", java.util.Map.of(), Set.of("SKILL_ADMIN")); + assertThat(read(first.skillId()).getActiveAvatarId()).isEqualTo(active); + var secondRemoval = publications.publish(namespace, entries("1.0.2"), "owner", SkillVisibility.PUBLIC, Set.of(), + true, SkillPublishAppService.AvatarAction.REMOVE, null, "skill", context); + var staleTask = reviewRows.findBySkillVersionIdAndStatus(secondRemoval.version().getId(), + com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING).orElseThrow(); + uploads.submit(namespace, "skill", "owner", upload(), context); + Long newAvatar = read(first.skillId()).getActiveAvatarId(); + assertThat(newAvatar).isNotEqualTo(active); + assertThatThrownBy(() -> reviews.approveReview(staleTask.getId(), "stranger", "approved", java.util.Map.of(), Set.of("SKILL_ADMIN"))) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainConflictException.class); + assertThat(read(first.skillId()).getActiveAvatarId()).isEqualTo(newAvatar); + } + + @Test void keepWhenAudienceExpandsIsPartOfSameTask() { + var first = publications.publish(namespace, entries("1.0.0"), "owner", SkillVisibility.PRIVATE, Set.of("SUPER_ADMIN"), + true, SkillPublishAppService.AvatarAction.REPLACE, upload(), "skill", context); + var expanded = publications.publish(namespace, entries("1.0.1"), "owner", SkillVisibility.PUBLIC, Set.of(), + true, SkillPublishAppService.AvatarAction.KEEP, null, "skill", context); + var task = reviewRows.findBySkillVersionIdAndStatus(expanded.version().getId(), + com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING).orElseThrow(); + assertThat(task.getAvatarCandidateId()).isNotNull(); + assertThat(reviewRows.findByAvatarCandidateIdAndStatus(task.getAvatarCandidateId(), + com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING)).hasSize(1); + reviews.approveReview(task.getId(), "stranger", "approved", java.util.Map.of(), Set.of("SKILL_ADMIN")); + assertThat(read(first.skillId()).getActiveAvatarAudience()).isEqualTo(SkillVisibility.PUBLIC); + } + + @Test void packageAndAvatarHaveOneTaskAndBecomePublishedTogether() { + var result = publications.publish(namespace, entries("1.0.0"), "owner", SkillVisibility.PUBLIC, Set.of(), + true, SkillPublishAppService.AvatarAction.REPLACE, upload(), "skill", context); + var task = reviewRows.findBySkillVersionIdAndStatus(result.version().getId(), + com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING).orElseThrow(); + assertThat(task.getAvatarCandidateId()).isEqualTo(read(result.skillId()).getPendingAvatarId()); + assertThat(reviewRows.findByAvatarCandidateIdAndStatus(task.getAvatarCandidateId(), + com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING)).hasSize(1); + assertThat(read(result.skillId()).getActiveAvatarId()).isNull(); + reviews.approveReview(task.getId(), "owner", "approved", + java.util.Map.of(namespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), Set.of()); + assertThat(read(result.skillId()).getActiveAvatarId()).isEqualTo(task.getAvatarCandidateId()); + assertThat(read(result.skillId()).getLatestVersionId()).isEqualTo(result.version().getId()); + assertThat(((SkillVersionRepository) versions).findById(result.version().getId()).orElseThrow().getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED); + } + + @Test void avatarOnlyDoesNotCreateVersionAndLateAuditFailureRollsBackBothDecisions() { + var result = publish("1.0.0", SkillPublishAppService.AvatarAction.KEEP, null, null); + long versionCount = versions.count(); + var status = uploads.submit(namespace, "skill", "owner", upload(), context); + var task = reviewRows.findByAvatarCandidateIdAndStatus(status.candidateId(), + com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING).getFirst(); + assertThat(task.getSkillVersionId()).isNull(); + doThrow(new IllegalStateException("test audit failure")).when(audit).record(any(), eq("SKILL_AVATAR_APPROVE"), any(), any(), any(), any(), any(), any()); + assertThatThrownBy(() -> reviews.approveReview(task.getId(), "owner", "approved", + java.util.Map.of(namespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), Set.of())) + .isInstanceOf(IllegalStateException.class); + assertThat(((com.iflytek.skillhub.domain.review.ReviewTaskRepository) reviewRows).findById(task.getId()).orElseThrow().getStatus()).isEqualTo(com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING); + assertThat(candidates.findById(status.candidateId()).orElseThrow().getStatus()).isEqualTo(SkillAvatarCandidate.Status.PENDING); + assertThat(read(result.skillId()).getActiveAvatarId()).isNull(); + assertThat(versions.count()).isEqualTo(versionCount); + } + + @Test void supersededBundleCannotPublishPackageWithoutPromisedAvatar() { + var result = publications.publish(namespace, entries("1.0.0"), "owner", SkillVisibility.PUBLIC, Set.of(), + true, SkillPublishAppService.AvatarAction.REPLACE, upload(), "skill", context); + var task = reviewRows.findBySkillVersionIdAndStatus(result.version().getId(), + com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING).orElseThrow(); + uploads.submit(namespace, "skill", "owner", upload(), context); + assertThatThrownBy(() -> reviews.approveReview(task.getId(), "owner", "approved", + java.util.Map.of(namespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), Set.of())) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainConflictException.class); + assertThat(((SkillVersionRepository) versions).findById(result.version().getId()).orElseThrow().getStatus()).isNotEqualTo(SkillVersionStatus.PUBLISHED); + } + + private SkillPublishService.PublishResult publish(String version, SkillPublishAppService.AvatarAction action, String id, String expected) { + return publications.publish(namespace, entries(version), "owner", SkillVisibility.PUBLIC, Set.of("SUPER_ADMIN"), + false, action, id, expected, context); + } + private List entries(String version) { + byte[] markdown = ("---\nname: skill\ndescription: Test skill\nversion: " + version + "\n---\n# Skill\nInstructions\n").getBytes(StandardCharsets.UTF_8); + return List.of(new PackageEntry("SKILL.md", markdown, markdown.length, "text/markdown")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java index 149f885d..16012dba 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; @@ -40,6 +41,7 @@ class AdminNamespaceAppServiceTest { private final NamespaceGovernanceService namespaceGovernanceService = mock(NamespaceGovernanceService.class); private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class); private final NamespaceMemberRepository namespaceMemberRepository = mock(NamespaceMemberRepository.class); + private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); private final NamespaceMemberCandidateService namespaceMemberCandidateService = mock(NamespaceMemberCandidateService.class); private final NamespaceAccessPolicy namespaceAccessPolicy = mock(NamespaceAccessPolicy.class); private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); @@ -49,6 +51,7 @@ class AdminNamespaceAppServiceTest { namespaceGovernanceService, namespaceMemberService, namespaceMemberRepository, + namespaceRepository, namespaceMemberCandidateService, namespaceAccessPolicy, userAccountRepository @@ -84,6 +87,8 @@ class AdminNamespaceAppServiceTest { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); NamespaceMember saved = member(10L, 1L, "user-2", NamespaceRole.ADMIN); when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")).thenReturn(Optional.empty()); @@ -100,6 +105,8 @@ class AdminNamespaceAppServiceTest { void updateMemberRoleRejectsDirectOwnerAssignment() { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); @@ -118,6 +125,8 @@ class AdminNamespaceAppServiceTest { NamespaceMember currentOwner = member(10L, 1L, "owner-1", NamespaceRole.OWNER); NamespaceMember newOwner = member(11L, 1L, "admin-1", NamespaceRole.ADMIN); when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndRoleIn(1L, List.of(NamespaceRole.OWNER))) @@ -138,6 +147,8 @@ class AdminNamespaceAppServiceTest { void globalNamespaceCannotBeMutatedFromAdminMemberApi() { Namespace global = namespace(1L, "global", NamespaceStatus.ACTIVE, NamespaceType.GLOBAL); when(namespaceService.getNamespaceBySlug("global")).thenReturn(global); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(global)); + when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L)); when(namespaceAccessPolicy.isImmutable(global)).thenReturn(true); assertThatThrownBy(() -> service.addMember( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java new file mode 100644 index 00000000..acb5a9ac --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.aliyun.green20220302.models.ImageModerationResponse; +import com.aliyun.green20220302.models.ImageModerationResponseBody; +import com.aliyun.green20220302.models.ImageModerationResponseBody.ImageModerationResponseBodyData; +import com.aliyun.green20220302.models.ImageModerationResponseBody.ImageModerationResponseBodyDataResult; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AliyunAvatarModerationProviderTest { + private final AliyunAvatarModerationProvider provider = + new AliyunAvatarModerationProvider("key", "secret", new ObjectMapper()); + + @Test + void recordsActualServiceAndApiWithoutInventingModelVersion() { + assertThat(provider.context().serviceId()).isEqualTo("profilePhotoCheck"); + assertThat(provider.context().apiVersion()).isEqualTo("2022-03-02"); + assertThat(provider.context().policyRevision()).contains("PASS", "BLOCK", "ERROR"); + assertThat(provider.context().modelVersion()).isNull(); + } + + @Test + void classifiesDocumentedRiskLevelsAndFailsClosedOnUnknown() { + assertThat(provider.classify(response("none"))).isEqualTo(AvatarModerationProvider.Verdict.PASS); + assertThat(provider.classify(response("low"))).isEqualTo(AvatarModerationProvider.Verdict.PASS); + assertThat(provider.classify(response("medium"))).isEqualTo(AvatarModerationProvider.Verdict.BLOCK); + assertThat(provider.classify(response("high"))).isEqualTo(AvatarModerationProvider.Verdict.BLOCK); + assertThat(provider.classify(response(null))).isEqualTo(AvatarModerationProvider.Verdict.ERROR); + assertThat(provider.classify(response("unknown"))).isEqualTo(AvatarModerationProvider.Verdict.ERROR); + assertThat(provider.classify(response("none").setStatusCode(500))) + .isEqualTo(AvatarModerationProvider.Verdict.ERROR); + assertThat(provider.classify(response("none").setBody( + new ImageModerationResponseBody().setCode(500)))) + .isEqualTo(AvatarModerationProvider.Verdict.ERROR); + assertThat(provider.classify(response("none").setBody( + new ImageModerationResponseBody().setCode(200).setData( + new ImageModerationResponseBodyData().setRiskLevel("none").setResult(List.of()))))) + .isEqualTo(AvatarModerationProvider.Verdict.ERROR); + } + + private ImageModerationResponse response(String level) { + ImageModerationResponseBodyData data = new ImageModerationResponseBodyData() + .setRiskLevel(level) + .setResult(List.of(new ImageModerationResponseBodyDataResult().setLabel("nonLabel"))); + return new ImageModerationResponse().setStatusCode(200) + .setBody(new ImageModerationResponseBody().setCode(200).setData(data)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AvatarReviewSettingsServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AvatarReviewSettingsServiceTest.java new file mode 100644 index 00000000..a742bd84 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AvatarReviewSettingsServiceTest.java @@ -0,0 +1,69 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.settings.SystemSetting; +import com.iflytek.skillhub.auth.settings.SystemSettingRepository; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.dto.AvatarReviewSettingsUpdateRequest; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AvatarReviewSettingsServiceTest { + @Mock private SystemSettingRepository repository; + @Mock private AvatarModerationGateway gateway; + @Mock private AuditLogService audit; + @Mock private RequestIdAccessor requestIds; + private AvatarReviewSettingsService service; + private SystemSetting setting; + + @BeforeEach + void setup() { + service = new AvatarReviewSettingsService(repository, gateway, audit, requestIds); + setting = new SystemSetting(AvatarReviewSettingsService.SETTING_KEY, + Map.of("machineEnabled", false, "manualEnabled", true)); + } + + @ParameterizedTest + @CsvSource({ "false,false", "false,true", "true,false", "true,true" }) + void updatesAllFourPolicies(boolean machine, boolean manual) { + when(repository.findBySettingKey(AvatarReviewSettingsService.SETTING_KEY)).thenReturn(Optional.of(setting)); + if (machine) when(gateway.requireConfiguredProvider()).thenReturn("xfyun"); + var updated = service.update(new AvatarReviewSettingsUpdateRequest(machine, manual, 0), "admin", + new AuditRequestContext(null, null)); + assertThat(updated.machineEnabled()).isEqualTo(machine); + assertThat(updated.manualEnabled()).isEqualTo(manual); + verify(repository).saveAndFlush(setting); + } + + @Test + void cannotEnableMachineReviewWithoutProvider() { + when(gateway.requireConfiguredProvider()).thenThrow(new DomainBadRequestException("providerUnavailable")); + assertThatThrownBy(() -> service.update(new AvatarReviewSettingsUpdateRequest(true, false, 0), "admin", + new AuditRequestContext(null, null))).isInstanceOf(DomainBadRequestException.class); + verify(repository, never()).saveAndFlush(setting); + } + + @Test + void staleVersionDoesNotOverwriteSettings() { + when(repository.findBySettingKey(AvatarReviewSettingsService.SETTING_KEY)).thenReturn(Optional.of(setting)); + assertThatThrownBy(() -> service.update(new AvatarReviewSettingsUpdateRequest(false, false, 1), "admin", + new AuditRequestContext(null, null))).isInstanceOf(DomainConflictException.class); + verify(repository, never()).saveAndFlush(setting); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java index 1f4ad1ac..b069b1b0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalCommandAppServiceTest.java @@ -80,7 +80,7 @@ class NamespacePortalCommandAppServiceTest { @Test void deleteNamespace_delegatesToDomainService() { Namespace namespace = namespace(7L, "team-alpha"); - when(namespaceService.getNamespaceBySlug("team-alpha")).thenReturn(namespace); + when(namespaceRepository.findIdBySlug("team-alpha")).thenReturn(Optional.of(7L)); MessageResponse response = service.deleteNamespace("team-alpha", "owner-1"); @@ -102,7 +102,7 @@ class NamespacePortalCommandAppServiceTest { ReflectionTestUtils.setField(member, "id", 10L); UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberService.addMember(1L, "user-2", NamespaceRole.ADMIN, "owner-1")) .thenReturn(member); when(userAccountRepository.findById("user-2")) @@ -122,7 +122,7 @@ class NamespacePortalCommandAppServiceTest { NamespaceMember member = new NamespaceMember(1L, "ghost", NamespaceRole.MEMBER); ReflectionTestUtils.setField(member, "id", 20L); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberService.addMember(1L, "ghost", NamespaceRole.MEMBER, "owner-1")) .thenReturn(member); when(userAccountRepository.findById("ghost")) @@ -142,7 +142,7 @@ class NamespacePortalCommandAppServiceTest { ReflectionTestUtils.setField(member, "id", 10L); UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberService.updateMemberRole(1L, "user-2", NamespaceRole.OWNER, "owner-1")) .thenReturn(member); when(userAccountRepository.findById("user-2")) @@ -166,7 +166,7 @@ class NamespacePortalCommandAppServiceTest { NamespaceMember member = new NamespaceMember(1L, "ghost", NamespaceRole.MEMBER); ReflectionTestUtils.setField(member, "id", 20L); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberService.updateMemberRole(1L, "ghost", NamespaceRole.ADMIN, "owner-1")) .thenReturn(member); when(userAccountRepository.findById("ghost")) @@ -186,7 +186,7 @@ class NamespacePortalCommandAppServiceTest { @Test void transferOwnership_success() { Namespace ns = namespace(1L, "team-a"); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); MessageResponse result = service.transferOwnership("team-a", "new-owner", "current-owner"); @@ -197,7 +197,7 @@ class NamespacePortalCommandAppServiceTest { @Test void transferOwnership_nonOwnerFails() { Namespace ns = namespace(1L, "team-a"); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); doThrow(new DomainBadRequestException("error.namespace.owner.current.invalid")) .when(namespaceMemberService).transferOwnership(1L, "non-owner", "new-owner"); @@ -208,7 +208,7 @@ class NamespacePortalCommandAppServiceTest { @Test void transferOwnership_targetNotFoundFails() { Namespace ns = namespace(1L, "team-a"); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); doThrow(new DomainBadRequestException("error.namespace.owner.new.notFound")) .when(namespaceMemberService).transferOwnership(1L, "owner-1", "non-existent"); @@ -220,7 +220,7 @@ class NamespacePortalCommandAppServiceTest { void transferOwnership_frozenNamespaceFails() { Namespace ns = namespace(1L, "team-a"); ns.setStatus(NamespaceStatus.FROZEN); - when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); doThrow(new DomainBadRequestException("error.namespace.readonly", "team-a")) .when(namespaceMemberService).transferOwnership(1L, "owner-1", "new-owner"); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewSkillDetailAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewSkillDetailAppServiceTest.java index 4ce8452a..1fc192b1 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewSkillDetailAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ReviewSkillDetailAppServiceTest.java @@ -51,6 +51,8 @@ class ReviewSkillDetailAppServiceTest { @Mock private SkillDownloadService skillDownloadService; + @Mock private com.iflytek.skillhub.domain.skill.SkillRepository skills; + @Mock private com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository candidates; private ReviewSkillDetailAppService service; @BeforeEach @@ -192,6 +194,28 @@ class ReviewSkillDetailAppServiceTest { .isInstanceOf(DomainForbiddenException.class); } + @Test + void removalReadinessBecomesFalseWhenAvatarChangesAfterSubmission() { + service.configureAvatars(skills, candidates); + var task = new ReviewTask(205L, 101L, 20L, "1.0.1", "owner-1"); + setField(task, "id", 42L); + task.prepareAvatarRemoval(21L, null); + var namespace = createNamespace(20L, "team-a"); + var skill = createSkill(101L, 20L, "skill-a"); + skill.activateAvatar(21L, SkillVisibility.PUBLIC); + var version = createVersion(205L, 101L, "1.0.1", SkillVersionStatus.PENDING_REVIEW); + given(reviewTaskRepository.findById(42L)).willReturn(Optional.of(task)); + given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace)); + given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN")); + given(reviewService.canViewReview(task, "admin", namespace.getType(), Map.of(), Set.of("SKILL_ADMIN"))).willReturn(true); + given(skills.findById(101L)).willReturn(Optional.of(skill)); + given(skillQueryService.getReviewSkillSnapshot(205L)).willReturn(new SkillQueryService.ReviewSkillSnapshotDTO( + skill, "Owner", version, null, List.of(version), List.of(), null, null)); + assertThat(service.getReviewSkillDetail(42L, "admin", Map.of()).avatarChange().readyForApproval()).isTrue(); + skill.activateAvatar(22L, SkillVisibility.PUBLIC); + assertThat(service.getReviewSkillDetail(42L, "admin", Map.of()).avatarChange().readyForApproval()).isFalse(); + } + private ReviewTask createReviewTask(Long reviewId, Long versionId, Long namespaceId, String submittedBy) { ReviewTask task = new ReviewTask(versionId, namespaceId, submittedBy); setField(task, "id", reviewId); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarAppServiceTest.java new file mode 100644 index 00000000..fbec1c6f --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarAppServiceTest.java @@ -0,0 +1,437 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository; +import com.iflytek.skillhub.infra.jpa.SkillJpaRepository; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.storage.ObjectStorageService; +import jakarta.persistence.EntityManager; +import java.io.ByteArrayInputStream; +import java.time.Instant; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.junit.jupiter.api.AfterEach; + +@ExtendWith(MockitoExtension.class) +class SkillAvatarAppServiceTest { + @Mock private SkillJpaRepository skills; + @Mock private NamespaceRepository namespaces; + @Mock private SkillAvatarCandidateJpaRepository candidates; + @Mock private ObjectStorageService storage; + @Mock private SkillAvatarImageProcessor imageProcessor; + @Mock private AvatarReviewSettingsService settings; + @Mock private AvatarModerationGateway moderation; + @Mock private VisibilityChecker visibility; + @Mock private SkillQueryService skillQueries; + @Mock private AuditLogService audit; + @Mock private RequestIdAccessor requestIds; + @Mock private EntityManager entityManager; + @Mock private PlatformTransactionManager transactionManager; + @Mock private com.iflytek.skillhub.domain.review.ReviewTaskRepository reviews; + @Mock private org.springframework.context.ApplicationEventPublisher events; + private SkillAvatarAppService service; + private Namespace namespace; + private Skill skill; + + @BeforeEach + void setup() { + service = new SkillAvatarAppService(skills, namespaces, candidates, storage, imageProcessor, + settings, moderation, visibility, skillQueries, audit, requestIds, entityManager, transactionManager); + service.configureUnifiedReviews(reviews, new com.iflytek.skillhub.domain.review.ReviewPermissionChecker()); + service.setEvents(events); + org.mockito.Mockito.lenient().when(moderation.context(any())).thenReturn( + new AvatarModerationProvider.Context("fixture-image", "test-v1", "fixture-policy-v1", null)); + org.mockito.Mockito.lenient().when(transactionManager.getTransaction(any())) + .thenAnswer(ignored -> new SimpleTransactionStatus()); + namespace = new Namespace("team", "Team", "owner"); + ReflectionTestUtils.setField(namespace, "id", 10L); + ReflectionTestUtils.setField(namespace, "type", NamespaceType.TEAM); + skill = new Skill(10L, "foo", "owner", SkillVisibility.PUBLIC); + ReflectionTestUtils.setField(skill, "id", 11L); + org.mockito.Mockito.lenient().when(skills.findByNamespaceIdAndSlugAndOwnerId(10L, "foo", "owner")) + .thenReturn(Optional.of(skill)); + } + + @AfterEach + void clearTransactionSynchronization() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void scopeCandidateUsesExistingAsynchronousSubmissionEvent() { + skill.activateAvatar(21L, SkillVisibility.PRIVATE); + var active = new SkillAvatarCandidate(11L, "owner", "avatar/private.png", "abc", SkillVisibility.PRIVATE, false, false, null); + active.activate(); + when(skills.findLockedById(11L)).thenReturn(Optional.of(skill)); + when(namespaces.findById(10L)).thenReturn(Optional.of(namespace)); + when(candidates.findById(21L)).thenReturn(Optional.of(active)); + when(storage.getObject("avatar/private.png")).thenReturn(new ByteArrayInputStream(new byte[]{1})); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(true, true, 1, null)); + when(moderation.requireConfiguredProvider()).thenReturn("xfyun"); + when(candidates.saveAndFlush(any())).thenAnswer(call -> { + SkillAvatarCandidate candidate = call.getArgument(0); + ReflectionTestUtils.setField(candidate, "id", 22L); + return candidate; + }); + var task = new com.iflytek.skillhub.domain.review.ReviewTask(99L, 11L, 10L, "1.0.1", "owner"); + TransactionSynchronizationManager.initSynchronization(); + service.attachScopeToReview(11L, SkillVisibility.PUBLIC, task, "owner", new AuditRequestContext(null, null)); + verify(events).publishEvent(new SkillAvatarUploadService.AvatarSubmittedEvent(22L, new AuditRequestContext(null, null))); + org.mockito.Mockito.verify(moderation, org.mockito.Mockito.never()).moderate(any(), any()); + org.assertj.core.api.Assertions.assertThat(task.getAvatarCandidateId()).isEqualTo(22L); + } + + @Test + void privateAvatarCannotBeReadByNamespaceAdmin() { + skill.setVisibility(SkillVisibility.PRIVATE); + skill.activateAvatar(21L, SkillVisibility.PRIVATE); + SkillQueryService.SkillDetailDTO detail = new SkillQueryService.SkillDetailDTO( + 11L, null, null, null, null, null, null, null, null, null, null, null, null, + false, null, null, null, false, false, false, false, null, null, null, null, null, null, null); + when(skillQueries.getSkillDetail(eq("team"), eq("foo"), eq("admin"), any())).thenReturn(detail); + when(((SkillRepository) skills).findById(11L)).thenReturn(Optional.of(skill)); + when(visibility.canAccess(eq(skill), eq("admin"), any(), any())).thenReturn(true); + assertThatThrownBy(() -> service.readActive("team", "foo", "admin", + Map.of(10L, NamespaceRole.ADMIN), Set.of())) + .isInstanceOf(DomainNotFoundException.class); + verifyNoInteractions(storage); + } + + @Test + void privateCandidateCannotBeReadByNamespaceAdmin() { + when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(((SkillRepository) skills).findById(11L)).thenReturn(Optional.of(skill)); + SkillAvatarCandidate candidate = new SkillAvatarCandidate(11L, "owner", "avatar/private.png", "abc", + SkillVisibility.PRIVATE, false, false, null); + ReflectionTestUtils.setField(candidate, "id", 21L); + when(candidates.findById(21L)).thenReturn(Optional.of(candidate)); + assertThatThrownBy(() -> service.readCandidate("team", "foo", 21L, "admin", + Map.of(10L, NamespaceRole.ADMIN), Set.of())) + .isInstanceOf(DomainNotFoundException.class); + verifyNoInteractions(storage); + } + + @Test + void expandingVisibilityCreatesNewCandidateWithDistinctObjectKey() { + skill.activateAvatar(21L, SkillVisibility.PRIVATE); + skill.setLatestVersionId(4L); + SkillAvatarCandidate old = new SkillAvatarCandidate(11L, "owner", "avatar/private.png", "abc", + SkillVisibility.PRIVATE, false, false, null); + old.activate(); + ReflectionTestUtils.setField(old, "id", 21L); + when(skills.findLockedById(11L)).thenReturn(Optional.of(skill)); + when(namespaces.findById(10L)).thenReturn(Optional.of(namespace)); + when(candidates.findById(21L)).thenReturn(Optional.of(old)); + when(storage.getObject("avatar/private.png")).thenReturn(new ByteArrayInputStream(new byte[] { 1 })); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(false, true, 1, null)); + when(candidates.saveAndFlush(any())).thenAnswer(invocation -> { + SkillAvatarCandidate created = invocation.getArgument(0); + ReflectionTestUtils.setField(created, "id", 22L); + return created; + }); + TransactionSynchronizationManager.initSynchronization(); + + var result = service.prepareScopeReview(11L, "publisher"); + + org.assertj.core.api.Assertions.assertThat(result.candidateStatus()).isEqualTo("PENDING"); + org.assertj.core.api.Assertions.assertThat(result.avatarUrl()).isNull(); + org.assertj.core.api.Assertions.assertThat(skill.getPendingAvatarId()).isEqualTo(22L); + org.assertj.core.api.Assertions.assertThat(old.getStatus()).isEqualTo(SkillAvatarCandidate.Status.ACTIVE); + org.mockito.ArgumentCaptor key = org.mockito.ArgumentCaptor.forClass(String.class); + verify(storage).putObject(key.capture(), any(), eq(1L), eq("image/png")); + org.assertj.core.api.Assertions.assertThat(key.getValue()).isNotEqualTo("avatar/private.png"); + org.mockito.ArgumentCaptor created = + org.mockito.ArgumentCaptor.forClass(SkillAvatarCandidate.class); + verify(candidates).saveAndFlush(created.capture()); + org.assertj.core.api.Assertions.assertThat(created.getValue().getScopeReviewVersionId()).isEqualTo(4L); + } + + @Test + void expandingVisibilityKeepsExistingTargetScopeCandidate() { + skill.activateAvatar(21L, SkillVisibility.PRIVATE); + skill.setPendingAvatarId(22L); + SkillAvatarCandidate pending = new SkillAvatarCandidate(11L, "owner", "avatar/new.png", "abc", + SkillVisibility.PUBLIC, false, true, null); + ReflectionTestUtils.setField(pending, "id", 22L); + when(skills.findLockedById(11L)).thenReturn(Optional.of(skill)); + when(namespaces.findById(10L)).thenReturn(Optional.of(namespace)); + when(candidates.findById(22L)).thenReturn(Optional.of(pending)); + + org.assertj.core.api.Assertions.assertThat(service.prepareScopeReview(11L, "publisher")).isNull(); + verifyNoInteractions(storage); + } + + @Test + void storageFailureLeavesOldAvatarHiddenAndLaterRecoveryCanCreateCandidate() { + skill.activateAvatar(21L, SkillVisibility.PRIVATE); + SkillAvatarCandidate old = new SkillAvatarCandidate(11L, "owner", "avatar/private.png", "abc", + SkillVisibility.PRIVATE, false, false, null); + old.activate(); + ReflectionTestUtils.setField(old, "id", 21L); + when(skills.findLockedById(11L)).thenReturn(Optional.of(skill)); + when(namespaces.findById(10L)).thenReturn(Optional.of(namespace)); + when(candidates.findById(21L)).thenReturn(Optional.of(old)); + when(storage.getObject("avatar/private.png")) + .thenThrow(new IllegalStateException("temporary storage outage")) + .thenReturn(new ByteArrayInputStream(new byte[] { 1 })); + when(settings.current()).thenReturn(new AvatarReviewSettingsResponse(false, true, 1, null)); + when(candidates.saveAndFlush(any())).thenAnswer(invocation -> { + SkillAvatarCandidate created = invocation.getArgument(0); + ReflectionTestUtils.setField(created, "id", 22L); + return created; + }); + + assertThatThrownBy(() -> service.prepareScopeReview(11L, "publisher")) + .isInstanceOf(IllegalStateException.class); + org.assertj.core.api.Assertions.assertThat(SkillAvatarUrls.current(skill, "team")).isNull(); + org.assertj.core.api.Assertions.assertThat(skill.getPendingAvatarId()).isNull(); + + TransactionSynchronizationManager.initSynchronization(); + var recovered = service.prepareScopeReview(11L, "publisher"); + org.assertj.core.api.Assertions.assertThat(recovered.candidateId()).isEqualTo(22L); + org.assertj.core.api.Assertions.assertThat(recovered.avatarUrl()).isNull(); + } + + @Test + void machineStorageFailureBecomesRetriableErrorThenPassesOnRecovery() { + skill.setPendingAvatarId(21L); + SkillAvatarCandidate candidate = new SkillAvatarCandidate(11L, "owner", "avatar/new.png", "abc", + SkillVisibility.PUBLIC, true, false, "xfyun"); + ReflectionTestUtils.setField(candidate, "id", 21L); + when(candidates.findById(21L)).thenReturn(Optional.of(candidate)); + when(storage.getObject("avatar/new.png")) + .thenThrow(new IllegalStateException("temporary storage outage")) + .thenReturn(new ByteArrayInputStream(new byte[] { 1 })); + when(skills.findLockedById(11L)).thenReturn(Optional.of(skill)); + when(namespaces.findById(10L)).thenReturn(Optional.of(namespace)); + when(moderation.moderate(eq("xfyun"), any(byte[].class))) + .thenReturn(AvatarModerationProvider.Verdict.PASS); + + var failed = service.processMachine(21L, new AuditRequestContext(null, null)); + org.assertj.core.api.Assertions.assertThat(failed.machineStatus()).isEqualTo("ERROR"); + org.assertj.core.api.Assertions.assertThat(failed.avatarUrl()).isNull(); + org.assertj.core.api.Assertions.assertThat(skill.getPendingAvatarId()).isEqualTo(21L); + assertThatThrownBy(() -> service.processMachine(21L, new AuditRequestContext(null, null))) + .isInstanceOf(DomainConflictException.class); + ReflectionTestUtils.setField(candidate, "machineNextRetryAt", Instant.now().minusSeconds(1)); + + var recovered = service.processMachine(21L, new AuditRequestContext(null, null)); + org.assertj.core.api.Assertions.assertThat(recovered.machineStatus()).isEqualTo("PASSED"); + org.assertj.core.api.Assertions.assertThat(recovered.avatarUrl()).isNotNull(); + org.assertj.core.api.Assertions.assertThat(skill.getPendingAvatarId()).isNull(); + } + + @Test + void scopeRecoveryReusesPendingErroredCandidateInsteadOfCreatingAnother() { + skill.activateAvatar(21L, SkillVisibility.PRIVATE); + skill.setPendingAvatarId(22L); + SkillAvatarCandidate pending = new SkillAvatarCandidate(11L, "owner", "avatar/new.png", "abc", + SkillVisibility.PUBLIC, true, false, "xfyun"); + pending.machineResult(SkillAvatarCandidate.MachineStatus.ERROR); + ReflectionTestUtils.setField(pending, "id", 22L); + when(skills.findLockedById(11L)).thenReturn(Optional.of(skill)); + when(namespaces.findById(10L)).thenReturn(Optional.of(namespace)); + when(candidates.findById(22L)).thenReturn(Optional.of(pending)); + + var result = service.prepareScopeReview(11L, "publisher"); + + org.assertj.core.api.Assertions.assertThat(result.candidateId()).isEqualTo(22L); + org.assertj.core.api.Assertions.assertThat(result.machineStatus()).isEqualTo("ERROR"); + verifyNoInteractions(storage); + } + + @Test + void abandonedPendingMachineCandidateIsRecoveredWithoutCreatingAnother() { + skill.activateAvatar(21L, SkillVisibility.PRIVATE); + skill.setPendingAvatarId(22L); + SkillAvatarCandidate pending = new SkillAvatarCandidate(11L, "owner", "avatar/new.png", "abc", + SkillVisibility.PUBLIC, true, false, "xfyun"); + ReflectionTestUtils.setField(pending, "id", 22L); + ReflectionTestUtils.setField(pending, "createdAt", Instant.now().minusSeconds(601)); + when(skills.findLockedById(11L)).thenReturn(Optional.of(skill)); + when(namespaces.findById(10L)).thenReturn(Optional.of(namespace)); + when(candidates.findById(22L)).thenReturn(Optional.of(pending)); + when(storage.getObject("avatar/new.png")).thenReturn(new ByteArrayInputStream(new byte[] { 1 })); + when(moderation.moderate(eq("xfyun"), any(byte[].class))) + .thenReturn(AvatarModerationProvider.Verdict.PASS); + + var abandoned = service.prepareScopeReview(11L, "publisher"); + var recovered = service.processMachine(abandoned.candidateId(), new AuditRequestContext(null, null)); + + org.assertj.core.api.Assertions.assertThat(recovered.machineStatus()).isEqualTo("PASSED"); + org.assertj.core.api.Assertions.assertThat(skill.getActiveAvatarId()).isEqualTo(22L); + org.mockito.Mockito.verify(candidates, org.mockito.Mockito.never()).saveAndFlush(any()); + } + + @Test + void lateMachineResultCannotOverwriteNewerAttempt() { + SkillAvatarCandidate candidate = pendingCandidate(); + when(moderation.moderate(eq("xfyun"), any(byte[].class))).thenAnswer(ignored -> { + Instant afterLease = Instant.now().plusSeconds(601); + org.assertj.core.api.Assertions.assertThat(candidate.claimMachineAttempt( + "newer-attempt", afterLease, afterLease.plusSeconds(600))).isTrue(); + return AvatarModerationProvider.Verdict.PASS; + }); + + var result = service.processMachine(21L, new AuditRequestContext(null, null)); + + org.assertj.core.api.Assertions.assertThat(result.machineStatus()).isEqualTo("PROCESSING"); + org.assertj.core.api.Assertions.assertThat(skill.getActiveAvatarId()).isNull(); + org.assertj.core.api.Assertions.assertThat(skill.getPendingAvatarId()).isEqualTo(21L); + } + + @Test + void expiredMachineLeaseIsReclaimedAfterCrash() { + skill.activateAvatar(21L, SkillVisibility.PRIVATE); + skill.setPendingAvatarId(22L); + SkillAvatarCandidate pending = new SkillAvatarCandidate(11L, "owner", "avatar/new.png", "abc", + SkillVisibility.PUBLIC, true, false, "xfyun"); + ReflectionTestUtils.setField(pending, "id", 22L); + Instant old = Instant.now().minusSeconds(601); + pending.claimMachineAttempt("crashed-attempt", old, old.plusSeconds(600)); + when(skills.findLockedById(11L)).thenReturn(Optional.of(skill)); + when(namespaces.findById(10L)).thenReturn(Optional.of(namespace)); + when(candidates.findById(22L)).thenReturn(Optional.of(pending)); + when(storage.getObject("avatar/new.png")).thenReturn(new ByteArrayInputStream(new byte[] { 1 })); + when(moderation.moderate(eq("xfyun"), any(byte[].class))) + .thenReturn(AvatarModerationProvider.Verdict.PASS); + + var expired = service.prepareScopeReview(11L, "publisher"); + var recovered = service.processMachine(expired.candidateId(), new AuditRequestContext(null, null)); + + org.assertj.core.api.Assertions.assertThat(expired.machineStatus()).isEqualTo("PROCESSING"); + org.assertj.core.api.Assertions.assertThat(recovered.machineStatus()).isEqualTo("PASSED"); + org.assertj.core.api.Assertions.assertThat(skill.getActiveAvatarId()).isEqualTo(22L); + org.mockito.Mockito.verify(candidates, org.mockito.Mockito.never()).saveAndFlush(any()); + } + + @Test + void processingMachineCandidateDoesNotEnterHumanReviewQueue() { + SkillAvatarCandidate pending = new SkillAvatarCandidate(11L, "owner", "avatar/new.png", "abc", + SkillVisibility.PUBLIC, true, true, "xfyun"); + pending.claimMachineAttempt("in-flight", Instant.now(), Instant.now().plusSeconds(600)); + when(candidates.findByStatusAndReviewStatusOrderByCreatedAtAsc( + SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.ReviewStatus.PENDING)) + .thenReturn(List.of(pending)); + + org.assertj.core.api.Assertions.assertThat(service.reviewQueue("reviewer", + Map.of(10L, NamespaceRole.ADMIN), Set.of())).isEmpty(); + verifyNoInteractions(skills); + } + + @Test + void nonOwnerCannotSubmitBeforeWritingToStorage() { + when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace)); + assertThatThrownBy(() -> service.submit("team", "foo", "stranger", new byte[] { 1 }, + new AuditRequestContext(null, null))).isInstanceOf(DomainForbiddenException.class); + verifyNoInteractions(storage); + } + + @Test + void staleApprovalCannotReplaceNewerCandidate() { + when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(((SkillRepository) skills).findById(11L)).thenReturn(Optional.of(skill)); + when(skills.findLockedById(11L)).thenReturn(Optional.of(skill)); + SkillAvatarCandidate candidate = new SkillAvatarCandidate(11L, "owner", "avatar/old.png", "abc", + SkillVisibility.PUBLIC, false, true, null); + when(candidates.findById(21L)).thenReturn(Optional.of(candidate)); + when(namespaces.findById(10L)).thenReturn(Optional.of(namespace)); + assertThatThrownBy(() -> service.decide("team", "foo", 21L, true, "fine", "reviewer", + Map.of(10L, NamespaceRole.ADMIN), Set.of(), new AuditRequestContext(null, null))) + .isInstanceOf(DomainConflictException.class); + org.assertj.core.api.Assertions.assertThat(candidate.getReviewStatus()) + .isEqualTo(SkillAvatarCandidate.ReviewStatus.PENDING); + } + + @Test + void reviewRequiresMatchingNamespaceRole() { + when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(((SkillRepository) skills).findById(11L)).thenReturn(Optional.of(skill)); + when(skills.findLockedById(11L)).thenReturn(Optional.of(skill)); + SkillAvatarCandidate candidate = new SkillAvatarCandidate(11L, "owner", "avatar/old.png", "abc", + SkillVisibility.PUBLIC, false, true, null); + when(candidates.findById(21L)).thenReturn(Optional.of(candidate)); + when(namespaces.findById(10L)).thenReturn(Optional.of(namespace)); + assertThatThrownBy(() -> service.decide("team", "foo", 21L, true, "fine", "reviewer", + Map.of(), Set.of(), new AuditRequestContext(null, null))) + .isInstanceOf(DomainForbiddenException.class); + } + + @Test + void unknownMachineResultLeavesCandidatePrivate() { + SkillAvatarCandidate candidate = pendingCandidate(); + when(moderation.moderate(org.mockito.ArgumentMatchers.eq("xfyun"), any(byte[].class))) + .thenReturn(AvatarModerationProvider.Verdict.ERROR); + + var result = service.processMachine(21L, new AuditRequestContext(null, null)); + + org.assertj.core.api.Assertions.assertThat(result.machineStatus()).isEqualTo("ERROR"); + org.assertj.core.api.Assertions.assertThat(result.avatarUrl()).isNull(); + org.assertj.core.api.Assertions.assertThat(skill.getPendingAvatarId()).isEqualTo(21L); + } + + @Test + void staleMachineResultCannotActivateSupersededCandidate() { + SkillAvatarCandidate candidate = pendingCandidate(); + when(moderation.moderate(org.mockito.ArgumentMatchers.eq("xfyun"), any(byte[].class))) + .thenAnswer(ignored -> { + candidate.supersede(); + skill.setPendingAvatarId(22L); + return AvatarModerationProvider.Verdict.PASS; + }); + + var result = service.processMachine(21L, new AuditRequestContext(null, null)); + + org.assertj.core.api.Assertions.assertThat(result.avatarUrl()).isNull(); + org.assertj.core.api.Assertions.assertThat(skill.getPendingAvatarId()).isEqualTo(22L); + org.assertj.core.api.Assertions.assertThat(candidate.getStatus()) + .isEqualTo(SkillAvatarCandidate.Status.SUPERSEDED); + } + + private SkillAvatarCandidate pendingCandidate() { + skill.setPendingAvatarId(21L); + SkillAvatarCandidate candidate = new SkillAvatarCandidate(11L, "owner", "avatar/one.png", "abc", + SkillVisibility.PUBLIC, true, false, "xfyun"); + ReflectionTestUtils.setField(candidate, "id", 21L); + when(candidates.findById(21L)).thenReturn(Optional.of(candidate)); + when(storage.getObject("avatar/one.png")).thenReturn(new ByteArrayInputStream(new byte[] { 1 })); + when(skills.findLockedById(11L)).thenReturn(Optional.of(skill)); + when(namespaces.findById(10L)).thenReturn(Optional.of(namespace)); + return candidate; + } + +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarImageProcessorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarImageProcessorTest.java new file mode 100644 index 00000000..3dd19c16 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarImageProcessorTest.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Test; + +class SkillAvatarImageProcessorTest { + private final SkillAvatarImageProcessor processor = new SkillAvatarImageProcessor(); + + @Test + void acceptsSquareJpegAndReencodesAsPng() throws Exception { + byte[] jpeg = image("jpeg", 48, 48); + byte[] result = processor.normalize(jpeg); + assertThat(result).startsWith((byte) 0x89, (byte) 'P', (byte) 'N', (byte) 'G'); + BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(result)); + assertThat(decoded.getWidth()).isEqualTo(48); + assertThat(decoded.getHeight()).isEqualTo(48); + } + + @Test + void rejectsOversizedMalformedAndNonSquareInput() throws Exception { + assertThatThrownBy(() -> processor.normalize(new byte[SkillAvatarImageProcessor.MAX_BYTES + 1])) + .isInstanceOf(DomainBadRequestException.class); + assertThatThrownBy(() -> processor.normalize(new byte[] {(byte) 0x89, 'P', 'N', 'G'})) + .isInstanceOf(DomainBadRequestException.class); + assertThatThrownBy(() -> processor.normalize(image("png", 48, 32))) + .isInstanceOf(DomainBadRequestException.class); + assertThatThrownBy(() -> processor.normalize(image("png", 1025, 1025))) + .isInstanceOf(DomainBadRequestException.class); + } + + private byte[] image(String format, int width, int height) throws Exception { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, format, out); + return out.toByteArray(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarUrlsTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarUrlsTest.java new file mode 100644 index 00000000..33c6ef42 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillAvatarUrlsTest.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import org.junit.jupiter.api.Test; + +class SkillAvatarUrlsTest { + @Test + void hidesAvatarWhenCurrentVisibilityExceedsApprovedAudience() { + assertThat(SkillAvatarUrls.current(1L, SkillVisibility.PRIVATE, SkillVisibility.NAMESPACE_ONLY, + "team", "foo")).isNull(); + assertThat(SkillAvatarUrls.current(1L, SkillVisibility.NAMESPACE_ONLY, SkillVisibility.PUBLIC, + "team", "foo")).isNull(); + assertThat(SkillAvatarUrls.current(1L, SkillVisibility.PUBLIC, SkillVisibility.PUBLIC, + "global", "foo")).isEqualTo("/api/v1/skills/global/foo/avatar"); + assertThat(SkillAvatarUrls.current(null, SkillVisibility.PUBLIC, SkillVisibility.PUBLIC, + "global", "foo")).isNull(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/XfyunAvatarModerationProviderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/XfyunAvatarModerationProviderTest.java new file mode 100644 index 00000000..04e50b24 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/XfyunAvatarModerationProviderTest.java @@ -0,0 +1,31 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +class XfyunAvatarModerationProviderTest { + private final XfyunAvatarModerationProvider provider = + new XfyunAvatarModerationProvider("app", "key", "secret", new ObjectMapper()); + + @Test + void recordsActualServiceAndApiWithoutInventingModelVersion() { + assertThat(provider.context().serviceId()).isEqualTo("image"); + assertThat(provider.context().apiVersion()).isEqualTo("v2"); + assertThat(provider.context().policyRevision()).contains("PASS", "BLOCK", "ERROR"); + assertThat(provider.context().modelVersion()).isNull(); + } + + @Test + void acceptsOnlyDocumentedPassAndBlockVerdicts() throws Exception { + assertThat(provider.parse("{\"code\":\"000000\",\"data\":{\"result\":{\"suggest\":\"pass\"}}}")) + .isEqualTo(AvatarModerationProvider.Verdict.PASS); + assertThat(provider.parse("{\"code\":\"000000\",\"data\":{\"result\":{\"suggest\":\"block\"}}}")) + .isEqualTo(AvatarModerationProvider.Verdict.BLOCK); + assertThat(provider.parse("{\"code\":\"000000\",\"data\":{\"result\":{\"suggest\":\"review\"}}}")) + .isEqualTo(AvatarModerationProvider.Verdict.ERROR); + assertThat(provider.parse("{\"code\":\"100001\"}")) + .isEqualTo(AvatarModerationProvider.Verdict.ERROR); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillAvatarMachineRecoveryTaskTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillAvatarMachineRecoveryTaskTest.java new file mode 100644 index 00000000..4c913e36 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillAvatarMachineRecoveryTaskTest.java @@ -0,0 +1,47 @@ +package com.iflytek.skillhub.task; + +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.SkillAvatarAppService; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillAvatarMachineRecoveryTaskTest { + @Test + void recoversAbandonedOrdinaryUploadAndContinuesAfterFailure() { + SkillAvatarCandidateJpaRepository candidates = mock(SkillAvatarCandidateJpaRepository.class); + SkillAvatarAppService avatars = mock(SkillAvatarAppService.class); + SkillAvatarCandidate first = candidate(11L); + SkillAvatarCandidate second = candidate(12L); + when(candidates.findAbandonedMachineCandidates(eq(0L), eq(SkillAvatarCandidate.Status.PENDING), + eq(SkillAvatarCandidate.MachineStatus.PENDING), eq(SkillAvatarCandidate.MachineStatus.PROCESSING), + any(Instant.class), any(Instant.class), eq(PageRequest.of(0, 100)))) + .thenReturn(List.of(first, second)); + doThrow(new IllegalStateException("secret-bearing failure")) + .when(avatars).processMachine(eq(11L), any(AuditRequestContext.class)); + + new SkillAvatarMachineRecoveryTask(candidates, avatars).recover(); + + verify(avatars).processMachine(eq(11L), any(AuditRequestContext.class)); + verify(avatars).processMachine(eq(12L), any(AuditRequestContext.class)); + } + + private SkillAvatarCandidate candidate(Long id) { + SkillAvatarCandidate candidate = new SkillAvatarCandidate(1L, "owner", "private.png", "sha", + SkillVisibility.PUBLIC, true, false, "xfyun"); + ReflectionTestUtils.setField(candidate, "id", id); + return candidate; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillAvatarScopeReviewRecoveryTaskTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillAvatarScopeReviewRecoveryTaskTest.java new file mode 100644 index 00000000..33e92f13 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillAvatarScopeReviewRecoveryTaskTest.java @@ -0,0 +1,69 @@ +package com.iflytek.skillhub.task; + +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.infra.jpa.SkillJpaRepository; +import com.iflytek.skillhub.listener.SkillAvatarScopeReviewListener; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.any; +import java.time.Instant; + +class SkillAvatarScopeReviewRecoveryTaskTest { + @Test + void scansOnlyUnreviewedExpandedScopesInBoundedIdOrder() { + SkillJpaRepository skills = mock(SkillJpaRepository.class); + SkillAvatarScopeReviewListener listener = mock(SkillAvatarScopeReviewListener.class); + Skill skill = new Skill(10L, "foo", "owner", SkillVisibility.PUBLIC); + ReflectionTestUtils.setField(skill, "id", 11L); + when(skills.findScopeReviewRecoveryCandidates(eq(0L), eq(SkillStatus.ACTIVE), eq(SkillVisibility.PRIVATE), + eq(SkillVisibility.NAMESPACE_ONLY), eq(SkillVisibility.PUBLIC), eq(SkillAvatarCandidate.Status.PENDING), + eq(SkillAvatarCandidate.MachineStatus.PENDING), eq(SkillAvatarCandidate.MachineStatus.PROCESSING), + eq(SkillAvatarCandidate.MachineStatus.ERROR), eq(3), eq(SkillAvatarCandidate.Status.REJECTED), + any(Instant.class), any(Instant.class), eq(PageRequest.of(0, 100)))) + .thenReturn(List.of(skill)); + + new SkillAvatarScopeReviewRecoveryTask(skills, listener).recover(); + + verify(listener).reviewScope(11L, null); + } + + @Test + void failedSkillDoesNotStarveLaterBatches() { + SkillJpaRepository skills = mock(SkillJpaRepository.class); + SkillAvatarScopeReviewListener listener = mock(SkillAvatarScopeReviewListener.class); + Skill first = new Skill(10L, "first", "owner", SkillVisibility.PUBLIC); + Skill second = new Skill(10L, "second", "owner", SkillVisibility.PUBLIC); + ReflectionTestUtils.setField(first, "id", 11L); + ReflectionTestUtils.setField(second, "id", 12L); + when(skills.findScopeReviewRecoveryCandidates(eq(0L), eq(SkillStatus.ACTIVE), eq(SkillVisibility.PRIVATE), + eq(SkillVisibility.NAMESPACE_ONLY), eq(SkillVisibility.PUBLIC), eq(SkillAvatarCandidate.Status.PENDING), + eq(SkillAvatarCandidate.MachineStatus.PENDING), eq(SkillAvatarCandidate.MachineStatus.PROCESSING), + eq(SkillAvatarCandidate.MachineStatus.ERROR), eq(3), eq(SkillAvatarCandidate.Status.REJECTED), + any(Instant.class), any(Instant.class), eq(PageRequest.of(0, 100)))) + .thenReturn(List.of(first)); + when(skills.findScopeReviewRecoveryCandidates(eq(11L), eq(SkillStatus.ACTIVE), eq(SkillVisibility.PRIVATE), + eq(SkillVisibility.NAMESPACE_ONLY), eq(SkillVisibility.PUBLIC), eq(SkillAvatarCandidate.Status.PENDING), + eq(SkillAvatarCandidate.MachineStatus.PENDING), eq(SkillAvatarCandidate.MachineStatus.PROCESSING), + eq(SkillAvatarCandidate.MachineStatus.ERROR), eq(3), eq(SkillAvatarCandidate.Status.REJECTED), + any(Instant.class), any(Instant.class), eq(PageRequest.of(0, 100)))) + .thenReturn(List.of(second)); + doThrow(new IllegalStateException("storage unavailable")).when(listener).reviewScope(11L, null); + SkillAvatarScopeReviewRecoveryTask task = new SkillAvatarScopeReviewRecoveryTask(skills, listener); + + org.assertj.core.api.Assertions.assertThatThrownBy(task::recover).isInstanceOf(IllegalStateException.class); + task.recover(); + + verify(listener).reviewScope(12L, null); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java index b5de4dbe..ecfd3794 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java @@ -12,6 +12,7 @@ import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -49,6 +50,7 @@ public class AccountMergeService { private final UserRoleBindingRepository userRoleBindingRepository; private final ApiTokenRepository apiTokenRepository; private final NamespaceMemberRepository namespaceMemberRepository; + private final NamespaceRepository namespaceRepository; private final Clock clock; public AccountMergeService(AccountMergeRequestRepository mergeRequestRepository, @@ -58,6 +60,7 @@ public class AccountMergeService { UserRoleBindingRepository userRoleBindingRepository, ApiTokenRepository apiTokenRepository, NamespaceMemberRepository namespaceMemberRepository, + NamespaceRepository namespaceRepository, Clock clock) { this.mergeRequestRepository = mergeRequestRepository; this.userAccountRepository = userAccountRepository; @@ -66,6 +69,7 @@ public class AccountMergeService { this.userRoleBindingRepository = userRoleBindingRepository; this.apiTokenRepository = apiTokenRepository; this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceRepository = namespaceRepository; this.clock = clock; } @@ -287,6 +291,8 @@ public class AccountMergeService { private void migrateNamespaceMemberships(String primaryUserId, String secondaryUserId) { List secondaryMemberships = namespaceMemberRepository.findByUserId(secondaryUserId); + secondaryMemberships.stream().map(NamespaceMember::getNamespaceId).distinct().sorted() + .forEach(namespaceRepository::findLockedById); for (NamespaceMember secondaryMembership : secondaryMemberships) { Optional existingPrimaryMembership = namespaceMemberRepository .findByNamespaceIdAndUserId(secondaryMembership.getNamespaceId(), primaryUserId); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 4603ad11..25412f32 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -55,7 +55,10 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/web/skills/*/reviews/me"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/reviews/me"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/skills/avatar-uploads"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/avatar-uploads/*/image"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/avatar"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions/*"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/download"), @@ -70,7 +73,10 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/tags/*/file"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/labels"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/skills/avatar-uploads"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/skills/avatar-uploads/*/image"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/avatar"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/versions/*"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/download"), @@ -293,6 +299,8 @@ public class RouteSecurityPolicyRegistry { "ANY /api/v1/auth/direct/login", "ANY /api/v1/auth/local/**", "ANY /api/v1/admin/**", + "POST /api/v1/skills/avatar-uploads", + "POST /api/web/skills/avatar-uploads", "POST /api/v1/suites/reviews/*/approve", "POST /api/v1/suites/reviews/*/reject", "POST /api/web/suites/reviews/*/approve", diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java index 08a6d480..49d678b5 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java @@ -20,6 +20,7 @@ import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -52,6 +53,8 @@ class AccountMergeServiceTest { private ApiTokenRepository apiTokenRepository; @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private NamespaceRepository namespaceRepository; private AccountMergeService service; private Clock clock; @@ -66,6 +69,7 @@ class AccountMergeServiceTest { userRoleBindingRepository, apiTokenRepository, namespaceMemberRepository, + namespaceRepository, clock ); } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 33fa4ec4..8914db47 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -34,6 +34,32 @@ class RouteSecurityPolicyRegistryTest { registry.accessLevel("GET", "/api/v1/resolve/team/demo")); } + @Test + void currentAvatarMayBePublicButCandidateAndReviewRoutesRequireAuthentication() { + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL, + registry.accessLevel("GET", "/api/v1/skills/global/demo/avatar")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL, + registry.accessLevel("GET", "/api/web/skills/global/demo/avatar")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("GET", "/api/v1/skills/global/demo/avatar/candidates/1/image")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("GET", "/api/v1/reviews")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("GET", "/api/web/reviews")); + } + + @Test + void stagedAvatarUploadRequiresSessionWhilePreviewRequiresAnAuthenticatedUploader() { + for (String prefix : List.of("/api/v1", "/api/web")) { + String upload = prefix + "/skills/avatar-uploads"; + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, registry.accessLevel("POST", upload)); + assertTrue(registry.sessionOnlyRoutes().contains("POST " + upload)); + assertFalse(registry.authorizeApiToken("POST", upload, ALL_SCOPES).allowed()); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("GET", upload + "/upload-id/image")); + } + } + @Test void reviewRoutesExposePublicListingButProtectCurrentUserMutations() { assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java index 1ef7d247..5b251a91 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.domain.namespace; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,21 +14,26 @@ public class GlobalNamespaceMembershipService { private final NamespaceRepository namespaceRepository; private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; public GlobalNamespaceMembershipService(NamespaceRepository namespaceRepository, - NamespaceMemberRepository namespaceMemberRepository) { + NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; } @Transactional public void ensureMember(String userId) { - Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE_SLUG) + userAccountRepository.findLockedById(userId); + Long namespaceId = namespaceRepository.findIdBySlug(GLOBAL_NAMESPACE_SLUG) .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); + namespaceRepository.findLockedById(namespaceId); - namespaceMemberRepository.findByNamespaceIdAndUserId(globalNamespace.getId(), userId) + namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) .orElseGet(() -> namespaceMemberRepository.save( - new NamespaceMember(globalNamespace.getId(), userId, NamespaceRole.MEMBER) + new NamespaceMember(namespaceId, userId, NamespaceRole.MEMBER) )); } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java index 984fe4fc..4e0072fd 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.audit.AuditDetail; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; 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; @@ -18,15 +19,18 @@ public class NamespaceGovernanceService { private final NamespaceMemberRepository namespaceMemberRepository; private final NamespaceAccessPolicy namespaceAccessPolicy; private final AuditLogService auditLogService; + private final UserAccountRepository userAccountRepository; public NamespaceGovernanceService(NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, NamespaceAccessPolicy namespaceAccessPolicy, - AuditLogService auditLogService) { + AuditLogService auditLogService, + UserAccountRepository userAccountRepository) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.namespaceAccessPolicy = namespaceAccessPolicy; this.auditLogService = auditLogService; + this.userAccountRepository = userAccountRepository; } @Transactional @@ -36,6 +40,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); NamespaceRole role = requireRole(namespace.getId(), actorUserId); if (namespace.getStatus() != NamespaceStatus.ACTIVE) { @@ -57,6 +62,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); if (namespace.getStatus() != NamespaceStatus.ACTIVE) { throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); @@ -73,6 +79,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); NamespaceRole role = requireRole(namespace.getId(), actorUserId); if (namespace.getStatus() != NamespaceStatus.FROZEN) { @@ -93,6 +100,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); if (namespace.getStatus() != NamespaceStatus.FROZEN) { throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); @@ -110,6 +118,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); NamespaceRole role = requireRole(namespace.getId(), actorUserId); if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { @@ -131,6 +140,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); @@ -147,6 +157,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); NamespaceRole role = requireRole(namespace.getId(), actorUserId); if (namespace.getStatus() != NamespaceStatus.ARCHIVED) { @@ -167,6 +178,7 @@ public class NamespaceGovernanceService { String requestId, String clientIp, String userAgent) { + userAccountRepository.findLockedById(actorUserId); Namespace namespace = loadNamespaceBySlug(slug); if (namespace.getStatus() != NamespaceStatus.ARCHIVED) { throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); @@ -178,7 +190,9 @@ public class NamespaceGovernanceService { } private Namespace loadNamespaceBySlug(String slug) { - Namespace namespace = namespaceRepository.findBySlug(slug) + Long namespaceId = namespaceRepository.findIdBySlug(slug) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + Namespace namespace = namespaceRepository.findLockedById(namespaceId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); if (namespaceAccessPolicy.isImmutable(namespace)) { throw new DomainBadRequestException("error.namespace.system.immutable", slug); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java new file mode 100644 index 00000000..df0d2b38 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java @@ -0,0 +1,70 @@ +package com.iflytek.skillhub.domain.namespace; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +@Entity +@Table(name = "namespace_invitation") +public class NamespaceInvitation { + @Id + @Column(name = "namespace_id") + private Long namespaceId; + + @Column(name = "token_hash", nullable = false, length = 64) + private String tokenHash; + + @Column(name = "issued_by", nullable = false, length = 128) + private String issuedBy; + + @Column(name = "issued_at", nullable = false) + private Instant issuedAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "consumed_by", length = 128) + private String consumedBy; + + @Column(name = "consumed_at") + private Instant consumedAt; + + @Column(name = "history_id", nullable = false) + private String historyId; + + public String getHistoryId() { return historyId; } + public void setHistoryId(String historyId) { this.historyId = historyId; } + + protected NamespaceInvitation() {} + + public NamespaceInvitation(Long namespaceId, String tokenHash, String issuedBy, Instant issuedAt, Instant expiresAt) { + this.namespaceId = namespaceId; + this.tokenHash = tokenHash; + this.issuedBy = issuedBy; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + } + + public Long getNamespaceId() { return namespaceId; } + public String getTokenHash() { return tokenHash; } + public String getIssuedBy() { return issuedBy; } + public Instant getIssuedAt() { return issuedAt; } + public Instant getExpiresAt() { return expiresAt; } + public String getConsumedBy() { return consumedBy; } + public Instant getConsumedAt() { return consumedAt; } + public void replace(String hash, String issuer, Instant now) { + this.tokenHash = hash; + this.issuedBy = issuer; + this.issuedAt = now; + this.expiresAt = now.plusSeconds(86400); + this.consumedBy = null; + this.consumedAt = null; + } + public void consume(String userId, Instant now) { + this.consumedBy = userId; + this.consumedAt = now; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationHistory.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationHistory.java new file mode 100644 index 00000000..665524d1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationHistory.java @@ -0,0 +1,80 @@ +package com.iflytek.skillhub.domain.namespace; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "namespace_invitation_history") +public class NamespaceInvitationHistory { + @Id + @Column(name = "id") + private String id; + @Column(name = "namespace_id") + private Long namespaceId; + @Column(name = "issued_by") + private String issuedBy; + @Column(name = "issued_by_name") + private String issuedByName; + @Column(name = "issued_at") + private Instant issuedAt; + @Column(name = "expires_at") + private Instant expiresAt; + @Column(name = "consumed_by") + private String consumedBy; + @Column(name = "consumed_by_name") + private String consumedByName; + @Column(name = "consumed_at") + private Instant consumedAt; + @Column(name = "ended_reason") + private String endedReason; + @Column(name = "ended_at") + private Instant endedAt; + @Column(name = "legacy") + private boolean legacy; + + protected NamespaceInvitationHistory() {} + + public NamespaceInvitationHistory(Long namespaceId, String issuer, String issuerName, Instant issuedAt, Instant expiresAt) { + this.id = UUID.randomUUID().toString(); + this.namespaceId = namespaceId; + this.issuedBy = issuer; + this.issuedByName = issuerName; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + } + + public void end(String reason, Instant now) { + if (consumedAt == null && endedAt == null && expiresAt.isAfter(now)) { + endedReason = reason; + endedAt = now; + } + } + + public void consume(String userId, String name, Instant now) { + consumedBy = userId; + consumedByName = name; + consumedAt = now; + } + + public String status(Instant now) { + if (consumedAt != null) return "USED"; + if (endedReason != null) return endedReason; + return expiresAt.isAfter(now) ? "ACTIVE" : "EXPIRED"; + } + public String getId() { return id; } + public Long getNamespaceId() { return namespaceId; } + public String getIssuedBy() { return issuedBy; } + public String getIssuedByName() { return issuedByName; } + public Instant getIssuedAt() { return issuedAt; } + public Instant getExpiresAt() { return expiresAt; } + public String getConsumedBy() { return consumedBy; } + public String getConsumedByName() { return consumedByName; } + public Instant getConsumedAt() { return consumedAt; } + public String getEndedReason() { return endedReason; } + public Instant getEndedAt() { return endedAt; } + public boolean isLegacy() { return legacy; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationHistoryRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationHistoryRepository.java new file mode 100644 index 00000000..8d8637fc --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationHistoryRepository.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface NamespaceInvitationHistoryRepository { + NamespaceInvitationHistory save(NamespaceInvitationHistory history); + Optional findById(String id); + Page findByNamespaceId(Long namespaceId, Pageable pageable); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java new file mode 100644 index 00000000..36e0f99b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationRepository.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.Optional; + +public interface NamespaceInvitationRepository { + Optional findByNamespaceId(Long namespaceId); + Optional findByTokenHash(String tokenHash); + Optional findNamespaceIdByTokenHash(String tokenHash); + NamespaceInvitation save(NamespaceInvitation invitation); + void deleteByNamespaceId(Long namespaceId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java new file mode 100644 index 00000000..590e1ff5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java @@ -0,0 +1,229 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.springframework.stereotype.Service; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.HexFormat; + +@Service +public class NamespaceInvitationService { + private static final SecureRandom RANDOM = new SecureRandom(); + private final NamespaceRepository namespaces; + private final NamespaceInvitationRepository invitations; + private final NamespaceMemberRepository members; + private final UserAccountRepository users; + private final AuditLogService audit; + private final Clock clock; + private final NamespaceInvitationHistoryRepository histories; + + public NamespaceInvitationService(NamespaceRepository namespaces, NamespaceInvitationRepository invitations, + NamespaceMemberRepository members, UserAccountRepository users, + AuditLogService audit, Clock clock, NamespaceInvitationHistoryRepository histories) { + this.namespaces = namespaces; + this.invitations = invitations; + this.members = members; + this.users = users; + this.audit = audit; + this.clock = clock; + this.histories = histories; + } + + public record Created(String token, Instant expiresAt, boolean replaced) {} + public enum PreviewOutcome { CONFIRM_REQUIRED, ALREADY_MEMBER } + public enum AcceptOutcome { JOINED, ALREADY_MEMBER } + public record Preview(String namespaceSlug, String namespaceDisplayName, String namespaceAvatarUrl, + PreviewOutcome outcome, Instant expiresAt) {} + public record Accepted(String namespaceSlug, AcceptOutcome outcome) {} + + @Transactional + public Created create(String slug, String actorId) { + return create(slug, actorId, false); + } + + @Transactional + public Created create(String slug, String actorId, boolean replace) { + requireActiveLockedUser(actorId); + Namespace namespace = lock(slug); + requireManager(namespace, actorId); + requireActiveTeam(namespace); + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + Instant now = clock.instant(); + NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElse(null); + boolean replaced = invitation != null && invitation.getConsumedAt() == null + && invitation.getExpiresAt().isAfter(now); + if (replaced && !replace) { + throw new DomainConflictException("error.namespace.invite.replaceConfirmationRequired"); + } + if (invitation != null) { + NamespaceInvitationHistory previous = history(invitation); + previous.end("REPLACED", now); + histories.save(previous); + } + NamespaceInvitationHistory history = histories.save(new NamespaceInvitationHistory(namespace.getId(), actorId, + users.findById(actorId).orElseThrow(this::invalid).getDisplayName(), now, now.plusSeconds(86400))); + if (invitation == null) { + invitation = new NamespaceInvitation(namespace.getId(), hash(token), actorId, now, now.plusSeconds(86400)); + } else { + invitation.replace(hash(token), actorId, now); + } + invitation.setHistoryId(history.getId()); + invitations.save(invitation); + record(actorId, replaced ? "NAMESPACE_INVITE_REPLACED" : "NAMESPACE_INVITE_CREATED", namespace.getId(), history.getId()); + return new Created(token, invitation.getExpiresAt(), replaced); + } + + @Transactional + public void revoke(String slug, String actorId) { + requireActiveLockedUser(actorId); + Namespace namespace = lock(slug); + requireManager(namespace, actorId); + requireActiveTeam(namespace); + NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElseThrow(this::invalid); + NamespaceInvitationHistory history = history(invitation); + history.end("REVOKED", clock.instant()); + histories.save(history); + invitations.deleteByNamespaceId(namespace.getId()); + record(actorId, "NAMESPACE_INVITE_REVOKED", namespace.getId(), history.getId()); + } + + @Transactional(readOnly = true) + public boolean hasActiveInvitation(String slug, String actorId) { + Namespace namespace = namespaces.findBySlug(slug) + .orElseThrow(() -> new DomainNotFoundException("error.namespace.invitation.invalid")); + requireManager(namespace, actorId); + return invitations.findByNamespaceId(namespace.getId()) + .filter(invite -> invite.getConsumedAt() == null && invite.getExpiresAt().isAfter(clock.instant())) + .isPresent(); + } + + @Transactional(readOnly = true) + public Preview preview(String token, String userId) { + NamespaceInvitation invitation = current(token); + Namespace namespace = namespaces.findById(invitation.getNamespaceId()).orElseThrow(this::invalid); + boolean member = members.findByNamespaceIdAndUserId(namespace.getId(), userId).isPresent(); + if (!member) { + requireActiveUser(userId); + requireValid(namespace, invitation); + } + return new Preview(namespace.getSlug(), namespace.getDisplayName(), namespace.getAvatarUrl(), + member ? PreviewOutcome.ALREADY_MEMBER : PreviewOutcome.CONFIRM_REQUIRED, invitation.getExpiresAt()); + } + + @Transactional + public Accepted accept(String token, String userId) { + // Membership writers and account merge lock users before the Namespace root. + // The FK check on namespace_member would otherwise invert that order. + requireActiveLockedUser(userId); + Long namespaceId = locateNamespaceId(token); + Namespace namespace = namespaces.findLockedById(namespaceId).orElseThrow(this::invalid); + NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElseThrow(this::invalid); + if (!MessageDigest.isEqual(invitation.getTokenHash().getBytes(StandardCharsets.US_ASCII), + hash(token).getBytes(StandardCharsets.US_ASCII))) { + throw invalid(); + } + if (members.findByNamespaceIdAndUserId(namespace.getId(), userId).isPresent()) { + return new Accepted(namespace.getSlug(), AcceptOutcome.ALREADY_MEMBER); + } + requireActiveUser(userId); + requireValid(namespace, invitation); + members.save(new NamespaceMember(namespace.getId(), userId, NamespaceRole.MEMBER)); + Instant acceptedAt = clock.instant(); + invitation.consume(userId, acceptedAt); + NamespaceInvitationHistory history = history(invitation); + history.consume(userId, users.findById(userId).orElseThrow(this::invalid).getDisplayName(), acceptedAt); + histories.save(history); + invitations.save(invitation); + record(userId, "NAMESPACE_INVITE_ACCEPTED", namespace.getId(), history.getId()); + return new Accepted(namespace.getSlug(), AcceptOutcome.JOINED); + } + + @Transactional(readOnly = true) + public Page history(String slug, String actorId, int page, int size) { + if (page < 0 || size < 1 || size > 50) { + throw new DomainBadRequestException("error.pagination.invalid", 50); + } + Namespace namespace = namespaces.findBySlug(slug).orElseThrow(this::invalid); + requireManager(namespace, actorId); + return histories.findByNamespaceId(namespace.getId(), PageRequest.of(page, size, + Sort.by(Sort.Direction.DESC, "issuedAt", "id"))); + } + + public Instant currentTime() { return clock.instant(); } + + private NamespaceInvitationHistory history(NamespaceInvitation invitation) { + return histories.findById(invitation.getHistoryId()).orElseThrow(() -> + new IllegalStateException("Current invitation history is missing")); + } + + private Namespace lock(String slug) { + Long namespaceId = namespaces.findIdBySlug(slug).orElseThrow(this::invalid); + return namespaces.findLockedById(namespaceId).orElseThrow(this::invalid); + } + + private NamespaceInvitation current(String token) { + if (token == null || !token.matches("[A-Za-z0-9_-]{43}")) throw invalid(); + return invitations.findByTokenHash(hash(token)).orElseThrow(this::invalid); + } + + private Long locateNamespaceId(String token) { + if (token == null || !token.matches("[A-Za-z0-9_-]{43}")) throw invalid(); + return invitations.findNamespaceIdByTokenHash(hash(token)).orElseThrow(this::invalid); + } + + private void requireManager(Namespace namespace, String userId) { + NamespaceRole role = members.findByNamespaceIdAndUserId(namespace.getId(), userId) + .map(NamespaceMember::getRole).orElse(null); + if (role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) throw invalid(); + requireActiveUser(userId); + } + + private void requireActiveUser(String userId) { + if (users.findById(userId).filter(user -> user.getStatus() == UserStatus.ACTIVE).isEmpty()) throw invalid(); + } + + private void requireActiveLockedUser(String userId) { + if (users.findLockedById(userId).filter(user -> user.getStatus() == UserStatus.ACTIVE).isEmpty()) throw invalid(); + } + + private void requireActiveTeam(Namespace namespace) { + if (namespace.getStatus() != NamespaceStatus.ACTIVE || namespace.getType() != NamespaceType.TEAM) throw invalid(); + } + + private void requireValid(Namespace namespace, NamespaceInvitation invitation) { + requireActiveTeam(namespace); + if (invitation.getConsumedAt() != null || !invitation.getExpiresAt().isAfter(clock.instant())) throw invalid(); + } + + private DomainNotFoundException invalid() { return new DomainNotFoundException("error.namespace.invitation.invalid"); } + + private String hash(String token) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(token.getBytes(StandardCharsets.US_ASCII))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + private void record(String actorId, String action, Long namespaceId, String invitationId) { + audit.record(actorId, action, "NAMESPACE", namespaceId, null, null, null, "{\"invitationId\":\"" + invitationId + "\"}"); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java index d8e8bc74..040f048b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.domain.namespace; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -18,17 +19,24 @@ public class NamespaceMemberService { private final NamespaceMemberRepository namespaceMemberRepository; private final NamespaceService namespaceService; private final NamespaceAccessPolicy namespaceAccessPolicy; + private final NamespaceRepository namespaceRepository; + private final UserAccountRepository userAccountRepository; public NamespaceMemberService(NamespaceMemberRepository namespaceMemberRepository, NamespaceService namespaceService, - NamespaceAccessPolicy namespaceAccessPolicy) { + NamespaceAccessPolicy namespaceAccessPolicy, + NamespaceRepository namespaceRepository, + UserAccountRepository userAccountRepository) { this.namespaceMemberRepository = namespaceMemberRepository; this.namespaceService = namespaceService; this.namespaceAccessPolicy = namespaceAccessPolicy; + this.namespaceRepository = namespaceRepository; + this.userAccountRepository = userAccountRepository; } @Transactional public NamespaceMember addMember(Long namespaceId, String userId, NamespaceRole role, String operatorUserId) { + lockUserThenNamespace(userId, namespaceId); assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); @@ -46,6 +54,7 @@ public class NamespaceMemberService { @Transactional public void removeMember(Long namespaceId, String userId, String operatorUserId) { + lockUserThenNamespace(userId, namespaceId); assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); @@ -61,6 +70,7 @@ public class NamespaceMemberService { @Transactional public NamespaceMember updateMemberRole(Long namespaceId, String userId, NamespaceRole newRole, String operatorUserId) { + lockUserThenNamespace(userId, namespaceId); assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); @@ -77,6 +87,14 @@ public class NamespaceMemberService { @Transactional public void transferOwnership(Long namespaceId, String currentOwnerId, String newOwnerId) { + if (currentOwnerId.compareTo(newOwnerId) <= 0) { + userAccountRepository.findLockedById(currentOwnerId); + userAccountRepository.findLockedById(newOwnerId); + } else { + userAccountRepository.findLockedById(newOwnerId); + userAccountRepository.findLockedById(currentOwnerId); + } + namespaceRepository.findLockedById(namespaceId); Namespace namespace = namespaceService.getNamespace(namespaceId); if (!namespaceAccessPolicy.canTransferOwnership(namespace)) { throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); @@ -117,4 +135,9 @@ public class NamespaceMemberService { throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); } } + + private void lockUserThenNamespace(String userId, Long namespaceId) { + userAccountRepository.findLockedById(userId); + namespaceRepository.findLockedById(namespaceId); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index cdedbfec..b235d2f5 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -11,11 +11,13 @@ import java.util.Optional; */ public interface NamespaceRepository { Optional findById(Long id); + Optional findLockedById(Long id); List findAll(); List findByIdIn(List ids); List findBySlugIn(List slugs); Page findByIdIn(List ids, Pageable pageable); Optional findBySlug(String slug); + Optional findIdBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); Namespace save(Namespace namespace); void delete(Namespace namespace); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java index 9cc5f27b..a7ed681e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,6 +25,7 @@ public class NamespaceService { private final SkillDraftRepository skillDraftRepository; private final ReviewTaskRepository reviewTaskRepository; private final PromotionRequestRepository promotionRequestRepository; + private final UserAccountRepository userAccountRepository; public NamespaceService(NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, @@ -31,7 +33,8 @@ public class NamespaceService { SkillRepository skillRepository, SkillDraftRepository skillDraftRepository, ReviewTaskRepository reviewTaskRepository, - PromotionRequestRepository promotionRequestRepository) { + PromotionRequestRepository promotionRequestRepository, + UserAccountRepository userAccountRepository) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.namespaceAccessPolicy = namespaceAccessPolicy; @@ -39,6 +42,7 @@ public class NamespaceService { this.skillDraftRepository = skillDraftRepository; this.reviewTaskRepository = reviewTaskRepository; this.promotionRequestRepository = promotionRequestRepository; + this.userAccountRepository = userAccountRepository; } /** @@ -47,6 +51,7 @@ public class NamespaceService { */ @Transactional public Namespace createNamespace(String slug, String displayName, String description, String creatorUserId) { + userAccountRepository.findLockedById(creatorUserId); SlugValidator.validate(slug); if (namespaceRepository.findBySlug(slug).isPresent()) { @@ -123,7 +128,8 @@ public class NamespaceService { */ @Transactional public void deleteNamespace(Long namespaceId, String operatorUserId) { - Namespace namespace = getNamespace(namespaceId); + Namespace namespace = namespaceRepository.findLockedById(namespaceId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); assertNotImmutable(namespace); NamespaceRole role = requireRole(namespaceId, operatorUserId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewAvatarParticipant.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewAvatarParticipant.java new file mode 100644 index 00000000..fb416148 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewAvatarParticipant.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.domain.review; + +/** Image changes participate in the same transaction and decision as the review task. */ +public interface ReviewAvatarParticipant { + void lock(ReviewTask task); + void validateApproval(ReviewTask task, String comment); + void decide(ReviewTask task, boolean approved, String actor, String comment); + void withdraw(ReviewTask task); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index 98b19649..02e7f275 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.domain.review; +import org.springframework.beans.factory.annotation.Autowired; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditDetail; import com.iflytek.skillhub.domain.namespace.Namespace; @@ -55,6 +56,11 @@ public class ReviewService { private final GovernanceNotificationService governanceNotificationService; private final EntityManager entityManager; private final Clock clock; + private ReviewAvatarParticipant avatarParticipant; + + @Autowired + public void setAvatarParticipant(ReviewAvatarParticipant participant) { this.avatarParticipant = participant; } + public ReviewService(ReviewTaskRepository reviewTaskRepository, SkillVersionRepository skillVersionRepository, @@ -178,6 +184,15 @@ public class ReviewService { ReviewTask task = reviewTaskRepository.findById(reviewTaskId) .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId)); + if (avatarParticipant != null) { + avatarParticipant.lock(task); + entityManager.refresh(task); + } + + if (avatarParticipant == null && (task.getAvatarCandidateId() != null || task.isAvatarRemoval() + || task.getSubjectType() == ReviewSubjectType.SKILL_METADATA)) { + throw new IllegalStateException("Avatar review participant unavailable"); + } if (task.getStatus() != ReviewTaskStatus.PENDING) { throw new DomainBadRequestException("review.not_pending", reviewTaskId); } @@ -191,6 +206,10 @@ public class ReviewService { throw new DomainForbiddenException("review.no_permission"); } + if (avatarParticipant != null) { avatarParticipant.validateApproval(task, comment); } + if (task.getSubjectType() == ReviewSubjectType.SKILL_METADATA) { + return decideMetadata(task, reviewerId, comment, true); + } SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); if (skillVersion.getStatus() == SkillVersionStatus.SCANNING) { @@ -233,6 +252,7 @@ public class ReviewService { skill.setUpdatedBy(reviewerId); skillRepository.save(skill); + if (avatarParticipant != null) { avatarParticipant.decide(task, true, reviewerId, comment); } eventPublisher.publishEvent(new SkillPublishedEvent( skill.getId(), skillVersion.getId(), reviewerId)); eventPublisher.publishEvent(new ReviewApprovedEvent( @@ -261,6 +281,15 @@ public class ReviewService { ReviewTask task = reviewTaskRepository.findById(reviewTaskId) .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId)); + if (avatarParticipant != null) { + avatarParticipant.lock(task); + entityManager.refresh(task); + } + + if (avatarParticipant == null && (task.getAvatarCandidateId() != null || task.isAvatarRemoval() + || task.getSubjectType() == ReviewSubjectType.SKILL_METADATA)) { + throw new IllegalStateException("Avatar review participant unavailable"); + } if (task.getStatus() != ReviewTaskStatus.PENDING) { throw new DomainBadRequestException("review.not_pending", reviewTaskId); } @@ -274,6 +303,9 @@ public class ReviewService { throw new DomainForbiddenException("review.no_permission"); } + if (task.getSubjectType() == ReviewSubjectType.SKILL_METADATA) { + return decideMetadata(task, reviewerId, comment, false); + } int updated = reviewTaskRepository.updateStatusWithVersion( reviewTaskId, ReviewTaskStatus.REJECTED, reviewerId, comment, task.getVersion()); if (updated == 0) { @@ -288,6 +320,7 @@ public class ReviewService { .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); skillVersion.setStatus(SkillVersionStatus.REJECTED); skillVersionRepository.save(skillVersion); + if (avatarParticipant != null) { avatarParticipant.decide(task, false, reviewerId, comment); } eventPublisher.publishEvent(new ReviewRejectedEvent( task.getId(), skill.getId(), skillVersion.getId(), reviewerId, task.getSubmittedBy(), comment)); @@ -317,6 +350,7 @@ public class ReviewService { throw new DomainForbiddenException("review.withdraw.not_submitter"); } + if (avatarParticipant != null) { avatarParticipant.lock(task); avatarParticipant.withdraw(task); } reviewTaskRepository.delete(task); SkillVersion skillVersion = skillVersionRepository.findById(skillVersionId) @@ -329,6 +363,32 @@ public class ReviewService { return skillGovernanceService.withdrawPendingVersion(skill, skillVersion, userId); } + private ReviewTask decideMetadata(ReviewTask task, String reviewer, String comment, boolean approved) { + if (avatarParticipant == null) { throw new IllegalStateException("Avatar review participant unavailable"); } + ReviewTaskStatus status = approved ? ReviewTaskStatus.APPROVED : ReviewTaskStatus.REJECTED; + if (reviewTaskRepository.updateStatusWithVersion(task.getId(), status, reviewer, comment, task.getVersion()) == 0) { + throw new ConcurrentModificationException("Review task was modified concurrently"); + } + syncReviewTaskState(task, status, reviewer, comment); + avatarParticipant.decide(task, approved, reviewer, comment); + governanceNotificationService.notifyUser(task.getSubmittedBy(), "REVIEW", "REVIEW_TASK", task.getId(), + approved ? "Review approved" : "Review rejected", AuditDetail.of("status", status.name())); + return task; + } + + @Transactional + public void withdrawMetadata(ReviewTask task, String userId) { + if (!task.getSubmittedBy().equals(userId)) { throw new DomainForbiddenException("review.withdraw.not_submitter"); } + if (avatarParticipant != null) { avatarParticipant.lock(task); entityManager.refresh(task); } + if (avatarParticipant == null && (task.getAvatarCandidateId() != null || task.isAvatarRemoval() + || task.getSubjectType() == ReviewSubjectType.SKILL_METADATA)) { + throw new IllegalStateException("Avatar review participant unavailable"); + } + if (task.getStatus() != ReviewTaskStatus.PENDING) { throw new DomainBadRequestException("review.not_pending", task.getId()); } + avatarParticipant.withdraw(task); + reviewTaskRepository.delete(task); + } + public boolean canReviewNamespace(ReviewTask task, String userId, com.iflytek.skillhub.domain.namespace.NamespaceType namespaceType, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewSubjectType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewSubjectType.java index e5cb4db6..7c65e4e9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewSubjectType.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewSubjectType.java @@ -3,5 +3,6 @@ package com.iflytek.skillhub.domain.review; /** Resource version types handled by the shared review queue. */ public enum ReviewSubjectType { SKILL_VERSION, + SKILL_METADATA, SUITE_VERSION } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java index 3dd66689..2cafb29c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java @@ -33,6 +33,17 @@ public class ReviewTask { @Column(name = "subject_version", nullable = false, length = 64) private String subjectVersion; + @Column(name = "avatar_candidate_id") + private Long avatarCandidateId; + + @Column(name = "avatar_removal_active_id") + private Long avatarRemovalActiveId; + @Column(name = "avatar_removal_pending_id") + private Long avatarRemovalPendingId; + + @Column(name = "avatar_removal", nullable = false) + private boolean avatarRemoval; + @Column(name = "namespace_id", nullable = false) private Long namespaceId; @@ -99,6 +110,28 @@ public class ReviewTask { return task; } + public static ReviewTask forSkillMetadata(Long skillId, Long namespaceId, String actor, Long candidateId) { + ReviewTask task = new ReviewTask(); + task.skillId = skillId; + task.subjectType = ReviewSubjectType.SKILL_METADATA; + task.subjectId = skillId; + task.subjectVersion = "avatar:" + candidateId; + task.namespaceId = namespaceId; + task.submittedBy = actor; + task.avatarCandidateId = candidateId; + return task; + } + + public Long getAvatarCandidateId() { return avatarCandidateId; } + public void setAvatarCandidateId(Long candidateId) { this.avatarCandidateId = candidateId; } + public Long getAvatarRemovalActiveId() { return avatarRemovalActiveId; } + public Long getAvatarRemovalPendingId() { return avatarRemovalPendingId; } + public void prepareAvatarRemoval(Long active, Long pending) { + avatarRemoval = true; avatarRemovalActiveId = active; avatarRemovalPendingId = pending; + } + public boolean isAvatarRemoval() { return avatarRemoval; } + public void setAvatarRemoval(boolean removal) { this.avatarRemoval = removal; } + public Long getId() { return id; } public Long getSkillVersionId() { return skillVersionId; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java index 8561d64a..62b6e1e7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java @@ -10,6 +10,7 @@ import java.util.Optional; * Domain repository contract for moderation review tasks and their state transitions. */ public interface ReviewTaskRepository { + default List findByAvatarCandidateIdAndStatus(Long candidateId, ReviewTaskStatus status) { return List.of(); } ReviewTask save(ReviewTask reviewTask); Optional findById(Long id); Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java index cc18cba0..7367345f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java @@ -42,6 +42,16 @@ public class Skill { @Column(name = "latest_version_id") private Long latestVersionId; + @Column(name = "active_avatar_id") + private Long activeAvatarId; + + @Enumerated(EnumType.STRING) + @Column(name = "active_avatar_audience", length = 32) + private SkillVisibility activeAvatarAudience; + + @Column(name = "pending_avatar_id") + private Long pendingAvatarId; + @Column(name = "download_count", nullable = false) private Long downloadCount = 0L; @@ -141,6 +151,12 @@ public class Skill { return latestVersionId; } + public Long getActiveAvatarId() { return activeAvatarId; } + + public SkillVisibility getActiveAvatarAudience() { return activeAvatarAudience; } + + public Long getPendingAvatarId() { return pendingAvatarId; } + public Long getDownloadCount() { return downloadCount; } @@ -214,6 +230,18 @@ public class Skill { this.latestVersionId = latestVersionId; } + public void activateAvatar(Long activeAvatarId, SkillVisibility audience) { + this.activeAvatarId = activeAvatarId; + this.activeAvatarAudience = audience; + } + + public void clearAvatar() { + this.activeAvatarId = null; + this.activeAvatarAudience = null; + } + + public void setPendingAvatarId(Long pendingAvatarId) { this.pendingAvatarId = pendingAvatarId; } + public void setCreatedBy(String createdBy) { this.createdBy = createdBy; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidate.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidate.java new file mode 100644 index 00000000..97ad71b9 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidate.java @@ -0,0 +1,246 @@ +package com.iflytek.skillhub.domain.skill; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "skill_avatar_candidate") +public class SkillAvatarCandidate { + public enum MachineStatus { SKIPPED, PENDING, PROCESSING, PASSED, BLOCKED, ERROR } + public enum ReviewStatus { NOT_REQUIRED, PENDING, APPROVED, REJECTED, APPEAL_PENDING } + public enum Status { PENDING, ACTIVE, REJECTED, SUPERSEDED, REMOVED } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "skill_id", nullable = false) + private Long skillId; + @Column(name = "submitted_by", nullable = false, length = 128) + private String submittedBy; + @Column(name = "object_key", length = 512) + private String objectKey; + @Column(name = "content_sha256", nullable = false, length = 64) + private String contentSha256; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private SkillVisibility audience; + @Column(name = "machine_enabled", nullable = false) + private boolean machineEnabled; + @Column(name = "manual_enabled", nullable = false) + private boolean manualEnabled; + @Column(name = "machine_provider", length = 32) + private String machineProvider; + @Column(name = "machine_service_id", length = 128) + private String machineServiceId; + @Column(name = "machine_api_version", length = 64) + private String machineApiVersion; + @Column(name = "machine_policy_revision", length = 256) + private String machinePolicyRevision; + @Column(name = "machine_model_version", length = 128) + private String machineModelVersion; + @Column(name = "review_bound", nullable = false) + private boolean reviewBound; + @Column(name = "scope_review_version_id") + private Long scopeReviewVersionId; + @Column(name = "scope_review", nullable = false) + private boolean scopeReview; + @Column(name = "machine_attempt_id", length = 36) + private String machineAttemptId; + @Column(name = "machine_lease_until") + private Instant machineLeaseUntil; + @Column(name = "machine_next_retry_at") + private Instant machineNextRetryAt; + @Column(name = "machine_attempt_count", nullable = false) + private int machineAttemptCount; + @Enumerated(EnumType.STRING) + @Column(name = "machine_status", nullable = false, length = 32) + private MachineStatus machineStatus; + @Enumerated(EnumType.STRING) + @Column(name = "review_status", nullable = false, length = 32) + private ReviewStatus reviewStatus; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private Status status; + @Column(name = "review_reason", columnDefinition = "TEXT") + private String reviewReason; + @Column(name = "reviewed_by", length = 128) + private String reviewedBy; + @Column(name = "reviewed_at") + private Instant reviewedAt; + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + @Column(name = "discard_at") + private Instant discardAt; + + protected SkillAvatarCandidate() {} + + public SkillAvatarCandidate(Long skillId, String submittedBy, String objectKey, String contentSha256, + SkillVisibility audience, boolean machineEnabled, boolean manualEnabled, + String machineProvider) { + this.skillId = skillId; + this.submittedBy = submittedBy; + this.objectKey = objectKey; + this.contentSha256 = contentSha256; + this.audience = audience; + this.machineEnabled = machineEnabled; + this.manualEnabled = manualEnabled; + this.machineProvider = machineProvider; + this.machineStatus = machineEnabled ? MachineStatus.PENDING : MachineStatus.SKIPPED; + this.reviewStatus = manualEnabled ? ReviewStatus.PENDING : ReviewStatus.NOT_REQUIRED; + this.status = Status.PENDING; + } + + @PrePersist + void prePersist() { createdAt = Instant.now(); updatedAt = createdAt; } + @PreUpdate + void preUpdate() { updatedAt = Instant.now(); } + + public String getMachineServiceId() { return machineServiceId; } + public String getMachineApiVersion() { return machineApiVersion; } + public String getMachinePolicyRevision() { return machinePolicyRevision; } + public String getMachineModelVersion() { return machineModelVersion; } + + /** Latest actual attempt context; previous attempts remain in the durable audit log. */ + public void recordMachineContext(String serviceId, String apiVersion, String policyRevision, String modelVersion) { + machineServiceId = serviceId; + machineApiVersion = apiVersion; + machinePolicyRevision = policyRevision; + machineModelVersion = modelVersion; + } + + public boolean isReviewBound() { return reviewBound; } + public void bindReview() { reviewBound = true; } + public void unifiedDecision(boolean approved, String actor, String reason) { + if (status != Status.PENDING) { throw new IllegalStateException("Avatar candidate is not pending"); } + reviewedBy = actor; + reviewReason = reason; + reviewedAt = Instant.now(); + reviewStatus = approved ? ReviewStatus.APPROVED : ReviewStatus.REJECTED; + if (!approved) { status = Status.REJECTED; discardAt = Instant.now().plusSeconds(30L * 24 * 60 * 60); } + } + + public Long getId() { return id; } + public Long getSkillId() { return skillId; } + public String getSubmittedBy() { return submittedBy; } + public String getObjectKey() { return objectKey; } + public String getContentSha256() { return contentSha256; } + public SkillVisibility getAudience() { return audience; } + public boolean isMachineEnabled() { return machineEnabled; } + public boolean isManualEnabled() { return manualEnabled; } + public String getMachineProvider() { return machineProvider; } + public Long getScopeReviewVersionId() { return scopeReviewVersionId; } + public boolean isScopeReview() { return scopeReview; } + public Instant getMachineLeaseUntil() { return machineLeaseUntil; } + public Instant getMachineNextRetryAt() { return machineNextRetryAt; } + public int getMachineAttemptCount() { return machineAttemptCount; } + public MachineStatus getMachineStatus() { return machineStatus; } + public ReviewStatus getReviewStatus() { return reviewStatus; } + public Status getStatus() { return status; } + public String getReviewReason() { return reviewReason; } + public String getReviewedBy() { return reviewedBy; } + public Instant getCreatedAt() { return createdAt; } + public Instant getDiscardAt() { return discardAt; } + + public void markScopeReviewVersion(Long versionId) { scopeReviewVersionId = versionId; scopeReview = true; } + + public boolean canRetryMachine(Instant now) { + return status == Status.PENDING && machineEnabled + && (machineStatus == MachineStatus.ERROR + && (machineNextRetryAt == null || !machineNextRetryAt.isAfter(now)) + || machineStatus == MachineStatus.PROCESSING + && machineLeaseUntil != null && !machineLeaseUntil.isAfter(now)); + } + + public boolean claimMachineAttempt(String attemptId, Instant now, Instant leaseUntil) { + if (status != Status.PENDING || !machineEnabled + || machineStatus != MachineStatus.PENDING && !canRetryMachine(now)) { + return false; + } + machineStatus = MachineStatus.PROCESSING; + machineAttemptId = attemptId; + machineLeaseUntil = leaseUntil; + machineNextRetryAt = null; + machineAttemptCount++; + return true; + } + + public boolean completeMachineAttempt(String attemptId, MachineStatus result) { + if (status != Status.PENDING || machineStatus != MachineStatus.PROCESSING + || !attemptId.equals(machineAttemptId) + || result == MachineStatus.PENDING || result == MachineStatus.PROCESSING + || result == MachineStatus.SKIPPED) { + return false; + } + machineStatus = result; + machineAttemptId = null; + machineLeaseUntil = null; + machineNextRetryAt = result == MachineStatus.ERROR ? Instant.now().plusSeconds(3600) : null; + return true; + } + + public void machineResult(MachineStatus result) { + if (status != Status.PENDING || !machineEnabled || result == MachineStatus.SKIPPED + || result == MachineStatus.PENDING || result == MachineStatus.PROCESSING) { + throw new IllegalStateException("Avatar candidate cannot accept this machine result"); + } + machineStatus = result; + } + + public void appeal() { + if (status != Status.PENDING || machineStatus != MachineStatus.BLOCKED || manualEnabled) { + throw new IllegalStateException("Avatar candidate cannot be appealed"); + } + reviewStatus = ReviewStatus.APPEAL_PENDING; + } + + public void approve(String actor, String reason) { + if (status != Status.PENDING || !(reviewStatus == ReviewStatus.PENDING || reviewStatus == ReviewStatus.APPEAL_PENDING)) { + throw new IllegalStateException("Avatar candidate is not reviewable"); + } + reviewedBy = actor; + reviewReason = reason; + reviewedAt = Instant.now(); + reviewStatus = ReviewStatus.APPROVED; + } + + public void reject(String actor, String reason) { + approve(actor, reason); + reviewStatus = ReviewStatus.REJECTED; + status = Status.REJECTED; + discardAt = Instant.now().plusSeconds(30L * 24 * 60 * 60); + } + + public void activate() { + if (status != Status.PENDING) { + throw new IllegalStateException("Avatar candidate is not pending"); + } + status = Status.ACTIVE; + } + + public void supersede() { + if (status == Status.PENDING || status == Status.ACTIVE) { + status = Status.SUPERSEDED; + discardAt = Instant.now().plusSeconds(30L * 24 * 60 * 60); + } + } + + public void remove() { + if (status == Status.PENDING || status == Status.ACTIVE) { + status = Status.REMOVED; + discardAt = Instant.now().plusSeconds(30L * 24 * 60 * 60); + } + } + + public void clearObjectKey() { objectKey = null; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateRepository.java new file mode 100644 index 00000000..4085d7e5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateRepository.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.skill; + +import java.util.List; + +public interface SkillAvatarCandidateRepository { + List findBySkillIdOrderByCreatedAtDesc(Long skillId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarUpload.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarUpload.java new file mode 100644 index 00000000..a464833a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarUpload.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.domain.skill; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; + +/** Private, uploader-owned image awaiting a successful skill submission. */ +@Entity +@Table(name = "skill_avatar_upload") +public class SkillAvatarUpload { + @Id + @Column(length = 36) + private String id; + @Column(name = "uploaded_by", nullable = false, length = 128) + private String uploadedBy; + @Column(name = "object_key", length = 512) + private String objectKey; + @Column(name = "content_sha256", nullable = false, length = 64) + private String contentSha256; + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + @Column(name = "candidate_id") + private Long candidateId; + + protected SkillAvatarUpload() {} + public SkillAvatarUpload(String id, String uploadedBy, String objectKey, String sha256, Instant expiresAt) { + this.id = id; + this.uploadedBy = uploadedBy; + this.objectKey = objectKey; + this.contentSha256 = sha256; + this.expiresAt = expiresAt; + } + public String getId() { return id; } + public String getUploadedBy() { return uploadedBy; } + public String getObjectKey() { return objectKey; } + public String getContentSha256() { return contentSha256; } + public Instant getExpiresAt() { return expiresAt; } + public Long getCandidateId() { return candidateId; } + public void consume(Long candidateId) { this.candidateId = candidateId; } + public void clearObjectKey() { objectKey = null; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java index 6308f753..616385c8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java @@ -9,6 +9,8 @@ import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.security.SecurityScanService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidateRepository; import com.iflytek.skillhub.domain.skill.SkillFile; import com.iflytek.skillhub.domain.skill.SkillFileRepository; import com.iflytek.skillhub.domain.skill.SkillRepository; @@ -49,6 +51,7 @@ public class SkillHardDeleteService { private final SkillRatingRepository skillRatingRepository; private final SkillReportRepository skillReportRepository; private final SkillVersionStatsRepository skillVersionStatsRepository; + private final SkillAvatarCandidateRepository avatarCandidates; private final ObjectStorageService objectStorageService; private final SkillStorageDeletionCompensationService compensationService; private final SecurityScanService securityScanService; @@ -65,6 +68,7 @@ public class SkillHardDeleteService { SkillRatingRepository skillRatingRepository, SkillReportRepository skillReportRepository, SkillVersionStatsRepository skillVersionStatsRepository, + SkillAvatarCandidateRepository avatarCandidates, ObjectStorageService objectStorageService, SkillStorageDeletionCompensationService compensationService, SecurityScanService securityScanService, @@ -80,6 +84,7 @@ public class SkillHardDeleteService { this.skillRatingRepository = skillRatingRepository; this.skillReportRepository = skillReportRepository; this.skillVersionStatsRepository = skillVersionStatsRepository; + this.avatarCandidates = avatarCandidates; this.objectStorageService = objectStorageService; this.compensationService = compensationService; this.securityScanService = securityScanService; @@ -117,6 +122,10 @@ public class SkillHardDeleteService { .forEach(storageKeys::add); storageKeys.add(buildBundleStorageKey(skill.getId(), version.getId())); } + avatarCandidates.findBySkillIdOrderByCreatedAtDesc(skill.getId()).stream() + .map(SkillAvatarCandidate::getObjectKey) + .filter(key -> key != null && !key.isBlank()) + .forEach(storageKeys::add); deleteStorageAfterCommit(skill, namespaceSlug, storageKeys); skill.setLatestVersionId(null); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 8fb01137..1e65d12e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.domain.skill.service; +import com.iflytek.skillhub.domain.review.ReviewAvatarParticipant; +import org.springframework.beans.factory.annotation.Autowired; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.event.ReviewSubmittedEvent; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; @@ -77,6 +79,18 @@ public class SkillPublishService { ); private static final Logger log = LoggerFactory.getLogger(SkillPublishService.class); + private ReviewAvatarParticipant avatarParticipant; + + @Autowired + public void setAvatarParticipant(ReviewAvatarParticipant participant) { + this.avatarParticipant = participant; + } + + private void withdrawTask(ReviewTask task) { + if (avatarParticipant != null) { avatarParticipant.lock(task); avatarParticipant.withdraw(task); } + reviewTaskRepository.delete(task); + } + public record PublishResult( Long skillId, String slug, @@ -508,7 +522,7 @@ public class SkillPublishService { if (bundleTarget == null) { for (SkillVersion pending : pendingVersions) { reviewTaskRepository.findBySkillVersionIdAndStatus(pending.getId(), ReviewTaskStatus.PENDING) - .ifPresent(reviewTaskRepository::delete); + .ifPresent(this::withdrawTask); pending.setStatus(SkillVersionStatus.UPLOADED); skillVersionRepository.save(pending); } @@ -565,6 +579,29 @@ public class SkillPublishService { // 9. Upload each file to storage and compute SHA-256 List skillFiles = new ArrayList<>(); + List uploadedKeys = new ArrayList<>(); + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCompletion(int result) { + if (result != STATUS_COMMITTED && !uploadedKeys.isEmpty()) { + try { + objectStorageService.deleteObjects(List.copyOf(uploadedKeys)); + } catch (RuntimeException failure) { + // Keys contain fresh version IDs only. A failed cleanup must never expose provider secrets. + try { + compensationService.recordRollbackFailure(namespaceSlug, skill.getSlug(), + List.copyOf(uploadedKeys), failure.getClass().getSimpleName()); + } catch (RuntimeException compensationFailure) { + log.warn("Failed to record rolled-back publication cleanup, failureType={}", + compensationFailure.getClass().getSimpleName()); + } + log.warn("Failed to clean rolled-back publication objects, failureType={}", + failure.getClass().getSimpleName()); + } + } + } + }); + } long totalSize = 0; Path bundleZip = null; @@ -615,6 +652,7 @@ public class SkillPublishService { zipOutput.finish(); } for (StagedPackageFile staged : stagedFiles) { + uploadedKeys.add(staged.storageKey()); try (InputStream stagedInput = Files.newInputStream(staged.path())) { objectStorageService.putObject( staged.storageKey(), stagedInput, staged.size(), staged.contentType()); @@ -626,6 +664,7 @@ public class SkillPublishService { } String bundleKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); long bundleSize = Files.size(bundleZip); + uploadedKeys.add(bundleKey); try (InputStream bundleInput = Files.newInputStream(bundleZip)) { objectStorageService.putObject( bundleKey, bundleInput, bundleSize, "application/zip"); @@ -762,7 +801,7 @@ public class SkillPublishService { // durable governance history. The database detaches those settled attempts from the // replaced version while retaining their skill/version snapshot. reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING) - .ifPresent(reviewTaskRepository::delete); + .ifPresent(this::withdrawTask); List files = skillFileRepository.findByVersionId(version.getId()); List storageKeys = new ArrayList<>(); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index ddea24d0..3f57af1e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -116,7 +116,9 @@ public class SkillQueryService { SkillLifecycleProjectionService.VersionProjection publishedVersion, SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion, String ownerPreviewReviewComment, - String resolutionMode + String resolutionMode, + Long activeAvatarId, + SkillVisibility activeAvatarAudience ) {} public record SkillVersionDetailDTO( @@ -249,7 +251,9 @@ public class SkillQueryService { publishedVersion, ownerPreviewVersion, ownerPreviewReviewComment, - projection.resolutionMode().name() + projection.resolutionMode().name(), + skill.getActiveAvatarId(), + skill.getActiveAvatarAudience() ); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillStorageDeletionCompensationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillStorageDeletionCompensationService.java index 3c7b2df0..4c811c73 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillStorageDeletionCompensationService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillStorageDeletionCompensationService.java @@ -10,6 +10,7 @@ import com.iflytek.skillhub.storage.ObjectStorageService; import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; @Service public class SkillStorageDeletionCompensationService { @@ -43,6 +44,12 @@ public class SkillStorageDeletionCompensationService { )); } + /** Persists cleanup after a publication transaction rolled back, including a newly-created Skill. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordRollbackFailure(String namespace, String slug, List storageKeys, String failureType) { + recordFailure(null, namespace, slug, storageKeys, failureType); + } + @Transactional public int retryPendingCleanup() { List records = diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java index c4e211bb..95ae6ad1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java @@ -24,12 +24,14 @@ class GlobalNamespaceMembershipServiceTest { @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository; private GlobalNamespaceMembershipService service; @BeforeEach void setUp() { - service = new GlobalNamespaceMembershipService(namespaceRepository, namespaceMemberRepository); + service = new GlobalNamespaceMembershipService(namespaceRepository, namespaceMemberRepository, userAccountRepository); } @Test @@ -37,7 +39,7 @@ class GlobalNamespaceMembershipServiceTest { Namespace global = new Namespace("global", "Global", "system"); setNamespaceId(global, 1L); - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.empty()); service.ensureMember("usr_1"); @@ -55,7 +57,7 @@ class GlobalNamespaceMembershipServiceTest { setNamespaceId(global, 1L); NamespaceMember existing = new NamespaceMember(1L, "usr_1", NamespaceRole.ADMIN); - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.of(existing)); service.ensureMember("usr_1"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java index 65290270..f164cc2e 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java @@ -3,7 +3,9 @@ package com.iflytek.skillhub.domain.namespace; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -30,14 +32,23 @@ class NamespaceGovernanceServiceTest { @Mock private AuditLogService auditLogService; + @Mock + private UserAccountRepository userAccountRepository; @InjectMocks private NamespaceGovernanceService governanceService; + @BeforeEach + void lockResolvedNamespace() { + org.mockito.Mockito.lenient().when(namespaceRepository.findLockedById(org.mockito.ArgumentMatchers.anyLong())) + .thenAnswer(call -> namespaceRepository.findBySlug("team-a")); + } + @Test void freezeNamespace_allowsAdminOnActiveTeamNamespace() { Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) .thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN))); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); @@ -54,6 +65,7 @@ class NamespaceGovernanceServiceTest { void archiveNamespace_rejectsAdminAndAllowsOnlyOwner() { Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) .thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN))); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); @@ -67,6 +79,7 @@ class NamespaceGovernanceServiceTest { void restoreNamespace_movesArchivedNamespaceBackToActive() { Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ARCHIVED); when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner-1")) .thenReturn(Optional.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); @@ -81,7 +94,8 @@ class NamespaceGovernanceServiceTest { @Test void freezeNamespace_rejectsGlobalNamespace() { Namespace namespace = namespace(1L, "global", NamespaceType.GLOBAL, NamespaceStatus.ACTIVE); - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("global")).thenReturn(Optional.of(1L)); + when(namespaceRepository.findLockedById(1L)).thenReturn(Optional.of(namespace)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); assertThrows(DomainBadRequestException.class, @@ -92,6 +106,7 @@ class NamespaceGovernanceServiceTest { void unfreezeNamespace_rejectsIllegalTransition() { Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findIdBySlug("team-a")).thenReturn(Optional.of(1L)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner-1")) .thenReturn(Optional.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java new file mode 100644 index 00000000..6d97da7d --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java @@ -0,0 +1,354 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; + +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.*; + +class NamespaceInvitationServiceTest { + private final NamespaceRepository namespaces = mock(NamespaceRepository.class); + private final NamespaceInvitationRepository invitations = mock(NamespaceInvitationRepository.class); + private final NamespaceMemberRepository members = mock(NamespaceMemberRepository.class); + private final UserAccountRepository users = mock(UserAccountRepository.class); + private final AuditLogService audit = mock(AuditLogService.class); + private final NamespaceInvitationHistoryRepository histories = mock(NamespaceInvitationHistoryRepository.class); + private final Clock clock = Clock.fixed(Instant.parse("2026-10-12T00:00:00Z"), ZoneOffset.UTC); + private final NamespaceInvitationService service = new NamespaceInvitationService(namespaces, invitations, members, users, audit, clock, histories); + + @BeforeEach + void setup() { + lenient().when(histories.save(any())).thenAnswer(call -> call.getArgument(0)); + lenient().when(histories.findById(org.mockito.ArgumentMatchers.nullable(String.class))).thenAnswer(call -> + Optional.of(new NamespaceInvitationHistory(7L, "owner", "Owner", clock.instant(), clock.instant().plusSeconds(86400)))); + Namespace namespace = new Namespace("team", "Team", "owner"); + // Entity ID is database-generated; mock the locked lookup with the same instance. + try { + var id = Namespace.class.getDeclaredField("id"); + id.setAccessible(true); + id.set(namespace, 7L); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace)); + when(namespaces.findIdBySlug("team")).thenReturn(Optional.of(7L)); + when(namespaces.findLockedById(7L)).thenReturn(Optional.of(namespace)); + when(namespaces.findById(7L)).thenReturn(Optional.of(namespace)); + when(members.findByNamespaceIdAndUserId(7L, "owner")) + .thenReturn(Optional.of(new NamespaceMember(7L, "owner", NamespaceRole.OWNER))); + UserAccount owner = mock(UserAccount.class); + when(owner.getStatus()).thenReturn(com.iflytek.skillhub.domain.user.UserStatus.ACTIVE); + when(users.findById("owner")).thenReturn(Optional.of(owner)); + when(users.findById("guest")).thenReturn(Optional.of(owner)); + lenient().when(users.findLockedById("owner")).thenReturn(Optional.of(owner)); + lenient().when(users.findLockedById("guest")).thenReturn(Optional.of(owner)); + } + + @Test + void createsSingleUseTokenWithoutPersistingRawValue() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + assertThat(created.token()).matches("[A-Za-z0-9_-]{43}"); + assertThat(saved.getValue().getTokenHash()).matches("[a-f0-9]{64}").doesNotContain(created.token()); + assertThat(created.expiresAt()).isEqualTo(Instant.parse("2026-10-13T00:00:00Z")); + assertThat(created.replaced()).isFalse(); + } + + @Test + void namespaceAdminCanCreateInvitation() { + Optional active = users.findById("owner"); + when(members.findByNamespaceIdAndUserId(7L, "admin")) + .thenReturn(Optional.of(new NamespaceMember(7L, "admin", NamespaceRole.ADMIN))); + when(users.findLockedById("admin")).thenReturn(active); + when(users.findById("admin")).thenReturn(active); + + assertThat(service.create("team", "admin").token()).matches("[A-Za-z0-9_-]{43}"); + verify(invitations).save(any()); + } + + @Test + void namespaceMemberCannotCreateInvitation() { + Optional active = users.findById("owner"); + when(members.findByNamespaceIdAndUserId(7L, "guest")) + .thenReturn(Optional.of(new NamespaceMember(7L, "guest", NamespaceRole.MEMBER))); + when(users.findLockedById("guest")).thenReturn(active); + + assertThatThrownBy(() -> service.create("team", "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(invitations, never()).save(any()); + } + + @Test + void ownerCannotCreateInvitationForGlobalFrozenOrArchivedNamespace() { + Namespace namespace = namespaces.findById(7L).orElseThrow(); + namespace.setType(NamespaceType.GLOBAL); + assertThatThrownBy(() -> service.create("team", "owner")) + .isInstanceOf(DomainNotFoundException.class); + + namespace.setType(NamespaceType.TEAM); + for (NamespaceStatus status : new NamespaceStatus[] {NamespaceStatus.FROZEN, NamespaceStatus.ARCHIVED}) { + namespace.setStatus(status); + assertThatThrownBy(() -> service.create("team", "owner")) + .as("Namespace status %s", status) + .isInstanceOf(DomainNotFoundException.class); + } + verify(invitations, never()).save(any()); + } + + @Test + void platformAdminWithoutNamespaceMembershipCannotCreateInvitation() { + Optional active = users.findById("owner"); + when(users.findLockedById("platform-admin")).thenReturn(active); + + assertThatThrownBy(() -> service.create("team", "platform-admin")) + .isInstanceOf(DomainNotFoundException.class); + verify(invitations, never()).save(any()); + } + + @Test + void ownerCanCreateInvitationForPersonalNamespaceRepresentedAsTeam() { + Namespace personal = new Namespace("personal", "Personal", "owner"); + try { + var id = Namespace.class.getDeclaredField("id"); + id.setAccessible(true); + id.set(personal, 8L); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + assertThat(personal.getType()).isEqualTo(NamespaceType.TEAM); + when(namespaces.findIdBySlug("personal")).thenReturn(Optional.of(8L)); + when(namespaces.findLockedById(8L)).thenReturn(Optional.of(personal)); + when(members.findByNamespaceIdAndUserId(8L, "owner")) + .thenReturn(Optional.of(new NamespaceMember(8L, "owner", NamespaceRole.OWNER))); + + assertThat(service.create("personal", "owner").token()).matches("[A-Za-z0-9_-]{43}"); + verify(invitations).save(any()); + } + + @Test + void revokedTokenCannotBePreviewed() { + assertThatThrownBy(() -> service.preview("A".repeat(43), "guest")) + .isInstanceOf(DomainNotFoundException.class) + .hasMessage("error.namespace.invitation.invalid"); + verify(invitations).findByTokenHash(any()); + } + + @Test + void acceptCreatesMemberAndConsumesExactlyTheCurrentInvitation() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation invitation = saved.getValue(); + when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation)); + when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); + namespaces.findById(7L).orElseThrow().setAvatarUrl("https://example.com/team.png"); + + NamespaceInvitationService.Preview preview = service.preview(created.token(), "guest"); + assertThat(preview.namespaceSlug()).isEqualTo("team"); + assertThat(preview.namespaceDisplayName()).isEqualTo("Team"); + assertThat(preview.namespaceAvatarUrl()).isEqualTo("https://example.com/team.png"); + assertThat(preview.outcome()).isEqualTo(NamespaceInvitationService.PreviewOutcome.CONFIRM_REQUIRED); + assertThat(preview.expiresAt()).isEqualTo(created.expiresAt()); + assertThat(service.accept(created.token(), "guest")) + .isEqualTo(new NamespaceInvitationService.Accepted("team", NamespaceInvitationService.AcceptOutcome.JOINED)); + + verify(members).save(argThat(member -> member.getRole() == NamespaceRole.MEMBER + && member.getNamespaceId().equals(7L) && member.getUserId().equals("guest"))); + assertThat(invitation.getConsumedBy()).isEqualTo("guest"); + assertThat(invitation.getConsumedAt()).isEqualTo(clock.instant()); + Optional guest = users.findById("guest"); + when(users.findLockedById("another")).thenReturn(guest); + when(users.findById("another")).thenReturn(guest); + assertThatThrownBy(() -> service.accept(created.token(), "another")) + .isInstanceOf(DomainNotFoundException.class); + } + + @Test + void nonActiveUserCannotPreviewOrAcceptAndDoesNotConsumeInvitation() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation invitation = saved.getValue(); + when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation)); + when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); + UserAccount pending = mock(UserAccount.class); + when(pending.getStatus()).thenReturn(UserStatus.PENDING); + when(users.findById("guest")).thenReturn(Optional.of(pending)); + when(users.findLockedById("guest")).thenReturn(Optional.of(pending)); + clearInvocations(invitations, members, audit); + + assertThatThrownBy(() -> service.preview(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + assertThatThrownBy(() -> service.accept(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(members, never()).save(any()); + verify(invitations, never()).save(any()); + assertThat(invitation.getConsumedAt()).isNull(); + } + + @Test + void existingMemberCanOpenCurrentConsumedInvitationWithoutReconsuming() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation invitation = saved.getValue(); + invitation.consume("someone", clock.instant()); + when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation)); + when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); + when(members.findByNamespaceIdAndUserId(7L, "guest")) + .thenReturn(Optional.of(new NamespaceMember(7L, "guest", NamespaceRole.MEMBER))); + + assertThat(service.preview(created.token(), "guest").outcome()) + .isEqualTo(NamespaceInvitationService.PreviewOutcome.ALREADY_MEMBER); + assertThat(service.accept(created.token(), "guest").outcome()) + .isEqualTo(NamespaceInvitationService.AcceptOutcome.ALREADY_MEMBER); + verify(members, never()).save(any()); + } + + @Test + void replacingCurrentInvitationMakesOldTokenInvalidAfterPreview() { + NamespaceInvitationService.Created first = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation invitation = saved.getValue(); + when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation)); + when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); + service.preview(first.token(), "guest"); + + assertThat(service.create("team", "owner", true).replaced()).isTrue(); + assertThatThrownBy(() -> service.accept(first.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(members, never()).save(any()); + } + + @Test + void revokeDeletesCurrentInvitationWithoutRemovingMembers() { + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(new NamespaceInvitation( + 7L, "a".repeat(64), "owner", clock.instant(), clock.instant().plusSeconds(86400)))); + service.revoke("team", "owner"); + verify(invitations).deleteByNamespaceId(7L); + verify(members, never()).deleteByNamespaceId(any()); + } + + @Test + void renewingConsumedInvitationDoesNotClaimToReplaceUsableLink() { + NamespaceInvitationService.Created first = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation old = saved.getValue(); + old.consume("guest", clock.instant()); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(old)); + + NamespaceInvitationService.Created renewed = service.create("team", "owner", false); + + assertThat(renewed.replaced()).isFalse(); + assertThat(renewed.token()).isNotEqualTo(first.token()); + } + + @Test + void nonMemberCannotPreviewOrAcceptAtExactExpiryInstant() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + NamespaceInvitation expired = invitationExpiringNow(); + when(invitations.findByTokenHash(expired.getTokenHash())).thenReturn(Optional.of(expired)); + when(invitations.findNamespaceIdByTokenHash(expired.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(expired)); + + assertThat(service.hasActiveInvitation("team", "owner")).isFalse(); + assertThatThrownBy(() -> service.preview(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + assertThatThrownBy(() -> service.accept(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(members, never()).save(any()); + assertThat(expired.getConsumedAt()).isNull(); + } + + @Test + void existingMemberCanPreviewAndAcceptCurrentExpiredInvitationWhileNamespaceFrozen() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + NamespaceInvitation expired = invitationExpiringNow(); + when(invitations.findByTokenHash(expired.getTokenHash())).thenReturn(Optional.of(expired)); + when(invitations.findNamespaceIdByTokenHash(expired.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(expired)); + when(members.findByNamespaceIdAndUserId(7L, "guest")) + .thenReturn(Optional.of(new NamespaceMember(7L, "guest", NamespaceRole.MEMBER))); + namespaces.findById(7L).orElseThrow().setStatus(NamespaceStatus.FROZEN); + clearInvocations(invitations, members, audit); + + assertThat(service.preview(created.token(), "guest").outcome()) + .isEqualTo(NamespaceInvitationService.PreviewOutcome.ALREADY_MEMBER); + assertThat(service.accept(created.token(), "guest").outcome()) + .isEqualTo(NamespaceInvitationService.AcceptOutcome.ALREADY_MEMBER); + verify(members, never()).save(any()); + verify(invitations, never()).save(any()); + assertThat(expired.getConsumedAt()).isNull(); + } + + @Test + void renewingAtExactExpiryDoesNotClaimToReplaceUsableLink() { + NamespaceInvitationService.Created first = service.create("team", "owner"); + NamespaceInvitation expired = invitationExpiringNow(); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(expired)); + + NamespaceInvitationService.Created renewed = service.create("team", "owner", false); + + assertThat(renewed.replaced()).isFalse(); + assertThat(renewed.token()).isNotEqualTo(first.token()); + } + + @Test + void historyStatusPreservesTerminalFactsAndUsesExactExpiryBoundary() { + var row = new NamespaceInvitationHistory(7L, "owner", "Owner", clock.instant().minusSeconds(86400), clock.instant()); + row.end("REPLACED", clock.instant()); + assertThat(row.status(clock.instant())).isEqualTo("EXPIRED"); + var used = new NamespaceInvitationHistory(7L, "owner", "Owner", clock.instant(), clock.instant().plusSeconds(86400)); + used.consume("guest", "Guest", clock.instant()); + used.end("REVOKED", clock.instant()); + assertThat(used.status(clock.instant().plusSeconds(100000))).isEqualTo("USED"); + } + + @Test + void historyRejectsMembersAndPlatformAdminsWithoutNamespaceManagementRole() { + assertThatThrownBy(() -> service.history("team", "guest", 0, 10)).isInstanceOf(DomainNotFoundException.class); + assertThatThrownBy(() -> service.history("team", "platform-admin", 0, 10)).isInstanceOf(DomainNotFoundException.class); + verify(histories, never()).findByNamespaceId(any(), any()); + } + + @Test + void inactiveManagerCannotReadHistoryAndFrozenNamespaceDoesNotRemoveReadPermission() { + var namespace = namespaces.findById(7L).orElseThrow(); + when(histories.findByNamespaceId(eq(7L), any())).thenReturn(org.springframework.data.domain.Page.empty()); + namespace.setStatus(NamespaceStatus.FROZEN); + assertThat(service.history("team", "owner", 0, 10)).isEmpty(); + namespace.setStatus(NamespaceStatus.ARCHIVED); + assertThat(service.history("team", "owner", 0, 10)).isEmpty(); + UserAccount inactive = mock(UserAccount.class); + when(inactive.getStatus()).thenReturn(UserStatus.MERGED); + when(users.findById("owner")).thenReturn(Optional.of(inactive)); + assertThatThrownBy(() -> service.history("team", "owner", 0, 10)).isInstanceOf(DomainNotFoundException.class); + } + + private NamespaceInvitation invitationExpiringNow() { + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + return new NamespaceInvitation(7L, saved.getValue().getTokenHash(), "owner", + clock.instant().minusSeconds(86400), clock.instant()); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java index 84d823c1..5d897a3d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.namespace; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -22,6 +23,10 @@ class NamespaceMemberServiceTest { private NamespaceService namespaceService; @Mock private NamespaceAccessPolicy namespaceAccessPolicy; + @Mock + private NamespaceRepository namespaceRepository; + @Mock + private UserAccountRepository userAccountRepository; @InjectMocks private NamespaceMemberService namespaceMemberService; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java index 23fa9e16..b2827094 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -42,6 +43,8 @@ class NamespaceServiceTest { @Mock private PromotionRequestRepository promotionRequestRepository; + @Mock + private UserAccountRepository userAccountRepository; @InjectMocks private NamespaceService namespaceService; @@ -218,7 +221,7 @@ class NamespaceServiceTest { Long namespaceId = 1L; String operatorUserId = "owner-1"; Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); - when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findLockedById(namespaceId)).thenReturn(Optional.of(namespace)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) @@ -238,7 +241,7 @@ class NamespaceServiceTest { Long namespaceId = 1L; String operatorUserId = "admin-1"; Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); - when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findLockedById(namespaceId)).thenReturn(Optional.of(namespace)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.ADMIN)).thenReturn(false); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) @@ -256,7 +259,7 @@ class NamespaceServiceTest { Long namespaceId = 1L; String operatorUserId = "owner-1"; Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); - when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findLockedById(namespaceId)).thenReturn(Optional.of(namespace)); when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) @@ -298,7 +301,7 @@ class NamespaceServiceTest { String owner = "owner-1"; Namespace namespace = new Namespace("draft-team", "Draft Team", owner); setField(namespace, "id", namespaceId); - when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findLockedById(namespaceId)).thenReturn(Optional.of(namespace)); when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, owner)) .thenReturn(Optional.of(new NamespaceMember(namespaceId, owner, NamespaceRole.OWNER))); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateTest.java new file mode 100644 index 00000000..2c65f209 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateTest.java @@ -0,0 +1,66 @@ +package com.iflytek.skillhub.domain.skill; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import java.time.Instant; + +class SkillAvatarCandidateTest { + @Test + void initialStatesCoverFourReviewPolicies() { + for (boolean machine : new boolean[] { false, true }) { + for (boolean manual : new boolean[] { false, true }) { + SkillAvatarCandidate candidate = new SkillAvatarCandidate(10L, "owner", "avatars/one", "abc", + SkillVisibility.PUBLIC, machine, manual, machine ? "xfyun" : null); + assertThat(candidate.getMachineStatus()).isEqualTo(machine + ? SkillAvatarCandidate.MachineStatus.PENDING : SkillAvatarCandidate.MachineStatus.SKIPPED); + assertThat(candidate.getReviewStatus()).isEqualTo(manual + ? SkillAvatarCandidate.ReviewStatus.PENDING : SkillAvatarCandidate.ReviewStatus.NOT_REQUIRED); + } + } + } + + @Test + void supersededCandidateCannotBeActivatedByLateMachineResult() { + SkillAvatarCandidate candidate = new SkillAvatarCandidate(10L, "owner", "avatars/one", "abc", + SkillVisibility.PUBLIC, true, false, "xfyun"); + candidate.supersede(); + + assertThat(candidate.getStatus()).isEqualTo(SkillAvatarCandidate.Status.SUPERSEDED); + assertThat(candidate.getDiscardAt()).isNotNull(); + assertThatThrownBy(() -> candidate.machineResult(SkillAvatarCandidate.MachineStatus.PASSED)) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(candidate::activate).isInstanceOf(IllegalStateException.class); + } + + @Test + void blockedMachineOnlyCandidateCanRequestManualAppeal() { + SkillAvatarCandidate candidate = new SkillAvatarCandidate(10L, "owner", "avatars/one", "abc", + SkillVisibility.PUBLIC, true, false, "aliyun"); + candidate.machineResult(SkillAvatarCandidate.MachineStatus.BLOCKED); + candidate.appeal(); + candidate.approve("reviewer", "false positive"); + candidate.activate(); + + assertThat(candidate.getStatus()).isEqualTo(SkillAvatarCandidate.Status.ACTIVE); + assertThat(candidate.getReviewedBy()).isEqualTo("reviewer"); + } + + @Test + void errorEnforcesOneHourCooldownBeforeManualOrAutomaticRetry() { + SkillAvatarCandidate candidate = new SkillAvatarCandidate(10L, "owner", "avatars/one", "abc", + SkillVisibility.PUBLIC, true, false, "xfyun"); + Instant now = Instant.now(); + assertThat(candidate.claimMachineAttempt("first", now, now.plusSeconds(600))).isTrue(); + assertThat(candidate.completeMachineAttempt("first", SkillAvatarCandidate.MachineStatus.ERROR)).isTrue(); + assertThat(candidate.getMachineAttemptCount()).isEqualTo(1); + assertThat(candidate.canRetryMachine(now.plusSeconds(3599))).isFalse(); + assertThat(candidate.claimMachineAttempt("too-early", now.plusSeconds(3599), now.plusSeconds(4199))) + .isFalse(); + Instant due = candidate.getMachineNextRetryAt(); + assertThat(candidate.canRetryMachine(due)).isTrue(); + assertThat(candidate.claimMachineAttempt("second", due, due.plusSeconds(600))).isTrue(); + assertThat(candidate.getMachineAttemptCount()).isEqualTo(2); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java index c500570e..56241774 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java @@ -64,6 +64,8 @@ class SkillHardDeleteServiceTest { @Mock private SkillVersionStatsRepository skillVersionStatsRepository; @Mock + private com.iflytek.skillhub.domain.skill.SkillAvatarCandidateRepository avatarCandidates; + @Mock private ObjectStorageService objectStorageService; @Mock private SkillStorageDeletionCompensationService compensationService; @@ -94,6 +96,7 @@ class SkillHardDeleteServiceTest { skillRatingRepository, skillReportRepository, skillVersionStatsRepository, + avatarCandidates, objectStorageService, compensationService, securityScanService, diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationHistoryJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationHistoryJpaRepository.java new file mode 100644 index 00000000..8a317c59 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationHistoryJpaRepository.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationHistory; +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationHistoryRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface NamespaceInvitationHistoryJpaRepository extends JpaRepository, NamespaceInvitationHistoryRepository { + @Override + java.util.Optional findById(String id); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java new file mode 100644 index 00000000..b305ef6e --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationJpaRepository.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.namespace.NamespaceInvitation; +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface NamespaceInvitationJpaRepository extends JpaRepository, NamespaceInvitationRepository { + Optional findByTokenHash(String tokenHash); + @Query("select invitation.namespaceId from NamespaceInvitation invitation where invitation.tokenHash = :tokenHash") + Optional findNamespaceIdByTokenHash(@Param("tokenHash") String tokenHash); + void deleteByNamespaceId(Long namespaceId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index ed9a6724..fd112bf3 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -6,6 +6,10 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import jakarta.persistence.LockModeType; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -17,9 +21,16 @@ import java.util.Optional; @Repository public interface NamespaceJpaRepository extends JpaRepository, NamespaceRepository { + @Override + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select n from Namespace n where n.id = :id") + Optional findLockedById(@Param("id") Long id); List findByIdIn(List ids); List findBySlugIn(List slugs); Page findByIdIn(List ids, Pageable pageable); Optional findBySlug(String slug); + @Override + @Query("select n.id from Namespace n where n.slug = :slug") + Optional findIdBySlug(@Param("slug") String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java index 02348701..6f25b6e6 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java @@ -22,6 +22,8 @@ import java.util.Optional; @Repository public interface ReviewTaskJpaRepository extends JpaRepository, ReviewTaskRepository { + List findByAvatarCandidateIdAndStatus(Long candidateId, ReviewTaskStatus status); + Optional findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status); @Override diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java new file mode 100644 index 00000000..c59d7849 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidateRepository; +import java.time.Instant; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface SkillAvatarCandidateJpaRepository extends JpaRepository, SkillAvatarCandidateRepository { + List findBySkillIdOrderByCreatedAtDesc(Long skillId); + boolean existsBySkillIdAndAudienceAndScopeReviewVersionIdAndStatus(Long skillId, + SkillVisibility audience, Long versionId, + SkillAvatarCandidate.Status status); + List findByStatusAndReviewStatusOrderByCreatedAtAsc( + SkillAvatarCandidate.Status status, SkillAvatarCandidate.ReviewStatus reviewStatus); + List findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(Instant before); + + @Query(""" + SELECT c FROM SkillAvatarCandidate c, Skill s + WHERE c.id > :afterId AND s.id = c.skillId AND s.pendingAvatarId = c.id + AND c.scopeReview = false AND c.status = :pendingStatus + AND ((c.machineStatus = :pendingMachine AND c.createdAt <= :staleBefore) + OR (c.machineStatus = :processingMachine AND c.machineLeaseUntil <= :now)) + ORDER BY c.id ASC + """) + List findAbandonedMachineCandidates(@Param("afterId") Long afterId, + @Param("pendingStatus") SkillAvatarCandidate.Status pendingStatus, + @Param("pendingMachine") SkillAvatarCandidate.MachineStatus pendingMachine, + @Param("processingMachine") SkillAvatarCandidate.MachineStatus processingMachine, + @Param("staleBefore") Instant staleBefore, @Param("now") Instant now, Pageable pageable); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarUploadJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarUploadJpaRepository.java new file mode 100644 index 00000000..acd51a34 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarUploadJpaRepository.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.skill.SkillAvatarUpload; +import jakarta.persistence.LockModeType; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface SkillAvatarUploadJpaRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM SkillAvatarUpload u WHERE u.id = :id") + Optional findLockedById(@Param("id") String id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM SkillAvatarUpload u WHERE u.candidateId IS NULL AND u.objectKey IS NOT NULL AND u.expiresAt <= :now ORDER BY u.expiresAt") + List findExpired(@Param("now") Instant now, Pageable page); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java index c9e623e2..c75e3d51 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java @@ -3,9 +3,12 @@ package com.iflytek.skillhub.infra.jpa; 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.SkillVisibility; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -14,13 +17,49 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; +import java.time.Instant; +import jakarta.persistence.LockModeType; /** * Base Spring Data JPA repository for persisted skill aggregates and common skill queries. */ @Repository public interface SkillJpaRepository extends JpaRepository, SkillRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM Skill s WHERE s.id = :id") + Optional findLockedById(@Param("id") Long id); List findByIdIn(List ids); + @Query(""" + SELECT s FROM Skill s WHERE s.id > :afterId AND s.status = :activeStatus AND s.activeAvatarId IS NOT NULL + AND ((s.activeAvatarAudience = :privateVisibility + AND s.visibility IN (:teamVisibility, :publicVisibility)) + OR (s.activeAvatarAudience = :teamVisibility AND s.visibility = :publicVisibility)) + AND NOT EXISTS (SELECT c.id FROM SkillAvatarCandidate c WHERE c.id = s.pendingAvatarId + AND c.audience = s.visibility AND c.status = :pendingStatus + AND (c.machineStatus NOT IN (:pendingMachine, :processingMachine, :errorMachine) + OR (c.machineStatus = :pendingMachine AND c.createdAt > :staleBefore) + OR (c.machineStatus = :processingMachine AND c.machineLeaseUntil > :now) + OR (c.machineStatus = :errorMachine AND (c.machineAttemptCount >= :maxAutoAttempts + OR c.machineNextRetryAt > :now)))) + AND NOT EXISTS (SELECT c.id FROM SkillAvatarCandidate c WHERE c.skillId = s.id + AND c.audience = s.visibility AND c.scopeReviewVersionId = s.latestVersionId + AND c.status = :rejectedStatus) + ORDER BY s.id ASC + """) + List findScopeReviewRecoveryCandidates(@Param("afterId") Long afterId, + @Param("activeStatus") SkillStatus activeStatus, + @Param("privateVisibility") SkillVisibility privateVisibility, + @Param("teamVisibility") SkillVisibility teamVisibility, + @Param("publicVisibility") SkillVisibility publicVisibility, + @Param("pendingStatus") SkillAvatarCandidate.Status pendingStatus, + @Param("pendingMachine") SkillAvatarCandidate.MachineStatus pendingMachine, + @Param("processingMachine") SkillAvatarCandidate.MachineStatus processingMachine, + @Param("errorMachine") SkillAvatarCandidate.MachineStatus errorMachine, + @Param("maxAutoAttempts") int maxAutoAttempts, + @Param("rejectedStatus") SkillAvatarCandidate.Status rejectedStatus, + @Param("staleBefore") Instant staleBefore, + @Param("now") Instant now, + Pageable pageable); List findByNamespaceIdAndSlug(Long namespaceId, String slug); List findByNamespaceIdInAndSlugIn(List namespaceIds, List slugs); Optional findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId); diff --git a/web/e2e/batch-import-members.spec.ts b/web/e2e/batch-import-members.spec.ts index 72cb7d4b..add14671 100644 --- a/web/e2e/batch-import-members.spec.ts +++ b/web/e2e/batch-import-members.spec.ts @@ -50,7 +50,9 @@ test.describe('Batch Import Members (Real API)', () => { } await page.goto(`/dashboard/namespaces/${namespace.slug}/members`) - await expect(page.getByRole('heading', { name: 'Member Management' })).toBeVisible() + await expect(page.getByRole('heading', { name: namespace.displayName, exact: true })).toBeVisible() + await expect(page.getByRole('navigation', { name: 'Space management' }) + .getByRole('link', { name: 'Members', exact: true })).toHaveAttribute('aria-current', 'page') const batchButton = page.getByRole('button', { name: 'Batch Import' }) await expect(batchButton).toBeVisible() diff --git a/web/e2e/dashboard-routes.spec.ts b/web/e2e/dashboard-routes.spec.ts index 57604d96..24caf056 100644 --- a/web/e2e/dashboard-routes.spec.ts +++ b/web/e2e/dashboard-routes.spec.ts @@ -43,6 +43,8 @@ test.describe('Dashboard Routes (Real API)', () => { await expect(page.getByRole('heading', { name: 'Namespace not found' })).toBeVisible() await page.goto('/dashboard/namespaces/e2e-missing-namespace/reviews') - await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() + const reviewSection = page.getByRole('region', { name: 'Namespace Reviews' }) + await expect(reviewSection).toBeVisible() + await expect(reviewSection.getByText('No review records', { exact: true })).toBeVisible() }) }) diff --git a/web/e2e/my-namespaces-data.spec.ts b/web/e2e/my-namespaces-data.spec.ts index 28284603..24d5798e 100644 --- a/web/e2e/my-namespaces-data.spec.ts +++ b/web/e2e/my-namespaces-data.spec.ts @@ -1,13 +1,31 @@ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' import { E2eTestDataBuilder } from './helpers/test-data-builder' +import { loginWithCredentials } from './helpers/session' + +async function findNamespaceCard(page: Page, slug: string) { + await page.goto('/dashboard/namespaces') + await expect(page.getByRole('heading', { name: 'My Namespaces' })).toBeVisible() + const card = page.getByTestId(`namespace-card-${slug}`) + const next = page.getByRole('button', { name: 'Next', exact: true }) + const firstCard = page.locator('[data-testid^="namespace-card-"]').first() + await expect(firstCard).toBeVisible() + while (!(await card.isVisible()) && await next.count() > 0 && await next.isEnabled()) { + const previousId = await firstCard.getAttribute('data-testid') + await next.click() + await expect(firstCard).not.toHaveAttribute('data-testid', previousId!) + } + await expect(card.getByText(`@${slug}`, { exact: true })).toBeVisible() + return card +} test.describe('My Namespaces Data (Real API)', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) - await page.context().setExtraHTTPHeaders({ - 'X-Mock-User-Id': 'local-admin', - }) + await loginWithCredentials(page, { + username: process.env.E2E_ADMIN_USERNAME ?? process.env.BOOTSTRAP_ADMIN_USERNAME ?? 'admin', + password: process.env.E2E_ADMIN_PASSWORD ?? process.env.BOOTSTRAP_ADMIN_PASSWORD ?? 'ChangeMe!2026', + }, testInfo) }) test('shows namespace created by request helper', async ({ page }, testInfo) => { @@ -15,11 +33,8 @@ test.describe('My Namespaces Data (Real API)', () => { await builder.init() try { - const namespace = await builder.ensureWritableNamespace() - await page.goto('/dashboard/namespaces') - - await expect(page.getByRole('heading', { name: 'My Namespaces' })).toBeVisible() - await expect(page.getByText(`@${namespace.slug}`)).toBeVisible() + const namespace = await builder.createNamespace('e2e-list') + await findNamespaceCard(page, namespace.slug) } finally { await builder.cleanup() } @@ -32,11 +47,12 @@ test.describe('My Namespaces Data (Real API)', () => { try { const namespace = await builder.createNamespace('e2e-delete') - await page.goto('/dashboard/namespaces') - - const namespaceCard = page.getByTestId(`namespace-card-${namespace.slug}`) - await expect(namespaceCard.getByText(`@${namespace.slug}`)).toBeVisible() + const namespaceCard = await findNamespaceCard(page, namespace.slug) + await namespaceCard.getByRole('button', { name: 'Enter space', exact: true }).click() + await page.getByRole('navigation', { name: 'Space management' }) + .getByRole('link', { name: 'Settings', exact: true }).click() + await expect(page.getByRole('heading', { name: 'Space lifecycle', exact: true })).toBeVisible() await page.getByTestId(`delete-namespace-${namespace.slug}`).click() await expect(page.getByTestId('namespace-action-dialog-delete')).toBeVisible() @@ -48,7 +64,8 @@ test.describe('My Namespaces Data (Real API)', () => { const deleteResponse = await deleteResponsePromise expect(deleteResponse.ok()).toBeTruthy() - await expect(page.getByText(`@${namespace.slug}`)).toHaveCount(0) + await expect(page).toHaveURL(/\/dashboard\/namespaces$/) + await expect(page.getByTestId(`namespace-card-${namespace.slug}`)).toHaveCount(0) } finally { await builder.cleanup() } @@ -66,10 +83,11 @@ test.describe('My Namespaces Data (Real API)', () => { description: 'Prevents namespace deletion during Playwright validation', }) - await page.goto('/dashboard/namespaces') - - const namespaceCard = page.getByTestId(`namespace-card-${namespace.slug}`) - await expect(namespaceCard.getByText(`@${namespace.slug}`)).toBeVisible() + const namespaceCard = await findNamespaceCard(page, namespace.slug) + await namespaceCard.getByRole('button', { name: 'Enter space', exact: true }).click() + await page.getByRole('navigation', { name: 'Space management' }) + .getByRole('link', { name: 'Settings', exact: true }).click() + await expect(page.getByRole('heading', { name: 'Space lifecycle', exact: true })).toBeVisible() await expect(page.getByTestId(`delete-namespace-${namespace.slug}`)).toHaveCount(0) } finally { await builder.cleanup() diff --git a/web/e2e/namespace-members-data.spec.ts b/web/e2e/namespace-members-data.spec.ts index c3ac91c7..d6c4ac8a 100644 --- a/web/e2e/namespace-members-data.spec.ts +++ b/web/e2e/namespace-members-data.spec.ts @@ -1,12 +1,15 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' -import { registerSession } from './helpers/session' +import { loginWithCredentials } from './helpers/session' import { E2eTestDataBuilder } from './helpers/test-data-builder' test.describe('Namespace Members Data (Real API)', () => { test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) - await registerSession(page, testInfo) + await loginWithCredentials(page, { + username: process.env.E2E_ADMIN_USERNAME ?? process.env.BOOTSTRAP_ADMIN_USERNAME ?? 'admin', + password: process.env.E2E_ADMIN_PASSWORD ?? process.env.BOOTSTRAP_ADMIN_PASSWORD ?? 'ChangeMe!2026', + }, testInfo) }) test('opens members management for writable namespace', async ({ page }, testInfo) => { @@ -14,12 +17,16 @@ test.describe('Namespace Members Data (Real API)', () => { await builder.init() try { - const namespace = await builder.ensureWritableNamespace() + const namespace = await builder.createNamespace('e2e-members') + expect(namespace.type).toBe('TEAM') await page.goto(`/dashboard/namespaces/${namespace.slug}/members`) - await expect(page.getByRole('heading', { name: 'Member Management' })).toBeVisible() - await expect(page.getByText(`@${namespace.slug}`).first()).toBeVisible() + await expect(page.getByRole('heading', { name: namespace.displayName, exact: true })).toBeVisible() + await expect(page.getByRole('navigation', { name: 'Space management' }) + .getByRole('link', { name: 'Members', exact: true })).toHaveAttribute('aria-current', 'page') + await expect(page.getByText(`@${namespace.slug}`, { exact: true })).toBeVisible() + await expect(page.getByRole('table')).toBeVisible() } finally { await builder.cleanup() } diff --git a/web/e2e/namespace-review-detail-access.spec.ts b/web/e2e/namespace-review-detail-access.spec.ts index 1daac06d..98c6279b 100644 --- a/web/e2e/namespace-review-detail-access.spec.ts +++ b/web/e2e/namespace-review-detail-access.spec.ts @@ -16,7 +16,10 @@ test.describe('Namespace Review Detail Access (Real API)', () => { await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`) - await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() + await expect(page.getByRole('heading', { name: seeded.namespace.displayName, exact: true })).toBeVisible() + await expect(page.getByRole('navigation', { name: 'Space management' }) + .getByRole('link', { name: 'Reviews', exact: true })).toHaveAttribute('aria-current', 'page') + await expect(page.getByRole('region', { name: 'Namespace Reviews' })).toBeVisible() await expect(page.getByText(`${seeded.namespace.slug}/${seeded.skill.slug}`)).toBeVisible() await page.getByRole('link', { name: 'Open review' }).first().click() @@ -37,7 +40,12 @@ test.describe('Namespace Review Detail Access (Real API)', () => { await page.goto('/dashboard/reviews') await expect(page).toHaveURL(/\/dashboard\/namespaces\/.+\/reviews$/) - await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() + const selectedSlug = decodeURIComponent(new URL(page.url()).pathname.split('/')[3]) + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await expect(page.getByText(`@${selectedSlug}`, { exact: true })).toBeVisible() + await expect(page.getByRole('navigation', { name: 'Space management' }) + .getByRole('link', { name: 'Reviews', exact: true })).toHaveAttribute('aria-current', 'page') + await expect(page.getByRole('region', { name: 'Namespace Reviews' })).toBeVisible() } finally { await seeded?.cleanup() } diff --git a/web/e2e/namespace-reviews-data.spec.ts b/web/e2e/namespace-reviews-data.spec.ts index b2cad5c6..9e929a2c 100644 --- a/web/e2e/namespace-reviews-data.spec.ts +++ b/web/e2e/namespace-reviews-data.spec.ts @@ -15,8 +15,12 @@ test.describe('Namespace Reviews Data (Real API)', () => { seeded = await createNamespaceReviewData(browser, page, testInfo) await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`) - await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() - await expect(page.getByText(`Review tasks for ${seeded.namespace.displayName}`)).toBeVisible() + await expect(page.getByRole('heading', { name: seeded.namespace.displayName, exact: true })).toBeVisible() + await expect(page.getByRole('navigation', { name: 'Space management' }) + .getByRole('link', { name: 'Reviews', exact: true })).toHaveAttribute('aria-current', 'page') + await expect(page.getByRole('region', { name: 'Namespace Reviews' })).toBeVisible() + await expect(page.getByText(`${seeded.namespace.slug}/${seeded.skill.slug}`, { exact: true })).toBeVisible() + await expect(page.getByRole('combobox', { name: 'Review status' })).toHaveText('Pending') } finally { await seeded?.cleanup() } diff --git a/web/e2e/namespace-skill-pagination.spec.ts b/web/e2e/namespace-skill-pagination.spec.ts index 64e61e09..52c56838 100644 --- a/web/e2e/namespace-skill-pagination.spec.ts +++ b/web/e2e/namespace-skill-pagination.spec.ts @@ -1,12 +1,15 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' -import { registerSession } from './helpers/session' +import { loginWithCredentials } from './helpers/session' import { E2eTestDataBuilder } from './helpers/test-data-builder' test.describe('Namespace Skill List Pagination (Real API)', () => { test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) - await registerSession(page, testInfo) + await loginWithCredentials(page, { + username: process.env.E2E_ADMIN_USERNAME ?? process.env.BOOTSTRAP_ADMIN_USERNAME ?? 'admin', + password: process.env.E2E_ADMIN_PASSWORD ?? process.env.BOOTSTRAP_ADMIN_PASSWORD ?? 'ChangeMe!2026', + }, testInfo) }) test('shows namespace page with skills and no pagination when under 20 skills', async ({ page }, testInfo) => { @@ -14,17 +17,28 @@ test.describe('Namespace Skill List Pagination (Real API)', () => { await builder.init() try { - const namespace = await builder.ensureWritableNamespace() + const namespace = await builder.createNamespace('e2e-pagination-small') await builder.publishSkill(namespace.slug) + const searchResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()) + return url.pathname === '/api/web/skills' + && url.searchParams.get('namespace') === namespace.slug + }) await page.goto(`/space/${namespace.slug}`) + const searchResponse = await searchResponsePromise + expect(searchResponse.ok()).toBeTruthy() + const search: { data: { total: number } } = await searchResponse.json() + expect(search.data.total).toBeLessThanOrEqual(20) await expect(page.getByText(`@${namespace.slug}`).first()).toBeVisible() await expect(page.getByRole('heading', { name: 'Skills', exact: true })).toBeVisible() - // Verify pagination controls do not appear when there are fewer than 20 skills - await expect(page.getByRole('button', { name: 'Previous' })).toHaveCount(0) - await expect(page.getByRole('button', { name: 'Next' })).toHaveCount(0) + // Fresh namespace keeps this fixture independent of previous tests. + const skillList = page.getByRole('heading', { name: 'Skills', exact: true }).locator('..') + await expect(skillList.getByRole('navigation', { name: 'Pagination', exact: true })).toHaveCount(0) + await expect(skillList.getByRole('button', { name: 'Previous', exact: true })).toHaveCount(0) + await expect(skillList.getByRole('button', { name: 'Next', exact: true })).toHaveCount(0) } finally { await builder.cleanup() } @@ -35,7 +49,7 @@ test.describe('Namespace Skill List Pagination (Real API)', () => { await builder.init() try { - const namespace = await builder.ensureWritableNamespace() + const namespace = await builder.createNamespace('e2e-pagination-many') // Build a fake skill list to simulate >20 skills without hitting rate limits. const fakeSkill = (i: number) => ({ @@ -77,8 +91,10 @@ test.describe('Namespace Skill List Pagination (Real API)', () => { await expect(page.getByRole('heading', { name: 'Skills', exact: true })).toBeVisible() // Verify pagination controls appear - const previousButton = page.getByRole('button', { name: 'Previous' }).first() - const nextButton = page.getByRole('button', { name: 'Next' }).first() + const skillList = page.getByRole('heading', { name: 'Skills', exact: true }).locator('..') + const pagination = skillList.getByRole('navigation', { name: 'Pagination', exact: true }) + const previousButton = pagination.getByRole('button', { name: 'Previous', exact: true }) + const nextButton = pagination.getByRole('button', { name: 'Next', exact: true }) await expect(previousButton).toBeVisible() await expect(nextButton).toBeVisible() @@ -87,16 +103,22 @@ test.describe('Namespace Skill List Pagination (Real API)', () => { await expect(previousButton).toBeDisabled() await expect(nextButton).toBeEnabled() + await expect(skillList.getByText('Fake Skill 0', { exact: true })).toBeVisible() // Navigate to second page await nextButton.click() // Second page: Previous should be enabled await expect(previousButton).toBeEnabled() + await expect(nextButton).toBeDisabled() + await expect(skillList.getByText('Fake Skill 20', { exact: true })).toBeVisible() + await expect(skillList.getByText('Fake Skill 0', { exact: true })).toHaveCount(0) + await expect(pagination.getByRole('button', { name: 'Go to page 2', exact: true })).toHaveAttribute('aria-current', 'page') // Navigate back to first page await previousButton.click() await expect(previousButton).toBeDisabled() await expect(nextButton).toBeEnabled() + await expect(skillList.getByText('Fake Skill 0', { exact: true })).toBeVisible() } finally { await builder.cleanup() } diff --git a/web/package.json b/web/package.json index d131b5e4..422cc977 100644 --- a/web/package.json +++ b/web/package.json @@ -58,6 +58,7 @@ "react-dom": "^19.0.0", "react-dropzone": "^15.0.0", "react-i18next": "^16.5.8", + "react-image-crop": "11.1.2", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", "rehype-sanitize": "^6.0.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a14647c0..2a4957f4 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: react-i18next: specifier: ^16.5.8 version: 16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + react-image-crop: + specifier: 11.1.2 + version: 11.1.2(react@19.2.4) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -2915,6 +2918,11 @@ packages: typescript: optional: true + react-image-crop@11.1.2: + resolution: {integrity: sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==} + peerDependencies: + react: '>=16.13.1' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6461,6 +6469,10 @@ snapshots: react-dom: 19.2.4(react@19.2.4) typescript: 5.9.3 + react-image-crop@11.1.2(react@19.2.4): + dependencies: + react: 19.2.4 + react-is@16.13.1: {} react-is@17.0.2: {} diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index da659024..c5ef67ab 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -172,6 +172,39 @@ describe('namespaceApi.delete', () => { }) }) +describe('namespace invitation API', () => { + it('uses the Issue #911 plural paths for status, create, revoke, preview, and accept', async () => { + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { cookie: 'XSRF-TOKEN=test-token' }, + }) + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ code: 0, msg: 'ok', data: {}, timestamp: '', requestId: '' }), + }) + vi.stubGlobal('fetch', fetchMock) + + await namespaceApi.invitationHistory('@team', 2, 10) + await namespaceApi.invitationStatus('@team') + await namespaceApi.createInvitation('@team', false) + await namespaceApi.revokeInvitation('@team') + await namespaceApi.previewInvitation('raw-token') + await namespaceApi.acceptInvitation('raw-token') + + expect(fetchMock.mock.calls.map(([url, init]) => [url, init.method ?? 'GET'])).toEqual([ + ['/api/web/namespaces/team/invitations/history?page=2&size=10', 'GET'], + ['/api/web/namespaces/team/invitations', 'GET'], + ['/api/web/namespaces/team/invitations', 'POST'], + ['/api/web/namespaces/team/invitations', 'DELETE'], + ['/api/web/namespace-invitations/preview', 'POST'], + ['/api/web/namespace-invitations/accept', 'POST'], + ]) + expect(JSON.parse(fetchMock.mock.calls[2][1].body)).toEqual({ replace: false }) + expect(JSON.parse(fetchMock.mock.calls[4][1].body)).toEqual({ token: 'raw-token' }) + }) +}) + describe('getDirectAuthRuntimeConfig', () => { it('returns disabled when no runtime config is present', () => { const config = getDirectAuthRuntimeConfig() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 09f2fd9d..2ab2f06f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,6 +1,6 @@ import createClient from 'openapi-fetch' import { BASE_PATH } from '@/shared/lib/base-path' -import type { paths } from './generated/schema' +import type { components, paths } from './generated/schema' import type { ChangePasswordRequest, PasswordResetConfirmRequest, @@ -36,6 +36,8 @@ import type { AuthMethod, LocalAuthCapabilities, SystemAuthSettings, + AvatarReviewSettings, + SkillAvatarStatus, ExternalRoleGrantRule, PlatformRole, OAuthProvider, @@ -46,6 +48,7 @@ import type { Namespace, CreateNamespaceRequest, NamespaceMember, + NamespaceInvitationHistoryPage, NamespaceCandidateUser, NotificationItem, NotificationPreferenceItem, @@ -459,6 +462,17 @@ export const authApi = { } export const systemConfigApi = { + getAvatarReview(): Promise { + return fetchJson('/api/v1/admin/system-config/avatar-review') + }, + + updateAvatarReview(request: Pick): Promise { + return fetchJson('/api/v1/admin/system-config/avatar-review', { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(request), + }) + }, getLocalAuth(): Promise { return fetchJson('/api/v1/admin/system-config/auth/local') }, @@ -503,6 +517,64 @@ export const systemConfigApi = { }, } +function avatarPath(namespace: string, slug: string): string { + return `/api/v1/skills/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/avatar` +} + +export const skillAvatarApi = { + stage(file: Blob): Promise { + const body = new FormData() + body.append('file', file, 'avatar.png') + return fetchJson(`${WEB_API_PREFIX}/skills/avatar-uploads`, { + method: 'POST', headers: getCsrfHeaders(), body, + }) + }, + submit(namespace: string, slug: string, uploadId: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/skills/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/avatar`, { + method: 'POST', headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ uploadId }), + }) + }, + status(namespace: string, slug: string): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/status`) + }, + upload(namespace: string, slug: string, file: Blob): Promise { + const body = new FormData() + body.append('file', file, 'avatar.png') + return fetchJson(avatarPath(namespace, slug), { + method: 'POST', headers: getCsrfHeaders(), body, + }) + }, + delete(namespace: string, slug: string): Promise { + return fetchJson(avatarPath(namespace, slug), { method: 'DELETE', headers: getCsrfHeaders() }) + }, + appeal(namespace: string, slug: string, candidateId: number): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/candidates/${candidateId}/appeal`, { + method: 'POST', headers: getCsrfHeaders(), + }) + }, + retry(namespace: string, slug: string, candidateId: number): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/candidates/${candidateId}/retry`, { + method: 'POST', headers: getCsrfHeaders(), + }) + }, + queue(): Promise { + return fetchJson('/api/v1/skills/avatar-review/queue') + }, + decide(namespace: string, slug: string, candidateId: number, approved: boolean, reason: string): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/candidates/${candidateId}/decision`, { + method: 'POST', headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ approved, reason }), + }) + }, + takedown(namespace: string, slug: string, reason: string): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/takedown`, { + method: 'POST', headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ reason }), + }) + }, +} + export const accountApi = { async initiateMerge(request: MergeInitiateRequest): Promise { return fetchJson('/api/v1/account/merge/initiate', { @@ -747,6 +819,42 @@ export const labelApi = { } export const namespaceApi = { + async invitationHistory(slug: string, page = 0, size = 10): Promise { + const params = new URLSearchParams({ page: String(page), size: String(size) }) + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitations/history?${params}`) + }, + + async invitationStatus(slug: string): Promise<{ active: boolean }> { + return fetchJson<{ active: boolean }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitations`) + }, + + async createInvitation(slug: string, replace: boolean): Promise<{ token: string; expiresAt: string; replaced: boolean }> { + return fetchJson<{ token: string; expiresAt: string; replaced: boolean }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitations`, { + method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ replace }), + }) + }, + + async revokeInvitation(slug: string): Promise { + await fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitations`, { + method: 'DELETE', headers: await ensureCsrfHeaders(), + }) + }, + + async previewInvitation(token: string): Promise<{ namespaceSlug: string; namespaceDisplayName: string; namespaceAvatarUrl: string | null; outcome: 'CONFIRM_REQUIRED' | 'ALREADY_MEMBER'; expiresAt: string }> { + return fetchJson<{ namespaceSlug: string; namespaceDisplayName: string; namespaceAvatarUrl: string | null; outcome: 'CONFIRM_REQUIRED' | 'ALREADY_MEMBER'; expiresAt: string }>(`${WEB_API_PREFIX}/namespace-invitations/preview`, { + method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ token }), + }) + }, + + async acceptInvitation(token: string): Promise<{ namespaceSlug: string; outcome: 'JOINED' | 'ALREADY_MEMBER' }> { + return fetchJson<{ namespaceSlug: string; outcome: 'JOINED' | 'ALREADY_MEMBER' }>(`${WEB_API_PREFIX}/namespace-invitations/accept`, { + method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ token }), + }) + }, + async create(request: CreateNamespaceRequest): Promise { const namespace = await fetchJson('/api/v1/namespaces', { method: 'POST', diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index f03228da..a52c6278 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -528,6 +528,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/system-config/avatar-review": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["current_2"]; + put: operations["update"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/system-config/auth/local": { parameters: { query?: never; @@ -1394,6 +1410,134 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/skills/{namespace}/{slug}/avatar/takedown": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["takedown"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/avatar/takedown": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["takedown_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/skills/{namespace}/{slug}/avatar/candidates/{candidateId}/retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["retry"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/avatar/candidates/{candidateId}/retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["retry_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/skills/{namespace}/{slug}/avatar/candidates/{candidateId}/appeal": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["appeal"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/avatar/candidates/{candidateId}/appeal": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["appeal_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/skills/{namespace}/{slug}/avatar": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["current"]; + put?: never; + post: operations["submit_2"]; + delete: operations["delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/avatar": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["current_1"]; + put?: never; + post: operations["submit_1_1"]; + delete: operations["delete_1"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{namespace}/{slug}/archive": { parameters: { query?: never; @@ -1458,6 +1602,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/skills/avatar-uploads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["upload"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/avatar-uploads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["upload_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/reviews/{id}/withdraw": { parameters: { query?: never; @@ -1970,6 +2146,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/namespaces/{slug}/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["status_2"]; + put?: never; + post: operations["create"]; + delete: operations["revoke"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespaces/{slug}/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["status_3"]; + put?: never; + post: operations["create_1"]; + delete: operations["revoke_1"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/namespaces/{slug}/freeze": { parameters: { query?: never; @@ -2066,6 +2274,70 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/namespace-invitations/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["preview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespace-invitations/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["preview_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/namespace-invitations/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["accept"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/namespace-invitations/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["accept_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/governance/notifications/{id}/read": { parameters: { query?: never; @@ -2213,7 +2485,7 @@ export interface paths { }; get: operations["list_4"]; put?: never; - post: operations["create"]; + post: operations["create_2"]; delete?: never; options?: never; head?: never; @@ -3691,6 +3963,70 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/skills/{namespace}/{slug}/avatar/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/skills/{namespace}/{slug}/avatar/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["status_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/avatar/candidates/{candidateId}/image": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["candidate"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/skills/{namespace}/{slug}/avatar/candidates/{candidateId}/image": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["candidate_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{namespace}/{slug}": { parameters: { query?: never; @@ -3723,6 +4059,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/skills/avatar-uploads/{uploadId}/image": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["uploadPreview"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/avatar-uploads/{uploadId}/image": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["uploadPreview_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/reviews/{id}/skill-detail": { parameters: { query?: never; @@ -4365,6 +4733,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/namespaces/{slug}/invitations/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["history_2"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/namespaces/{slug}/invitations/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["history_3"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/me/suites/workspace": { parameters: { query?: never; @@ -5595,7 +5995,7 @@ export interface paths { get?: never; put?: never; post?: never; - delete: operations["revoke"]; + delete: operations["revoke_2"]; options?: never; head?: never; patch?: never; @@ -6030,6 +6430,29 @@ export interface components { /** Format: date-time */ updatedAt?: string; }; + AvatarReviewSettingsUpdateRequest: { + machineEnabled?: boolean; + manualEnabled?: boolean; + /** Format: int64 */ + version?: number; + }; + ApiResponseAvatarReviewSettingsResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AvatarReviewSettingsResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AvatarReviewSettingsResponse: { + machineEnabled?: boolean; + manualEnabled?: boolean; + /** Format: int64 */ + version?: number; + /** Format: date-time */ + updatedAt?: string; + }; SystemAuthSettingsUpdateRequest: { passwordLoginEnabled: boolean; selfRegistrationEnabled: boolean; @@ -6287,6 +6710,36 @@ export interface components { ConfirmPublishRequest: { version: string; }; + SkillAvatarReasonRequest: { + reason: string; + }; + ApiResponseSkillAvatarStatusResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillAvatarStatusResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillAvatarStatusResponse: { + avatarUrl?: string; + /** Format: int64 */ + candidateId?: number; + candidateStatus?: string; + machineStatus?: string; + reviewStatus?: string; + reviewReason?: string; + candidateUrl?: string; + namespace?: string; + slug?: string; + /** Format: date-time */ + machineNextRetryAt?: string; + automaticRetryExhausted?: boolean; + }; + SkillAvatarSubmitRequest: { + uploadId: string; + }; ApiResponsePublishResponse: { /** Format: int32 */ code?: number; @@ -6308,6 +6761,21 @@ export interface components { /** Format: int64 */ totalSize?: number; }; + ApiResponseSkillAvatarUploadResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillAvatarUploadResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillAvatarUploadResponse: { + uploadId: string; + previewUrl: string; + /** Format: date-time */ + expiresAt: string; + }; ReviewActionRequest: { comment?: string; }; @@ -6344,6 +6812,9 @@ export interface components { /** Format: int64 */ subjectVersionId?: number; subjectSlug?: string; + /** Format: int64 */ + avatarCandidateId?: number; + avatarRemoval?: boolean; }; ReviewTaskRequest: { /** Format: int64 */ @@ -6481,9 +6952,62 @@ export interface components { success?: boolean; error?: string; }; + NamespaceInvitationCreateRequest: { + replace?: boolean; + }; + ApiResponseCreated: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["Created"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + Created: { + token?: string; + /** Format: date-time */ + expiresAt?: string; + replaced?: boolean; + }; NamespaceLifecycleRequest: { reason?: string; }; + NamespaceInvitationTokenRequest: { + token: string; + }; + ApiResponsePreview: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["Preview"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + Preview: { + namespaceSlug?: string; + namespaceDisplayName?: string; + namespaceAvatarUrl?: string; + /** @enum {string} */ + outcome?: "CONFIRM_REQUIRED" | "ALREADY_MEMBER"; + /** Format: date-time */ + expiresAt?: string; + }; + Accepted: { + namespaceSlug?: string; + /** @enum {string} */ + outcome?: "JOINED" | "ALREADY_MEMBER"; + }; + ApiResponseAccepted: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["Accepted"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; ApiResponseGovernanceNotificationResponse: { /** Format: int32 */ code?: number; @@ -7151,6 +7675,7 @@ export interface components { resolutionMode?: string; complianceSnapshot?: components["schemas"]["ComplianceSnapshotResponse"]; labels?: components["schemas"]["SkillLabelDto"][]; + avatarUrl?: string; }; ApiResponseBoolean: { /** Format: int32 */ @@ -7444,6 +7969,7 @@ export interface components { resolutionMode?: string; entryForSuites?: components["schemas"]["SkillSuiteReferenceResponse"][]; memberOfSuites?: components["schemas"]["PageResponseSkillSuiteReferenceResponse"]; + avatarUrl?: string; }; ApiResponseReviewSkillDetailResponse: { /** Format: int32 */ @@ -7454,6 +7980,16 @@ export interface components { timestamp?: string; requestId?: string; }; + AvatarChange: { + /** Format: int64 */ + candidateId?: number; + currentAvatarUrl?: string; + candidatePreviewUrl?: string; + machineStatus?: string; + reviewStatus?: string; + readyForApproval?: boolean; + removal?: boolean; + }; ReviewSkillDetailResponse: { skill?: components["schemas"]["SkillDetailResponse"]; versions?: components["schemas"]["SkillVersionResponse"][]; @@ -7462,6 +7998,7 @@ export interface components { documentationContent?: string; downloadUrl?: string; activeVersion?: string; + avatarChange?: components["schemas"]["AvatarChange"]; }; ApiResponseListReviewTaskResponse: { /** Format: int32 */ @@ -7725,6 +8262,55 @@ export interface components { email?: string; status?: string; }; + ApiResponseInvitationHistoryPage: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["InvitationHistoryPage"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + InvitationHistoryItem: { + invitationId?: string; + issuedBy?: string; + issuedByName?: string; + /** Format: date-time */ + issuedAt?: string; + /** Format: date-time */ + expiresAt?: string; + status?: string; + consumedBy?: string; + consumedByName?: string; + /** Format: date-time */ + consumedAt?: string; + /** Format: date-time */ + endedAt?: string; + legacy?: boolean; + }; + InvitationHistoryPage: { + items?: components["schemas"]["InvitationHistoryItem"][]; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + totalPages?: number; + }; + ApiResponseInvitationStatus: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["InvitationStatus"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + InvitationStatus: { + active?: boolean; + }; ApiResponsePageResponseNamespaceResponse: { /** Format: int32 */ code?: number; @@ -10254,6 +10840,50 @@ export interface operations { }; }; }; + current_2: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAvatarReviewSettingsResponse"]; + }; + }; + }; + }; + update: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AvatarReviewSettingsUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAvatarReviewSettingsResponse"]; + }; + }; + }; + }; getLocalSettings: { parameters: { query?: never; @@ -11589,6 +12219,310 @@ export interface operations { }; }; }; + takedown: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillAvatarReasonRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + takedown_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillAvatarReasonRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + retry: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + candidateId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"]; + }; + }; + }; + }; + retry_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + candidateId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"]; + }; + }; + }; + }; + appeal: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + candidateId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"]; + }; + }; + }; + }; + appeal_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + candidateId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"]; + }; + }; + }; + }; + current: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + submit_2: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + "application/json": components["schemas"]["SkillAvatarSubmitRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"]; + }; + }; + }; + }; + delete: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + current_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + submit_1_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + "application/json": components["schemas"]["SkillAvatarSubmitRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"]; + }; + }; + }; + }; + delete_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; archiveSkill: { parameters: { query?: never; @@ -11648,6 +12582,9 @@ export interface operations { query: { visibility: string; confirmWarnings?: boolean; + avatarAction?: "KEEP" | "REPLACE" | "REMOVE"; + avatarUploadId?: string; + expectedSlug?: string; }; header?: never; path: { @@ -11680,6 +12617,9 @@ export interface operations { query: { visibility: string; confirmWarnings?: boolean; + avatarAction?: "KEEP" | "REPLACE" | "REMOVE"; + avatarUploadId?: string; + expectedSlug?: string; }; header?: never; path: { @@ -11707,6 +12647,60 @@ export interface operations { }; }; }; + upload: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillAvatarUploadResponse"]; + }; + }; + }; + }; + upload_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillAvatarUploadResponse"]; + }; + }; + }; + }; withdrawReview_2: { parameters: { query?: never; @@ -12655,6 +13649,146 @@ export interface operations { }; }; }; + status_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseInvitationStatus"]; + }; + }; + }; + }; + create: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["NamespaceInvitationCreateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCreated"]; + }; + }; + }; + }; + revoke: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + status_3: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseInvitationStatus"]; + }; + }; + }; + }; + create_1: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["NamespaceInvitationCreateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCreated"]; + }; + }; + }; + }; + revoke_1: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; freezeNamespace: { parameters: { query?: never; @@ -12851,6 +13985,102 @@ export interface operations { }; }; }; + preview: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceInvitationTokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePreview"]; + }; + }; + }; + }; + preview_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceInvitationTokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePreview"]; + }; + }; + }; + }; + accept: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceInvitationTokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccepted"]; + }; + }; + }; + }; + accept_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NamespaceInvitationTokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccepted"]; + }; + }; + }; + }; markNotificationRead: { parameters: { query?: never; @@ -13138,7 +14368,7 @@ export interface operations { }; }; }; - create: { + create_2: { parameters: { query?: never; header?: never; @@ -15555,6 +16785,100 @@ export interface operations { }; }; }; + status: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"]; + }; + }; + }; + }; + status_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillAvatarStatusResponse"]; + }; + }; + }; + }; + candidate: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + candidateId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + candidate_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + candidateId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; getSkillDetail: { parameters: { query?: never; @@ -15651,6 +16975,50 @@ export interface operations { }; }; }; + uploadPreview: { + parameters: { + query?: never; + header?: never; + path: { + uploadId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + uploadPreview_1: { + parameters: { + query?: never; + header?: never; + path: { + uploadId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; getReviewSkillDetail: { parameters: { query?: never; @@ -16567,6 +17935,56 @@ export interface operations { }; }; }; + history_2: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseInvitationHistoryPage"]; + }; + }; + }; + }; + history_3: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseInvitationHistoryPage"]; + }; + }; + }; + }; listMySkillSuiteWorkspace: { parameters: { query?: { @@ -18353,7 +19771,7 @@ export interface operations { }; }; }; - revoke: { + revoke_2: { parameters: { query?: never; header?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 1c999825..d4ea200b 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -36,6 +36,27 @@ export interface SystemAuthSettings { updatedAt: string } +export interface AvatarReviewSettings { + machineEnabled: boolean + manualEnabled: boolean + version: number + updatedAt: string +} + +export interface SkillAvatarStatus { + avatarUrl: string | null + candidateId: number | null + candidateStatus: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'SUPERSEDED' | 'REMOVED' | null + machineStatus: 'SKIPPED' | 'PENDING' | 'PROCESSING' | 'PASSED' | 'BLOCKED' | 'ERROR' | null + machineNextRetryAt: string | null + automaticRetryExhausted: boolean + reviewStatus: 'NOT_REQUIRED' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'APPEAL_PENDING' | null + reviewReason: string | null + candidateUrl: string | null + namespace: string + slug: string +} + export interface ExternalRoleGrantRule { id: number providerCode: string @@ -252,6 +273,7 @@ export interface SkillSummary { ownerPreviewVersion?: SkillLifecycleVersion resolutionMode?: string complianceSnapshot?: ComplianceSnapshot + avatarUrl?: string | null } export type LabelItem = Omit & { @@ -343,8 +365,9 @@ export type SkillDetail = Omit< | 'entryForSuites' | 'memberOfSuites' >, - 'labels' | 'headlineVersion' | 'publishedVersion' | 'ownerPreviewVersion' | 'entryForSuites' | 'memberOfSuites' + 'avatarUrl' | 'labels' | 'headlineVersion' | 'publishedVersion' | 'ownerPreviewVersion' | 'entryForSuites' | 'memberOfSuites' > & { + avatarUrl?: string | null labels?: LabelItem[] headlineVersion?: SkillLifecycleVersion publishedVersion?: SkillLifecycleVersion @@ -583,7 +606,7 @@ export interface ReviewTask { skillVersionId: number | null namespace: string skillSlug?: string | null - version: string + version: string | null status: 'PENDING' | 'APPROVED' | 'REJECTED' submittedBy: string submittedByName?: string @@ -592,10 +615,12 @@ export interface ReviewTask { reviewComment?: string submittedAt: string reviewedAt?: string - subjectType?: 'SKILL_VERSION' | 'SUITE_VERSION' + subjectType?: 'SKILL_VERSION' | 'SUITE_VERSION' | 'SKILL_METADATA' subjectId?: number | null subjectVersionId?: number | null subjectSlug?: string | null + avatarCandidateId?: number | null + avatarRemoval?: boolean } export interface ReviewProgress extends Pick +export type NamespaceInvitationHistoryPage = Omit, 'items'> & { + items: NamespaceInvitationHistory[] +} diff --git a/web/src/app/router.test.ts b/web/src/app/router.test.ts index f8a95847..50c879cb 100644 --- a/web/src/app/router.test.ts +++ b/web/src/app/router.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { isRedirect } from '@tanstack/react-router' // The router module captures window.location.search at module load time. // We test the exported ORIGINAL_URL_SEARCH constant and the buildReturnTo @@ -51,4 +52,23 @@ describe('router', () => { const childPaths = children.map((route) => route.fullPath ?? route.path) expect(childPaths).toContain('/device') }) + + it('returns unauthenticated invitation visitors to /join even with a query and deployment base path', async () => { + const children = (router.routeTree.children ?? []) as Array<{ + fullPath?: string + options?: { beforeLoad?: (context: { location: { pathname: string; searchStr: string; hash: string } }) => Promise } + }> + const join = children.find((route) => route.fullPath === '/join') + expect(join?.options?.beforeLoad).toBeDefined() + + await expect(join!.options!.beforeLoad!({ + location: { pathname: '/skillhub/join', searchStr: '?x=1', hash: '#invitation-token' }, + })).rejects.toSatisfy((error: unknown) => { + expect(isRedirect(error)).toBe(true) + if (!isRedirect(error)) return false + expect(error.options.to).toBe('/login') + expect(error.options.search).toEqual({ returnTo: '/join' }) + return true + }) + }) }) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 54573332..12ff0075 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -7,6 +7,7 @@ import { RouteError } from '@/shared/components/route-error' import { createRedirectAuthenticated, createRequireAuth, isSafeAuthReturnTo } from '@/shared/lib/auth-route' import { clearDynamicImportReloadGuard, recoverFromDynamicImportError } from '@/shared/lib/dynamic-import-recovery' import { normalizeSearchQuery } from '@/shared/lib/search-query' +import { captureInvitationFragment } from '@/shared/lib/namespace-invitation' /** * Central route registry for the SkillHub web app. @@ -16,6 +17,7 @@ import { normalizeSearchQuery } from '@/shared/lib/search-query' */ // Capture original URL before TanStack Router rewrites it const ORIGINAL_URL_SEARCH = typeof window !== 'undefined' ? window.location.search : '' +captureInvitationFragment() // Export for use in cli-auth page export { ORIGINAL_URL_SEARCH } @@ -95,6 +97,7 @@ const SuitesPage = createLazyRouteComponent(() => import('@/pages/suites'), 'Sui const SuiteDetailPage = createLazyRouteComponent(() => import('@/pages/suite-detail'), 'SuiteDetailPage') const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage') const NamespacePage = createLazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage') +const JoinPage = createLazyRouteComponent(() => import('@/pages/join'), 'JoinPage') const SkillDetailPage = createLazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage') const SkillVersionComparePage = createLazyRouteComponent(() => import('@/pages/skill-version-compare'), 'SkillVersionComparePage') const dashboardRouteOptions = { silentFallback: true } satisfies LazyRouteOptions @@ -137,6 +140,9 @@ const MyNamespacesPage = createLazyRouteComponent( 'MyNamespacesPage', dashboardRouteOptions, ) +const NamespaceManagementPage = createLazyRouteComponent(() => import('@/pages/dashboard/namespace-management'), 'NamespaceManagementPage') +const NamespaceSettingsPage = createLazyRouteComponent(() => import('@/pages/dashboard/namespace-settings'), 'NamespaceSettingsPage') + const NamespaceMembersPage = createLazyRouteComponent( () => import('@/pages/dashboard/namespace-members'), 'NamespaceMembersPage', @@ -233,6 +239,7 @@ const rootRoute = createRootRoute({ }) const requireAuth = createRequireAuth(getCurrentUser) +const requireJoinAuth = createRequireAuth(getCurrentUser, '/join') const redirectAuthenticated = createRedirectAuthenticated(getCurrentUser) const landingRoute = createRoute({ @@ -323,6 +330,13 @@ const namespaceRoute = createRoute({ component: NamespacePage, }) +const joinRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'join', + beforeLoad: requireJoinAuth, + component: JoinPage, +}) + const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/space/$namespace/$slug', @@ -370,11 +384,15 @@ const dashboardPublishRoute = createRoute({ validateSearch: (search: Record): { namespace?: string visibility?: string + updateName?: string + updateSkill?: string resubmitSkill?: string resubmitVersion?: string } => ({ namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace : undefined, visibility: typeof search.visibility === 'string' && search.visibility ? search.visibility : undefined, + updateName: typeof search.updateName === 'string' && search.updateName ? search.updateName : undefined, + updateSkill: typeof search.updateSkill === 'string' && search.updateSkill ? search.updateSkill : undefined, resubmitSkill: typeof search.resubmitSkill === 'string' && search.resubmitSkill ? search.resubmitSkill : undefined, @@ -465,6 +483,20 @@ const dashboardNamespacesRoute = createRoute({ component: MyNamespacesPage, }) +const dashboardNamespaceManagementRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/namespaces/$slug', + beforeLoad: requireAuth, + component: NamespaceManagementPage, +}) + +const dashboardNamespaceSettingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/namespaces/$slug/settings', + beforeLoad: requireAuth, + component: NamespaceSettingsPage, +}) + const dashboardNamespaceMembersRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/namespaces/$slug/members', @@ -678,6 +710,7 @@ const routeTree = rootRoute.addChildren([ suiteDetailRoute, termsRoute, namespaceRoute, + joinRoute, skillDetailRoute, skillVersionCompareRoute, dashboardRoute, @@ -690,6 +723,8 @@ const routeTree = rootRoute.addChildren([ dashboardSuiteEditRoute, dashboardSuiteVersionCreateRoute, dashboardNamespacesRoute, + dashboardNamespaceManagementRoute, + dashboardNamespaceSettingsRoute, dashboardNamespaceMembersRoute, dashboardNamespaceReviewsRoute, dashboardNamespaceReviewDetailRoute, diff --git a/web/src/features/namespace/invitation-controls.test.tsx b/web/src/features/namespace/invitation-controls.test.tsx new file mode 100644 index 00000000..0d3cf895 --- /dev/null +++ b/web/src/features/namespace/invitation-controls.test.tsx @@ -0,0 +1,275 @@ +/** @vitest-environment jsdom */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const api = vi.hoisted(() => ({ + invitationStatus: vi.fn(), + invitationHistory: vi.fn().mockResolvedValue({ items: [], total: 0, totalPages: 0, page: 0, size: 10 }), + createInvitation: vi.fn(), + revokeInvitation: vi.fn(), + previewInvitation: vi.fn(), +})) + +vi.mock('@/api/client', () => ({ namespaceApi: api, getAppBaseUrl: () => undefined })) +const auth = vi.hoisted(() => ({ user: { userId: 'owner' } })) +vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => auth })) +vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), info: vi.fn() } })) +vi.mock('@/i18n/config', () => ({ default: { exists: () => false } })) +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) + +import { ApiError } from '@/shared/lib/api-error' +import { InvitationControls } from './invitation-controls' + +describe('InvitationControls', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + window.sessionStorage.clear() + auth.user = { userId: 'owner' } + Object.values(api).forEach((mock) => mock.mockReset()) + api.invitationHistory.mockResolvedValue({ items: [], total: 0, totalPages: 0, page: 0, size: 10 }) + }) + + async function openConfirmation(action: 'replace' | 'revoke') { + api.invitationStatus.mockResolvedValue({ active: true }) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + const button = await screen.findByRole('button', { name: action === 'replace' ? 'invitation.replaceAction' : 'invitation.revoke' }) + await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false)) + fireEvent.click(button) + return screen.findByRole('dialog') + } + + it.each(['replace', 'revoke'] as const)('shows a reachable error after %s fails', async (action) => { + api.createInvitation.mockRejectedValue(new Error('unavailable')) + api.revokeInvitation.mockRejectedValue(new Error('unavailable')) + const dialog = await openConfirmation(action) + if (action === 'replace') { + expect(api.invitationStatus).toHaveBeenCalledTimes(2) + expect(api.createInvitation).not.toHaveBeenCalled() + } + + fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.confirm' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + expect(screen.getByRole('alert').textContent).toBe('invitation.error') + expect(action === 'replace' ? api.createInvitation : api.revokeInvitation).toHaveBeenCalledTimes(1) + }) + + it.each(['consumed', 'expired'])('creates without a replacement prompt when the cached link was %s', async () => { + api.invitationStatus.mockResolvedValueOnce({ active: true }).mockResolvedValue({ active: false }) + api.createInvitation.mockImplementation(() => new Promise(() => {})) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + const createButton = await screen.findByRole('button', { name: 'invitation.replaceAction' }) + await waitFor(() => expect((createButton as HTMLButtonElement).disabled).toBe(false)) + expect(screen.getByRole('button', { name: 'invitation.revoke' })).toBeTruthy() + + fireEvent.click(createButton) + + await waitFor(() => expect(api.createInvitation).toHaveBeenCalledWith('team', false)) + expect(api.invitationStatus).toHaveBeenCalledTimes(2) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('does not create or confirm replacement when the fresh status fails despite cached active data', async () => { + api.invitationStatus.mockResolvedValueOnce({ active: true }).mockRejectedValue(new Error('unavailable')) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + const createButton = await screen.findByRole('button', { name: 'invitation.replaceAction' }) + await waitFor(() => expect((createButton as HTMLButtonElement).disabled).toBe(false)) + + fireEvent.click(createButton) + + await screen.findByRole('alert') + expect(api.invitationStatus).toHaveBeenCalledTimes(2) + expect(api.createInvitation).not.toHaveBeenCalled() + expect(api.revokeInvitation).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it.each(['create', 'replace', 'revoke'] as const)('refreshes history only after %s succeeds', async (action) => { + api.invitationStatus.mockResolvedValue({ active: action !== 'create' }) + api.previewInvitation.mockResolvedValue({ namespaceSlug: 'team', expiresAt: '2999-01-01T00:00:00Z' }) + const historyPage = { items: [], page: 0, size: 10, total: 0, totalPages: 0 } + api.invitationHistory.mockResolvedValue(historyPage) + let succeed: () => void = () => {} + if (action === 'revoke') api.revokeInvitation.mockImplementation(() => new Promise((resolve) => { succeed = () => { api.invitationStatus.mockResolvedValue({ active: false }); resolve() } })) + else api.createInvitation.mockImplementation(() => new Promise((resolve) => { succeed = () => { api.invitationStatus.mockResolvedValue({ active: true }); resolve({ token: 'a'.repeat(43), replaced: action === 'replace' }) } })) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + const button = await screen.findByRole('button', { name: action === 'create' ? 'invitation.create' : action === 'replace' ? 'invitation.replaceAction' : 'invitation.revoke' }) + await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false)) + await waitFor(() => expect(api.invitationHistory).toHaveBeenCalledTimes(1)) + fireEvent.click(button) + if (action !== 'create') { + const dialog = await screen.findByRole('dialog') + fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.confirm' })) + } + await waitFor(() => expect(action === 'revoke' ? api.revokeInvitation : api.createInvitation).toHaveBeenCalledTimes(1)) + expect(api.invitationHistory).toHaveBeenCalledTimes(1) + succeed() + await waitFor(() => expect(api.invitationHistory).toHaveBeenCalledTimes(2)) + }) + + const token = 'a'.repeat(43) + const storageKey = 'skillhub.namespace.issued-invitation:owner:team' + const validPreview = { namespaceSlug: 'team', expiresAt: '2999-01-01T00:00:00Z', outcome: 'ALREADY_MEMBER' } + + function mount(client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) { + return render() + } + + it('keeps the generated link after dialog reopen and a new QueryClient (tab reload)', async () => { + api.invitationStatus.mockResolvedValueOnce({ active: false }).mockResolvedValueOnce({ active: false }).mockResolvedValue({ active: true }) + api.createInvitation.mockResolvedValue({ token, replaced: false }) + api.previewInvitation.mockResolvedValue(validPreview) + const view = mount() + const button = screen.getByRole('button', { name: 'invitation.create' }) + await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false)) + fireEvent.click(button) + await screen.findByRole('textbox') + expect(window.sessionStorage.getItem(storageKey)).toBe(token) + view.unmount() + mount() + expect((await screen.findByRole('textbox') as HTMLInputElement).value).toContain(`/join#invite=${token}`) + expect(api.createInvitation).toHaveBeenCalledTimes(1) + expect(api.previewInvitation).toHaveBeenCalledWith(token) + expect(screen.getByRole('button', { name: 'invitation.copy' })).toBeTruthy() + }) + + it.each(['consumed', 'expired', 'revoked'])('clears the remembered link when server reports %s/inactive', async () => { + window.sessionStorage.setItem(storageKey, token) + api.invitationStatus.mockResolvedValue({ active: false }) + mount() + await waitFor(() => expect(window.sessionStorage.getItem(storageKey)).toBeNull()) + expect(screen.queryByRole('textbox')).toBeNull() + expect(api.previewInvitation).not.toHaveBeenCalled() + }) + + it('clears a replaced token and explains why the active link cannot be recovered', async () => { + window.sessionStorage.setItem(storageKey, token) + api.invitationStatus.mockResolvedValue({ active: true }) + api.previewInvitation.mockRejectedValue(new ApiError('invalid', 404)) + mount() + await screen.findByText('invitation.activeUnavailable') + expect(window.sessionStorage.getItem(storageKey)).toBeNull() + expect(screen.queryByRole('textbox')).toBeNull() + expect(api.createInvitation).not.toHaveBeenCalled() + }) + + it('hides remembered links on validation network failure but retains them for retry', async () => { + window.sessionStorage.setItem(storageKey, token) + api.invitationStatus.mockResolvedValue({ active: true }) + api.previewInvitation.mockRejectedValue(new ApiError('network', 0)) + mount() + await screen.findByRole('alert') + expect(window.sessionStorage.getItem(storageKey)).toBe(token) + expect(screen.queryByRole('textbox')).toBeNull() + }) + + it('does not display a token saved by a different authenticated user', async () => { + window.sessionStorage.setItem(storageKey, token) + auth.user = { userId: 'another-admin' } + api.invitationStatus.mockResolvedValue({ active: true }) + mount() + await screen.findByText('invitation.activeUnavailable') + expect(screen.queryByRole('textbox')).toBeNull() + expect(api.previewInvitation).not.toHaveBeenCalled() + }) + + it('clears the token after confirmed revoke', async () => { + window.sessionStorage.setItem(storageKey, token) + api.invitationStatus.mockResolvedValue({ active: true }) + api.previewInvitation.mockResolvedValue(validPreview) + api.revokeInvitation.mockImplementation(async () => { api.invitationStatus.mockResolvedValue({ active: false }) }) + mount() + await screen.findByRole('textbox') + fireEvent.click(screen.getByRole('button', { name: 'invitation.revoke' })) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'dialog.confirm' })) + await waitFor(() => expect(window.sessionStorage.getItem(storageKey)).toBeNull()) + await waitFor(() => expect(screen.queryByRole('textbox')).toBeNull()) + }) + + it('keeps only an in-memory copy and explains the limitation when storage is unavailable', async () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('storage unavailable') }) + api.invitationStatus.mockResolvedValueOnce({ active: false }).mockResolvedValueOnce({ active: false }).mockResolvedValue({ active: true }) + api.createInvitation.mockResolvedValue({ token, replaced: false }) + api.previewInvitation.mockResolvedValue(validPreview) + mount() + const button = screen.getByRole('button', { name: 'invitation.create' }) + await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false)) + fireEvent.click(button) + await screen.findByRole('textbox') + expect(screen.getByText('invitation.storageUnavailable')).toBeTruthy() + expect(window.sessionStorage.getItem(storageKey)).toBeNull() + }) + + it('does not show a previously validated cached link while reopening validation fails', async () => { + window.sessionStorage.setItem(storageKey, token) + api.invitationStatus.mockResolvedValue({ active: true }) + api.previewInvitation.mockResolvedValue(validPreview) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const view = mount(client) + await screen.findByRole('textbox') + view.unmount() + api.previewInvitation.mockRejectedValue(new ApiError('network', 0)) + mount(client) + expect(screen.queryByRole('textbox')).toBeNull() + await screen.findByRole('alert') + expect(screen.queryByRole('textbox')).toBeNull() + expect(window.sessionStorage.getItem(storageKey)).toBe(token) + }) + + it('replaces the remembered link only after confirmation and restores the new link on reopen', async () => { + window.sessionStorage.setItem(storageKey, token) + const nextToken = 'b'.repeat(43) + api.invitationStatus.mockResolvedValue({ active: true }) + api.previewInvitation.mockResolvedValue(validPreview) + api.createInvitation.mockResolvedValue({ token: nextToken, replaced: true }) + const view = mount() + await screen.findByRole('textbox') + fireEvent.click(screen.getByRole('button', { name: 'invitation.replaceAction' })) + const dialog = await screen.findByRole('dialog') + expect(api.createInvitation).not.toHaveBeenCalled() + expect(window.sessionStorage.getItem(storageKey)).toBe(token) + fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.confirm' })) + await waitFor(() => expect(window.sessionStorage.getItem(storageKey)).toBe(nextToken)) + await waitFor(() => expect((screen.getByRole('textbox') as HTMLInputElement).value).toContain(nextToken)) + expect(api.createInvitation).toHaveBeenCalledWith('team', true) + view.unmount() + mount() + expect((await screen.findByRole('textbox') as HTMLInputElement).value).toContain(nextToken) + expect(api.createInvitation).toHaveBeenCalledTimes(1) + }) + + it('shows the verified expiration with the link and distinguishes replacement from creation', async () => { + window.sessionStorage.setItem(storageKey, token) + api.invitationStatus.mockResolvedValue({ active: true }) + api.previewInvitation.mockResolvedValue(validPreview) + mount() + await screen.findByRole('textbox') + expect(screen.getByText(/invitation.expiresAtLabel/).textContent).toContain('2999') + expect(screen.getByRole('status').textContent).toBe('invitation.statusActive') + expect(screen.queryByRole('button', { name: 'invitation.create' })).toBeNull() + expect(screen.getByRole('button', { name: 'invitation.replaceAction' })).toBeTruthy() + expect(screen.getByText(/invitation.singleUse/)).toBeTruthy() + expect(screen.getByText('invitation.accessPolicy')).toBeTruthy() + }) + + it('retries failed validation without creating or replacing an invitation', async () => { + window.sessionStorage.setItem(storageKey, token) + api.invitationStatus.mockResolvedValue({ active: true }) + api.previewInvitation.mockRejectedValueOnce(new ApiError('network', 0)).mockResolvedValue(validPreview) + mount() + await screen.findByRole('alert') + expect(screen.getByRole('status').textContent).toBe('invitation.statusError') + fireEvent.click(screen.getByRole('button', { name: 'invitation.retry' })) + expect((await screen.findByRole('textbox') as HTMLInputElement).value).toContain(token) + expect(screen.queryByRole('alert')).toBeNull() + expect(api.createInvitation).not.toHaveBeenCalled() + expect(api.revokeInvitation).not.toHaveBeenCalled() + }) + +}) diff --git a/web/src/features/namespace/invitation-controls.tsx b/web/src/features/namespace/invitation-controls.tsx new file mode 100644 index 00000000..81723a1a --- /dev/null +++ b/web/src/features/namespace/invitation-controls.tsx @@ -0,0 +1,167 @@ +import { useId, useRef, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { Copy, Link2, Loader2, RefreshCw, ShieldCheck, Trash2, Users } from 'lucide-react' +import { getAppBaseUrl, namespaceApi } from '@/api/client' +import { useAuth } from '@/features/auth/use-auth' +import { ApiError } from '@/shared/lib/api-error' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { BASE_PATH } from '@/shared/lib/base-path' +import { invitationUrl } from '@/shared/lib/namespace-invitation' +import { resolvePublicRegistryUrl } from '@/shared/lib/registry-url' +import { toast } from '@/shared/lib/toast' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { InvitationHistory } from './invitation-history' + +export function InvitationControls({ slug, compact = false }: { slug: string; compact?: boolean }) { + const { t, i18n } = useTranslation() + const linkId = useId() + const queryClient = useQueryClient() + const { user } = useAuth() + const storageKey = `skillhub.namespace.issued-invitation:${user?.userId ?? ''}:${slug}` + const inMemoryToken = useRef<{ key: string; token: string } | null>(null) + const [storageUnavailable, setStorageUnavailable] = useState(false) + const [confirm, setConfirm] = useState<'replace' | 'revoke' | null>(null) + const clearStoredToken = () => { + inMemoryToken.current = null + try { window.sessionStorage.removeItem(storageKey) } catch { /* Storage may be unavailable. */ } + } + const status = useQuery({ + queryKey: ['namespace-invitation-status', slug, user?.userId], + enabled: !compact && !!user, + staleTime: 0, + queryFn: async () => { + const current = await namespaceApi.invitationStatus(slug) + if (!current.active) { + clearStoredToken() + return { active: false, token: null, expiresAt: null } + } + let token = inMemoryToken.current?.key === storageKey ? inMemoryToken.current.token : null + try { token = window.sessionStorage.getItem(storageKey) ?? token } catch { /* Use the current dialog's copy. */ } + if (!token) return { active: true, token: null, expiresAt: null } + let expiresAt: string | null = null + try { + const preview = await namespaceApi.previewInvitation(token) + expiresAt = preview.expiresAt + if (preview.namespaceSlug !== slug || Date.parse(preview.expiresAt) <= Date.now()) { + clearStoredToken() + return { active: true, token: null, expiresAt: null } + } + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 404) throw error + clearStoredToken() + return { active: true, token: null, expiresAt: null } + } + return { active: true, token, expiresAt } + }, + }) + const link = !status.isFetching && !status.isError && status.data?.token + ? invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), status.data.token) + : null + const expiresAt = !status.isFetching && !status.isError && status.data?.expiresAt + ? new Intl.DateTimeFormat(i18n?.language ?? 'zh', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(status.data.expiresAt)) + : null + const copyLink = async (url: string, replaced = false) => { + try { + await navigator.clipboard.writeText(url) + toast.success(t(replaced ? 'invitation.replacedCopied' : 'invitation.copied')) + } catch { toast.info(t('invitation.manualCopy')) } + } + const create = useMutation({ + mutationFn: (replace: boolean) => namespaceApi.createInvitation(slug, replace), + onSuccess: async ({ token, replaced }) => { + inMemoryToken.current = { key: storageKey, token } + try { window.sessionStorage.setItem(storageKey, token) } catch { setStorageUnavailable(true) } + const url = invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), token) + await copyLink(url, replaced) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }), + queryClient.invalidateQueries({ queryKey: ['namespace-invitation-history', slug, user?.userId] }), + ]) + }, + }) + const revoke = useMutation({ + mutationFn: () => namespaceApi.revokeInvitation(slug), + onSuccess: async () => { + clearStoredToken() + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }), + queryClient.invalidateQueries({ queryKey: ['namespace-invitation-history', slug, user?.userId] }), + ]) + toast.success(t('invitation.revoked')) + }, + }) + const handleCreate = async () => { + const result = await status.refetch() + if (result.isError || !result.data) return + const current = result.data + if (current.active) setConfirm('replace') + else create.mutate(false) + } + return ( +
event.stopPropagation() : undefined}> + {!compact ? ( +
+
+ + + {status.isFetching ? +
+ {link ? ( +
+ +
+ event.currentTarget.select()} /> + +
+ {expiresAt ?

{t('invitation.expiresAtLabel')}: {expiresAt}

: null} +

{t(storageUnavailable ? 'invitation.storageUnavailable' : 'invitation.localLinkHint')}

+
+ ) : null} + {!link && !status.isFetching && !status.isError && status.data?.active ?

{t('invitation.activeUnavailable')}

: null} + {!status.isError && !status.data?.active ? ( +
+ ) : null} +
+ ) : } + {!compact ? ( +
+

{t('invitation.detailsTitle')}

+
+
+
+ ) : null} + {(create.error || revoke.error || status.error) ? ( +
+

{t('invitation.error')}

+ {status.error ? : null} +
+ ) : null} + {!compact && !status.isError && status.data?.active ? ( +
+ {t('invitation.managementTitle')} +
+ + +
+
+ ) : null} + {!compact && user ? : null} + { if (!open) setConfirm(null) }} + title={t(confirm === 'revoke' ? 'invitation.revokeTitle' : 'invitation.replaceTitle')} + description={t(confirm === 'revoke' ? 'invitation.revokeDescription' : 'invitation.replaceDescription')} + confirmText={t('dialog.confirm')} + onConfirm={async () => { + try { + if (confirm === 'revoke') await revoke.mutateAsync() + else await create.mutateAsync(true) + } catch { + setConfirm(null) + } + }} /> +
+ ) +} diff --git a/web/src/features/namespace/invitation-history.test.tsx b/web/src/features/namespace/invitation-history.test.tsx new file mode 100644 index 00000000..57e2a722 --- /dev/null +++ b/web/src/features/namespace/invitation-history.test.tsx @@ -0,0 +1,106 @@ +/** @vitest-environment jsdom */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const api = vi.hoisted(() => ({ invitationHistory: vi.fn() })) +const auth = vi.hoisted(() => ({ user: { userId: 'owner' } as { userId: string } | null })) +vi.mock('@/api/client', () => ({ namespaceApi: api })) +vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => auth })) +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }) })) +import { InvitationHistory } from './invitation-history' + +const record = { + invitationId: 'record-1', issuedBy: 'owner', issuedByName: 'Owner then', + issuedAt: '2026-10-13T12:00:00Z', expiresAt: '2026-10-14T12:00:00Z', + status: 'USED', consumedBy: 'joined-user', consumedByName: 'Original joiner', + consumedAt: '2026-10-13T13:00:00Z', endedAt: '2026-10-13T13:00:00Z', legacy: true, +} +const page = (items = [record], pageIndex = 0, totalPages = 1) => ({ items, page: pageIndex, size: 5, total: items.length, totalPages }) +function mount(slug = 'team', client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) { + return render() +} +afterEach(() => { cleanup(); api.invitationHistory.mockReset(); auth.user = { userId: 'owner' } }) + +describe('InvitationHistory', () => { + it('shows immutable join facts and legacy status without link or restoration actions', async () => { + api.invitationHistory.mockResolvedValue(page()) + mount() + const table = await screen.findByRole('table') + expect(within(table).getByText('Owner then')).toBeTruthy() + expect(within(table).queryByText('joined-user')).toBeNull() + expect(within(table).getByText('Original joiner').getAttribute('title')).toBe('Original joiner (joined-user)') + expect(within(table).getByText('Original joiner').getAttribute('aria-label')).toBe('Original joiner (joined-user)') + expect(within(table).getByText('Original joiner')).toBeTruthy() + expect(within(table).getByText('invitation.historyUsed')).toBeTruthy() + expect(within(table).getByText('invitation.historyLegacy')).toBeTruthy() + expect(screen.queryByRole('textbox')).toBeNull() + expect(screen.queryByRole('link')).toBeNull() + expect(api.invitationHistory).toHaveBeenCalledWith('team', 0, 5) + }) + it('requests real next page, displays loading without previous page rows, and resets for another namespace', async () => { + api.invitationHistory.mockResolvedValueOnce(page([record], 0, 2)) + mount() + await screen.findByRole('table') + let finish: (value: ReturnType) => void = () => {} + api.invitationHistory.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve })) + fireEvent.click(screen.getByRole('button', { name: 'pagination.next' })) + await waitFor(() => expect(api.invitationHistory).toHaveBeenLastCalledWith('team', 1, 5)) + expect(screen.queryByText('Original joiner')).toBeNull() + expect(screen.getByRole('status').textContent).toBe('invitation.historyLoading') + finish(page([{ ...record, invitationId: 'record-2', consumedByName: 'Second joiner' }], 1, 2)) + await screen.findAllByText('Second joiner') + cleanup() + api.invitationHistory.mockResolvedValue(page([], 0)) + mount('another') + await screen.findByText('invitation.historyEmpty') + expect(api.invitationHistory).toHaveBeenLastCalledWith('another', 0, 5) + expect(screen.queryByText('Second joiner')).toBeNull() + }) + it('uses snapshot names with full identifiers available without a second visible line', async () => { + const id = 'usr_6ac9b4a8-4d82-4233-a544-6a1f6b5f6208' + const name = 'An exceptionally long historical display name that stays on one line' + api.invitationHistory.mockResolvedValue(page([{ ...record, consumedBy: id, consumedByName: name }])) + mount() + const table = await screen.findByRole('table') + const identity = within(table).getByText(name) + expect(identity.getAttribute('title')).toBe(`${name} (${id})`) + expect(identity.getAttribute('aria-label')).toBe(`${name} (${id})`) + expect(identity.classList.contains('truncate')).toBe(true) + expect(within(table).queryByText(id)).toBeNull() + expect(identity.children.length).toBe(0) + }) + it('abbreviates a missing-name legacy identifier while retaining the full identifier', async () => { + const id = 'usr_6ac9b4a8-4d82-4233-a544-6a1f6b5f6208' + api.invitationHistory.mockResolvedValue(page([{ ...record, consumedBy: id, consumedByName: '' }])) + mount() + const table = await screen.findByRole('table') + const identity = within(table).getByText('usr_6ac9…5f6208') + expect(identity.getAttribute('title')).toBe(id) + expect(identity.getAttribute('aria-label')).toBe(id) + expect(within(table).queryByText(id)).toBeNull() + }) + it('keeps a short missing-name identifier readable in full', async () => { + api.invitationHistory.mockResolvedValue(page([{ ...record, consumedByName: '' }])) + mount() + const table = await screen.findByRole('table') + expect(within(table).getByText('joined-user').getAttribute('title')).toBe('joined-user') + }) + it('hides prior records when refresh fails including 403, then allows retry', async () => { + api.invitationHistory.mockResolvedValueOnce(page()).mockRejectedValueOnce(new Error('403 forbidden')).mockResolvedValue(page([])) + mount() + await screen.findByRole('table') + fireEvent.click(screen.getByRole('button', { name: 'invitation.historyRefresh' })) + await screen.findByRole('alert') + expect(screen.queryByRole('table')).toBeNull() + expect(screen.queryByText('Original joiner')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'invitation.historyRefresh' })) + await screen.findByText('invitation.historyEmpty') + expect(screen.queryByRole('alert')).toBeNull() + }) + it('does not request history without a logged in identity', () => { + auth.user = null + mount() + expect(api.invitationHistory).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/features/namespace/invitation-history.tsx b/web/src/features/namespace/invitation-history.tsx new file mode 100644 index 00000000..460f8a5e --- /dev/null +++ b/web/src/features/namespace/invitation-history.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { Loader2, RefreshCw } from 'lucide-react' +import { namespaceApi } from '@/api/client' +import type { NamespaceInvitationHistory } from '@/api/types' +import { useAuth } from '@/features/auth/use-auth' +import { Pagination } from '@/shared/components/pagination' +import { Button } from '@/shared/ui/button' + +const HISTORY_PAGE_SIZE = 5 + +const statusKeys: Record = { + ACTIVE: 'invitation.historyActive', USED: 'invitation.historyUsed', + REPLACED: 'invitation.historyReplaced', REVOKED: 'invitation.historyRevoked', + EXPIRED: 'invitation.historyExpired', +} + +/** Mounted only within the OWNER/ADMIN invitation dialog; the API checks the same role. */ +export function InvitationHistory({ slug }: { slug: string }) { + const { t, i18n } = useTranslation() + const { user } = useAuth() + const [page, setPage] = useState(0) + const history = useQuery({ + queryKey: ['namespace-invitation-history', slug, user?.userId, page, HISTORY_PAGE_SIZE], + enabled: !!user, + staleTime: 0, + retry: false, + queryFn: () => namespaceApi.invitationHistory(slug, page, HISTORY_PAGE_SIZE), + }) + const formatDate = (value: string) => new Intl.DateTimeFormat(i18n?.language ?? 'zh', { + dateStyle: 'short', timeStyle: 'short', + }).format(new Date(value)) + const identity = (name: string | null | undefined, id: string | null | undefined) => { + const shortId = id && id.length > 18 ? `${id.slice(0, 8)}…${id.slice(-6)}` : id + const label = name && id ? `${name} (${id})` : name || id || t('invitation.historyUnknownUser') + return + {name || shortId || t('invitation.historyUnknownUser')} + + } + const status = (item: NamespaceInvitationHistory) => ( + + {t(statusKeys[item.status])} + {item.legacy ? {t('invitation.historyLegacy')} : null} + + ) + const joined = (item: NamespaceInvitationHistory) => item.consumedAt ? ( + <>{identity(item.consumedByName, item.consumedBy)} + ) : {t('invitation.historyNoJoin')} + + return ( +
+
+

{t('invitation.historyTitle')}

+ +
+

{t('invitation.historyDescription')}

+ {history.isPending ?

: history.isError ? ( +

{t('invitation.historyError')}

+ ) : history.data?.items.length ? ( + <> +
+ + + + + + + + + {history.data.items.map((item) => + + + + + )} +
{t('invitation.historyTitle')}
{t('invitation.historyCreator')}{t('invitation.historyCreatedAt')} / {t('invitation.historyExpiresAt')}{t('invitation.historyStatus')}{t('invitation.historyJoined')}
{identity(item.issuedByName, item.issuedBy)}{status(item)}{joined(item)}
+
    {history.data.items.map((item) =>
  • +
    {t('invitation.historyCreator')}: {identity(item.issuedByName, item.issuedBy)}
    {status(item)}
    +
    +
    {t('invitation.historyCreatedAt')}
    +
    {t('invitation.historyExpiresAt')}
    +
    {t('invitation.historyJoined')}
    {joined(item)}
    +
    +
  • )}
+
+ {history.data.totalPages > 1 ? : null} + + ) :

{t('invitation.historyEmpty')}

} +
+ ) +} diff --git a/web/src/features/namespace/namespace-actions.ts b/web/src/features/namespace/namespace-actions.ts new file mode 100644 index 00000000..f8bdb48f --- /dev/null +++ b/web/src/features/namespace/namespace-actions.ts @@ -0,0 +1,119 @@ +export type PendingNamespaceAction = + | { action: 'freeze'; slug: string; name: string } + | { action: 'unfreeze'; slug: string; name: string } + | { action: 'archive'; slug: string; name: string } + | { action: 'restore'; slug: string; name: string } + | { action: 'delete'; slug: string; name: string } + +type NamespaceActionCopy = { + title: string + description: string + confirmText: string + successTitle: string + successDescription: string + errorTitle: string + variant: 'default' | 'destructive' +} + +type NamespaceActionMutation = { + mutateAsync: (input: { slug: string }) => Promise +} + +type NamespaceActionMutations = { + freeze: NamespaceActionMutation + unfreeze: NamespaceActionMutation + archive: NamespaceActionMutation + restore: NamespaceActionMutation + delete: NamespaceActionMutation +} + +type NamespaceActionToast = { + success: (title: string, description?: string) => void + error: (title: string, description?: string) => void +} + +export function resolveNamespaceActionCopy( + t: (key: string, options?: Record) => string, + action: PendingNamespaceAction['action'], + name: string, +): NamespaceActionCopy { + if (action === 'freeze') { + return { + title: t('myNamespaces.freezeConfirmTitle'), + description: t('myNamespaces.freezeConfirmDescription', { name }), + confirmText: t('myNamespaces.freeze'), + successTitle: t('myNamespaces.freezeSuccessTitle'), + successDescription: t('myNamespaces.freezeSuccessDescription', { name }), + errorTitle: t('myNamespaces.freezeErrorTitle'), + variant: 'default', + } + } + if (action === 'unfreeze') { + return { + title: t('myNamespaces.unfreezeConfirmTitle'), + description: t('myNamespaces.unfreezeConfirmDescription', { name }), + confirmText: t('myNamespaces.unfreeze'), + successTitle: t('myNamespaces.unfreezeSuccessTitle'), + successDescription: t('myNamespaces.unfreezeSuccessDescription', { name }), + errorTitle: t('myNamespaces.unfreezeErrorTitle'), + variant: 'default', + } + } + if (action === 'archive') { + return { + title: t('myNamespaces.archiveConfirmTitle'), + description: t('myNamespaces.archiveConfirmDescription', { name }), + confirmText: t('myNamespaces.archive'), + successTitle: t('myNamespaces.archiveSuccessTitle'), + successDescription: t('myNamespaces.archiveSuccessDescription', { name }), + errorTitle: t('myNamespaces.archiveErrorTitle'), + variant: 'destructive', + } + } + if (action === 'delete') { + return { + title: t('myNamespaces.deleteConfirmTitle'), + description: t('myNamespaces.deleteConfirmDescription', { name }), + confirmText: t('myNamespaces.delete'), + successTitle: t('myNamespaces.deleteSuccessTitle'), + successDescription: t('myNamespaces.deleteSuccessDescription', { name }), + errorTitle: t('myNamespaces.deleteErrorTitle'), + variant: 'destructive', + } + } + return { + title: t('myNamespaces.restoreConfirmTitle'), + description: t('myNamespaces.restoreConfirmDescription', { name }), + confirmText: t('myNamespaces.restore'), + successTitle: t('myNamespaces.restoreSuccessTitle'), + successDescription: t('myNamespaces.restoreSuccessDescription', { name }), + errorTitle: t('myNamespaces.restoreErrorTitle'), + variant: 'default', + } +} + +export async function executeNamespaceAction( + pendingAction: PendingNamespaceAction, + mutations: NamespaceActionMutations, + copy: NamespaceActionCopy, + notifications: NamespaceActionToast, +) { + try { + if (pendingAction.action === 'freeze') { + await mutations.freeze.mutateAsync({ slug: pendingAction.slug }) + } else if (pendingAction.action === 'unfreeze') { + await mutations.unfreeze.mutateAsync({ slug: pendingAction.slug }) + } else if (pendingAction.action === 'archive') { + await mutations.archive.mutateAsync({ slug: pendingAction.slug }) + } else if (pendingAction.action === 'delete') { + await mutations.delete.mutateAsync({ slug: pendingAction.slug }) + } else { + await mutations.restore.mutateAsync({ slug: pendingAction.slug }) + } + + notifications.success(copy.successTitle, copy.successDescription) + } catch (error) { + notifications.error(copy.errorTitle, error instanceof Error ? error.message : '') + throw error + } +} diff --git a/web/src/features/namespace/namespace-management-nav.tsx b/web/src/features/namespace/namespace-management-nav.tsx new file mode 100644 index 00000000..c542cfd3 --- /dev/null +++ b/web/src/features/namespace/namespace-management-nav.tsx @@ -0,0 +1,44 @@ +import { ArrowLeft } from 'lucide-react' +import { Link } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { useAuth } from '@/features/auth/use-auth' +import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' +import { cn } from '@/shared/lib/utils' + +export function NamespaceManagementBack() { + const { t } = useTranslation() + return ( + +