feat(avatar): add owner and reviewer workflows

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-12 11:49:48 +08:00
parent 6d42bd6708
commit f7e96bb8a4
36 changed files with 1180 additions and 14 deletions

View file

@ -18,6 +18,17 @@
</properties>
<dependencies>
<!-- Private local images use the official moderation upload token flow; the existing MinIO client cannot use that token. -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>green20220302</artifactId>
<version>3.3.3</version>
</dependency>
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.16.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>

View file

@ -0,0 +1,43 @@
package com.iflytek.skillhub.controller.admin;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse;
import com.iflytek.skillhub.dto.AvatarReviewSettingsUpdateRequest;
import com.iflytek.skillhub.service.AvatarReviewSettingsService;
import com.iflytek.skillhub.service.AuditRequestContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/admin/system-config/avatar-review")
@PreAuthorize("hasRole('SUPER_ADMIN')")
public class AvatarReviewSettingsController extends BaseApiController {
private final AvatarReviewSettingsService service;
public AvatarReviewSettingsController(AvatarReviewSettingsService service, ApiResponseFactory responses) {
super(responses);
this.service = service;
}
@GetMapping
public ApiResponse<AvatarReviewSettingsResponse> current() {
return ok("response.success.read", service.current());
}
@PutMapping
public ApiResponse<AvatarReviewSettingsResponse> update(@Valid @RequestBody AvatarReviewSettingsUpdateRequest request,
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest httpRequest) {
return ok("response.success.updated", service.update(request, principal.userId(),
AuditRequestContext.from(httpRequest)));
}
}

View file

@ -0,0 +1,148 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.SkillAvatarDecisionRequest;
import com.iflytek.skillhub.dto.SkillAvatarStatusResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.SkillAvatarAppService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
public class SkillAvatarController extends BaseApiController {
private final SkillAvatarAppService service;
public SkillAvatarController(SkillAvatarAppService service, ApiResponseFactory responses) {
super(responses);
this.service = service;
}
@GetMapping("/{namespace}/{slug}/avatar")
public ResponseEntity<InputStreamResource> current(@PathVariable String namespace, @PathVariable String slug,
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles) {
InputStream image = service.readActive(namespace, slug, userId(principal), roles, platformRoles(principal));
return png(image);
}
@PostMapping(value = "/{namespace}/{slug}/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<SkillAvatarStatusResponse> submit(@PathVariable String namespace, @PathVariable String slug,
@RequestParam("file") MultipartFile file, @AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) throws IOException {
if (file.getSize() < 1 || file.getSize() > com.iflytek.skillhub.service.SkillAvatarImageProcessor.MAX_BYTES) {
throw new DomainBadRequestException("error.skill.avatar.invalidImage");
}
AuditRequestContext context = AuditRequestContext.from(httpRequest);
SkillAvatarStatusResponse created = service.submit(namespace, slug, userId(principal), file.getBytes(), context);
if ("PENDING".equals(created.machineStatus()) && "PENDING".equals(created.candidateStatus())) {
return ok("response.success.created", service.processMachine(created.candidateId(), context));
}
return ok("response.success.created", created);
}
@GetMapping("/{namespace}/{slug}/avatar/status")
public ApiResponse<SkillAvatarStatusResponse> status(@PathVariable String namespace, @PathVariable String slug,
@AuthenticationPrincipal PlatformPrincipal principal) {
return ok("response.success.read", service.ownerStatus(namespace, slug, userId(principal)));
}
@GetMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/image")
public ResponseEntity<InputStreamResource> candidate(@PathVariable String namespace, @PathVariable String slug,
@PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles) {
return png(service.readCandidate(namespace, slug, candidateId, userId(principal), roles, platformRoles(principal)));
}
@PostMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/appeal")
public ApiResponse<SkillAvatarStatusResponse> appeal(@PathVariable String namespace, @PathVariable String slug,
@PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request) {
return ok("response.success.updated", service.appeal(namespace, slug, candidateId,
userId(principal), AuditRequestContext.from(request)));
}
@PostMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/retry")
public ApiResponse<SkillAvatarStatusResponse> retry(@PathVariable String namespace, @PathVariable String slug,
@PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request) {
service.requireCurrentOwnerCandidate(namespace, slug, candidateId, userId(principal));
return ok("response.success.updated", service.processMachine(candidateId, AuditRequestContext.from(request)));
}
@GetMapping("/avatar-review/queue")
public ApiResponse<List<SkillAvatarStatusResponse>> queue(
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles) {
return ok("response.success.read", service.reviewQueue(userId(principal), roles, platformRoles(principal)));
}
@PostMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/decision")
public ApiResponse<SkillAvatarStatusResponse> decide(@PathVariable String namespace, @PathVariable String slug,
@PathVariable long candidateId, @Valid @RequestBody SkillAvatarDecisionRequest decision,
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
HttpServletRequest request) {
return ok("response.success.updated", service.decide(namespace, slug, candidateId,
decision.approved(), decision.reason(), userId(principal), roles, platformRoles(principal),
AuditRequestContext.from(request)));
}
@DeleteMapping("/{namespace}/{slug}/avatar")
public ApiResponse<Void> delete(@PathVariable String namespace, @PathVariable String slug,
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
HttpServletRequest request) {
service.remove(namespace, slug, userId(principal), false, null, roles, platformRoles(principal),
AuditRequestContext.from(request));
return ok("response.success.updated", null);
}
@PostMapping("/{namespace}/{slug}/avatar/takedown")
public ApiResponse<Void> takedown(@PathVariable String namespace, @PathVariable String slug,
@RequestBody String reason, @AuthenticationPrincipal PlatformPrincipal principal,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
HttpServletRequest request) {
service.remove(namespace, slug, userId(principal), true, reason, roles, platformRoles(principal),
AuditRequestContext.from(request));
return ok("response.success.updated", null);
}
private ResponseEntity<InputStreamResource> png(InputStream input) {
return ResponseEntity.ok().contentType(MediaType.IMAGE_PNG).cacheControl(CacheControl.noStore())
.body(new InputStreamResource(input));
}
private String userId(PlatformPrincipal principal) {
return principal == null ? null : principal.userId();
}
private Set<String> platformRoles(PlatformPrincipal principal) {
return principal == null || principal.platformRoles() == null ? Set.of() : principal.platformRoles();
}
}

View file

