mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
feat(avatar): stage images before skill publication
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
8c7ea2c41a
commit
1a7ffbe6e2
44 changed files with 1635 additions and 128 deletions
|
|
@ -1,6 +1,14 @@
|
|||
# 技能头像与审核
|
||||
|
||||
技能所有者可在技能详情页上传 PNG/JPEG,网页提供方形裁剪和缩放。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。
|
||||
技能所有者可在新增/更新技能页面选择自定义 PNG/JPEG 头像,网页提供方形裁剪和缩放;不提供预设图标。更新默认保留头像,也可替换或移除。“我的技能”提供单独修改头像的入口,无需发布新版本;详情页展示头像及审核状态。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。
|
||||
|
||||
## 上传与提交
|
||||
|
||||
选择图片后先裁剪并上传到私有存储,上传成功后才允许提交技能或头像修改;未选择头像也可提交新技能。上传失败可重试,但不能把失败或尚未完成的上传随技能提交。上传成功仅表示文件已保存,不表示审核通过。
|
||||
|
||||
提交成功后图片才关联到技能并进入对应审核流程。更新时未修改头像会保留原头像;选择移除时先预览首字母,提交成功后才恢复兜底并取消待审头像申请,历史记录保留。取消编辑或提交失败不会改变原头像及已有待审申请。未关联的临时图片保持私有;上传引用 24 小时后过期,过期未关联图片由清理任务定期回收,提交时过期需要重新上传。
|
||||
|
||||
技能内容与头像分别显示审核状态。技能发布或内容审核通过不能代替头像审核;新头像通过审核前仍显示旧头像或首字母。单独修改头像不创建新的技能版本。
|
||||
|
||||
## 审核策略
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ npx @astron-team/skillhub@latest publish ./my-skill --namespace my-team
|
|||
|
||||
访问 `http://localhost:3000/dashboard/publish`,选择命名空间、上传 zip 文件、选择可见性后点击「发布」。
|
||||
|
||||
可选上传自定义技能头像:选择 PNG/JPEG、裁剪并等待私有上传成功,再提交技能。头像上传中或失败时不能提交;没有选择头像则正常提交并使用技能名首字母。更新技能时默认保留原头像,可选择替换或移除;移除仅在提交成功后生效,取消或提交失败不影响原头像。
|
||||
|
||||
技能和头像各有审核状态。文件上传成功不代表头像审核通过,头像所需审核在提交后进行,通过前显示旧头像或首字母。仅需修改头像时,可从“我的技能”操作,不需要发布新的技能版本。
|
||||
|
||||
4. **使用 REST API 发布**
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -20,3 +20,19 @@
|
|||
- 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 直接使用规则保持不变。
|
||||
|
||||
原验收只覆盖详情页独立提交流程,不能证明上述新增/更新流程;实施后须补充对应测试和浏览器验收。真实供应商调用仍未验证。
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ Skill 目前没有稳定的图像标识,卡片和详情难以快速区分。
|
|||
## What Changes
|
||||
|
||||
- 为个人、团队和全局 Skill 增加由 Skill 所有者维护的可选头像;所有展示位置使用一致的首字兜底。
|
||||
- 上传最终裁剪图片,经服务端规范化后存为私有候选;头像属于 Skill,不随版本更新重置。
|
||||
- 在新增/更新技能页面支持可选自定义头像与裁剪,先私有上传成功后随业务提交关联;更新默认保留,可替换或移除,不提供预设图标。
|
||||
- “我的技能”支持单独修改头像,无需新版本;详情页展示头像和审核状态。头像属于 Skill,未明确替换或移除时不随版本更新重置。
|
||||
- 临时图片与正式申请分离,未关联图片定期清理;技能与头像分别显示审核状态。
|
||||
- 按实际可见范围处理审核:仅本人可见的 PRIVATE 头像可直接使用;团队可见或公开头像按部署方的机器、人工审核开关处理;全局头像由平台管理员审核。
|
||||
- 部署时选择唯一机器审核供应商并配置凭证,提供讯飞和阿里云适配;后台仅管理审核开关。机器失败不放行,机器误判可申请人工复核。
|
||||
- 仅 Skill 所有者可提交、更换或主动删除;管理员可以审核、复核和撤下违规头像。旧候选失效后不能被迟到的审核结果激活。
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@
|
|||
|
||||
Skill SHALL 支持可选头像和统一兜底。
|
||||
|
||||
头像属于 Skill 容器而非 SkillVersion。摘要与详情只返回当前已激活的稳定头像访问地址;无头像时所有 Skill 展示位置使用展示名称首字符的大写兜底。新版本发布不得重置头像。
|
||||
头像属于 Skill 容器而非 SkillVersion。摘要与详情只返回当前已激活的稳定头像访问地址;无头像时所有 Skill 展示位置使用展示名称首字符的大写兜底。新版本发布未明确选择替换或移除头像时不得重置头像。
|
||||
|
||||
#### Scenario: 旧 Skill 没有头像
|
||||
- **WHEN** 客户端读取无头像的 Skill
|
||||
- **THEN** 不需数据回填即可展示一致的首字兜底
|
||||
|
||||
#### Scenario: 发布新版本
|
||||
- **WHEN** 已有头像的 Skill 发布新版本
|
||||
- **WHEN** 已有头像的 Skill 发布新版本,且未选择替换或移除头像
|
||||
- **THEN** 原头像保持不变
|
||||
|
||||
### Requirement: REQ-AV-02 仅 Skill 所有者 SHALL 维护头像
|
||||
|
|
@ -110,8 +110,55 @@ PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使
|
|||
|
||||
前后端 SHALL 提供可理解的头像提交与审核闭环。
|
||||
|
||||
所有者可以在 Skill 详情查看当前头像、提交候选、状态和驳回原因;审核员可以读取其管辖范围候选并处理,机器单开时可以处理例外申诉。所有展示 Skill 身份的位置使用同一头像与兜底组件。
|
||||
新增和更新 Skill 页面提供可选自定义头像、方形裁剪、上传预览及上传状态,不提供预设图标选择;未上传时使用首字母兜底。更新页面展示当前头像并提供保留、替换、移除三种动作,默认保留。“我的技能”提供单独修改头像的入口,复用同一套上传与裁剪组件,不要求发布新的 SkillVersion。详情页展示头像、审核状态和驳回原因,不再承载完整上传表单。技能内容与头像分别显示提交和审核状态,技能内容审核通过不代表头像审核通过。审核员可以读取其管辖范围候选并处理,机器单开时可以处理例外申诉。所有展示 Skill 身份的位置使用同一头像与兜底组件。
|
||||
|
||||
#### Scenario: 申请等待审核
|
||||
- **WHEN** 所有者上传候选后需人工决定
|
||||
- **THEN** 页面展示待审状态且已发布头像维持不变
|
||||
|
||||
|
||||
### 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 恢复首字母兜底,现有待审头像申请失效且历史保留
|
||||
|
||||
#### Scenario: 移除后旧结果到达
|
||||
- **WHEN** 移除已成功执行,而旧申请的机器结果或人工审核动作随后到达
|
||||
- **THEN** 已移除头像不被恢复
|
||||
|
||||
#### Scenario: 放弃移除或提交失败
|
||||
- **WHEN** 所有者选择移除,但取消编辑或提交失败
|
||||
- **THEN** 原头像及待审申请保持原状
|
||||
|
|
|
|||
|
|
@ -23,3 +23,17 @@
|
|||
- [x] 4.3 独立 Review、Draft PR、CI 与验收报告。
|
||||
|
||||
验证结果以 PR #947 的检查和交付验收记录为准。真实讯飞/阿里云计费审核接口未发起调用。
|
||||
|
||||
|
||||
## 5. 发布交互调整(2026-10-13,已确认需求,待实施)
|
||||
|
||||
前述勾选项表示原实现及其验证完成,不代表本节完成。PR #947 的原合并就绪结论需要在本节实施后重新验证;当前预览仍为原详情页上传流程。
|
||||
|
||||
- [x] 5.1 实现所有者临时私有上传、预览、引用校验及未关联文件清理,覆盖越权引用与关联/清理并发。
|
||||
- [x] 5.2 扩展技能提交以处理保留、关联新头像和移除;保证技能提交与头像操作共同成功、失败不改变旧状态、重复提交不重复申请。
|
||||
- [x] 5.3 在新增/更新页接入统一自定义上传裁剪组件,上传未成功禁止提交,默认保留旧头像,支持移除预览。
|
||||
- [x] 5.4 在“我的技能”增加无需新版本的头像维护入口;详情页改为展示及审核状态,技能与头像状态分别呈现,更新三种语言文案。
|
||||
- [x] 5.5 覆盖新增、更新保留/替换/移除、取消、上传失败、提交失败、单独维护、旧审核失效、临时清理及原审核策略回归。
|
||||
- [ ] 5.6 更新相关用户文档、OpenAPI 类型及独立 Review,构建当前 SHA 的预览并进行真实浏览器验收,更新同一个 PR #947 的验收记录。
|
||||
|
||||
真实讯飞/阿里云接入是独立的部署联调项,需要已开通能力及部署凭证;模拟测试不得标记为真实调用通过。
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ 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;
|
||||
|
|
@ -38,10 +42,36 @@ import org.springframework.web.multipart.MultipartFile;
|
|||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||
public class SkillAvatarController extends BaseApiController {
|
||||
private final SkillAvatarAppService service;
|
||||
private final SkillAvatarUploadService uploads;
|
||||
|
||||
public SkillAvatarController(SkillAvatarAppService service, ApiResponseFactory responses) {
|
||||
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<SkillAvatarUploadResponse> 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<InputStreamResource> 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<SkillAvatarStatusResponse> 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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record SkillAvatarSubmitRequest(@NotBlank String uploadId) {}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -86,7 +86,7 @@ public class SkillAvatarAppService {
|
|||
@Transactional
|
||||
public SkillAvatarStatusResponse submit(String namespaceSlug, String slug, String actor, byte[] image,
|
||||
AuditRequestContext context) {
|
||||
Skill skill = lockedSkill(namespaceSlug, slug);
|
||||
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);
|
||||
|
|
@ -102,13 +102,12 @@ public class SkillAvatarAppService {
|
|||
}
|
||||
if (skill.getPendingAvatarId() != null) {
|
||||
SkillAvatarCandidate pending = candidates.findById(skill.getPendingAvatarId()).orElse(null);
|
||||
if (pending != null && pending.getStatus() == SkillAvatarCandidate.Status.PENDING
|
||||
&& pending.getAudience() == skill.getVisibility()) {
|
||||
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.canRetryMachine(now) && pending.getMachineAttemptCount() < 3) || abandonedBeforeClaim)
|
||||
return pending.getAudience() == skill.getVisibility() && ((pending.canRetryMachine(now) && pending.getMachineAttemptCount() < 3) || abandonedBeforeClaim)
|
||||
? status(skill, pending, namespaceSlug(skill)) : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -135,11 +134,6 @@ public class SkillAvatarAppService {
|
|||
private SkillAvatarStatusResponse createCandidate(Skill skill, String namespaceSlug, String submittedBy,
|
||||
String auditActor, String action, byte[] normalized,
|
||||
AuditRequestContext context) {
|
||||
AvatarReviewSettingsResponse policy = settings.current();
|
||||
boolean privateOnly = skill.getVisibility() == SkillVisibility.PRIVATE;
|
||||
boolean machineEnabled = !privateOnly && policy.machineEnabled();
|
||||
boolean manualEnabled = !privateOnly && policy.manualEnabled();
|
||||
String provider = machineEnabled ? moderation.requireConfiguredProvider() : null;
|
||||
String sha256 = sha256(normalized);
|
||||
String key = "skill-avatars/" + skill.getId() + "/" + UUID.randomUUID() + ".png";
|
||||
storage.putObject(key, new ByteArrayInputStream(normalized), normalized.length, "image/png");
|
||||
|
|
@ -151,11 +145,35 @@ public class SkillAvatarAppService {
|
|||
}
|
||||
});
|
||||
|
||||
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 createStoredCandidate(skill, namespaceSlug, actor, actor, "SKILL_AVATAR_SUBMIT", objectKey, sha256,
|
||||
audience == null ? skill.getVisibility() : audience, context);
|
||||
}
|
||||
|
||||
private SkillAvatarStatusResponse createStoredCandidate(Skill skill, String namespaceSlug, String submittedBy,
|
||||
String auditActor, String action, String key, String sha256, SkillVisibility audience, AuditRequestContext context) {
|
||||
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(SkillAvatarCandidate::supersede);
|
||||
}
|
||||
SkillAvatarCandidate candidate = new SkillAvatarCandidate(skill.getId(), submittedBy, key, sha256,
|
||||
skill.getVisibility(), machineEnabled, manualEnabled, provider);
|
||||
audience, machineEnabled, manualEnabled, provider);
|
||||
if ("SKILL_AVATAR_SCOPE_REVIEW".equals(action)) {
|
||||
candidate.markScopeReviewVersion(skill.getLatestVersionId());
|
||||
}
|
||||
|
|
@ -184,6 +202,17 @@ public class SkillAvatarAppService {
|
|||
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 -> {
|
||||
|
|
@ -231,7 +260,7 @@ public class SkillAvatarAppService {
|
|||
|
||||
@Transactional(readOnly = true)
|
||||
public SkillAvatarStatusResponse ownerStatus(String namespaceSlug, String slug, String actor) {
|
||||
Skill skill = findSkill(namespaceSlug, slug);
|
||||
Skill skill = findOwnedSkill(namespaceSlug, slug, actor);
|
||||
requireOwner(skill, actor);
|
||||
return status(skill, candidates.findBySkillIdOrderByCreatedAtDesc(skill.getId()).stream().findFirst().orElse(null),
|
||||
namespaceSlug);
|
||||
|
|
@ -240,8 +269,12 @@ public class SkillAvatarAppService {
|
|||
@Transactional(readOnly = true)
|
||||
public InputStream readActive(String namespaceSlug, String slug, String actor,
|
||||
Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles) {
|
||||
skillQueries.getSkillDetail(namespaceSlug, slug, actor, namespaceRoles == null ? Map.of() : namespaceRoles);
|
||||
Skill skill = findSkill(namespaceSlug, slug);
|
||||
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) {
|
||||
|
|
@ -257,8 +290,8 @@ public class SkillAvatarAppService {
|
|||
@Transactional(readOnly = true)
|
||||
public InputStream readCandidate(String namespaceSlug, String slug, long candidateId, String actor,
|
||||
Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles) {
|
||||
Skill skill = findSkill(namespaceSlug, slug);
|
||||
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)) {
|
||||
|
|
@ -270,7 +303,7 @@ public class SkillAvatarAppService {
|
|||
@Transactional
|
||||
public SkillAvatarStatusResponse appeal(String namespaceSlug, String slug, long candidateId, String actor,
|
||||
AuditRequestContext context) {
|
||||
Skill skill = lockedSkill(namespaceSlug, slug);
|
||||
Skill skill = lockedOwnedSkill(namespaceSlug, slug, actor);
|
||||
requireOwner(skill, actor);
|
||||
SkillAvatarCandidate candidate = currentPending(skill, candidateId);
|
||||
candidate.appeal();
|
||||
|
|
@ -280,7 +313,7 @@ public class SkillAvatarAppService {
|
|||
|
||||
@Transactional(readOnly = true)
|
||||
public void requireCurrentOwnerCandidate(String namespaceSlug, String slug, long candidateId, String actor) {
|
||||
Skill skill = findSkill(namespaceSlug, slug);
|
||||
Skill skill = findOwnedSkill(namespaceSlug, slug, actor);
|
||||
requireOwner(skill, actor);
|
||||
if (!Objects.equals(skill.getPendingAvatarId(), candidateId)) {
|
||||
throw new DomainConflictException("error.skill.avatar.notPending");
|
||||
|
|
@ -291,7 +324,7 @@ public class SkillAvatarAppService {
|
|||
public SkillAvatarStatusResponse decide(String namespaceSlug, String slug, long candidateId, boolean approved,
|
||||
String reason, String actor, Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles, AuditRequestContext context) {
|
||||
Skill skill = lockedSkill(namespaceSlug, slug);
|
||||
Skill skill = lockAndRefresh(findCandidateSkill(namespaceSlug, slug, findCandidate(candidateId)));
|
||||
requireReviewer(skill, actor, namespaceRoles, platformRoles);
|
||||
SkillAvatarCandidate candidate = currentPending(skill, candidateId);
|
||||
if (candidate.isMachineEnabled() && candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PASSED
|
||||
|
|
@ -317,7 +350,7 @@ public class SkillAvatarAppService {
|
|||
public void remove(String namespaceSlug, String slug, String actor, boolean byAdmin, String reason,
|
||||
Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles,
|
||||
AuditRequestContext context) {
|
||||
Skill skill = lockedSkill(namespaceSlug, slug);
|
||||
Skill skill = byAdmin ? lockedSkill(namespaceSlug, slug) : lockedOwnedSkill(namespaceSlug, slug, actor);
|
||||
if (byAdmin) {
|
||||
requireReviewer(skill, actor, namespaceRoles, platformRoles);
|
||||
if (reason == null || reason.isBlank()) {
|
||||
|
|
@ -387,9 +420,40 @@ public class SkillAvatarAppService {
|
|||
.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) {
|
||||
Skill skill = findSkill(namespaceSlug, slug);
|
||||
return skills.findLockedById(skill.getId()).orElseThrow(() -> notFound());
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
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;
|
||||
|
||||
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) {
|
||||
// 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) {
|
||||
// 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 = avatars.submitStored(namespace, slug, actor,
|
||||
upload.getObjectKey(), upload.getContentSha256(), audience, context);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
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;
|
||||
|
||||
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<PackageEntry> entries, String actor,
|
||||
SkillVisibility visibility, Set<String> 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);
|
||||
if (action == AvatarAction.REPLACE) {
|
||||
uploads.submitForAudience(namespace, result.slug(), actor, uploadId, visibility, context);
|
||||
} else if (action == AvatarAction.REMOVE) {
|
||||
avatars.remove(namespace, result.slug(), actor, false, null, Map.of(), roles, context);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ 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;
|
||||
|
|
@ -12,15 +14,21 @@ import org.springframework.transaction.annotation.Transactional;
|
|||
public class SkillAvatarCleanupTask {
|
||||
private final SkillAvatarCandidateJpaRepository candidates;
|
||||
private final ObjectStorageService storage;
|
||||
private final SkillAvatarUploadJpaRepository uploads;
|
||||
|
||||
public SkillAvatarCleanupTask(SkillAvatarCandidateJpaRepository candidates, ObjectStorageService storage) {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -294,3 +294,7 @@ promotion.revocation.required=Use the reviewed revocation workflow to remove a p
|
|||
promotion.revocation.page_invalid=Page must be nonnegative and size must be between 1 and 100
|
||||
error.auth.settings.unavailable=Authentication settings are unavailable
|
||||
error.system.roleGrant.unverifiedProvider=This identity provider does not attest verified email and cannot use initial role rules
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -235,3 +235,7 @@ error.suite.bundle.member.stateChanged=Состояние связанного S
|
|||
error.suite.bundle.actor.inactive=Пользователь операции Skill Suite Bundle больше не активен
|
||||
error.auth.settings.unavailable=Настройки аутентификации временно недоступны
|
||||
error.system.roleGrant.unverifiedProvider=Этот поставщик удостоверений не подтверждает адрес почты и не поддерживает правила первоначального назначения роли
|
||||
|
||||
error.skill.avatar.uploadExpired=Срок действия загрузки аватара истёк. Загрузите изображение снова.
|
||||
error.skill.avatar.invalidUpload=Перед отправкой выберите успешно загруженный аватар.
|
||||
error.skill.avatar.skillMismatch=Загруженный пакет не соответствует обновляемому навыку.
|
||||
|
|
|
|||
|
|
@ -294,3 +294,7 @@ promotion.revocation.required=已提升的技能须通过审核撤销流程删
|
|||
promotion.revocation.page_invalid=页码不能为负数,每页数量须在 1 到 100 之间
|
||||
error.auth.settings.unavailable=认证配置暂时不可用
|
||||
error.system.roleGrant.unverifiedProvider=该身份来源不提供已验证邮箱,不能用于首次角色授权规则
|
||||
|
||||
error.skill.avatar.uploadExpired=头像上传引用已过期,请重新上传。
|
||||
error.skill.avatar.invalidUpload=请选择已上传成功的头像后再提交。
|
||||
error.skill.avatar.skillMismatch=上传的技能包与正在更新的技能不一致。
|
||||
|
|
|
|||
|
|
@ -443,6 +443,10 @@ class SkillAvatarRecoveryPostgresTest {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,351 @@
|
|||
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({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(); }
|
||||
@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 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;
|
||||
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);
|
||||
namespaces.saveAndFlush(new Namespace(namespace, "Upload testing", "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);
|
||||
}
|
||||
|
||||
@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();
|
||||
avatars.decide(namespace, "skill", myCandidate, true, "approved", "owner",
|
||||
java.util.Map.of(namespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER), Set.of(), context);
|
||||
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<List<String>>) (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.
|
||||
jdbc.update("INSERT INTO namespace_member(namespace_id, user_id, role) VALUES (?, 'owner', 'OWNER')", namespaceId());
|
||||
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(); }
|
||||
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<PackageEntry> 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ 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;
|
||||
|
|
@ -73,6 +74,8 @@ class SkillAvatarAppServiceTest {
|
|||
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
|
||||
|
|
@ -86,8 +89,11 @@ class SkillAvatarAppServiceTest {
|
|||
void privateAvatarCannotBeReadByNamespaceAdmin() {
|
||||
skill.setVisibility(SkillVisibility.PRIVATE);
|
||||
skill.activateAvatar(21L, SkillVisibility.PRIVATE);
|
||||
when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace));
|
||||
when(skills.findByNamespaceIdAndSlug(10L, "foo")).thenReturn(List.of(skill));
|
||||
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()))
|
||||
|
|
@ -98,7 +104,7 @@ class SkillAvatarAppServiceTest {
|
|||
@Test
|
||||
void privateCandidateCannotBeReadByNamespaceAdmin() {
|
||||
when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace));
|
||||
when(skills.findByNamespaceIdAndSlug(10L, "foo")).thenReturn(List.of(skill));
|
||||
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);
|
||||
|
|
@ -319,7 +325,7 @@ class SkillAvatarAppServiceTest {
|
|||
|
||||
@Test
|
||||
void nonOwnerCannotSubmitBeforeWritingToStorage() {
|
||||
stubSkillLookup();
|
||||
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);
|
||||
|
|
@ -327,17 +333,28 @@ class SkillAvatarAppServiceTest {
|
|||
|
||||
@Test
|
||||
void staleApprovalCannotReplaceNewerCandidate() {
|
||||
stubSkillLookup();
|
||||
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);
|
||||
verifyNoInteractions(candidates);
|
||||
org.assertj.core.api.Assertions.assertThat(candidate.getReviewStatus())
|
||||
.isEqualTo(SkillAvatarCandidate.ReviewStatus.PENDING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reviewRequiresMatchingNamespaceRole() {
|
||||
stubSkillLookup();
|
||||
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("SKILL_ADMIN"), new AuditRequestContext(null, null)))
|
||||
|
|
@ -387,9 +404,4 @@ class SkillAvatarAppServiceTest {
|
|||
return candidate;
|
||||
}
|
||||
|
||||
private void stubSkillLookup() {
|
||||
when(namespaces.findBySlug("team")).thenReturn(Optional.of(namespace));
|
||||
when(skills.findByNamespaceIdAndSlug(10L, "foo")).thenReturn(List.of(skill));
|
||||
when(skills.findLockedById(11L)).thenReturn(Optional.of(skill));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ 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.authenticated(HttpMethod.GET, "/api/v1/skills/avatar-review/queue"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/avatar"),
|
||||
|
|
@ -72,6 +74,8 @@ 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.authenticated(HttpMethod.GET, "/api/web/skills/avatar-review/queue"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/avatar"),
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -565,6 +565,29 @@ public class SkillPublishService {
|
|||
|
||||
// 9. Upload each file to storage and compute SHA-256
|
||||
List<SkillFile> skillFiles = new ArrayList<>();
|
||||
List<String> 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 +638,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 +650,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");
|
||||
|
|
|
|||
|
|
@ -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<String> storageKeys, String failureType) {
|
||||
recordFailure(null, namespace, slug, storageKeys, failureType);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int retryPendingCleanup() {
|
||||
List<SkillStorageDeletionCompensation> records =
|
||||
|
|
|
|||
|
|
@ -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<SkillAvatarUpload, String> {
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT u FROM SkillAvatarUpload u WHERE u.id = :id")
|
||||
Optional<SkillAvatarUpload> 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<SkillAvatarUpload> findExpired(@Param("now") Instant now, Pageable page);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -521,6 +521,19 @@ function avatarPath(namespace: string, slug: string): string {
|
|||
}
|
||||
|
||||
export const skillAvatarApi = {
|
||||
stage(file: Blob): Promise<components['schemas']['SkillAvatarUploadResponse']> {
|
||||
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<SkillAvatarStatus> {
|
||||
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<SkillAvatarStatus> {
|
||||
return fetchJson<SkillAvatarStatus>(`${avatarPath(namespace, slug)}/status`)
|
||||
},
|
||||
|
|
|
|||
216
web/src/api/generated/schema.d.ts
vendored
216
web/src/api/generated/schema.d.ts
vendored
|
|
@ -1509,7 +1509,7 @@ export interface paths {
|
|||
};
|
||||
get: operations["current"];
|
||||
put?: never;
|
||||
post: operations["submit"];
|
||||
post: operations["submit_2"];
|
||||
delete: operations["delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
|
|
@ -1525,7 +1525,7 @@ export interface paths {
|
|||
};
|
||||
get: operations["current_1"];
|
||||
put?: never;
|
||||
post: operations["submit_1"];
|
||||
post: operations["submit_1_1"];
|
||||
delete: operations["delete_1"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
|
|
@ -1596,6 +1596,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;
|
||||
|
|
@ -1925,7 +1957,7 @@ export interface paths {
|
|||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["submit_2"];
|
||||
post: operations["submit"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
|
|
@ -1941,7 +1973,7 @@ export interface paths {
|
|||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["submit_3"];
|
||||
post: operations["submit_1"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
|
|
@ -2115,7 +2147,7 @@ export interface paths {
|
|||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["status"];
|
||||
get: operations["status_2"];
|
||||
put?: never;
|
||||
post: operations["create"];
|
||||
delete: operations["revoke"];
|
||||
|
|
@ -2131,7 +2163,7 @@ export interface paths {
|
|||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["status_1"];
|
||||
get: operations["status_3"];
|
||||
put?: never;
|
||||
post: operations["create_1"];
|
||||
delete: operations["revoke_1"];
|
||||
|
|
@ -3915,6 +3947,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/skills/avatar-review/queue": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -6298,6 +6362,9 @@ export interface components {
|
|||
approved?: boolean;
|
||||
reason: string;
|
||||
};
|
||||
SkillAvatarSubmitRequest: {
|
||||
uploadId: string;
|
||||
};
|
||||
ApiResponsePublishResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
|
|
@ -6319,6 +6386,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;
|
||||
};
|
||||
|
|
@ -11644,7 +11726,7 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
submit: {
|
||||
submit_2: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -11654,12 +11736,13 @@ export interface operations {
|
|||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
/** Format: binary */
|
||||
file: string;
|
||||
};
|
||||
"application/json": components["schemas"]["SkillAvatarSubmitRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
|
|
@ -11720,7 +11803,7 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
submit_1: {
|
||||
submit_1_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -11730,12 +11813,13 @@ export interface operations {
|
|||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
/** Format: binary */
|
||||
file: string;
|
||||
};
|
||||
"application/json": components["schemas"]["SkillAvatarSubmitRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
|
|
@ -11832,6 +11916,9 @@ export interface operations {
|
|||
query: {
|
||||
visibility: string;
|
||||
confirmWarnings?: boolean;
|
||||
avatarAction?: "KEEP" | "REPLACE" | "REMOVE";
|
||||
avatarUploadId?: string;
|
||||
expectedSlug?: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
|
|
@ -11864,6 +11951,9 @@ export interface operations {
|
|||
query: {
|
||||
visibility: string;
|
||||
confirmWarnings?: boolean;
|
||||
avatarAction?: "KEEP" | "REPLACE" | "REMOVE";
|
||||
avatarUploadId?: string;
|
||||
expectedSlug?: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
|
|
@ -11891,6 +11981,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;
|
||||
|
|
@ -12499,7 +12643,7 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
submit_2: {
|
||||
submit: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -12523,7 +12667,7 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
submit_3: {
|
||||
submit_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -12839,7 +12983,7 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
status: {
|
||||
status_2: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -12909,7 +13053,7 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
status_1: {
|
||||
status_3: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -15945,6 +16089,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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
queue: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -381,11 +381,15 @@ const dashboardPublishRoute = createRoute({
|
|||
validateSearch: (search: Record<string, unknown>): {
|
||||
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,
|
||||
|
|
|
|||
106
web/src/features/skill/skill-avatar-editor.test.tsx
Normal file
106
web/src/features/skill/skill-avatar-editor.test.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const api = vi.hoisted(() => ({ stage: vi.fn() }))
|
||||
vi.mock('@/api/client', () => ({ skillAvatarApi: api, buildApiUrl: (url: string) => url }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
import { SkillAvatarEditor } from './skill-avatar-editor'
|
||||
|
||||
function selectImage() {
|
||||
fireEvent.change(screen.getByLabelText('skillAvatar.choose'), { target: { files: [new File(['image'], 'avatar.png', { type: 'image/png' })] } })
|
||||
}
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(done => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
const uploaded = { uploadId: 'private-upload-1', previewUrl: '/private/preview', expiresAt: '2099-01-01T00:00:00Z' }
|
||||
|
||||
describe('SkillAvatarEditor staged uploads', () => {
|
||||
beforeEach(() => {
|
||||
api.stage.mockReset()
|
||||
vi.stubGlobal('Image', class { src = ''; naturalWidth = 1024; naturalHeight = 768; decode() { return Promise.resolve() } })
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:chosen'), revokeObjectURL: vi.fn() })
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage: vi.fn() } as unknown as CanvasRenderingContext2D)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(callback => callback(new Blob(['cropped'], { type: 'image/png' })))
|
||||
})
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
it('requires upload before submit and stages the cropped file without associating a skill', async () => {
|
||||
const change = vi.fn()
|
||||
const task = deferred<typeof uploaded>()
|
||||
api.stage.mockReturnValue(task.promise)
|
||||
render(<SkillAvatarEditor name="Foo" currentUrl="/active" onChange={change} />)
|
||||
selectImage()
|
||||
expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' }))
|
||||
await waitFor(() => expect(api.stage).toHaveBeenCalledOnce())
|
||||
expect(api.stage.mock.calls[0][0]).toBeInstanceOf(Blob)
|
||||
expect(screen.getByRole('status').textContent).toBe('skillAvatar.uploadStates.UPLOADING')
|
||||
task.resolve(uploaded)
|
||||
await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true }))
|
||||
expect(screen.getByAltText('skillAvatar.cropPreview').getAttribute('src')).toBe(uploaded.previewUrl)
|
||||
})
|
||||
|
||||
it('blocks invalid selections until canceled without touching the active avatar', () => {
|
||||
const change = vi.fn()
|
||||
render(<SkillAvatarEditor name="Foo" currentUrl="/active" onChange={change} />)
|
||||
fireEvent.change(screen.getByLabelText('skillAvatar.choose'), { target: { files: [new File(['bad'], 'bad.svg', { type: 'image/svg+xml' })] } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('skillAvatar.invalid')
|
||||
expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false })
|
||||
expect(api.stage).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.discardChange' }))
|
||||
expect(change).toHaveBeenLastCalledWith({ action: 'KEEP', ready: true })
|
||||
expect(screen.getByRole('status').textContent).toBe('skillAvatar.uploadStates.KEEP')
|
||||
})
|
||||
|
||||
it('keeps submission blocked after upload failure and allows a successful retry', async () => {
|
||||
const change = vi.fn()
|
||||
api.stage.mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValueOnce(uploaded)
|
||||
render(<SkillAvatarEditor name="Foo" onChange={change} />)
|
||||
selectImage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' }))
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('storage unavailable')
|
||||
expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.uploadRetry' }))
|
||||
await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true }))
|
||||
})
|
||||
|
||||
it.each(['discardChange', 'remove'] as const)('ignores an old upload completion after %s', async action => {
|
||||
const change = vi.fn()
|
||||
const task = deferred<typeof uploaded>()
|
||||
api.stage.mockReturnValue(task.promise)
|
||||
render(<SkillAvatarEditor name="Foo" currentUrl="/active" canRemove onChange={change} />)
|
||||
selectImage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' }))
|
||||
await waitFor(() => expect(api.stage).toHaveBeenCalledOnce())
|
||||
fireEvent.click(screen.getByRole('button', { name: `skillAvatar.${action}` }))
|
||||
task.resolve(uploaded)
|
||||
await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: action === 'remove' ? 'REMOVE' : 'KEEP', ready: true }))
|
||||
expect(change.mock.calls.some(([value]) => value.uploadId)).toBe(false)
|
||||
expect(screen.queryByAltText('skillAvatar.cropPreview')).toBeNull()
|
||||
})
|
||||
|
||||
it('invalidates a staged reference when a different image is selected', async () => {
|
||||
const change = vi.fn()
|
||||
api.stage.mockResolvedValue(uploaded)
|
||||
render(<SkillAvatarEditor name="Foo" onChange={change} />)
|
||||
selectImage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' }))
|
||||
await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true }))
|
||||
selectImage()
|
||||
expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false })
|
||||
expect(screen.getByRole('status').textContent).toBe('skillAvatar.uploadStates.SELECTED')
|
||||
})
|
||||
|
||||
it('previews removal as a draft and cancel restores the original image', () => {
|
||||
const change = vi.fn()
|
||||
render(<SkillAvatarEditor name="Foo" currentUrl="/active" canRemove onChange={change} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.remove' }))
|
||||
expect(change).toHaveBeenLastCalledWith({ action: 'REMOVE', ready: true })
|
||||
expect(screen.getByText('F')).toBeTruthy()
|
||||
expect(api.stage).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.discardChange' }))
|
||||
expect(document.querySelector('img')?.getAttribute('src')).toBe('/active')
|
||||
})
|
||||
})
|
||||
100
web/src/features/skill/skill-avatar-editor.tsx
Normal file
100
web/src/features/skill/skill-avatar-editor.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { buildApiUrl, skillAvatarApi } from '@/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { SkillAvatar } from './skill-avatar'
|
||||
|
||||
export type AvatarEdit = { action: 'KEEP' | 'REPLACE' | 'REMOVE'; uploadId?: string; ready: boolean }
|
||||
export const KEEP_AVATAR: AvatarEdit = { action: 'KEEP', ready: true }
|
||||
|
||||
async function cropSquare(source: string, zoom: number): Promise<Blob> {
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = canvas.height = 512
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('Canvas unavailable')
|
||||
const side = Math.min(image.naturalWidth, image.naturalHeight) / zoom
|
||||
context.drawImage(image, (image.naturalWidth - side) / 2, (image.naturalHeight - side) / 2, side, side, 0, 0, 512, 512)
|
||||
return new Promise((resolve, reject) => canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Image conversion failed')), 'image/png'))
|
||||
}
|
||||
|
||||
/** A private upload is staged here; only the parent's successful submit associates it. */
|
||||
export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disabled = false, onChange }: {
|
||||
name: string; currentUrl?: string | null; canRemove?: boolean; disabled?: boolean; onChange: (edit: AvatarEdit) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [sourceUrl, setSourceUrl] = useState<string | null>(null)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [state, setState] = useState<'KEEP' | 'SELECTED' | 'UPLOADING' | 'READY' | 'ERROR' | 'REMOVE'>('KEEP')
|
||||
const [error, setError] = useState('')
|
||||
const generation = useRef(0)
|
||||
const input = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => () => { generation.current += 1 }, [])
|
||||
useEffect(() => () => { if (sourceUrl) URL.revokeObjectURL(sourceUrl) }, [sourceUrl])
|
||||
|
||||
function reset(remove = false) {
|
||||
generation.current += 1
|
||||
setSourceUrl(null)
|
||||
setPreviewUrl(null)
|
||||
setError('')
|
||||
setState(remove ? 'REMOVE' : 'KEEP')
|
||||
if (input.current) input.current.value = ''
|
||||
onChange(remove ? { action: 'REMOVE', ready: true } : KEEP_AVATAR)
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
if (!sourceUrl) return
|
||||
const attempt = ++generation.current
|
||||
setState('UPLOADING')
|
||||
setError('')
|
||||
onChange({ action: 'REPLACE', ready: false })
|
||||
try {
|
||||
const staged = await skillAvatarApi.stage(await cropSquare(sourceUrl, zoom))
|
||||
if (attempt !== generation.current) return
|
||||
setPreviewUrl(staged.previewUrl)
|
||||
setState('READY')
|
||||
onChange({ action: 'REPLACE', uploadId: staged.uploadId, ready: true })
|
||||
} catch (cause) {
|
||||
if (attempt !== generation.current) return
|
||||
setState('ERROR')
|
||||
setError(cause instanceof Error ? cause.message : t('skillAvatar.failed'))
|
||||
}
|
||||
}
|
||||
|
||||
return <section className="space-y-3" aria-label={t('skillAvatar.title')}>
|
||||
<h2 className="font-semibold">{t('skillAvatar.optionalTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('skillAvatar.stagedHelp')}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{state === 'READY' && previewUrl ? <img src={buildApiUrl(previewUrl)} alt={t('skillAvatar.cropPreview')} className="h-20 w-20 rounded-lg object-cover" />
|
||||
: <SkillAvatar name={name} url={state === 'REMOVE' ? null : currentUrl} size="h-20 w-20 text-2xl" />}
|
||||
<p role="status" className="text-sm text-muted-foreground">{t(`skillAvatar.uploadStates.${state}`)}</p>
|
||||
</div>
|
||||
<Input ref={input} type="file" disabled={disabled || state === 'UPLOADING'} accept="image/png,image/jpeg" aria-label={t('skillAvatar.choose')} onChange={event => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
generation.current += 1
|
||||
setPreviewUrl(null)
|
||||
setSourceUrl(null)
|
||||
onChange({ action: 'REPLACE', ready: false })
|
||||
if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 2 * 1024 * 1024) {
|
||||
setState('ERROR'); setError(t('skillAvatar.invalid')); return
|
||||
}
|
||||
setSourceUrl(URL.createObjectURL(file))
|
||||
setZoom(1); setError(''); setState('SELECTED')
|
||||
}} />
|
||||
{sourceUrl && state !== 'READY' && <div className="space-y-2">
|
||||
<div className="h-40 w-40 overflow-hidden rounded-lg border"><img src={sourceUrl} alt={t('skillAvatar.cropPreview')} className="h-full w-full object-cover" style={{ transform: `scale(${zoom})` }} /></div>
|
||||
<label className="block text-sm">{t('skillAvatar.zoom')} <input type="range" min="1" max="3" step="0.1" value={zoom} disabled={disabled || state === 'UPLOADING'} onChange={event => setZoom(Number(event.target.value))} /></label>
|
||||
<Button type="button" disabled={disabled || state === 'UPLOADING'} onClick={() => void upload()}>{t(state === 'ERROR' ? 'skillAvatar.uploadRetry' : 'skillAvatar.cropUpload')}</Button>
|
||||
</div>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state !== 'KEEP' && <Button type="button" variant="outline" disabled={disabled} onClick={() => reset()}>{t('skillAvatar.discardChange')}</Button>}
|
||||
{canRemove && state !== 'REMOVE' && <Button type="button" variant="outline" disabled={disabled} onClick={() => reset(true)}>{t('skillAvatar.remove')}</Button>}
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
</section>
|
||||
}
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const api = vi.hoisted(() => ({ status: vi.fn() }))
|
||||
const api = vi.hoisted(() => ({ status: vi.fn(), delete: vi.fn(), submit: vi.fn() }))
|
||||
vi.mock('@/api/client', () => ({ skillAvatarApi: api, buildApiUrl: (url: string) => url }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
import { SkillAvatarManager } from './skill-avatar-manager'
|
||||
|
||||
describe('SkillAvatarManager appeal', () => {
|
||||
afterEach(() => { cleanup(); api.status.mockReset() })
|
||||
afterEach(() => { cleanup(); api.status.mockReset(); api.delete.mockReset(); api.submit.mockReset() })
|
||||
|
||||
it.each([
|
||||
['PENDING', 'BLOCKED', 'NOT_REQUIRED', true],
|
||||
|
|
@ -21,7 +21,7 @@ describe('SkillAvatarManager appeal', () => {
|
|||
api.status.mockResolvedValue({ avatarUrl: null, candidateId: 7, candidateStatus, machineStatus, reviewStatus, candidateUrl: null, reviewReason: null })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" /></QueryClientProvider>)
|
||||
expect((await screen.findByRole('status')).textContent).toContain(`skillAvatar.states.${candidateStatus}`)
|
||||
expect(await screen.findByText(new RegExp(`skillAvatar.states.${candidateStatus}`))).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'skillAvatar.appeal' }) !== null).toBe(expected)
|
||||
})
|
||||
|
||||
|
|
@ -36,4 +36,26 @@ describe('SkillAvatarManager appeal', () => {
|
|||
expect(screen.getByText('skillAvatar.automaticRetryStopped')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'skillAvatar.retry' }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
it('renders detail status without upload, removal or retry controls', async () => {
|
||||
api.status.mockResolvedValue({ avatarUrl: '/active', candidateId: 7, candidateStatus: 'REJECTED', machineStatus: 'BLOCKED', reviewStatus: 'REJECTED', reviewReason: 'not suitable' })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" readOnly /></QueryClientProvider>)
|
||||
expect(await screen.findByText('not suitable')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('skillAvatar.choose')).toBeNull()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not remove an avatar until save and preserves the draft when the server rejects it', async () => {
|
||||
api.status.mockResolvedValue({ avatarUrl: '/active', candidateId: 7, candidateStatus: 'ACTIVE', machineStatus: 'SKIPPED', reviewStatus: 'APPROVED' })
|
||||
api.delete.mockRejectedValue(new Error('save failed'))
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" /></QueryClientProvider>)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'skillAvatar.remove' }))
|
||||
expect(api.delete).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.saveChanges' }))
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('save failed')
|
||||
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('team', 'foo'))
|
||||
expect(screen.getByText('skillAvatar.uploadStates.REMOVE')).toBeTruthy()
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,89 +1,57 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { buildApiUrl, skillAvatarApi } from '@/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { SkillAvatarEditor, KEEP_AVATAR, type AvatarEdit } from './skill-avatar-editor'
|
||||
|
||||
function cropSquare(image: HTMLImageElement, zoom: number): Promise<Blob> {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 512
|
||||
canvas.height = 512
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return Promise.reject(new Error('Canvas unavailable'))
|
||||
const side = Math.min(image.naturalWidth, image.naturalHeight) / zoom
|
||||
context.drawImage(image, (image.naturalWidth - side) / 2, (image.naturalHeight - side) / 2, side, side, 0, 0, 512, 512)
|
||||
return new Promise((resolve, reject) => canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error('Image conversion failed')), 'image/png'))
|
||||
}
|
||||
|
||||
export function SkillAvatarManager({ namespace, slug }: { namespace: string; slug: string }) {
|
||||
export function SkillAvatarManager({ namespace, slug, name = slug, readOnly = false }: { namespace: string; slug: string; name?: string; readOnly?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const key = ['skill-avatar', namespace, slug]
|
||||
const status = useQuery({ queryKey: key, queryFn: () => skillAvatarApi.status(namespace, slug) })
|
||||
const [sourceUrl, setSourceUrl] = useState<string | null>(null)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [edit, setEdit] = useState<AvatarEdit>(KEEP_AVATAR)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
useEffect(() => () => { if (sourceUrl) URL.revokeObjectURL(sourceUrl) }, [sourceUrl])
|
||||
const [editorVersion, setEditorVersion] = useState(0)
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (action: { kind: 'upload'; blob: Blob } | { kind: 'remove' | 'appeal' | 'retry'; id?: number }) => {
|
||||
if (action.kind === 'upload') return skillAvatarApi.upload(namespace, slug, action.blob)
|
||||
mutationFn: async (action: { kind: 'upload'; uploadId: string } | { kind: 'remove' | 'appeal' | 'retry'; id?: number }) => {
|
||||
if (action.kind === 'upload') return skillAvatarApi.submit(namespace, slug, action.uploadId)
|
||||
if (action.kind === 'remove') return skillAvatarApi.delete(namespace, slug)
|
||||
if (action.kind === 'appeal') return skillAvatarApi.appeal(namespace, slug, action.id!)
|
||||
return skillAvatarApi.retry(namespace, slug, action.id!)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (_data, action) => {
|
||||
setError('')
|
||||
setSourceUrl(null)
|
||||
if (action.kind === 'upload' || action.kind === 'remove') {
|
||||
setEdit(KEEP_AVATAR)
|
||||
setEditorVersion(value => value + 1)
|
||||
}
|
||||
await Promise.all([queryClient.invalidateQueries({ queryKey: key }), queryClient.invalidateQueries({ queryKey: ['skill'] }), queryClient.invalidateQueries({ queryKey: ['skills'] })])
|
||||
},
|
||||
onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')),
|
||||
})
|
||||
async function submit() {
|
||||
if (!sourceUrl) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const image = new Image()
|
||||
image.src = sourceUrl
|
||||
await image.decode()
|
||||
await mutation.mutateAsync({ kind: 'upload', blob: await cropSquare(image, zoom) })
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t('skillAvatar.failed'))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
const candidate = status.data
|
||||
const retryCoolingDown = !!candidate?.machineNextRetryAt && Date.parse(candidate.machineNextRetryAt) > Date.now()
|
||||
return (
|
||||
<Card className="space-y-3 p-4">
|
||||
<h2 className="font-semibold">{t('skillAvatar.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('skillAvatar.ownerHelp')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t(readOnly ? 'skillAvatar.detailHelp' : 'skillAvatar.ownerHelp')}</p>
|
||||
{candidate?.candidateStatus && <p role="status" className="text-sm">{t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)} · {t(`skillAvatar.machine.${candidate.machineStatus ?? 'SKIPPED'}`)}</p>}
|
||||
{candidate?.reviewReason && <p className="text-sm">{candidate.reviewReason}</p>}
|
||||
{candidate?.machineStatus === 'ERROR' && retryCoolingDown && <p className="text-sm text-muted-foreground">{t('skillAvatar.retryAfter', { time: new Date(candidate.machineNextRetryAt!).toLocaleString() })}</p>}
|
||||
{candidate?.machineStatus === 'ERROR' && candidate.automaticRetryExhausted && <p className="text-sm text-muted-foreground">{t('skillAvatar.automaticRetryStopped')}</p>}
|
||||
{candidate?.candidateUrl && <img src={buildApiUrl(candidate.candidateUrl)} alt={t('skillAvatar.candidatePreview')} className="h-24 w-24 rounded-lg object-cover" />}
|
||||
<Input type="file" accept="image/png,image/jpeg" aria-label={t('skillAvatar.choose')} onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 2 * 1024 * 1024) { setError(t('skillAvatar.invalid')); return }
|
||||
if (sourceUrl) URL.revokeObjectURL(sourceUrl)
|
||||
setSourceUrl(URL.createObjectURL(file))
|
||||
setZoom(1)
|
||||
setError('')
|
||||
}} />
|
||||
{sourceUrl && <div className="space-y-2">
|
||||
<div className="h-40 w-40 overflow-hidden rounded-lg border"><img src={sourceUrl} alt={t('skillAvatar.cropPreview')} className="h-full w-full object-cover" style={{ transform: `scale(${zoom})` }} /></div>
|
||||
<label className="block text-sm">{t('skillAvatar.zoom')} <input type="range" min="1" max="3" step="0.1" value={zoom} onChange={(event) => setZoom(Number(event.target.value))} /></label>
|
||||
<Button disabled={busy || mutation.isPending} onClick={() => void submit()}>{t('skillAvatar.submit')}</Button>
|
||||
</div>}
|
||||
{!readOnly && <>
|
||||
<SkillAvatarEditor key={editorVersion} name={name} currentUrl={candidate?.avatarUrl} canRemove={!!candidate?.avatarUrl || candidate?.candidateStatus === 'PENDING'} disabled={mutation.isPending || status.isLoading || status.isError} onChange={setEdit} />
|
||||
<Button disabled={edit.action === 'KEEP' || !edit.ready || mutation.isPending || status.isLoading || status.isError} onClick={() => mutation.mutate(edit.action === 'REMOVE' ? { kind: 'remove' } : { kind: 'upload', uploadId: edit.uploadId! })}>{t('skillAvatar.saveChanges')}</Button>
|
||||
</>}
|
||||
{status.isError && <p role="alert" className="text-sm text-destructive">{t('skillAvatar.failed')}</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(candidate?.avatarUrl || candidate?.candidateStatus === 'PENDING') && <Button variant="outline" disabled={mutation.isPending} onClick={() => void mutation.mutateAsync({ kind: 'remove' })}>{t('skillAvatar.remove')}</Button>}
|
||||
{candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED'
|
||||
{!readOnly && candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED'
|
||||
&& candidate.reviewStatus === 'NOT_REQUIRED' && candidate.candidateId
|
||||
&& <Button variant="outline" disabled={mutation.isPending} onClick={() => void mutation.mutateAsync({ kind: 'appeal', id: candidate.candidateId! })}>{t('skillAvatar.appeal')}</Button>}
|
||||
{candidate?.machineStatus === 'ERROR' && candidate.candidateId && <Button variant="outline" disabled={mutation.isPending || retryCoolingDown} onClick={() => void mutation.mutateAsync({ kind: 'retry', id: candidate.candidateId! })}>{t('skillAvatar.retry')}</Button>}
|
||||
&& <Button variant="outline" disabled={mutation.isPending} onClick={() => mutation.mutate({ kind: 'appeal', id: candidate.candidateId! })}>{t('skillAvatar.appeal')}</Button>}
|
||||
{!readOnly && candidate?.machineStatus === 'ERROR' && candidate.candidateId && <Button variant="outline" disabled={mutation.isPending || retryCoolingDown} onClick={() => mutation.mutate({ kind: 'retry', id: candidate.candidateId! })}>{t('skillAvatar.retry')}</Button>}
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -2596,6 +2596,24 @@
|
|||
}
|
||||
},
|
||||
"skillAvatar": {
|
||||
"submittedNotice": "Avatar submitted separately. Check its review status under My skills → Manage avatar.",
|
||||
"removedNotice": "Avatar removed; the first letter is shown.",
|
||||
"detailHelp": "Avatar and skill content are reviewed separately. To change, appeal or retry, select Manage avatar in My skills.",
|
||||
"optionalTitle": "Skill avatar (optional)",
|
||||
"stagedHelp": "Choose a PNG or JPEG under 2 MB, crop and upload it first. Changes take effect only after submission succeeds. Avatar review is separate from skill review.",
|
||||
"cropUpload": "Upload cropped image",
|
||||
"uploadRetry": "Retry upload",
|
||||
"discardChange": "Cancel avatar change",
|
||||
"saveChanges": "Save avatar changes",
|
||||
"manage": "Manage avatar",
|
||||
"uploadStates": {
|
||||
"KEEP": "Current avatar will be kept. No avatar uses the first letter.",
|
||||
"SELECTED": "Crop and upload before submitting.",
|
||||
"UPLOADING": "Uploading image…",
|
||||
"READY": "Uploaded privately. Submit to apply and start avatar review.",
|
||||
"ERROR": "Upload failed. Retry or cancel this change before submitting.",
|
||||
"REMOVE": "Avatar will be removed after successful submission."
|
||||
},
|
||||
"title": "Skill avatar",
|
||||
"ownerHelp": "Upload and crop a square image. The current avatar stays visible until the new one is approved.",
|
||||
"status": "Status",
|
||||
|
|
|
|||
|
|
@ -2598,6 +2598,24 @@
|
|||
"reset": "Попробовать снова"
|
||||
},
|
||||
"skillAvatar": {
|
||||
"submittedNotice": "Аватар отправлен отдельно. Статус проверки доступен в «Мои навыки → Управление аватаром».",
|
||||
"removedNotice": "Аватар удалён; отображается первая буква.",
|
||||
"detailHelp": "Аватар и содержимое навыка проверяются отдельно. Для изменения, апелляции или повторной проверки выберите «Управление аватаром» в «Мои навыки».",
|
||||
"optionalTitle": "Аватар навыка (необязательно)",
|
||||
"stagedHelp": "Выберите PNG или JPEG до 2 МБ, обрежьте и загрузите. Изменения применяются после успешной отправки. Аватар и содержимое навыка проверяются отдельно.",
|
||||
"cropUpload": "Загрузить обрезанное изображение",
|
||||
"uploadRetry": "Повторить загрузку",
|
||||
"discardChange": "Отменить изменение аватара",
|
||||
"saveChanges": "Сохранить аватар",
|
||||
"manage": "Управление аватаром",
|
||||
"uploadStates": {
|
||||
"KEEP": "Текущий аватар сохраняется. Без аватара отображается первая буква.",
|
||||
"SELECTED": "Обрежьте и загрузите изображение перед отправкой.",
|
||||
"UPLOADING": "Загрузка изображения…",
|
||||
"READY": "Изображение загружено в закрытое хранилище. Отправьте для применения и проверки.",
|
||||
"ERROR": "Ошибка загрузки. Повторите или отмените изменение перед отправкой.",
|
||||
"REMOVE": "Аватар будет удалён после успешной отправки."
|
||||
},
|
||||
"title": "Аватар навыка",
|
||||
"ownerHelp": "Загрузите и обрежьте квадратное изображение. Текущий аватар останется до одобрения нового.",
|
||||
"status": "Статус",
|
||||
|
|
|
|||
|
|
@ -2595,6 +2595,24 @@
|
|||
}
|
||||
},
|
||||
"skillAvatar": {
|
||||
"submittedNotice": "头像修改已单独提交,可在“我的技能 → 管理头像”查看结果。",
|
||||
"removedNotice": "头像已移除,恢复首字母。",
|
||||
"detailHelp": "头像与技能内容分别审核。如需修改、申诉或重试,请在“我的技能”选择“管理头像”。",
|
||||
"optionalTitle": "技能头像(选填)",
|
||||
"stagedHelp": "选择 2 MB 以下的 PNG 或 JPEG,裁剪后先上传。提交成功后才应用修改,头像审核与技能内容审核分别进行。",
|
||||
"cropUpload": "上传裁剪图片",
|
||||
"uploadRetry": "重试上传",
|
||||
"discardChange": "取消头像修改",
|
||||
"saveChanges": "保存头像修改",
|
||||
"manage": "管理头像",
|
||||
"uploadStates": {
|
||||
"KEEP": "保留当前头像;未设置时显示名称首字母。",
|
||||
"SELECTED": "请先裁剪并上传,成功后才能提交。",
|
||||
"UPLOADING": "图片上传中…",
|
||||
"READY": "已上传至私有存储,提交后才应用并进入头像审核。",
|
||||
"ERROR": "上传失败,请重试或取消头像修改后再提交。",
|
||||
"REMOVE": "提交成功后移除头像,恢复首字母。"
|
||||
},
|
||||
"title": "技能头像",
|
||||
"ownerHelp": "上传并裁剪方形图片。新头像通过审核后才会替换当前头像。",
|
||||
"status": "状态",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,8 @@ describe('MySkillsPage', () => {
|
|||
to: '/dashboard/publish',
|
||||
search: {
|
||||
namespace: 'team-ai',
|
||||
updateSkill: 'team-agent',
|
||||
updateName: 'Team Agent',
|
||||
visibility: 'PRIVATE',
|
||||
},
|
||||
})
|
||||
|
|
@ -219,6 +221,8 @@ describe('MySkillsPage', () => {
|
|||
to: '/dashboard/publish',
|
||||
search: {
|
||||
namespace: 'team-ai',
|
||||
updateSkill: 'default-visibility-agent',
|
||||
updateName: 'Default Visibility Agent',
|
||||
visibility: 'PUBLIC',
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPen
|
|||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { buildReturnTo } from '@/shared/lib/auth-route'
|
||||
import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/shared/ui/dialog'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { getMySkillEmptyStateKey, getMySkillFilters, type MySkillFilter } from './my-skill-filters'
|
||||
|
||||
|
|
@ -57,6 +59,7 @@ export function MySkillsPage() {
|
|||
// Input remains local until the user explicitly submits the search.
|
||||
const [keywordInput, setKeywordInput] = useState(keyword)
|
||||
|
||||
const [avatarTarget, setAvatarTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
|
||||
const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
|
||||
const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
|
||||
const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null)
|
||||
|
|
@ -109,10 +112,10 @@ export function MySkillsPage() {
|
|||
updateSearch({ q: undefined, namespace: undefined, page: 0 })
|
||||
}
|
||||
|
||||
const handleUpdateSkill = (namespace: string, visibility?: string) => {
|
||||
const handleUpdateSkill = (namespace: string, slug: string, name: string, visibility?: string) => {
|
||||
navigate({
|
||||
to: '/dashboard/publish',
|
||||
search: { namespace, visibility: visibility || 'PUBLIC' },
|
||||
search: { namespace, updateSkill: slug, updateName: name, visibility: visibility || 'PUBLIC' },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -420,7 +423,11 @@ export function MySkillsPage() {
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<div className="flex flex-wrap items-center gap-2 pl-4">
|
||||
{filter !== 'HIDDEN' && <Button size="sm" variant="outline" onClick={event => {
|
||||
event.stopPropagation()
|
||||
setAvatarTarget({ namespace: skill.namespace, slug: skill.slug, name: skill.displayName })
|
||||
}}>{t('skillAvatar.manage')}</Button>}
|
||||
{canRestoreHidden ? (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -438,7 +445,7 @@ export function MySkillsPage() {
|
|||
variant="outline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleUpdateSkill(skill.namespace, skill.visibility ?? 'PUBLIC')
|
||||
handleUpdateSkill(skill.namespace, skill.slug, skill.displayName, skill.visibility ?? 'PUBLIC')
|
||||
}}
|
||||
>
|
||||
{t('mySkills.update')}
|
||||
|
|
@ -538,6 +545,13 @@ export function MySkillsPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={!!avatarTarget} onOpenChange={open => { if (!open) setAvatarTarget(null) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{t('skillAvatar.manage')}</DialogTitle><DialogDescription>{avatarTarget?.name}</DialogDescription></DialogHeader>
|
||||
{avatarTarget && <SkillAvatarManager namespace={avatarTarget.namespace} slug={avatarTarget.slug} name={avatarTarget.name} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!restoreHiddenTarget}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
83
web/src/pages/dashboard/publish-avatar.test.tsx
Normal file
83
web/src/pages/dashboard/publish-avatar.test.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const mocks = vi.hoisted(() => ({ publish: vi.fn(), navigate: vi.fn(), search: { namespace: 'team', visibility: 'PUBLIC', updateSkill: 'foo' } }))
|
||||
vi.mock('@tanstack/react-query', () => ({ useQuery: () => ({ data: { avatarUrl: '/owner-active' }, isLoading: false, isError: false }) }))
|
||||
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => mocks.navigate, useSearch: () => mocks.search }))
|
||||
vi.mock('react-i18next', async () => ({ ...await vi.importActual<typeof import('react-i18next')>('react-i18next'), useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
||||
usePublishSkill: () => ({ mutateAsync: mocks.publish, isPending: false }),
|
||||
useSkillDetail: () => ({ data: { displayName: 'Foo', avatarUrl: '/active' }, isLoading: false, isError: false }),
|
||||
}))
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({ useMyNamespaces: () => ({ data: [{ slug: 'team', displayName: 'Team' }], isLoading: false }) }))
|
||||
vi.mock('@/features/publish/upload-zone', () => ({ UploadZone: ({ onFileSelect }: { onFileSelect: (file: File) => void }) => <button onClick={() => onFileSelect(new File(['zip'], 'skill.zip'))}>choose package</button> }))
|
||||
vi.mock('@/features/skill/skill-avatar-editor', () => ({
|
||||
KEEP_AVATAR: { action: 'KEEP', ready: true },
|
||||
SkillAvatarEditor: ({ onChange }: { onChange: (value: { action: 'KEEP' | 'REPLACE' | 'REMOVE'; ready: boolean; uploadId?: string }) => void }) => <>
|
||||
<button onClick={() => onChange({ action: 'REPLACE', ready: false })}>image pending</button>
|
||||
<button onClick={() => onChange({ action: 'REPLACE', ready: true, uploadId: 'private-reference' })}>image uploaded</button>
|
||||
<button onClick={() => onChange({ action: 'REMOVE', ready: true })}>remove draft</button>
|
||||
</>,
|
||||
}))
|
||||
vi.mock('@/shared/lib/navigate-after-overlays', () => ({ navigateAfterOverlays: (fn: () => void) => fn() }))
|
||||
vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
import { PublishPage } from './publish'
|
||||
|
||||
describe('Publish avatar submission', () => {
|
||||
beforeEach(() => { mocks.publish.mockReset(); mocks.navigate.mockReset(); mocks.search.updateSkill = 'foo' })
|
||||
afterEach(cleanup)
|
||||
|
||||
it('blocks publishing a selected image until staged upload completes', async () => {
|
||||
mocks.publish.mockResolvedValue({ namespace: 'team', slug: 'foo', version: '1.1', status: 'PENDING_REVIEW' })
|
||||
render(<PublishPage />)
|
||||
fireEvent.click(screen.getByText('choose package'))
|
||||
fireEvent.click(screen.getByText('image pending'))
|
||||
expect(screen.getByRole('button', { name: 'publish.confirm' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByText('image uploaded'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(expect.objectContaining({ avatarAction: 'REPLACE', avatarUploadId: 'private-reference', expectedSlug: 'foo' })))
|
||||
})
|
||||
|
||||
it('preserves the uploaded reference after a failed skill submission so retry does not reupload', async () => {
|
||||
mocks.publish.mockRejectedValueOnce(new Error('package invalid')).mockResolvedValueOnce({ namespace: 'team', slug: 'foo', version: '1.1', status: 'PUBLISHED' })
|
||||
render(<PublishPage />)
|
||||
fireEvent.click(screen.getByText('choose package'))
|
||||
fireEvent.click(screen.getByText('image uploaded'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.navigate).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledTimes(2))
|
||||
expect(mocks.publish.mock.calls[1][0]).toEqual(mocks.publish.mock.calls[0][0])
|
||||
})
|
||||
|
||||
it('sends removal only with the successful update request, not while editing', async () => {
|
||||
mocks.publish.mockResolvedValue({ namespace: 'team', slug: 'foo', version: '1.1', status: 'PENDING_REVIEW' })
|
||||
render(<PublishPage />)
|
||||
fireEvent.click(screen.getByText('remove draft'))
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByText('choose package'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(expect.objectContaining({ avatarAction: 'REMOVE', expectedSlug: 'foo' })))
|
||||
})
|
||||
|
||||
it('keeps the existing avatar by default when updating only skill content', async () => {
|
||||
mocks.publish.mockResolvedValue({ namespace: 'team', slug: 'foo', version: '1.1', status: 'PENDING_REVIEW' })
|
||||
render(<PublishPage />)
|
||||
fireEvent.click(screen.getByText('choose package'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(expect.objectContaining({ avatarAction: 'KEEP', avatarUploadId: undefined, expectedSlug: 'foo' })))
|
||||
})
|
||||
it('clears the avatar draft when navigating to another skill in the same namespace', async () => {
|
||||
mocks.publish.mockResolvedValue({ namespace: 'team', slug: 'bar', version: '1.1', status: 'PENDING_REVIEW' })
|
||||
const view = render(<PublishPage />)
|
||||
fireEvent.click(screen.getByText('choose package'))
|
||||
fireEvent.click(screen.getByText('remove draft'))
|
||||
mocks.search.updateSkill = 'bar'
|
||||
view.rerender(<PublishPage />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish.confirm' }))
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(expect.objectContaining({ avatarAction: 'KEEP', avatarUploadId: undefined, expectedSlug: 'bar' })))
|
||||
})
|
||||
|
||||
})
|
||||
|
|
@ -5,6 +5,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
const useSearchMock = vi.fn()
|
||||
const selectRecords: Array<{ value?: string }> = []
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({ useQuery: () => ({ data: null, isLoading: false, isError: false }) }))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearch: () => useSearchMock(),
|
||||
|
|
@ -20,6 +22,8 @@ vi.mock('react-i18next', async () => {
|
|||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/skill/skill-avatar-editor', () => ({ SkillAvatarEditor: () => null, KEEP_AVATAR: { action: 'KEEP', ready: true } }))
|
||||
|
||||
vi.mock('@/features/publish/upload-zone', () => ({
|
||||
UploadZone: () => null,
|
||||
}))
|
||||
|
|
@ -49,6 +53,7 @@ vi.mock('@/shared/ui/card', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
||||
useSkillDetail: () => ({ data: null, isLoading: false, isError: false }),
|
||||
usePublishSkill: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { UploadZone } from '@/features/publish/upload-zone'
|
||||
|
|
@ -22,13 +23,14 @@ import {
|
|||
} from '@/shared/ui/select'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { SkillAvatarEditor, KEEP_AVATAR, type AvatarEdit } from '@/features/skill/skill-avatar-editor'
|
||||
import { usePublishSkill } from '@/shared/hooks/use-skill-queries'
|
||||
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { navigateAfterOverlays } from '@/shared/lib/navigate-after-overlays'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { ApiError, skillAvatarApi } from '@/api/client'
|
||||
|
||||
const EMPTY_NAMESPACE_VALUE = '__select_namespace__'
|
||||
|
||||
|
|
@ -37,6 +39,8 @@ export function PublishPage() {
|
|||
const navigate = useNavigate()
|
||||
const search = useSearch({ from: '/dashboard/publish' })
|
||||
const prefill = normalizePublishPrefill(search)
|
||||
const updateSlug = typeof search.updateSkill === 'string' ? search.updateSkill : prefill.resubmitSkill
|
||||
const [avatarEdit, setAvatarEdit] = useState<AvatarEdit>(KEEP_AVATAR)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [namespaceSlug, setNamespaceSlug] = useState<string>(prefill.namespace)
|
||||
const [visibility, setVisibility] = useState<string>(prefill.visibility)
|
||||
|
|
@ -46,6 +50,8 @@ export function PublishPage() {
|
|||
|
||||
const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces()
|
||||
const publishMutation = usePublishSkill()
|
||||
const avatarTarget = useQuery({ queryKey: ['skill-avatar', namespaceSlug, updateSlug], queryFn: () => skillAvatarApi.status(namespaceSlug, updateSlug), enabled: !!namespaceSlug && !!updateSlug })
|
||||
const avatarUnavailable = !!updateSlug && (avatarTarget.isLoading || avatarTarget.isError)
|
||||
const selectedNamespace = namespaces?.find((ns) => ns.slug === namespaceSlug)
|
||||
const namespaceOnlyLabel = selectedNamespace?.type === 'GLOBAL'
|
||||
? t('publish.visibilityOptions.loggedInUsersOnly')
|
||||
|
|
@ -54,7 +60,8 @@ export function PublishPage() {
|
|||
useEffect(() => {
|
||||
setNamespaceSlug(prefill.namespace)
|
||||
setVisibility(prefill.visibility)
|
||||
}, [prefill.namespace, prefill.visibility])
|
||||
setAvatarEdit(KEEP_AVATAR)
|
||||
}, [prefill.namespace, prefill.visibility, updateSlug])
|
||||
|
||||
const handleRemoveSelectedFile = () => {
|
||||
setSelectedFile(null)
|
||||
|
|
@ -81,7 +88,7 @@ export function PublishPage() {
|
|||
}
|
||||
|
||||
const publishSkill = async (confirmWarnings = false) => {
|
||||
if (!selectedFile || !namespaceSlug) {
|
||||
if (!selectedFile || !namespaceSlug || !avatarEdit.ready || avatarUnavailable) {
|
||||
toast.error(t('publish.selectRequired'))
|
||||
return
|
||||
}
|
||||
|
|
@ -92,19 +99,23 @@ export function PublishPage() {
|
|||
file: selectedFile,
|
||||
visibility,
|
||||
confirmWarnings,
|
||||
avatarAction: avatarEdit.action,
|
||||
avatarUploadId: avatarEdit.uploadId,
|
||||
expectedSlug: updateSlug || undefined,
|
||||
})
|
||||
setPrecheckWarnings([])
|
||||
setWarningDialogOpen(false)
|
||||
const avatarNotice = avatarEdit.action === 'REPLACE' ? t('skillAvatar.submittedNotice') : avatarEdit.action === 'REMOVE' ? t('skillAvatar.removedNotice') : ''
|
||||
const skillLabel = `${result.namespace}/${result.slug}@${result.version}`
|
||||
if (result.status === 'PUBLISHED') {
|
||||
toast.success(
|
||||
t('publish.publishedTitle'),
|
||||
t('publish.publishedDescription', { skill: skillLabel })
|
||||
[t('publish.publishedDescription', { skill: skillLabel }), avatarNotice].filter(Boolean).join(' ')
|
||||
)
|
||||
} else {
|
||||
toast.success(
|
||||
t('publish.pendingReviewTitle'),
|
||||
t('publish.pendingReviewDescription', { skill: skillLabel })
|
||||
[t('publish.pendingReviewDescription', { skill: skillLabel }), avatarNotice].filter(Boolean).join(' ')
|
||||
)
|
||||
}
|
||||
navigateAfterOverlays(() => {
|
||||
|
|
@ -187,9 +198,11 @@ export function PublishPage() {
|
|||
<div className="h-11 animate-shimmer rounded-lg" />
|
||||
) : (
|
||||
<Select
|
||||
disabled={!!updateSlug || publishMutation.isPending}
|
||||
value={normalizeSelectValue(namespaceSlug) ?? EMPTY_NAMESPACE_VALUE}
|
||||
onValueChange={(value) => {
|
||||
setNamespaceSlug(value === EMPTY_NAMESPACE_VALUE ? '' : value)
|
||||
setAvatarEdit(KEEP_AVATAR)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="namespace">
|
||||
|
|
@ -251,11 +264,14 @@ export function PublishPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<SkillAvatarEditor key={`${namespaceSlug}/${updateSlug}`} name={search.updateName ?? updateSlug} currentUrl={avatarTarget.data?.avatarUrl} canRemove={!!updateSlug} disabled={publishMutation.isPending || avatarUnavailable} onChange={setAvatarEdit} />
|
||||
{avatarTarget.isError && <p role="alert" className="text-sm text-destructive">{t('skillAvatar.failed')}</p>}
|
||||
|
||||
<Button
|
||||
className="w-full text-primary-foreground disabled:text-primary-foreground"
|
||||
size="lg"
|
||||
onClick={handlePublish}
|
||||
disabled={!selectedFile || !namespaceSlug || publishMutation.isPending}
|
||||
disabled={!selectedFile || !namespaceSlug || !avatarEdit.ready || avatarUnavailable || publishMutation.isPending}
|
||||
>
|
||||
{publishMutation.isPending ? t('publish.publishing') : t('publish.confirm')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -903,7 +903,7 @@ export function SkillDetailPage() {
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4"><SkillAvatar name={skill.displayName} url={skill.avatarUrl} size="h-16 w-16 text-2xl" /><h1 className="text-balance break-words text-4xl font-bold font-heading text-foreground [overflow-wrap:anywhere]">{skill.displayName}</h1></div>
|
||||
{user?.userId === skill.ownerId && <SkillAvatarManager namespace={namespace} slug={slug} />}
|
||||
{user?.userId === skill.ownerId && <SkillAvatarManager namespace={namespace} slug={slug} readOnly />}
|
||||
{skill.avatarUrl && user?.userId !== skill.ownerId && (namespace === 'global'
|
||||
? isPromotionAdmin
|
||||
: myNamespaces?.some((item) => item.slug === namespace && (item.currentUserRole === 'OWNER' || item.currentUserRole === 'ADMIN')))
|
||||
|
|
|
|||
|
|
@ -44,12 +44,15 @@ async function getSkillDocumentation(namespace: string, slug: string, version: s
|
|||
return fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/file?path=${encodeURIComponent(path)}`)
|
||||
}
|
||||
|
||||
async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean }): Promise<PublishResult> {
|
||||
async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean; avatarAction?: 'KEEP' | 'REPLACE' | 'REMOVE'; avatarUploadId?: string; expectedSlug?: string }): Promise<PublishResult> {
|
||||
const cleanNamespace = params.namespace.startsWith('@') ? params.namespace.slice(1) : params.namespace
|
||||
const formData = new FormData()
|
||||
formData.append('file', params.file)
|
||||
formData.append('visibility', params.visibility)
|
||||
formData.append('confirmWarnings', String(params.confirmWarnings === true))
|
||||
formData.append('avatarAction', params.avatarAction ?? 'KEEP')
|
||||
if (params.avatarUploadId) formData.append('avatarUploadId', params.avatarUploadId)
|
||||
if (params.expectedSlug) formData.append('expectedSlug', params.expectedSlug)
|
||||
|
||||
return fetchJson<PublishResult>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/publish`, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue