diff --git a/docs/26-skill-avatars.md b/docs/26-skill-avatars.md new file mode 100644 index 00000000..958287f5 --- /dev/null +++ b/docs/26-skill-avatars.md @@ -0,0 +1,31 @@ +# 技能头像与审核 + +技能所有者可在技能详情页上传 PNG/JPEG,网页提供方形裁剪和缩放。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。 + +## 审核策略 + +超级管理员在“系统配置”中分别开关机器审核和人工审核。默认关闭机器审核、开启人工审核。两个开关都关闭时直接发布;只开启机器时,通过即发布、阻止或未知结果保持私有;只开启人工时进入审核队列;两者都开启时先机器后人工。机器阻止后所有者可申请人工复核;机器服务失败时可重试,不会自动放行。相同图片且机器供应商与审核策略未变时可复用机器结果;扩大可见范围时必须重新人工审核。`PRIVATE` 技能头像由所有者直接使用。 + +团队范围由团队管理员审核,全局范围由平台管理员审核。头像只能由技能所有者提交、替换或删除;管理员可审核和撤下,不可代传。新申请使旧待审申请失效;旧审核决定不能覆盖新头像。被拒绝、替换、删除的候选图片私有保留 30 天后清理,文字审核记录继续保留。头像撤下后显示首字母兜底。 + +## 部署配置 + +在部署密钥配置中指定唯一供应商及其凭证,然后才可从后台开启机器审核。Spring 属性如下;将值映射到部署 Secret/环境变量时请遵循当前部署平台的配置方式,不要写入仓库或后台数据库: + +| 供应商 | 属性 | +| --- | --- | +| 通用 | `skillhub.avatar.moderation.provider`:`xfyun` 或 `aliyun` | +| 讯飞 | `skillhub.avatar.moderation.xfyun.app-id`、`skillhub.avatar.moderation.xfyun.access-key-id`、`skillhub.avatar.moderation.xfyun.access-key-secret` | +| 阿里云 | `skillhub.avatar.moderation.aliyun.access-key-id`、`skillhub.avatar.moderation.aliyun.access-key-secret` | + +后台只显示两个启停开关,不回显供应商或密钥。阿里云适配器使用上海地域的 `profilePhotoCheck`,通过短期上传令牌把私有候选送到官方 OSS 临时对象,再调用同步审核;不会公开候选 URL。官方返回 `RiskLevel=none/low` 通过,`medium/high` 保持私有并允许人工复核。把 `medium` 归为阻止是本产品策略。响应非 200、空结果或未知值均不放行。机器接口可能按量计费,开启前由部署方确认供应商账号与费用。 + +讯飞官方图片审核接口要求把 `accessKeySecret` 放在请求 URL 参数中。应用不会记录完整 URL 或异常原文,但反向代理、出口代理、链路追踪及第三方日志仍可能记录该 URL。部署方使用讯飞适配器前,必须确认相关网络组件不采集请求 URL,限制凭证权限并建立轮换机制。若无法满足,先不要开启该适配器。 + +## 与原 Issue 的差异 + +原 Issue 提议自动图片审核;本期产品决策改为“上传、裁剪、机器审核可选、人工审核可选”的四种策略。默认使用人工审核,机器审核仅在部署好单一供应商且管理员开启后运行。个人头像不在本期范围。 + +## 验收要点 + +分别验证四种开关组合、候选与当前头像隔离、拥有者和审核员权限、审核通过/驳回/复核、相同图片复用机器结果、未知机器结果不放行、连续替换后旧审核无效、删除后首字母兜底、30 天清理以及团队/全局可见范围变化。外部供应商真实计费调用需要部署方测试账号与授权;单元测试使用模拟响应。 diff --git a/openspec/changes/add-skill-avatars-and-moderation/.openspec.yaml b/openspec/changes/add-skill-avatars-and-moderation/.openspec.yaml new file mode 100644 index 00000000..b9ebfc72 --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/add-skill-avatars-and-moderation/design.md b/openspec/changes/add-skill-avatars-and-moderation/design.md new file mode 100644 index 00000000..9e668b08 --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/design.md @@ -0,0 +1,22 @@ +## Context + +当前 Skill 无头像字段。详情、搜索和“我的技能”由不同查询路径投影,Web 卡片与详情也没有统一头像组件。`ObjectStorageService` 支持写入、读取和删除,但本地实现不产生预签名 URL。现有 ReviewTask 只处理 SkillVersion/SuiteVersion;头像不能伪装成版本审核。V68 已提供 versioned `system_setting`。 + +## Decisions + +1. 为 Skill 保存当前已激活头像引用;另建头像候选及决定记录。候选包含所属 Skill、提交者、私有对象键、内容哈希、目标可见范围、提交时审核策略快照、机器结果、人工决定、状态和时间。所有状态转移按候选 ID 和当前候选 ID 条件更新,防止迟到结果覆盖。 +2. 单独实现头像审核服务和查询队列,不扩展现有 SkillVersion 审核分支。头像上传 API 显式校验 `skill.ownerId == actor`;审核、复核和撤下分别校验团队管理员或平台管理员。系统管理员若不是所有者也不能代传。 +3. 图片通过受控 GET 接口提供。私人和待审对象必须鉴权,当前公开头像可提供稳定、无时效的访问 URL;服务端不得将对象存储临时签名地址持久化。公开范围变更之前须重新判断头像是否已获目标范围许可,避免私人图片借 Skill 可见性变化直接泄露。 +4. 上传只接受最终裁剪图,服务端独立解码、校验、重新编码并去掉元数据。上限和格式以现有上传约定及测试验证后在实现中固定,避免前端校验成为唯一防线。 +5. `system_setting` 新增独立头像审核键,仅保存机器和人工开关。部署配置只允许一个供应商及其 Secret;后台不展示供应商或 Secret。适配器在服务端主动调用,供应商异常保留候选和重试,不自动通过。供应商实现与 API 参数在接入时以官方文档验证。 +6. 开关在提交候选时快照。PRIVATE 且仅所有者可见直接激活;其他候选按照快照执行。机器通过且人工关闭可激活;机器阻止时允许例外申诉;人工开启时有权审核员可改判并留理由。 +7. 替换待审候选时旧候选变为 SUPERSEDED。删除和管理员撤下立即取消当前头像并恢复兜底。失效图片保留私有 30 天,再由有界清理任务删除;审计记录不随图片删除。 +8. `SkillSummaryResponse` 的所有映射路径、详情 DTO 与生成类型包含当前头像 URL;Web 使用一个头像/兜底组件,所有者操作和审核队列分别展示其可执行动作。 + +## Risks and verification + +- 私人候选和已发布头像的读取权限用非所有者、匿名和跨团队请求验证。 +- 上传、更换、审核和撤下并发时验证当前候选不被旧结果激活。 +- V68 后迁移在已有数据上通过;旧 Skill 无头像时无需回填。 +- 服务端图片解码处理需设置资源上限;机器供应商凭证不得进入日志、数据库或前端。 +- 原 Issue 的“机器不得直接拒绝”与本轮方案不同。机器单开模式的质量和误判处理需在运行验证中单列,不以外部 API 返回成功代替有效审核验证。 diff --git a/openspec/changes/add-skill-avatars-and-moderation/proposal.md b/openspec/changes/add-skill-avatars-and-moderation/proposal.md new file mode 100644 index 00000000..d8cb2f5c --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/proposal.md @@ -0,0 +1,32 @@ +## Why + +Skill 目前没有稳定的图像标识,卡片和详情难以快速区分。上传图片后还需要保证文件安全、访问权限和可配置的内容审核,不让待审图片提前公开。 + +## What Changes + +- 为个人、团队和全局 Skill 增加由 Skill 所有者维护的可选头像;所有展示位置使用一致的首字兜底。 +- 上传最终裁剪图片,经服务端规范化后存为私有候选;头像属于 Skill,不随版本更新重置。 +- 按实际可见范围处理审核:仅本人可见的 PRIVATE 头像可直接使用;团队可见或公开头像按部署方的机器、人工审核开关处理;全局头像由平台管理员审核。 +- 部署时选择唯一机器审核供应商并配置凭证,提供讯飞和阿里云适配;后台仅管理审核开关。机器失败不放行,机器误判可申请人工复核。 +- 仅 Skill 所有者可提交、更换或主动删除;管理员可以审核、复核和撤下违规头像。旧候选失效后不能被迟到的审核结果激活。 +- 被驳回、被替换或主动删除的图片私有保留 30 天后清理,文字审计记录保留。 + +## Capabilities + +### New Capabilities + +- `skill-avatars`: Skill 头像、权限、候选生命周期、审核策略、访问和清理。 + +### Modified Capabilities + +无。 + +## Impact + +- Skill 领域、数据库迁移、对象存储、公开摘要和详情 API、OpenAPI 生成类型。 +- 系统配置、机器审核供应商配置、团队及平台审核入口、审计和清理任务。 +- Skill 卡片、详情、所有者操作和审核页面;后端、前端与浏览器验证。 + +## 相对 Issue #910 的调整 + +原 Issue 建议机器审核首版仅作风险分流。本方案允许部署方单独开启机器审核并据此自动发布或阻止发布,同时提供误判申诉。实现和验收必须明确这一差异、供应商故障时不放行,以及机器判定质量的回归验证。 diff --git a/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md new file mode 100644 index 00000000..a47375e0 --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md @@ -0,0 +1,117 @@ +## Purpose + +为 Skill 提供安全、一致、可治理的自定义头像。 + +## ADDED Requirements + +### Requirement: REQ-AV-01 Skill SHALL 有可选头像和统一兜底 + +Skill SHALL 支持可选头像和统一兜底。 + +头像属于 Skill 容器而非 SkillVersion。摘要与详情只返回当前已激活的稳定头像访问地址;无头像时所有 Skill 展示位置使用展示名称首字符的大写兜底。新版本发布不得重置头像。 + +#### Scenario: 旧 Skill 没有头像 +- **WHEN** 客户端读取无头像的 Skill +- **THEN** 不需数据回填即可展示一致的首字兜底 + +#### Scenario: 发布新版本 +- **WHEN** 已有头像的 Skill 发布新版本 +- **THEN** 原头像保持不变 + +### Requirement: REQ-AV-02 仅 Skill 所有者 SHALL 维护头像 + +头像维护接口 SHALL 仅允许 Skill 所有者调用。 + +只有当前 Skill 所有者可提交、更换或主动删除头像。团队及平台管理员的审核权不授予代传权;有权管理员可以撤下违规头像并记录原因。所有者停用时本期不新增所有权交接或代传机制。 + +#### Scenario: 管理员不是所有者 +- **WHEN** 团队或平台管理员尝试上传、替换或主动删除别人的 Skill 头像 +- **THEN** 服务端拒绝,不依赖前端隐藏按钮 + +#### Scenario: 所有者停用 +- **WHEN** 头像已发布而所有者无法操作 +- **THEN** 有权管理员仍可撤下该头像并恢复兜底,但不能代传新头像 + +### Requirement: REQ-AV-03 服务端 SHALL 验证并私存最终裁剪图片 + +服务端 SHALL 验证并私存最终裁剪图片。 + +Web 端提供正方形裁剪且只上传最终裁剪图。服务端独立校验文件签名、尺寸、字节与像素限制,解码、重新编码并去除元数据。待审候选只在私有存储中,且只能由提交者和有权审核员读取;不得持久化临时预签名 URL 作为头像地址。 + +#### Scenario: 伪造图片或畸形文件 +- **WHEN** 上传的文件签名、解码结果或大小不符合限制 +- **THEN** 服务端拒绝创建候选,不公开文件 + +#### Scenario: 候选待审 +- **WHEN** 新候选尚未通过所需审核 +- **THEN** 公众继续看到原头像或兜底,无法读取候选图片 + +### Requirement: REQ-AV-04 审核 SHALL 依据实际可见范围 + +头像审核 SHALL 依据实际可见范围执行。 + +PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使用。NAMESPACE_ONLY 或 PUBLIC 的团队 Skill 使用团队范围策略和团队管理员人工审核;全局 Skill 使用全局范围策略和平台管理员人工审核。个人 Namespace 当前按 TEAM 处理,不能仅凭名称免审。可见范围扩大时,未经过目标范围审核的头像不得直接对新受众公开。 + +#### Scenario: 个人私有 Skill 扩大可见范围 +- **WHEN** 原来仅本人可见的 Skill 变为团队可见或公开 +- **THEN** 原头像进入目标范围审核,审核完成前新受众只能看到兜底或先前已获目标范围批准的头像 + +#### Scenario: 团队 Skill 提升至全局 +- **WHEN** 团队 Skill 派生全局 Skill +- **THEN** 团队头像不会自动成为全局头像;全局候选按全局策略审核 + +### Requirement: REQ-AV-05 部署方 SHALL 控制两项审核开关 + +部署方 SHALL 可独立控制机器和人工审核开关。 + +机器与人工审核开关保存于独立的系统设置键,后台仅显示和修改这两个开关;唯一机器供应商及凭证由部署配置确定,凭证不得写入系统设置或回显。机器开关开启而供应商未正确配置时应拒绝设置。 + +#### Scenario: 四种策略 +- **WHEN** 机器/人工为关/开、开/开、开/关、关/关 +- **THEN** 分别执行人工通过、机器后人工、机器通过自动发布且判违规不发布、直接发布 +- **AND** 关闭全部审核时后台显示风险提示 + +### Requirement: REQ-AV-06 机器审核 SHALL 失败关闭并支持复核 + +机器审核 SHALL 在失败时保持候选私有,并支持例外复核。 + +服务端主动调用部署时确定的讯飞或阿里云适配器,首版不接受外部结果回调。机器调用失败、超时或结果不明不能视为通过;候选保持私有并可重试。机器判违规且人工审核开启时审核员可记理由改判;仅机器开启时提交者可申请例外人工复核,不把全部申请放入人工队列。 +同一最终图片在供应商和审核策略未变时可以复用机器结果;新目标范围的人工决定不能复用。图片、供应商或策略改变时必须重新检测。 + +#### Scenario: 供应商超时 +- **WHEN** 机器审核超时 +- **THEN** 候选不公开,系统保留可重试状态 + +#### Scenario: 机器误判 +- **WHEN** 提交者对机器阻止的候选申请复核 +- **THEN** 相应范围的管理员可以批准或驳回,记录决定人和理由 + +### Requirement: REQ-AV-07 候选状态 SHALL 防止旧结果激活 + +候选状态 SHALL 防止已失效申请被迟到结果激活。 + +提交候选时固定当时的审核策略。一个 Skill 的新申请替换待审旧申请,旧申请保留为已被替换状态;迟到的机器结果或审核动作不得激活它。管理员本人也是所有者时可以自审,但提交和决定都须留痕。 + +#### Scenario: 旧机器结果晚到 +- **WHEN** 新候选已经替换旧候选,旧候选的机器结果才返回 +- **THEN** 旧候选保持失效,当前头像不受其影响 + +### Requirement: REQ-AV-08 删除与撤下 SHALL 恢复兜底并保留审计 + +删除与撤下 SHALL 立即恢复兜底并保留审计。 + +所有者主动删除当前头像无需审核;管理员违规撤下时须记录操作者和原因。两种动作均立即恢复兜底。被驳回、被替换或主动删除的图片私有保留 30 天后清理,文字申请与审核记录继续保留;确认违法的图片可提前清理。 + +#### Scenario: 管理员撤下 +- **WHEN** 有权管理员撤下违规的已发布头像 +- **THEN** 对外立即显示兜底,审计保留原因与操作者 + +### Requirement: REQ-AV-09 前后端 SHALL 提供可理解的操作闭环 + +前后端 SHALL 提供可理解的头像提交与审核闭环。 + +所有者可以在 Skill 详情查看当前头像、提交候选、状态和驳回原因;审核员可以读取其管辖范围候选并处理,机器单开时可以处理例外申诉。所有展示 Skill 身份的位置使用同一头像与兜底组件。 + +#### Scenario: 申请等待审核 +- **WHEN** 所有者上传候选后需人工决定 +- **THEN** 页面展示待审状态且已发布头像维持不变 diff --git a/openspec/changes/add-skill-avatars-and-moderation/tasks.md b/openspec/changes/add-skill-avatars-and-moderation/tasks.md new file mode 100644 index 00000000..4c14a4af --- /dev/null +++ b/openspec/changes/add-skill-avatars-and-moderation/tasks.md @@ -0,0 +1,23 @@ +## 1. 领域与存储 + +- [ ] 1.1 增加 Skill 头像指针、候选/审计持久化和向后兼容迁移。 +- [ ] 1.2 实现图片解码、大小及像素上限、规范化、私有写入、受控读取与 30 天清理。 +- [ ] 1.3 实现所有者提交/替换/删除、管理员审核/复核/撤下和旧候选失效保护。 + +## 2. 审核配置与供应商 + +- [ ] 2.1 在 `system_setting` 增加独立头像审核开关,验证四种策略及未配置供应商时拒绝开启。 +- [ ] 2.2 依据官方接口实现讯飞和阿里云适配器,部署只选择一家,凭证仅使用部署 Secret。 +- [ ] 2.3 处理超时、未知结果、重试、人工改判和例外申诉。 + +## 3. API 与 Web + +- [ ] 3.1 扩展 Skill 摘要/详情和头像操作/审核接口,重新生成 OpenAPI 类型。 +- [ ] 3.2 实现统一头像兜底、所有者裁剪/提交/状态及审核员队列。 +- [ ] 3.3 更新中文、英文、俄文文案和操作说明。 + +## 4. 验证与交付 + +- [ ] 4.1 覆盖权限、文件安全、状态并发、策略矩阵、可见范围变化和清理边界。 +- [ ] 4.2 运行相关后端、前端、API 类型检查、容器化本地预览及浏览器操作。 +- [ ] 4.3 独立 Review、Draft PR、CI 与验收报告。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java index f589f846..df2efd81 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.SkillAvatarDecisionRequest; +import com.iflytek.skillhub.dto.SkillAvatarReasonRequest; import com.iflytek.skillhub.dto.SkillAvatarStatusResponse; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.SkillAvatarAppService; @@ -125,10 +126,10 @@ public class SkillAvatarController extends BaseApiController { @PostMapping("/{namespace}/{slug}/avatar/takedown") public ApiResponse takedown(@PathVariable String namespace, @PathVariable String slug, - @RequestBody String reason, @AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody SkillAvatarReasonRequest input, @AuthenticationPrincipal PlatformPrincipal principal, @RequestAttribute(value = "userNsRoles", required = false) Map roles, HttpServletRequest request) { - service.remove(namespace, slug, userId(principal), true, reason, roles, platformRoles(principal), + service.remove(namespace, slug, userId(principal), true, input.reason(), roles, platformRoles(principal), AuditRequestContext.from(request)); return ok("response.success.updated", null); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarReasonRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarReasonRequest.java new file mode 100644 index 00000000..423781e9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarReasonRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SkillAvatarReasonRequest(@NotBlank String reason) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java index b0b84714..d0439e72 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java @@ -2,4 +2,4 @@ package com.iflytek.skillhub.dto; public record SkillAvatarStatusResponse(String avatarUrl, Long candidateId, String candidateStatus, String machineStatus, String reviewStatus, String reviewReason, - String candidateUrl) {} + String candidateUrl, String namespace, String slug) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java index 3b6dffc4..8bf4c53d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java @@ -8,6 +8,7 @@ import com.aliyun.green20220302.models.ImageModerationResponse; import com.aliyun.green20220302.models.ImageModerationResponseBody; import com.aliyun.oss.OSS; import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.ClientBuilderConfiguration; import com.aliyun.oss.model.PutObjectRequest; import com.aliyun.teaopenapi.models.Config; import com.aliyun.teautil.models.RuntimeOptions; @@ -51,7 +52,9 @@ public class AliyunAvatarModerationProvider implements AvatarModerationProvider config.setAccessKeySecret(accessKeySecret); config.setEndpoint(ENDPOINT); Client client = new Client(config); - DescribeUploadTokenResponse tokenResponse = client.describeUploadToken(); + RuntimeOptions runtime = new RuntimeOptions().setConnectTimeout(3000).setReadTimeout(8000) + .setAutoretry(false); + DescribeUploadTokenResponse tokenResponse = client.describeUploadTokenWithOptions(runtime); if (tokenResponse == null || tokenResponse.getBody() == null || !Objects.equals(tokenResponse.getBody().getCode(), 200)) { return Verdict.ERROR; @@ -61,8 +64,11 @@ public class AliyunAvatarModerationProvider implements AvatarModerationProvider || token.getOssInternetEndPoint() == null || token.getSecurityToken() == null) { return Verdict.ERROR; } + ClientBuilderConfiguration ossConfig = new ClientBuilderConfiguration(); + ossConfig.setConnectionTimeout(3000); + ossConfig.setSocketTimeout(8000); oss = new OSSClientBuilder().build(token.getOssInternetEndPoint(), token.getAccessKeyId(), - token.getAccessKeySecret(), token.getSecurityToken()); + token.getAccessKeySecret(), token.getSecurityToken(), ossConfig); String objectName = token.getFileNamePrefix() + UUID.randomUUID() + ".png"; oss.putObject(new PutObjectRequest(token.getBucketName(), objectName, new ByteArrayInputStream(normalizedPng))); @@ -73,7 +79,7 @@ public class AliyunAvatarModerationProvider implements AvatarModerationProvider "ossBucketName", token.getBucketName(), "ossObjectName", objectName, "dataId", UUID.randomUUID().toString()))); - ImageModerationResponse response = client.imageModerationWithOptions(request, new RuntimeOptions()); + ImageModerationResponse response = client.imageModerationWithOptions(request, runtime); return classify(response); } catch (Exception failure) { // SDK exception text may include credential or temporary upload details. diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java index 6b63331c..5183be87 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java @@ -365,7 +365,8 @@ public class SkillAvatarAppService { candidate == null ? null : candidate.getReviewStatus().name(), candidate == null ? null : candidate.getReviewReason(), candidate == null || candidate.getObjectKey() == null ? null - : SkillAvatarUrls.candidate(namespaceSlug, skill, candidate.getId())); + : SkillAvatarUrls.candidate(namespaceSlug, skill, candidate.getId()), + namespaceSlug, skill.getSlug()); } private String sha256(byte[] image) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java index 0a7b3a20..a7a6019a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java @@ -22,6 +22,15 @@ class AliyunAvatarModerationProviderTest { assertThat(provider.classify(response("high"))).isEqualTo(AvatarModerationProvider.Verdict.BLOCK); assertThat(provider.classify(response(null))).isEqualTo(AvatarModerationProvider.Verdict.ERROR); assertThat(provider.classify(response("unknown"))).isEqualTo(AvatarModerationProvider.Verdict.ERROR); + assertThat(provider.classify(response("none").setStatusCode(500))) + .isEqualTo(AvatarModerationProvider.Verdict.ERROR); + assertThat(provider.classify(response("none").setBody( + new ImageModerationResponseBody().setCode(500)))) + .isEqualTo(AvatarModerationProvider.Verdict.ERROR); + assertThat(provider.classify(response("none").setBody( + new ImageModerationResponseBody().setCode(200).setData( + new ImageModerationResponseBodyData().setRiskLevel("none").setResult(List.of()))))) + .isEqualTo(AvatarModerationProvider.Verdict.ERROR); } private ImageModerationResponse response(String level) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 09f2fd9d..2d3d07fa 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -36,6 +36,8 @@ import type { AuthMethod, LocalAuthCapabilities, SystemAuthSettings, + AvatarReviewSettings, + SkillAvatarStatus, ExternalRoleGrantRule, PlatformRole, OAuthProvider, @@ -459,6 +461,17 @@ export const authApi = { } export const systemConfigApi = { + getAvatarReview(): Promise { + return fetchJson('/api/v1/admin/system-config/avatar-review') + }, + + updateAvatarReview(request: Pick): Promise { + return fetchJson('/api/v1/admin/system-config/avatar-review', { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(request), + }) + }, getLocalAuth(): Promise { return fetchJson('/api/v1/admin/system-config/auth/local') }, @@ -503,6 +516,51 @@ export const systemConfigApi = { }, } +function avatarPath(namespace: string, slug: string): string { + return `/api/v1/skills/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/avatar` +} + +export const skillAvatarApi = { + status(namespace: string, slug: string): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/status`) + }, + upload(namespace: string, slug: string, file: Blob): Promise { + const body = new FormData() + body.append('file', file, 'avatar.png') + return fetchJson(avatarPath(namespace, slug), { + method: 'POST', headers: getCsrfHeaders(), body, + }) + }, + delete(namespace: string, slug: string): Promise { + return fetchJson(avatarPath(namespace, slug), { method: 'DELETE', headers: getCsrfHeaders() }) + }, + appeal(namespace: string, slug: string, candidateId: number): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/candidates/${candidateId}/appeal`, { + method: 'POST', headers: getCsrfHeaders(), + }) + }, + retry(namespace: string, slug: string, candidateId: number): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/candidates/${candidateId}/retry`, { + method: 'POST', headers: getCsrfHeaders(), + }) + }, + queue(): Promise { + return fetchJson('/api/v1/skills/avatar-review/queue') + }, + decide(namespace: string, slug: string, candidateId: number, approved: boolean, reason: string): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/candidates/${candidateId}/decision`, { + method: 'POST', headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ approved, reason }), + }) + }, + takedown(namespace: string, slug: string, reason: string): Promise { + return fetchJson(`${avatarPath(namespace, slug)}/takedown`, { + method: 'POST', headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ reason }), + }) + }, +} + export const accountApi = { async initiateMerge(request: MergeInitiateRequest): Promise { return fetchJson('/api/v1/account/merge/initiate', { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 1c999825..9120b4e7 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -36,6 +36,25 @@ export interface SystemAuthSettings { updatedAt: string } +export interface AvatarReviewSettings { + machineEnabled: boolean + manualEnabled: boolean + version: number + updatedAt: string +} + +export interface SkillAvatarStatus { + avatarUrl: string | null + candidateId: number | null + candidateStatus: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'SUPERSEDED' | 'REMOVED' | null + machineStatus: 'SKIPPED' | 'PENDING' | 'PASSED' | 'BLOCKED' | 'ERROR' | null + reviewStatus: 'NOT_REQUIRED' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'APPEAL_PENDING' | null + reviewReason: string | null + candidateUrl: string | null + namespace: string + slug: string +} + export interface ExternalRoleGrantRule { id: number providerCode: string @@ -252,6 +271,7 @@ export interface SkillSummary { ownerPreviewVersion?: SkillLifecycleVersion resolutionMode?: string complianceSnapshot?: ComplianceSnapshot + avatarUrl?: string | null } export type LabelItem = Omit & { @@ -345,6 +365,7 @@ export type SkillDetail = Omit< >, 'labels' | 'headlineVersion' | 'publishedVersion' | 'ownerPreviewVersion' | 'entryForSuites' | 'memberOfSuites' > & { + avatarUrl?: string | null labels?: LabelItem[] headlineVersion?: SkillLifecycleVersion publishedVersion?: SkillLifecycleVersion diff --git a/web/src/features/skill/avatar-review-settings.tsx b/web/src/features/skill/avatar-review-settings.tsx new file mode 100644 index 00000000..615c9c9a --- /dev/null +++ b/web/src/features/skill/avatar-review-settings.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { systemConfigApi } from '@/api/client' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' + +export function AvatarReviewSettingsPanel() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const settings = useQuery({ queryKey: ['system-config', 'avatar-review'], queryFn: systemConfigApi.getAvatarReview }) + const [draft, setDraft] = useState({ machineEnabled: false, manualEnabled: true }) + const [error, setError] = useState('') + const [saved, setSaved] = useState(false) + useEffect(() => { if (settings.data) setDraft({ machineEnabled: settings.data.machineEnabled, manualEnabled: settings.data.manualEnabled }) }, [settings.data]) + const update = useMutation({ + mutationFn: systemConfigApi.updateAvatarReview, + onSuccess: async () => { setError(''); setSaved(true); await queryClient.invalidateQueries({ queryKey: ['system-config', 'avatar-review'] }) }, + onError: (cause) => { setSaved(false); setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')) }, + }) + const dirty = !!settings.data && (settings.data.machineEnabled !== draft.machineEnabled || settings.data.manualEnabled !== draft.manualEnabled) + return + {t('skillAvatar.settingsTitle')}

{t('skillAvatar.settingsHelp')}

+ + {settings.isLoading &&

{t('skillAvatar.loading')}

} + {settings.isError &&

{t('skillAvatar.failed')}

} + {settings.data && <> + {(['machineEnabled', 'manualEnabled'] as const).map((field) => )} + + } + {saved &&

{t('skillAvatar.saved')}

} + {error &&

{error}

} +
+
+} diff --git a/web/src/features/skill/skill-avatar-manager.tsx b/web/src/features/skill/skill-avatar-manager.tsx new file mode 100644 index 00000000..ba54ef50 --- /dev/null +++ b/web/src/features/skill/skill-avatar-manager.tsx @@ -0,0 +1,86 @@ +import { useEffect, 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' + +function cropSquare(image: HTMLImageElement, zoom: number): Promise { + 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 }) { + 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(null) + const [zoom, setZoom] = useState(1) + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + useEffect(() => () => { if (sourceUrl) URL.revokeObjectURL(sourceUrl) }, [sourceUrl]) + 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) + 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 () => { + setError('') + setSourceUrl(null) + 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 + return ( + +

{t('skillAvatar.title')}

+

{t('skillAvatar.ownerHelp')}

+ {candidate?.candidateStatus &&

{t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)} · {t(`skillAvatar.machine.${candidate.machineStatus ?? 'SKIPPED'}`)}

} + {candidate?.reviewReason &&

{candidate.reviewReason}

} + {candidate?.candidateUrl && {t('skillAvatar.candidatePreview')}} + { + 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 &&
+
{t('skillAvatar.cropPreview')}
+ + +
} +
+ {(candidate?.avatarUrl || candidate?.candidateStatus === 'PENDING') && } + {candidate?.candidateStatus === 'REJECTED' && candidate.candidateId && } + {candidate?.machineStatus === 'ERROR' && candidate.candidateId && } +
+ {error &&

{error}

} +
+ ) +} diff --git a/web/src/features/skill/skill-avatar-review.tsx b/web/src/features/skill/skill-avatar-review.tsx new file mode 100644 index 00000000..27ffc401 --- /dev/null +++ b/web/src/features/skill/skill-avatar-review.tsx @@ -0,0 +1,41 @@ +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' + +export function SkillAvatarReviewQueue({ namespace }: { namespace?: string }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [reasons, setReasons] = useState>({}) + const [error, setError] = useState('') + const queue = useQuery({ queryKey: ['skill-avatar-review'], queryFn: skillAvatarApi.queue }) + const decide = useMutation({ + mutationFn: (input: { namespace: string; slug: string; candidateId: number; approved: boolean; reason: string }) => + skillAvatarApi.decide(input.namespace, input.slug, input.candidateId, input.approved, input.reason), + onSuccess: async () => { setError(''); await queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] }) }, + onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')), + }) + const items = queue.data?.filter((item) => !namespace || item.namespace === namespace) ?? [] + return +

{t('skillAvatar.reviewTitle')}

+ {queue.isLoading &&

{t('skillAvatar.loading')}

} + {queue.isError &&

{t('skillAvatar.failed')}

} + {!queue.isLoading && !queue.isError && items.length === 0 &&

{t('skillAvatar.emptyQueue')}

} + {items.map((item) => item.candidateId &&
+ {item.candidateUrl && {t('skillAvatar.candidatePreview')}} +
+

@{item.namespace}/{item.slug}

+

{t(`skillAvatar.machine.${item.machineStatus ?? 'SKIPPED'}`)}

+ setReasons({ ...reasons, [item.candidateId!]: event.target.value })} placeholder={t('skillAvatar.reason')} aria-label={t('skillAvatar.reason')} /> +
+ + +
+
+
)} + {error &&

{error}

} +
+} diff --git a/web/src/features/skill/skill-avatar-takedown.tsx b/web/src/features/skill/skill-avatar-takedown.tsx new file mode 100644 index 00000000..f5ff821b --- /dev/null +++ b/web/src/features/skill/skill-avatar-takedown.tsx @@ -0,0 +1,24 @@ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { skillAvatarApi } from '@/api/client' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' + +export function SkillAvatarTakedown({ namespace, slug }: { namespace: string; slug: string }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [reason, setReason] = useState('') + const [error, setError] = useState('') + const mutation = useMutation({ + mutationFn: () => skillAvatarApi.takedown(namespace, slug, reason), + onSuccess: async () => { setError(''); setReason(''); await Promise.all([queryClient.invalidateQueries({ queryKey: ['skill'] }), queryClient.invalidateQueries({ queryKey: ['skills'] })]) }, + onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')), + }) + return
+

{t('skillAvatar.takedownHelp')}

+ setReason(event.target.value)} placeholder={t('skillAvatar.reason')} aria-label={t('skillAvatar.reason')} /> + + {error &&

{error}

} +
+} diff --git a/web/src/features/skill/skill-avatar.tsx b/web/src/features/skill/skill-avatar.tsx new file mode 100644 index 00000000..09b1d540 --- /dev/null +++ b/web/src/features/skill/skill-avatar.tsx @@ -0,0 +1,12 @@ +import { useState } from 'react' +import { buildApiUrl } from '@/api/client' + +export function SkillAvatar({ name, url, size = 'h-10 w-10' }: { name: string; url?: string | null; size?: string }) { + const [failedUrl, setFailedUrl] = useState(null) + const imageUrl = url && failedUrl !== url ? buildApiUrl(url) : null + return ( + + ) +} diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index 7af306ab..27098660 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -4,6 +4,7 @@ import { useAuth } from '@/features/auth/use-auth' import { useStarredIdSet } from '@/features/social/use-star' import { Card } from '@/shared/ui/card' import { NamespaceBadge } from '@/shared/components/namespace-badge' +import { SkillAvatar } from './skill-avatar' import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle' import { formatCompactCount } from '@/shared/lib/number-format' import { formatRelativeTime } from '@/shared/lib/format-relative-time' @@ -51,7 +52,8 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard >
-
+
+