@ -25,6 +25,8 @@ import com.iflytek.skillhub.dto.SkillVersionResponse;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.service.SkillLabelAppService;
import com.iflytek.skillhub.service.SkillAvatarUrls;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.service.ComplianceSnapshotProjectionService;
import com.iflytek.skillhub.service.SkillSuiteAppService;
import org.springframework.core.io.InputStreamResource;
@ -127,7 +129,9 @@ public class SkillController extends BaseApiController {
memberOfSuites.items().stream()
.filter(SkillSuiteReferenceResponse::currentSkillEntry)
.toList(),
memberOfSuites
memberOfSuites,
SkillAvatarUrls.current(detail.activeAvatarId(), detail.activeAvatarAudience(),
SkillVisibility.valueOf(detail.visibility()), namespace, detail.slug())
);
return ok("response.success.read", response);

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
public record AvatarReviewSettingsResponse(boolean machineEnabled, boolean manualEnabled,
long version, Instant updatedAt) {}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.Min;
public record AvatarReviewSettingsUpdateRequest(boolean machineEnabled, boolean manualEnabled,
@Min(0) long version) {}

View file

@ -0,0 +1,5 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record SkillAvatarDecisionRequest(boolean approved, @NotBlank String reason) {}

View file

@ -0,0 +1,5 @@
package com.iflytek.skillhub.dto;
public record SkillAvatarStatusResponse(String avatarUrl, Long candidateId, String candidateStatus,
String machineStatus, String reviewStatus, String reviewReason,
String candidateUrl) {}

View file

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

View file

@ -32,9 +32,23 @@ public record SkillSummaryResponse(
* that do not opt in.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
List<SkillLabelDto> labels
List<SkillLabelDto> labels,
String avatarUrl
) {
public SkillSummaryResponse(
Long id, String slug, String displayName, String summary, String visibility, String status,
Long downloadCount, Integer starCount, BigDecimal ratingAvg, Integer ratingCount,
String namespace, Instant updatedAt, String ownerId, String ownerDisplayName,
boolean canSubmitPromotion, SkillLifecycleVersionResponse headlineVersion,
SkillLifecycleVersionResponse publishedVersion, SkillLifecycleVersionResponse ownerPreviewVersion,
String resolutionMode, ComplianceSnapshotResponse complianceSnapshot, List<SkillLabelDto> labels) {
this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg,
ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion,
headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot,
labels, null);
}
/**
* Summary without label projection.
*/
@ -93,6 +107,6 @@ public record SkillSummaryResponse(
return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount,
starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName,
canSubmitPromotion, headlineVersion,
publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels);
publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels, avatarUrl);
}
}

View file

@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.service.SkillAvatarUrls;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@ -145,7 +146,8 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
toLifecycleVersion(ownerPreviewVersion),
projection.resolutionMode().name(),
null,
null
null,
SkillAvatarUrls.current(skill, namespace != null ? namespace.getSlug() : "")
);
}

View file

@ -0,0 +1,108 @@
package com.iflytek.skillhub.service;
import com.aliyun.green20220302.Client;
import com.aliyun.green20220302.models.DescribeUploadTokenResponse;
import com.aliyun.green20220302.models.DescribeUploadTokenResponseBody.DescribeUploadTokenResponseBodyData;
import com.aliyun.green20220302.models.ImageModerationRequest;
import com.aliyun.green20220302.models.ImageModerationResponse;
import com.aliyun.green20220302.models.ImageModerationResponseBody;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.PutObjectRequest;
import com.aliyun.teaopenapi.models.Config;
import com.aliyun.teautil.models.RuntimeOptions;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayInputStream;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/** Uses Alibaba Cloud's short-lived private upload token; never exposes a public candidate URL. */
@Component
public class AliyunAvatarModerationProvider implements AvatarModerationProvider {
private static final String ENDPOINT = "green-cip.cn-shanghai.aliyuncs.com";
private final String accessKeyId;
private final String accessKeySecret;
private final ObjectMapper mapper;
public AliyunAvatarModerationProvider(
@Value("${skillhub.avatar.moderation.aliyun.access-key-id:}") String accessKeyId,
@Value("${skillhub.avatar.moderation.aliyun.access-key-secret:}") String accessKeySecret,
ObjectMapper mapper) {
this.accessKeyId = accessKeyId;
this.accessKeySecret = accessKeySecret;
this.mapper = mapper;
}
@Override public String code() { return "aliyun"; }
@Override public boolean configured() { return !accessKeyId.isBlank() && !accessKeySecret.isBlank(); }
@Override public Verdict moderate(byte[] normalizedPng) {
if (!configured()) {
return Verdict.ERROR;
}
OSS oss = null;
try {
Config config = new Config();
config.setAccessKeyId(accessKeyId);
config.setAccessKeySecret(accessKeySecret);
config.setEndpoint(ENDPOINT);
Client client = new Client(config);
DescribeUploadTokenResponse tokenResponse = client.describeUploadToken();
if (tokenResponse == null || tokenResponse.getBody() == null
|| !Objects.equals(tokenResponse.getBody().getCode(), 200)) {
return Verdict.ERROR;
}
DescribeUploadTokenResponseBodyData token = tokenResponse.getBody().getData();
if (token == null || token.getBucketName() == null || token.getFileNamePrefix() == null
|| token.getOssInternetEndPoint() == null || token.getSecurityToken() == null) {
return Verdict.ERROR;
}
oss = new OSSClientBuilder().build(token.getOssInternetEndPoint(), token.getAccessKeyId(),
token.getAccessKeySecret(), token.getSecurityToken());
String objectName = token.getFileNamePrefix() + UUID.randomUUID() + ".png";
oss.putObject(new PutObjectRequest(token.getBucketName(), objectName,
new ByteArrayInputStream(normalizedPng)));
ImageModerationRequest request = new ImageModerationRequest();
request.setService("profilePhotoCheck");
request.setServiceParameters(mapper.writeValueAsString(Map.of(
"ossBucketName", token.getBucketName(),
"ossObjectName", objectName,
"dataId", UUID.randomUUID().toString())));
ImageModerationResponse response = client.imageModerationWithOptions(request, new RuntimeOptions());
return classify(response);
} catch (Exception failure) {
// SDK exception text may include credential or temporary upload details.
return Verdict.ERROR;
} finally {
if (oss != null) {
oss.shutdown();
}
}
}
Verdict classify(ImageModerationResponse response) {
if (response == null || !Objects.equals(response.getStatusCode(), 200)) {
return Verdict.ERROR;
}
ImageModerationResponseBody body = response.getBody();
if (body == null || !Objects.equals(body.getCode(), 200) || body.getData() == null
|| body.getData().getResult() == null || body.getData().getResult().isEmpty()) {
return Verdict.ERROR;
}
if (body.getData().getRiskLevel() == null) {
return Verdict.ERROR;
}
// Official guidance treats medium as requiring human review. BLOCK keeps the image
// private and lets the owner request the platform's exceptional manual review.
return switch (body.getData().getRiskLevel()) {
case "none", "low" -> Verdict.PASS;
case "medium", "high" -> Verdict.BLOCK;
default -> Verdict.ERROR;
};
}
}

View file

@ -0,0 +1,37 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class AvatarModerationGateway {
private final String selectedProvider;
private final List<AvatarModerationProvider> providers;
public AvatarModerationGateway(@Value("${skillhub.avatar.moderation.provider:}") String selectedProvider,
List<AvatarModerationProvider> providers) {
this.selectedProvider = selectedProvider;
this.providers = providers;
}
public String requireConfiguredProvider() {
return providers.stream()
.filter(provider -> provider.code().equals(selectedProvider) && provider.configured())
.findFirst()
.map(AvatarModerationProvider::code)
.orElseThrow(() -> new DomainBadRequestException("error.skill.avatar.providerUnavailable"));
}
public AvatarModerationProvider.Verdict moderate(String providerCode, byte[] image) {
if (!providerCode.equals(selectedProvider)) {
return AvatarModerationProvider.Verdict.ERROR;
}
return providers.stream()
.filter(provider -> provider.code().equals(providerCode) && provider.configured())
.findFirst()
.map(provider -> provider.moderate(image))
.orElse(AvatarModerationProvider.Verdict.ERROR);
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.service;
/** A deployment-selected image reviewer; implementations must never log credentials or raw URLs. */
public interface AvatarModerationProvider {
enum Verdict { PASS, BLOCK, ERROR }
String code();
boolean configured();
Verdict moderate(byte[] normalizedPng);
}

View file

@ -0,0 +1,64 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.settings.SystemSetting;
import com.iflytek.skillhub.auth.settings.SystemSettingRepository;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse;
import com.iflytek.skillhub.dto.AvatarReviewSettingsUpdateRequest;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.util.Map;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AvatarReviewSettingsService {
public static final String SETTING_KEY = "skill.avatar-review";
private final SystemSettingRepository settings;
private final AvatarModerationGateway gateway;
private final AuditLogService audit;
private final RequestIdAccessor requestIds;
public AvatarReviewSettingsService(SystemSettingRepository settings, AvatarModerationGateway gateway,
AuditLogService audit, RequestIdAccessor requestIds) {
this.settings = settings;
this.gateway = gateway;
this.audit = audit;
this.requestIds = requestIds;
}
public AvatarReviewSettingsResponse current() {
SystemSetting setting = load();
return new AvatarReviewSettingsResponse(
Boolean.TRUE.equals(setting.getValue().get("machineEnabled")),
!Boolean.FALSE.equals(setting.getValue().get("manualEnabled")),
setting.getVersion(), setting.getUpdatedAt());
}
@Transactional
public AvatarReviewSettingsResponse update(AvatarReviewSettingsUpdateRequest request, String actor,
AuditRequestContext context) {
if (request.machineEnabled()) {
gateway.requireConfiguredProvider();
}
SystemSetting setting = load();
if (setting.getVersion() != request.version()) {
throw new DomainConflictException("error.system.settings.version.conflict");
}
Map<String, Object> previous = setting.getValue();
setting.update(Map.of("machineEnabled", request.machineEnabled(),
"manualEnabled", request.manualEnabled()), actor);
settings.saveAndFlush(setting);
audit.record(actor, "SKILL_AVATAR_SETTINGS_UPDATE", "SYSTEM_SETTING", setting.getId(),
requestIds.current(), context.clientIp(), context.userAgent(),
AuditDetail.builder().put("previous", previous).put("current", setting.getValue()).build());
return current();
}
private SystemSetting load() {
return settings.findBySettingKey(SETTING_KEY)
.orElseThrow(() -> new IllegalStateException("Missing required skill.avatar-review setting"));
}
}

View file

@ -87,7 +87,8 @@ public class ReviewSkillDetailAppService {
null,
"REVIEW_TASK",
List.of(),
new PageResponse<>(List.of(), 0, 0, 20)
new PageResponse<>(List.of(), 0, 0, 20),
SkillAvatarUrls.current(snapshot.skill(), context.namespace().getSlug())
);
List<SkillVersionResponse> versions = snapshot.versions().stream()

View file

@ -0,0 +1,387 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse;
import com.iflytek.skillhub.dto.SkillAvatarStatusResponse;
import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository;
import com.iflytek.skillhub.infra.jpa.SkillJpaRepository;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.storage.ObjectStorageService;
import jakarta.persistence.EntityManager;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@Service
public class SkillAvatarAppService {
private final SkillJpaRepository skills;
private final NamespaceRepository namespaces;
private final SkillAvatarCandidateJpaRepository candidates;
private final ObjectStorageService storage;
private final SkillAvatarImageProcessor imageProcessor;
private final AvatarReviewSettingsService settings;
private final AvatarModerationGateway moderation;
private final VisibilityChecker visibility;
private final SkillQueryService skillQueries;
private final AuditLogService audit;
private final RequestIdAccessor requestIds;
private final EntityManager entityManager;
public SkillAvatarAppService(SkillJpaRepository skills, NamespaceRepository namespaces,
SkillAvatarCandidateJpaRepository candidates, ObjectStorageService storage,
SkillAvatarImageProcessor imageProcessor, AvatarReviewSettingsService settings,
AvatarModerationGateway moderation, VisibilityChecker visibility,
SkillQueryService skillQueries, AuditLogService audit, RequestIdAccessor requestIds,
EntityManager entityManager) {
this.skills = skills;
this.namespaces = namespaces;
this.candidates = candidates;
this.storage = storage;
this.imageProcessor = imageProcessor;
this.settings = settings;
this.moderation = moderation;
this.visibility = visibility;
this.skillQueries = skillQueries;
this.audit = audit;
this.requestIds = requestIds;
this.entityManager = entityManager;
}
@Transactional
public SkillAvatarStatusResponse submit(String namespaceSlug, String slug, String actor, byte[] image,
AuditRequestContext context) {
Skill skill = lockedSkill(namespaceSlug, slug);
requireOwner(skill, actor);
byte[] normalized = imageProcessor.normalize(image);
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");
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCompletion(int result) {
if (result != STATUS_COMMITTED) {
storage.deleteObject(key);
}
}
});
if (skill.getPendingAvatarId() != null) {
candidates.findById(skill.getPendingAvatarId()).ifPresent(SkillAvatarCandidate::supersede);
}
SkillAvatarCandidate candidate = new SkillAvatarCandidate(skill.getId(), actor, key, sha256,
skill.getVisibility(), machineEnabled, manualEnabled, provider);
if (machineEnabled) {
// Only the machine verdict is reusable. Every new audience has its own human decision.
boolean reusedPass = !candidates.findByContentSha256AndMachineProviderAndMachineEnabledTrueAndManualEnabledAndMachineStatus(
sha256, provider, manualEnabled, SkillAvatarCandidate.MachineStatus.PASSED).stream()
.findFirst().isEmpty();
boolean reusedBlock = !candidates.findByContentSha256AndMachineProviderAndMachineEnabledTrueAndManualEnabledAndMachineStatus(
sha256, provider, manualEnabled, SkillAvatarCandidate.MachineStatus.BLOCKED).stream()
.findFirst().isEmpty();
if (reusedPass) {
candidate.machineResult(SkillAvatarCandidate.MachineStatus.PASSED);
} else if (reusedBlock) {
candidate.machineResult(SkillAvatarCandidate.MachineStatus.BLOCKED);
}
}
candidate = candidates.saveAndFlush(candidate);
skill.setPendingAvatarId(candidate.getId());
if (!machineEnabled && !manualEnabled
|| candidate.getMachineStatus() == SkillAvatarCandidate.MachineStatus.PASSED && !manualEnabled) {
activate(skill, candidate);
}
log(actor, "SKILL_AVATAR_SUBMIT", candidate.getId(), context,
AuditDetail.builder().put("skillId", skill.getId()).put("audience", candidate.getAudience().name()).build());
return status(skill, candidate, namespaceSlug);
}
/** Runs outside submit's transaction; result application locks the Skill before changing pointers. */
@Transactional
public SkillAvatarStatusResponse processMachine(long candidateId, AuditRequestContext context) {
SkillAvatarCandidate candidate = findCandidate(candidateId);
if (candidate.getStatus() != SkillAvatarCandidate.Status.PENDING
|| candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PENDING
&& candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.ERROR) {
throw new DomainConflictException("error.skill.avatar.notPending");
}
byte[] bytes;
try (InputStream input = storage.getObject(candidate.getObjectKey())) {
bytes = input.readAllBytes();
} catch (IOException failure) {
throw new IllegalStateException("Cannot read private avatar candidate", failure);
}
AvatarModerationProvider.Verdict verdict = moderation.moderate(candidate.getMachineProvider(), bytes);
Skill skill = skills.findLockedById(candidate.getSkillId()).orElseThrow(() -> notFound());
entityManager.refresh(candidate);
if (!Objects.equals(skill.getPendingAvatarId(), candidateId)
|| candidate.getStatus() != SkillAvatarCandidate.Status.PENDING) {
return status(skill, candidate, namespaceSlug(skill));
}
candidate.machineResult(switch (verdict) {
case PASS -> SkillAvatarCandidate.MachineStatus.PASSED;
case BLOCK -> SkillAvatarCandidate.MachineStatus.BLOCKED;
case ERROR -> SkillAvatarCandidate.MachineStatus.ERROR;
});
if (verdict == AvatarModerationProvider.Verdict.PASS && !candidate.isManualEnabled()) {
activate(skill, candidate);
}
log(candidate.getSubmittedBy(), "SKILL_AVATAR_MACHINE_RESULT", candidate.getId(), context,
AuditDetail.of("verdict", verdict.name()));
return status(skill, candidate, namespaceSlug(skill));
}
@Transactional(readOnly = true)
public SkillAvatarStatusResponse ownerStatus(String namespaceSlug, String slug, String actor) {
Skill skill = findSkill(namespaceSlug, slug);
requireOwner(skill, actor);
return status(skill, candidates.findBySkillIdOrderByCreatedAtDesc(skill.getId()).stream().findFirst().orElse(null),
namespaceSlug);
}
@Transactional(readOnly = true)
public InputStream readActive(String namespaceSlug, String slug, String actor,
Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles) {
skillQueries.getSkillDetail(namespaceSlug, slug, actor, namespaceRoles == null ? Map.of() : namespaceRoles);
Skill skill = findSkill(namespaceSlug, slug);
if (!visibility.canAccess(skill, actor, namespaceRoles, platformRoles)
|| SkillAvatarUrls.current(skill, namespaceSlug) == null) {
throw notFound();
}
SkillAvatarCandidate candidate = findCandidate(skill.getActiveAvatarId());
if (candidate.getStatus() != SkillAvatarCandidate.Status.ACTIVE || candidate.getObjectKey() == null) {
throw notFound();
}
return storage.getObject(candidate.getObjectKey());
}
@Transactional(readOnly = true)
public InputStream readCandidate(String namespaceSlug, String slug, long candidateId, String actor,
Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles) {
Skill skill = findSkill(namespaceSlug, slug);
SkillAvatarCandidate candidate = findCandidate(candidateId);
if (!skill.getId().equals(candidate.getSkillId()) || candidate.getObjectKey() == null
|| !skill.getOwnerId().equals(actor) && !canReview(skill, actor, namespaceRoles, platformRoles)) {
throw notFound();
}
return storage.getObject(candidate.getObjectKey());
}
@Transactional
public SkillAvatarStatusResponse appeal(String namespaceSlug, String slug, long candidateId, String actor,
AuditRequestContext context) {
Skill skill = lockedSkill(namespaceSlug, slug);
requireOwner(skill, actor);
SkillAvatarCandidate candidate = currentPending(skill, candidateId);
candidate.appeal();
log(actor, "SKILL_AVATAR_APPEAL", candidateId, context, AuditDetail.of("skillId", skill.getId()));
return status(skill, candidate, namespaceSlug);
}
@Transactional(readOnly = true)
public void requireCurrentOwnerCandidate(String namespaceSlug, String slug, long candidateId, String actor) {
Skill skill = findSkill(namespaceSlug, slug);
requireOwner(skill, actor);
if (!Objects.equals(skill.getPendingAvatarId(), candidateId)) {
throw new DomainConflictException("error.skill.avatar.notPending");
}
}
@Transactional
public SkillAvatarStatusResponse decide(String namespaceSlug, String slug, long candidateId, boolean approved,
String reason, String actor, Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles, AuditRequestContext context) {
Skill skill = lockedSkill(namespaceSlug, slug);
requireReviewer(skill, actor, namespaceRoles, platformRoles);
SkillAvatarCandidate candidate = currentPending(skill, candidateId);
if (candidate.isMachineEnabled() && candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PASSED
&& candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.BLOCKED) {
throw new DomainConflictException("error.skill.avatar.machineIncomplete");
}
if (reason == null || reason.isBlank()) {
throw new DomainBadRequestException("error.skill.avatar.reasonRequired");
}
if (approved) {
candidate.approve(actor, reason.trim());
activate(skill, candidate);
} else {
candidate.reject(actor, reason.trim());
skill.setPendingAvatarId(null);
}
log(actor, approved ? "SKILL_AVATAR_APPROVE" : "SKILL_AVATAR_REJECT", candidateId, context,
AuditDetail.builder().put("skillId", skill.getId()).put("reason", reason.trim()).build());
return status(skill, candidate, namespaceSlug);
}
@Transactional
public void remove(String namespaceSlug, String slug, String actor, boolean byAdmin, String reason,
Map<Long, NamespaceRole> namespaceRoles, Set<String> platformRoles,
AuditRequestContext context) {
Skill skill = lockedSkill(namespaceSlug, slug);
if (byAdmin) {
requireReviewer(skill, actor, namespaceRoles, platformRoles);
if (reason == null || reason.isBlank()) {
throw new DomainBadRequestException("error.skill.avatar.reasonRequired");
}
} else {
requireOwner(skill, actor);
}
if (skill.getActiveAvatarId() == null && skill.getPendingAvatarId() == null) {
throw notFound();
}
if (skill.getActiveAvatarId() != null) {
candidates.findById(skill.getActiveAvatarId()).ifPresent(SkillAvatarCandidate::remove);
}
if (skill.getPendingAvatarId() != null) {
candidates.findById(skill.getPendingAvatarId()).ifPresent(SkillAvatarCandidate::remove);
}
skill.clearAvatar();
skill.setPendingAvatarId(null);
log(actor, byAdmin ? "SKILL_AVATAR_TAKEDOWN" : "SKILL_AVATAR_DELETE", skill.getId(), context,
AuditDetail.of("reason", reason == null ? "" : reason.trim()));
}
@Transactional(readOnly = true)
public List<SkillAvatarStatusResponse> reviewQueue(String actor, Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles) {
return java.util.stream.Stream.concat(
candidates.findByStatusAndReviewStatusOrderByCreatedAtAsc(
SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.ReviewStatus.PENDING).stream(),
candidates.findByStatusAndReviewStatusOrderByCreatedAtAsc(
SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.ReviewStatus.APPEAL_PENDING).stream())
.filter(candidate -> candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.ERROR)
.filter(candidate -> candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PENDING)
.filter(candidate -> ((SkillRepository) skills).findById(candidate.getSkillId())
.filter(skill -> canReview(skill, actor, namespaceRoles, platformRoles)).isPresent())
.map(candidate -> {
Skill skill = ((SkillRepository) skills).findById(candidate.getSkillId()).orElseThrow(() -> notFound());
return status(skill, candidate, namespaceSlug(skill));
}).toList();
}
private SkillAvatarCandidate currentPending(Skill skill, long candidateId) {
if (!Objects.equals(skill.getPendingAvatarId(), candidateId)) {
throw new DomainConflictException("error.skill.avatar.notPending");
}
SkillAvatarCandidate candidate = findCandidate(candidateId);
if (candidate.getStatus() != SkillAvatarCandidate.Status.PENDING) {
throw new DomainConflictException("error.skill.avatar.notPending");
}
return candidate;
}
private void activate(Skill skill, SkillAvatarCandidate candidate) {
if (skill.getActiveAvatarId() != null) {
candidates.findById(skill.getActiveAvatarId()).ifPresent(SkillAvatarCandidate::supersede);
}
candidate.activate();
skill.activateAvatar(candidate.getId(), candidate.getAudience());
skill.setPendingAvatarId(null);
}
private Skill findSkill(String namespaceSlug, String slug) {
Namespace namespace = namespaces.findBySlug(namespaceSlug).orElseThrow(() -> notFound());
return skills.findByNamespaceIdAndSlug(namespace.getId(), slug).stream()
.filter(skill -> skill.getStatus() == SkillStatus.ACTIVE).findFirst()
.orElseThrow(() -> notFound());
}
private Skill lockedSkill(String namespaceSlug, String slug) {
Skill skill = findSkill(namespaceSlug, slug);
return skills.findLockedById(skill.getId()).orElseThrow(() -> notFound());
}
private SkillAvatarCandidate findCandidate(Long candidateId) {
return candidates.findById(candidateId).orElseThrow(() -> notFound());
}
private String namespaceSlug(Skill skill) {
return namespaces.findById(skill.getNamespaceId()).orElseThrow(() -> notFound()).getSlug();
}
private void requireOwner(Skill skill, String actor) {
if (actor == null || !skill.getOwnerId().equals(actor)) {
throw new DomainForbiddenException("error.skill.avatar.ownerOnly");
}
}
private void requireReviewer(Skill skill, String actor, Map<Long, NamespaceRole> roles,
Set<String> platformRoles) {
if (actor == null || !canReview(skill, actor, roles, platformRoles)) {
throw new DomainForbiddenException("error.skill.avatar.reviewerOnly");
}
}
private boolean canReview(Skill skill, String actor, Map<Long, NamespaceRole> roles,
Set<String> platformRoles) {
if (actor == null) {
return false;
}
Namespace namespace = namespaces.findById(skill.getNamespaceId()).orElseThrow(() -> notFound());
if (namespace.getType() == NamespaceType.GLOBAL) {
return platformRoles != null && (platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"));
}
NamespaceRole role = roles == null ? null : roles.get(skill.getNamespaceId());
return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN;
}
private SkillAvatarStatusResponse status(Skill skill, SkillAvatarCandidate candidate, String namespaceSlug) {
return new SkillAvatarStatusResponse(SkillAvatarUrls.current(skill, namespaceSlug),
candidate == null ? null : candidate.getId(),
candidate == null ? null : candidate.getStatus().name(),
candidate == null ? null : candidate.getMachineStatus().name(),
candidate == null ? null : candidate.getReviewStatus().name(),
candidate == null ? null : candidate.getReviewReason(),
candidate == null || candidate.getObjectKey() == null ? null
: SkillAvatarUrls.candidate(namespaceSlug, skill, candidate.getId()));
}
private String sha256(byte[] image) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(image));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 unavailable", impossible);
}
}
private void log(String actor, String action, Long targetId, AuditRequestContext context, String detail) {
audit.record(actor, action, "SKILL_AVATAR", targetId, requestIds.current(),
context.clientIp(), context.userAgent(), detail);
}
private DomainNotFoundException notFound() {
return new DomainNotFoundException("error.skill.avatar.notFound");
}
}