{skill.displayName}

diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a863e68d..2daf9d12 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1598,6 +1598,8 @@ "save": "Save review", "cancel": "Cancel", "delete": "Delete review", + "takedown": "Take down avatar", + "takedownHelp": "Enter a reason to remove the current avatar. The skill will show its initial.", "saved": "Review saved", "deleted": "Review deleted; your star rating was kept", "textRequired": "Enter a review before saving", @@ -2566,5 +2568,46 @@ "VERSION_UNAVAILABLE": "Version unavailable", "VISIBILITY_INCOMPATIBLE": "Visibility incompatible" } + }, + "skillAvatar": { + "title": "Skill avatar", + "ownerHelp": "Upload and crop a square image. The current avatar stays visible until the new one is approved.", + "status": "Status", + "candidatePreview": "Avatar candidate", + "choose": "Choose avatar image", + "cropPreview": "Crop preview", + "zoom": "Zoom", + "submit": "Submit avatar", + "remove": "Remove current avatar", + "appeal": "Request review", + "retry": "Retry machine review", + "failed": "Action failed. Please try again.", + "invalid": "Choose a PNG or JPEG image under 2 MB.", + "reviewTitle": "Avatar review", + "loading": "Loading", + "emptyQueue": "No avatars awaiting review", + "reason": "Review reason (required to reject)", + "approve": "Approve", + "reject": "Reject", + "settingsTitle": "Avatar review settings", + "settingsHelp": "The machine provider is set at deployment; these switches control machine and manual review.", + "machineEnabled": "Enable machine review", + "manualEnabled": "Enable manual review", + "save": "Save settings", + "saved": "Saved", + "states": { + "PENDING": "Pending", + "ACTIVE": "Active", + "REJECTED": "Rejected", + "SUPERSEDED": "Superseded", + "REMOVED": "Removed" + }, + "machine": { + "SKIPPED": "Machine review off", + "PENDING": "Machine review pending", + "PASSED": "Machine review passed", + "BLOCKED": "Machine review blocked", + "ERROR": "Machine review failed" + } } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 5f7d8503..4cd4fdf5 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1820,6 +1820,8 @@ "save": "Сохранить отзыв", "cancel": "Отмена", "delete": "Удалить отзыв", + "takedown": "Снять аватар", + "takedownHelp": "Укажите причину снятия текущего аватара. Будет показана первая буква названия.", "saved": "Отзыв сохранён", "deleted": "Отзыв удалён, оценка сохранена", "textRequired": "Введите текст отзыва", @@ -2568,5 +2570,46 @@ "title": "Что-то пошло не так", "description": "На этой странице произошла ошибка. Можно вернуться назад или перезагрузить раздел.", "reset": "Попробовать снова" + }, + "skillAvatar": { + "title": "Аватар навыка", + "ownerHelp": "Загрузите и обрежьте квадратное изображение. Текущий аватар останется до одобрения нового.", + "status": "Статус", + "candidatePreview": "Новый аватар", + "choose": "Выбрать изображение", + "cropPreview": "Предпросмотр обрезки", + "zoom": "Масштаб", + "submit": "Отправить аватар", + "remove": "Удалить аватар", + "appeal": "Запросить пересмотр", + "retry": "Повторить автоматическую проверку", + "failed": "Ошибка. Повторите попытку.", + "invalid": "Выберите PNG или JPEG размером до 2 МБ.", + "reviewTitle": "Проверка аватаров", + "loading": "Загрузка", + "emptyQueue": "Нет аватаров на проверке", + "reason": "Причина (обязательна при отклонении)", + "approve": "Одобрить", + "reject": "Отклонить", + "settingsTitle": "Настройки проверки аватаров", + "settingsHelp": "Поставщик автоматической проверки задаётся при развертывании; здесь включаются виды проверки.", + "machineEnabled": "Автоматическая проверка", + "manualEnabled": "Ручная проверка", + "save": "Сохранить", + "saved": "Сохранено", + "states": { + "PENDING": "Ожидает", + "ACTIVE": "Активен", + "REJECTED": "Отклонён", + "SUPERSEDED": "Заменён", + "REMOVED": "Удалён" + }, + "machine": { + "SKIPPED": "Проверка выключена", + "PENDING": "Проверяется", + "PASSED": "Проверка пройдена", + "BLOCKED": "Заблокирован", + "ERROR": "Ошибка проверки" + } } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index ba4d66fa..3ba0f918 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1597,6 +1597,8 @@ "save": "保存评价", "cancel": "取消", "delete": "删除评价", + "takedown": "撤下头像", + "takedownHelp": "请输入原因后撤下当前头像,技能将显示默认首字母。", "saved": "评价已保存", "deleted": "评价已删除,星级评分已保留", "textRequired": "请先填写评价内容", @@ -2565,5 +2567,46 @@ "VERSION_UNAVAILABLE": "版本不可用", "VISIBILITY_INCOMPATIBLE": "可见性不兼容" } + }, + "skillAvatar": { + "title": "技能头像", + "ownerHelp": "上传并裁剪方形图片。新头像通过审核后才会替换当前头像。", + "status": "状态", + "candidatePreview": "待审核头像", + "choose": "选择头像图片", + "cropPreview": "裁剪预览", + "zoom": "放大", + "submit": "提交头像", + "remove": "删除当前头像", + "appeal": "申请复核", + "retry": "重试机器审核", + "failed": "操作失败,请稍后重试", + "invalid": "请选择不超过 2 MB 的 PNG 或 JPEG 图片", + "reviewTitle": "头像审核", + "loading": "加载中", + "emptyQueue": "暂无待审核头像", + "reason": "审核原因(驳回时必填)", + "approve": "通过", + "reject": "驳回", + "settingsTitle": "头像审核设置", + "settingsHelp": "机器审核服务在部署时指定;这里只控制是否启用机器与人工审核。", + "machineEnabled": "启用机器审核", + "manualEnabled": "启用人工审核", + "save": "保存设置", + "saved": "已保存", + "states": { + "PENDING": "待处理", + "ACTIVE": "已使用", + "REJECTED": "已拒绝", + "SUPERSEDED": "已替换", + "REMOVED": "已删除" + }, + "machine": { + "SKIPPED": "未启用机器审核", + "PENDING": "机器审核中", + "PASSED": "机器审核通过", + "BLOCKED": "机器审核阻止", + "ERROR": "机器审核失败" + } } } diff --git a/web/src/pages/admin/system-config.tsx b/web/src/pages/admin/system-config.tsx index b754387b..31c20ad2 100644 --- a/web/src/pages/admin/system-config.tsx +++ b/web/src/pages/admin/system-config.tsx @@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { AvatarReviewSettingsPanel } from '@/features/skill/avatar-review-settings' export function SystemConfigPage() { const { t } = useTranslation() @@ -105,6 +106,7 @@ export function SystemConfigPage() {
{error ?

{error}

: null} {message ?

{message}

: null} + {t('systemConfig.localAuth')} diff --git a/web/src/pages/dashboard/namespace-reviews.tsx b/web/src/pages/dashboard/namespace-reviews.tsx index fb995e8a..2981cc35 100644 --- a/web/src/pages/dashboard/namespace-reviews.tsx +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries' import { useReviewList } from '@/features/review/use-review-list' +import { SkillAvatarReviewQueue } from '@/features/skill/skill-avatar-review' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Pagination } from '@/shared/components/pagination' import { NamespaceHeader } from '@/features/namespace/namespace-header' @@ -161,6 +162,7 @@ export function NamespaceReviewsPage() { {readOnlyMessage} ) : null} +
) diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index a1ed02bc..90cbc27b 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -25,6 +25,7 @@ import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Pagination } from '@/shared/components/pagination' import { formatLocalDateTime } from '@/shared/lib/date-time' import { ProfileReviewTable } from './profile-review-table' +import { SkillAvatarReviewQueue } from '@/features/skill/skill-avatar-review' type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' type TimeSortDirection = 'ASC' | 'DESC' @@ -265,6 +266,8 @@ export function ReviewsPage() {
+ {isSkillAdmin && } + {showTypeTabs ? ( diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index db17304d..fb912c30 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -4,6 +4,9 @@ import { Link, useParams, useNavigate, useRouterState, useSearch } from '@tansta import { useMutation, useQueryClient } from '@tanstack/react-query' import { ArrowLeft, ArrowUpCircle, Boxes, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' +import { SkillAvatar } from '@/features/skill/skill-avatar' +import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager' +import { SkillAvatarTakedown } from '@/features/skill/skill-avatar-takedown' import { resolvePackageRelativeLink } from '@/features/skill/package-relative-link' import { FileTree } from '@/features/skill/file-tree' import { FilePreviewDialog } from '@/features/skill/file-preview-dialog' @@ -29,6 +32,7 @@ import { StarButton } from '@/features/social/star-button' import { SubscribeButton } from '@/features/social/subscribe-button' import { SkillReviews } from '@/features/social/skill-reviews' import { useAuth } from '@/features/auth/use-auth' +import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { adminApi, ApiError, buildApiUrl, WEB_API_PREFIX } from '@/api/client' import { useSubmitSkillReport } from '@/features/report/use-skill-reports' import { SecurityAuditSummary } from '@/features/security-audit/security-audit-summary' @@ -166,6 +170,7 @@ export function SkillDetailPage() { const overviewQuietGenerationRef = useRef(0) const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' }) const { user, hasRole } = useAuth() + const { data: myNamespaces } = useMyNamespaces(Boolean(user)) const isPromotionAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const detailQueriesEnabled = isSkillDetailQueriesEnabled(skillDeleted) const qns = detailQueriesEnabled ? namespace : '' @@ -897,7 +902,12 @@ export function SkillDetailPage() { )}
-

{skill.displayName}

+

{skill.displayName}

+ {user?.userId === skill.ownerId && } + {skill.avatarUrl && user?.userId !== skill.ownerId && (namespace === 'global' + ? isPromotionAdmin + : myNamespaces?.some((item) => item.slug === namespace && (item.currentUserRole === 'OWNER' || item.currentUserRole === 'ADMIN'))) + && } {skill.ownerDisplayName && (
diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index aabd83c9..fb9582a9 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -78,10 +78,11 @@ function invalidateNamespaceQueries(queryClient: ReturnType