View file

@ -0,0 +1,36 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
public final class SkillAvatarUrls {
private SkillAvatarUrls() {}
public static String current(Skill skill, String namespaceSlug) {
return current(skill.getActiveAvatarId(), skill.getActiveAvatarAudience(), skill.getVisibility(),
namespaceSlug, skill.getSlug());
}
public static String current(Long activeAvatarId, SkillVisibility approved, SkillVisibility current,
String namespaceSlug, String skillSlug) {
if (activeAvatarId == null || approved == null) {
return null;
}
if (current == SkillVisibility.PUBLIC && approved != SkillVisibility.PUBLIC
|| current == SkillVisibility.NAMESPACE_ONLY && approved == SkillVisibility.PRIVATE) {
return null;
}
return "/api/v1/skills/" + segment(namespaceSlug) + "/" + segment(skillSlug) + "/avatar";
}
public static String candidate(String namespaceSlug, Skill skill, long candidateId) {
return "/api/v1/skills/" + segment(namespaceSlug) + "/" + segment(skill.getSlug())
+ "/avatar/candidates/" + candidateId + "/image";
}
private static String segment(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
}
}

View file

@ -274,7 +274,8 @@ public class SkillSearchAppService {
headlineVersion != null
? complianceSnapshotProjectionService.fromParsedMetadataJson(headlineVersion.parsedMetadataJson())
: null,
null
null,
SkillAvatarUrls.current(skill, namespaceSlug)
);
}

View file

@ -0,0 +1,114 @@
package com.iflytek.skillhub.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/** Xfyun image compliance API v2. Its documented query contains a secret: never log URI or exceptions. */
@Component
public class XfyunAvatarModerationProvider implements AvatarModerationProvider {
private static final String ENDPOINT = "https://audit.iflyaisol.com/audit/v2/image";
private static final DateTimeFormatter UTC_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ");
private final String appId;
private final String accessKeyId;
private final String accessKeySecret;
private final ObjectMapper mapper;
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
public XfyunAvatarModerationProvider(
@Value("${skillhub.avatar.moderation.xfyun.app-id:}") String appId,
@Value("${skillhub.avatar.moderation.xfyun.access-key-id:}") String accessKeyId,
@Value("${skillhub.avatar.moderation.xfyun.access-key-secret:}") String accessKeySecret,
ObjectMapper mapper) {
this.appId = appId;
this.accessKeyId = accessKeyId;
this.accessKeySecret = accessKeySecret;
this.mapper = mapper;
}
@Override public String code() { return "xfyun"; }
@Override public boolean configured() {
return !appId.isBlank() && !accessKeyId.isBlank() && !accessKeySecret.isBlank();
}
@Override public Verdict moderate(byte[] normalizedPng) {
if (!configured()) {
return Verdict.ERROR;
}
try {
TreeMap<String, String> params = new TreeMap<>(Map.of(
"accessKeyId", accessKeyId,
"accessKeySecret", accessKeySecret,
"appId", appId,
"modeType", "base64",
"utc", OffsetDateTime.now(ZoneOffset.UTC).format(UTC_FORMAT),
"uuid", UUID.randomUUID().toString()));
String base = encode(params);
params.put("signature", sign(base));
String payload = mapper.writeValueAsString(Map.of("content", Base64.getEncoder().encodeToString(normalizedPng)));
HttpRequest request = HttpRequest.newBuilder(URI.create(ENDPOINT + "?" + encode(params)))
.timeout(Duration.ofSeconds(8))
.header("Content-Type", "application/json;charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(payload)).build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return response.statusCode() == 200 ? parse(response.body()) : Verdict.ERROR;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return Verdict.ERROR;
} catch (IOException | IllegalArgumentException | InvalidKeyException | NoSuchAlgorithmException failure) {
// The request URI includes accessKeySecret. Do not log or propagate exception text.
return Verdict.ERROR;
}
}
Verdict parse(String response) throws IOException {
JsonNode body = mapper.readTree(response);
if (!"000000".equals(body.path("code").asText())) {
return Verdict.ERROR;
}
return switch (body.path("data").path("result").path("suggest").asText()) {
case "pass" -> Verdict.PASS;
case "block" -> Verdict.BLOCK;
default -> Verdict.ERROR;
};
}
private String sign(String base) throws NoSuchAlgorithmException, InvalidKeyException {
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(accessKeySecret.getBytes(StandardCharsets.UTF_8), "HmacSHA1"));
return Base64.getEncoder().encodeToString(mac.doFinal(base.getBytes(StandardCharsets.UTF_8)));
}
private String encode(Map<String, String> params) {
StringBuilder query = new StringBuilder();
params.forEach((key, value) -> {
if (value != null && !value.isEmpty()) {
if (!query.isEmpty()) query.append('&');
query.append(key).append('=').append(URLEncoder.encode(value, StandardCharsets.UTF_8));
}
});
return query.toString();
}
}

View file

@ -0,0 +1,29 @@
package com.iflytek.skillhub.task;
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate;
import com.iflytek.skillhub.infra.jpa.SkillAvatarCandidateJpaRepository;
import com.iflytek.skillhub.storage.ObjectStorageService;
import java.time.Instant;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class SkillAvatarCleanupTask {
private final SkillAvatarCandidateJpaRepository candidates;
private final ObjectStorageService storage;
public SkillAvatarCleanupTask(SkillAvatarCandidateJpaRepository candidates, ObjectStorageService storage) {
this.candidates = candidates;
this.storage = storage;
}
@Scheduled(cron = "0 35 2 * * ?")
@Transactional
public void removeExpiredImages() {
for (SkillAvatarCandidate candidate : candidates.findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(Instant.now())) {
storage.deleteObject(candidate.getObjectKey());
candidate.clearObjectKey();
}
}
}

View file

@ -1,9 +1,10 @@
ALTER TABLE skill ADD COLUMN active_avatar_id BIGINT;
ALTER TABLE skill ADD COLUMN active_avatar_audience VARCHAR(32);
ALTER TABLE skill ADD COLUMN pending_avatar_id BIGINT;
CREATE TABLE skill_avatar_candidate (
id BIGSERIAL PRIMARY KEY,
skill_id BIGINT NOT NULL REFERENCES skill(id),
skill_id BIGINT NOT NULL REFERENCES skill(id) ON DELETE CASCADE,
submitted_by VARCHAR(128) NOT NULL,
object_key VARCHAR(512),
content_sha256 VARCHAR(64) NOT NULL,
@ -25,3 +26,6 @@ CREATE TABLE skill_avatar_candidate (
CREATE INDEX idx_avatar_candidate_skill ON skill_avatar_candidate(skill_id, created_at DESC);
CREATE INDEX idx_avatar_candidate_review ON skill_avatar_candidate(status, review_status, created_at);
CREATE INDEX idx_avatar_candidate_discard ON skill_avatar_candidate(discard_at) WHERE object_key IS NOT NULL;
INSERT INTO system_setting(setting_key, value_json)
VALUES ('skill.avatar-review', '{"machineEnabled":false,"manualEnabled":true}'::jsonb);

View file

@ -129,6 +129,14 @@ review_task.not_found_for_version=No pending review submission found for version
error.skill.publish.summary.tooLong=Skill description must not exceed {0} characters
error.skill.notFound=Skill not found: {0}
error.skill.access.denied=Access denied to skill: {0}
error.skill.avatar.invalidImage=Upload a square PNG or JPEG image up to 1024 pixels and 2 MB.
error.skill.avatar.providerUnavailable=The configured image moderation provider is unavailable.
error.skill.avatar.notPending=This avatar request is no longer pending.
error.skill.avatar.machineIncomplete=Wait for image moderation before reviewing this avatar.
error.skill.avatar.reasonRequired=Enter a reason for this decision.
error.skill.avatar.ownerOnly=Only the skill owner can change its avatar.
error.skill.avatar.reviewerOnly=You cannot review this skill avatar.
error.skill.avatar.notFound=Skill avatar not found.
error.skill.status.notActive=Skill is not active
error.skill.lifecycle.noPermission=Only the skill owner or namespace admin can manage this skill
error.skill.version.exists=Version already exists: {0}

View file

@ -126,6 +126,14 @@ review_task.not_found_for_version=Не найдена ожидающая зая
error.skill.publish.summary.tooLong=Описание скилла не должно превышать {0} символов
error.skill.notFound=Скилл не найден: {0}
error.skill.access.denied=Нет доступа к скиллу: {0}
error.skill.avatar.invalidImage=Загрузите квадратное изображение PNG или JPEG до 2 МБ и 1024 пикселей.
error.skill.avatar.providerUnavailable=Сервис автоматической проверки изображений недоступен.
error.skill.avatar.notPending=Эта заявка на аватар больше не ожидает рассмотрения.
error.skill.avatar.machineIncomplete=Дождитесь автоматической проверки изображения.
error.skill.avatar.reasonRequired=Укажите причину решения.
error.skill.avatar.ownerOnly=Изменять аватар может только владелец скилла.
error.skill.avatar.reviewerOnly=У вас нет прав на проверку этого аватара.
error.skill.avatar.notFound=Аватар скилла не найден.
error.skill.status.notActive=Скилл не активен
error.skill.lifecycle.noPermission=Управлять этим скиллом может только владелец скилла или администратор пространства имён
error.skill.version.exists=Версия уже существует: {0}

View file

@ -129,6 +129,14 @@ review_task.not_found_for_version=未找到该版本对应的待审核记录:{
error.skill.publish.summary.tooLong=技能描述长度不能超过 {0} 个字符
error.skill.notFound=未找到技能:{0}
error.skill.access.denied=没有权限访问技能:{0}
error.skill.avatar.invalidImage=请上传不超过 2 MB、边长不超过 1024 像素的正方形 PNG 或 JPEG 图片。
error.skill.avatar.providerUnavailable=部署的图片机器审核服务不可用。
error.skill.avatar.notPending=这条头像申请已失效,不能继续处理。
error.skill.avatar.machineIncomplete=请等待图片机器审核完成后再处理。
error.skill.avatar.reasonRequired=请填写处理原因。
error.skill.avatar.ownerOnly=只有技能所有者可以维护头像。
error.skill.avatar.reviewerOnly=你没有审核此技能头像的权限。
error.skill.avatar.notFound=未找到技能头像。
error.skill.status.notActive=技能未处于 ACTIVE 状态
error.skill.lifecycle.noPermission=只有技能所有者或命名空间管理员可以管理该技能
error.skill.version.exists=版本已存在:{0}

View file

@ -202,7 +202,8 @@ class ReviewPortalControllerTest {
null,
"REVIEW_TASK",
List.of(),
new com.iflytek.skillhub.dto.PageResponse<>(List.of(), 0, 0, 20)
new com.iflytek.skillhub.dto.PageResponse<>(List.of(), 0, 0, 20),
null
),
List.of(new SkillVersionResponse(100L, "1.2.0", "PENDING_REVIEW", null, 1, 10L, null, true, null)),
List.of(new SkillFileResponse(1L, "README.md", 123L, "text/markdown", "sha")),

View file

@ -186,7 +186,9 @@ class SkillControllerTest {
null,
new com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService.VersionProjection(11L, "1.1.0", "PENDING_REVIEW"),
null,
"OWNER_PREVIEW"
"OWNER_PREVIEW",
null,
null
));
SkillSuiteReferenceResponse suiteReference = new SkillSuiteReferenceResponse(
9L, "team", "demo-suite", "Demo Suite", "2.0.0", 3,

View file

@ -0,0 +1,34 @@
package com.iflytek.skillhub.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.aliyun.green20220302.models.ImageModerationResponse;
import com.aliyun.green20220302.models.ImageModerationResponseBody;
import com.aliyun.green20220302.models.ImageModerationResponseBody.ImageModerationResponseBodyData;
import com.aliyun.green20220302.models.ImageModerationResponseBody.ImageModerationResponseBodyDataResult;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import org.junit.jupiter.api.Test;
class AliyunAvatarModerationProviderTest {
private final AliyunAvatarModerationProvider provider =
new AliyunAvatarModerationProvider("key", "secret", new ObjectMapper());
@Test
void classifiesDocumentedRiskLevelsAndFailsClosedOnUnknown() {
assertThat(provider.classify(response("none"))).isEqualTo(AvatarModerationProvider.Verdict.PASS);
assertThat(provider.classify(response("low"))).isEqualTo(AvatarModerationProvider.Verdict.PASS);
assertThat(provider.classify(response("medium"))).isEqualTo(AvatarModerationProvider.Verdict.BLOCK);
assertThat(provider.classify(response("high"))).isEqualTo(AvatarModerationProvider.Verdict.BLOCK);
assertThat(provider.classify(response(null))).isEqualTo(AvatarModerationProvider.Verdict.ERROR);
assertThat(provider.classify(response("unknown"))).isEqualTo(AvatarModerationProvider.Verdict.ERROR);
}
private ImageModerationResponse response(String level) {
ImageModerationResponseBodyData data = new ImageModerationResponseBodyData()
.setRiskLevel(level)
.setResult(List.of(new ImageModerationResponseBodyDataResult().setLabel("nonLabel")));
return new ImageModerationResponse().setStatusCode(200)
.setBody(new ImageModerationResponseBody().setCode(200).setData(data));
}
}

View file

@ -0,0 +1,23 @@
package com.iflytek.skillhub.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
class XfyunAvatarModerationProviderTest {
private final XfyunAvatarModerationProvider provider =
new XfyunAvatarModerationProvider("app", "key", "secret", new ObjectMapper());
@Test
void acceptsOnlyDocumentedPassAndBlockVerdicts() throws Exception {
assertThat(provider.parse("{\"code\":\"000000\",\"data\":{\"result\":{\"suggest\":\"pass\"}}}"))
.isEqualTo(AvatarModerationProvider.Verdict.PASS);
assertThat(provider.parse("{\"code\":\"000000\",\"data\":{\"result\":{\"suggest\":\"block\"}}}"))
.isEqualTo(AvatarModerationProvider.Verdict.BLOCK);
assertThat(provider.parse("{\"code\":\"000000\",\"data\":{\"result\":{\"suggest\":\"review\"}}}"))
.isEqualTo(AvatarModerationProvider.Verdict.ERROR);
assertThat(provider.parse("{\"code\":\"100001\"}"))
.isEqualTo(AvatarModerationProvider.Verdict.ERROR);
}
}

View file

@ -45,6 +45,10 @@ public class Skill {
@Column(name = "active_avatar_id")
private Long activeAvatarId;
@Enumerated(EnumType.STRING)
@Column(name = "active_avatar_audience", length = 32)
private SkillVisibility activeAvatarAudience;
@Column(name = "pending_avatar_id")
private Long pendingAvatarId;
@ -149,6 +153,8 @@ public class Skill {
public Long getActiveAvatarId() { return activeAvatarId; }
public SkillVisibility getActiveAvatarAudience() { return activeAvatarAudience; }
public Long getPendingAvatarId() { return pendingAvatarId; }
public Long getDownloadCount() {
@ -224,7 +230,15 @@ public class Skill {
this.latestVersionId = latestVersionId;
}
public void setActiveAvatarId(Long activeAvatarId) { this.activeAvatarId = activeAvatarId; }
public void activateAvatar(Long activeAvatarId, SkillVisibility audience) {
this.activeAvatarId = activeAvatarId;
this.activeAvatarAudience = audience;
}
public void clearAvatar() {
this.activeAvatarId = null;
this.activeAvatarAudience = null;
}
public void setPendingAvatarId(Long pendingAvatarId) { this.pendingAvatarId = pendingAvatarId; }

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.domain.skill;
import java.util.List;
public interface SkillAvatarCandidateRepository {
List<SkillAvatarCandidate> findBySkillIdOrderByCreatedAtDesc(Long skillId);
}

View file

@ -9,6 +9,8 @@ import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.security.SecurityScanService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate;
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidateRepository;
import com.iflytek.skillhub.domain.skill.SkillFile;
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
import com.iflytek.skillhub.domain.skill.SkillRepository;
@ -49,6 +51,7 @@ public class SkillHardDeleteService {
private final SkillRatingRepository skillRatingRepository;
private final SkillReportRepository skillReportRepository;
private final SkillVersionStatsRepository skillVersionStatsRepository;
private final SkillAvatarCandidateRepository avatarCandidates;
private final ObjectStorageService objectStorageService;
private final SkillStorageDeletionCompensationService compensationService;
private final SecurityScanService securityScanService;
@ -65,6 +68,7 @@ public class SkillHardDeleteService {
SkillRatingRepository skillRatingRepository,
SkillReportRepository skillReportRepository,
SkillVersionStatsRepository skillVersionStatsRepository,
SkillAvatarCandidateRepository avatarCandidates,
ObjectStorageService objectStorageService,
SkillStorageDeletionCompensationService compensationService,
SecurityScanService securityScanService,
@ -80,6 +84,7 @@ public class SkillHardDeleteService {
this.skillRatingRepository = skillRatingRepository;
this.skillReportRepository = skillReportRepository;
this.skillVersionStatsRepository = skillVersionStatsRepository;
this.avatarCandidates = avatarCandidates;
this.objectStorageService = objectStorageService;
this.compensationService = compensationService;
this.securityScanService = securityScanService;
@ -117,6 +122,10 @@ public class SkillHardDeleteService {
.forEach(storageKeys::add);
storageKeys.add(buildBundleStorageKey(skill.getId(), version.getId()));
}
avatarCandidates.findBySkillIdOrderByCreatedAtDesc(skill.getId()).stream()
.map(SkillAvatarCandidate::getObjectKey)
.filter(key -> key != null && !key.isBlank())
.forEach(storageKeys::add);
deleteStorageAfterCommit(skill, namespaceSlug, storageKeys);
skill.setLatestVersionId(null);

View file

@ -116,7 +116,9 @@ public class SkillQueryService {
SkillLifecycleProjectionService.VersionProjection publishedVersion,
SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion,
String ownerPreviewReviewComment,
String resolutionMode
String resolutionMode,
Long activeAvatarId,
SkillVisibility activeAvatarAudience
) {}
public record SkillVersionDetailDTO(
@ -249,7 +251,9 @@ public class SkillQueryService {
publishedVersion,
ownerPreviewVersion,
ownerPreviewReviewComment,
projection.resolutionMode().name()
projection.resolutionMode().name(),
skill.getActiveAvatarId(),
skill.getActiveAvatarAudience()
);
}

View file

@ -64,6 +64,8 @@ class SkillHardDeleteServiceTest {
@Mock
private SkillVersionStatsRepository skillVersionStatsRepository;
@Mock
private com.iflytek.skillhub.domain.skill.SkillAvatarCandidateRepository avatarCandidates;
@Mock
private ObjectStorageService objectStorageService;
@Mock
private SkillStorageDeletionCompensationService compensationService;
@ -94,6 +96,7 @@ class SkillHardDeleteServiceTest {
skillRatingRepository,
skillReportRepository,
skillVersionStatsRepository,
avatarCandidates,
objectStorageService,
compensationService,
securityScanService,

View file

@ -1,12 +1,16 @@
package com.iflytek.skillhub.infra.jpa;
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate;
import com.iflytek.skillhub.domain.skill.SkillAvatarCandidateRepository;
import java.time.Instant;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SkillAvatarCandidateJpaRepository extends JpaRepository<SkillAvatarCandidate, Long> {
public interface SkillAvatarCandidateJpaRepository extends JpaRepository<SkillAvatarCandidate, Long>, SkillAvatarCandidateRepository {
List<SkillAvatarCandidate> findBySkillIdOrderByCreatedAtDesc(Long skillId);
List<SkillAvatarCandidate> findByContentSha256AndMachineProviderAndMachineEnabledTrueAndManualEnabledAndMachineStatus(
String contentSha256, String machineProvider, boolean manualEnabled,
SkillAvatarCandidate.MachineStatus machineStatus);
List<SkillAvatarCandidate> findByStatusAndReviewStatusOrderByCreatedAtAsc(
SkillAvatarCandidate.Status status, SkillAvatarCandidate.ReviewStatus reviewStatus);
List<SkillAvatarCandidate> findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(Instant before);

View file

@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.skill.SkillStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@ -14,12 +15,16 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import jakarta.persistence.LockModeType;
/**
* Base Spring Data JPA repository for persisted skill aggregates and common skill queries.
*/
@Repository
public interface SkillJpaRepository extends JpaRepository<Skill, Long>, SkillRepository {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Skill s WHERE s.id = :id")
Optional<Skill> findLockedById(@Param("id") Long id);
List<Skill> findByIdIn(List<Long> ids);
List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
List<Skill> findByNamespaceIdInAndSlugIn(List<Long> namespaceIds, List<String> slugs);