diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index 876d9ddc..0dc91ad7 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -18,6 +18,17 @@ + + + com.aliyun + green20220302 + 3.3.3 + + + com.aliyun.oss + aliyun-sdk-oss + 3.16.3 + org.springframework.boot spring-boot-starter-web diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AvatarReviewSettingsController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AvatarReviewSettingsController.java new file mode 100644 index 00000000..939bfae5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AvatarReviewSettingsController.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse; +import com.iflytek.skillhub.dto.AvatarReviewSettingsUpdateRequest; +import com.iflytek.skillhub.service.AvatarReviewSettingsService; +import com.iflytek.skillhub.service.AuditRequestContext; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/system-config/avatar-review") +@PreAuthorize("hasRole('SUPER_ADMIN')") +public class AvatarReviewSettingsController extends BaseApiController { + private final AvatarReviewSettingsService service; + + public AvatarReviewSettingsController(AvatarReviewSettingsService service, ApiResponseFactory responses) { + super(responses); + this.service = service; + } + + @GetMapping + public ApiResponse current() { + return ok("response.success.read", service.current()); + } + + @PutMapping + public ApiResponse update(@Valid @RequestBody AvatarReviewSettingsUpdateRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest httpRequest) { + return ok("response.success.updated", service.update(request, principal.userId(), + AuditRequestContext.from(httpRequest))); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java new file mode 100644 index 00000000..f589f846 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillAvatarController.java @@ -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 current(@PathVariable String namespace, @PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles) { + InputStream image = service.readActive(namespace, slug, userId(principal), roles, platformRoles(principal)); + return png(image); + } + + @PostMapping(value = "/{namespace}/{slug}/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse submit(@PathVariable String namespace, @PathVariable String slug, + @RequestParam("file") MultipartFile file, @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) throws IOException { + if (file.getSize() < 1 || file.getSize() > com.iflytek.skillhub.service.SkillAvatarImageProcessor.MAX_BYTES) { + throw new DomainBadRequestException("error.skill.avatar.invalidImage"); + } + AuditRequestContext context = AuditRequestContext.from(httpRequest); + SkillAvatarStatusResponse created = service.submit(namespace, slug, userId(principal), file.getBytes(), context); + if ("PENDING".equals(created.machineStatus()) && "PENDING".equals(created.candidateStatus())) { + return ok("response.success.created", service.processMachine(created.candidateId(), context)); + } + return ok("response.success.created", created); + } + + @GetMapping("/{namespace}/{slug}/avatar/status") + public ApiResponse status(@PathVariable String namespace, @PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal) { + return ok("response.success.read", service.ownerStatus(namespace, slug, userId(principal))); + } + + @GetMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/image") + public ResponseEntity candidate(@PathVariable String namespace, @PathVariable String slug, + @PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles) { + return png(service.readCandidate(namespace, slug, candidateId, userId(principal), roles, platformRoles(principal))); + } + + @PostMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/appeal") + public ApiResponse appeal(@PathVariable String namespace, @PathVariable String slug, + @PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) { + return ok("response.success.updated", service.appeal(namespace, slug, candidateId, + userId(principal), AuditRequestContext.from(request))); + } + + @PostMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/retry") + public ApiResponse retry(@PathVariable String namespace, @PathVariable String slug, + @PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) { + service.requireCurrentOwnerCandidate(namespace, slug, candidateId, userId(principal)); + return ok("response.success.updated", service.processMachine(candidateId, AuditRequestContext.from(request))); + } + + @GetMapping("/avatar-review/queue") + public ApiResponse> queue( + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles) { + return ok("response.success.read", service.reviewQueue(userId(principal), roles, platformRoles(principal))); + } + + @PostMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/decision") + public ApiResponse decide(@PathVariable String namespace, @PathVariable String slug, + @PathVariable long candidateId, @Valid @RequestBody SkillAvatarDecisionRequest decision, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + HttpServletRequest request) { + return ok("response.success.updated", service.decide(namespace, slug, candidateId, + decision.approved(), decision.reason(), userId(principal), roles, platformRoles(principal), + AuditRequestContext.from(request))); + } + + @DeleteMapping("/{namespace}/{slug}/avatar") + public ApiResponse delete(@PathVariable String namespace, @PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + HttpServletRequest request) { + service.remove(namespace, slug, userId(principal), false, null, roles, platformRoles(principal), + AuditRequestContext.from(request)); + return ok("response.success.updated", null); + } + + @PostMapping("/{namespace}/{slug}/avatar/takedown") + public ApiResponse takedown(@PathVariable String namespace, @PathVariable String slug, + @RequestBody String reason, @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map 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 png(InputStream input) { + return ResponseEntity.ok().contentType(MediaType.IMAGE_PNG).cacheControl(CacheControl.noStore()) + .body(new InputStreamResource(input)); + } + + private String userId(PlatformPrincipal principal) { + return principal == null ? null : principal.userId(); + } + + private Set platformRoles(PlatformPrincipal principal) { + return principal == null || principal.platformRoles() == null ? Set.of() : principal.platformRoles(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 213f78d9..1062db56 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -25,6 +25,8 @@ import com.iflytek.skillhub.dto.SkillVersionResponse; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.SkillLabelAppService; +import com.iflytek.skillhub.service.SkillAvatarUrls; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.service.ComplianceSnapshotProjectionService; import com.iflytek.skillhub.service.SkillSuiteAppService; import org.springframework.core.io.InputStreamResource; @@ -127,7 +129,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); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsResponse.java new file mode 100644 index 00000000..80c46b8f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsResponse.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +public record AvatarReviewSettingsResponse(boolean machineEnabled, boolean manualEnabled, + long version, Instant updatedAt) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsUpdateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsUpdateRequest.java new file mode 100644 index 00000000..7ef4eb24 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AvatarReviewSettingsUpdateRequest.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Min; + +public record AvatarReviewSettingsUpdateRequest(boolean machineEnabled, boolean manualEnabled, + @Min(0) long version) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarDecisionRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarDecisionRequest.java new file mode 100644 index 00000000..4f2d4c02 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarDecisionRequest.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SkillAvatarDecisionRequest(boolean approved, @NotBlank String reason) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java new file mode 100644 index 00000000..b0b84714 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillAvatarStatusResponse.java @@ -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) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index a3825624..9f0665ba 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -30,5 +30,6 @@ public record SkillDetailResponse( String ownerPreviewReviewComment, String resolutionMode, List entryForSuites, - PageResponse memberOfSuites + PageResponse memberOfSuites, + String avatarUrl ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index fdabac73..1773bec4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -32,9 +32,23 @@ public record SkillSummaryResponse( * that do not opt in. */ @JsonInclude(JsonInclude.Include.NON_NULL) - List labels + List labels, + String avatarUrl ) { + public SkillSummaryResponse( + Long id, String slug, String displayName, String summary, String visibility, String status, + Long downloadCount, Integer starCount, BigDecimal ratingAvg, Integer ratingCount, + String namespace, Instant updatedAt, String ownerId, String ownerDisplayName, + boolean canSubmitPromotion, SkillLifecycleVersionResponse headlineVersion, + SkillLifecycleVersionResponse publishedVersion, SkillLifecycleVersionResponse ownerPreviewVersion, + String resolutionMode, ComplianceSnapshotResponse complianceSnapshot, List labels) { + this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg, + ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion, + headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, + labels, null); + } + /** * Summary without label projection. */ @@ -93,6 +107,6 @@ public record SkillSummaryResponse( return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion, headlineVersion, - publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels); + publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels, avatarUrl); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java index e7a3acd7..7ad9e013 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.service.SkillAvatarUrls; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -145,7 +146,8 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository { toLifecycleVersion(ownerPreviewVersion), projection.resolutionMode().name(), null, - null + null, + SkillAvatarUrls.current(skill, namespace != null ? namespace.getSlug() : "") ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java new file mode 100644 index 00000000..3b6dffc4 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AliyunAvatarModerationProvider.java @@ -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; + }; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationGateway.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationGateway.java new file mode 100644 index 00000000..13528cad --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationGateway.java @@ -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 providers; + + public AvatarModerationGateway(@Value("${skillhub.avatar.moderation.provider:}") String selectedProvider, + List providers) { + this.selectedProvider = selectedProvider; + this.providers = providers; + } + + public String requireConfiguredProvider() { + return providers.stream() + .filter(provider -> provider.code().equals(selectedProvider) && provider.configured()) + .findFirst() + .map(AvatarModerationProvider::code) + .orElseThrow(() -> new DomainBadRequestException("error.skill.avatar.providerUnavailable")); + } + + public AvatarModerationProvider.Verdict moderate(String providerCode, byte[] image) { + if (!providerCode.equals(selectedProvider)) { + return AvatarModerationProvider.Verdict.ERROR; + } + return providers.stream() + .filter(provider -> provider.code().equals(providerCode) && provider.configured()) + .findFirst() + .map(provider -> provider.moderate(image)) + .orElse(AvatarModerationProvider.Verdict.ERROR); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationProvider.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationProvider.java new file mode 100644 index 00000000..c694c4b7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarModerationProvider.java @@ -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); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarReviewSettingsService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarReviewSettingsService.java new file mode 100644 index 00000000..86e7bba5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AvatarReviewSettingsService.java @@ -0,0 +1,64 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.settings.SystemSetting; +import com.iflytek.skillhub.auth.settings.SystemSettingRepository; +import com.iflytek.skillhub.domain.audit.AuditDetail; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse; +import com.iflytek.skillhub.dto.AvatarReviewSettingsUpdateRequest; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AvatarReviewSettingsService { + public static final String SETTING_KEY = "skill.avatar-review"; + + private final SystemSettingRepository settings; + private final AvatarModerationGateway gateway; + private final AuditLogService audit; + private final RequestIdAccessor requestIds; + + public AvatarReviewSettingsService(SystemSettingRepository settings, AvatarModerationGateway gateway, + AuditLogService audit, RequestIdAccessor requestIds) { + this.settings = settings; + this.gateway = gateway; + this.audit = audit; + this.requestIds = requestIds; + } + + public AvatarReviewSettingsResponse current() { + SystemSetting setting = load(); + return new AvatarReviewSettingsResponse( + Boolean.TRUE.equals(setting.getValue().get("machineEnabled")), + !Boolean.FALSE.equals(setting.getValue().get("manualEnabled")), + setting.getVersion(), setting.getUpdatedAt()); + } + + @Transactional + public AvatarReviewSettingsResponse update(AvatarReviewSettingsUpdateRequest request, String actor, + AuditRequestContext context) { + if (request.machineEnabled()) { + gateway.requireConfiguredProvider(); + } + SystemSetting setting = load(); + if (setting.getVersion() != request.version()) { + throw new DomainConflictException("error.system.settings.version.conflict"); + } + Map previous = setting.getValue(); + setting.update(Map.of("machineEnabled", request.machineEnabled(), + "manualEnabled", request.manualEnabled()), actor); + settings.saveAndFlush(setting); + audit.record(actor, "SKILL_AVATAR_SETTINGS_UPDATE", "SYSTEM_SETTING", setting.getId(), + requestIds.current(), context.clientIp(), context.userAgent(), + AuditDetail.builder().put("previous", previous).put("current", setting.getValue()).build()); + return current(); + } + + private SystemSetting load() { + return settings.findBySettingKey(SETTING_KEY) + .orElseThrow(() -> new IllegalStateException("Missing required skill.avatar-review setting")); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java index 49a629ab..769e1989 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java @@ -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 versions = snapshot.versions().stream() diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java new file mode 100644 index 00000000..6b63331c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarAppService.java @@ -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 namespaceRoles, Set 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 namespaceRoles, Set 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 namespaceRoles, + Set 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 namespaceRoles, Set 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 reviewQueue(String actor, Map namespaceRoles, + Set platformRoles) { + return java.util.stream.Stream.concat( + candidates.findByStatusAndReviewStatusOrderByCreatedAtAsc( + SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.ReviewStatus.PENDING).stream(), + candidates.findByStatusAndReviewStatusOrderByCreatedAtAsc( + SkillAvatarCandidate.Status.PENDING, SkillAvatarCandidate.ReviewStatus.APPEAL_PENDING).stream()) + .filter(candidate -> candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.ERROR) + .filter(candidate -> candidate.getMachineStatus() != SkillAvatarCandidate.MachineStatus.PENDING) + .filter(candidate -> ((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 roles, + Set platformRoles) { + if (actor == null || !canReview(skill, actor, roles, platformRoles)) { + throw new DomainForbiddenException("error.skill.avatar.reviewerOnly"); + } + } + + private boolean canReview(Skill skill, String actor, Map roles, + Set platformRoles) { + if (actor == null) { + return false; + } + Namespace namespace = namespaces.findById(skill.getNamespaceId()).orElseThrow(() -> notFound()); + if (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"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUrls.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUrls.java new file mode 100644 index 00000000..1f980765 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillAvatarUrls.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +public final class SkillAvatarUrls { + private SkillAvatarUrls() {} + + public static String current(Skill skill, String namespaceSlug) { + return current(skill.getActiveAvatarId(), skill.getActiveAvatarAudience(), skill.getVisibility(), + namespaceSlug, skill.getSlug()); + } + + public static String current(Long activeAvatarId, SkillVisibility approved, SkillVisibility current, + String namespaceSlug, String skillSlug) { + if (activeAvatarId == null || approved == null) { + return null; + } + if (current == SkillVisibility.PUBLIC && approved != SkillVisibility.PUBLIC + || current == SkillVisibility.NAMESPACE_ONLY && approved == SkillVisibility.PRIVATE) { + return null; + } + return "/api/v1/skills/" + segment(namespaceSlug) + "/" + segment(skillSlug) + "/avatar"; + } + + public static String candidate(String namespaceSlug, Skill skill, long candidateId) { + return "/api/v1/skills/" + segment(namespaceSlug) + "/" + segment(skill.getSlug()) + + "/avatar/candidates/" + candidateId + "/image"; + } + + private static String segment(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 458a30ee..8af16c49 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -274,7 +274,8 @@ public class SkillSearchAppService { headlineVersion != null ? complianceSnapshotProjectionService.fromParsedMetadataJson(headlineVersion.parsedMetadataJson()) : null, - null + null, + SkillAvatarUrls.current(skill, namespaceSlug) ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/XfyunAvatarModerationProvider.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/XfyunAvatarModerationProvider.java new file mode 100644 index 00000000..9b85cbdc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/XfyunAvatarModerationProvider.java @@ -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 params = new TreeMap<>(Map.of( + "accessKeyId", accessKeyId, + "accessKeySecret", accessKeySecret, + "appId", appId, + "modeType", "base64", + "utc", OffsetDateTime.now(ZoneOffset.UTC).format(UTC_FORMAT), + "uuid", UUID.randomUUID().toString())); + String base = encode(params); + params.put("signature", sign(base)); + String payload = mapper.writeValueAsString(Map.of("content", Base64.getEncoder().encodeToString(normalizedPng))); + HttpRequest request = HttpRequest.newBuilder(URI.create(ENDPOINT + "?" + encode(params))) + .timeout(Duration.ofSeconds(8)) + .header("Content-Type", "application/json;charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(payload)).build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 200 ? parse(response.body()) : Verdict.ERROR; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return Verdict.ERROR; + } catch (IOException | IllegalArgumentException | InvalidKeyException | NoSuchAlgorithmException failure) { + // The request URI includes accessKeySecret. Do not log or propagate exception text. + return Verdict.ERROR; + } + } + + Verdict parse(String response) throws IOException { + JsonNode body = mapper.readTree(response); + if (!"000000".equals(body.path("code").asText())) { + return Verdict.ERROR; + } + return switch (body.path("data").path("result").path("suggest").asText()) { + case "pass" -> Verdict.PASS; + case "block" -> Verdict.BLOCK; + default -> Verdict.ERROR; + }; + } + + private String sign(String base) throws NoSuchAlgorithmException, InvalidKeyException { + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(accessKeySecret.getBytes(StandardCharsets.UTF_8), "HmacSHA1")); + return Base64.getEncoder().encodeToString(mac.doFinal(base.getBytes(StandardCharsets.UTF_8))); + } + + private String encode(Map params) { + StringBuilder query = new StringBuilder(); + params.forEach((key, value) -> { + if (value != null && !value.isEmpty()) { + if (!query.isEmpty()) query.append('&'); + query.append(key).append('=').append(URLEncoder.encode(value, StandardCharsets.UTF_8)); + } + }); + return query.toString(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarCleanupTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarCleanupTask.java new file mode 100644 index 00000000..9a4c6ef9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillAvatarCleanupTask.java @@ -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(); + } + } +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql b/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql index ffc5fabf..18e990de 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V69__skill_avatars.sql @@ -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); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 24bf9324..febcfcd0 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -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} diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 566d443e..1c5aaea0 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -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} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 90bad54b..8d72536c 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -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} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java index 3e35e561..a942b481 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java @@ -202,7 +202,8 @@ class ReviewPortalControllerTest { null, "REVIEW_TASK", List.of(), - new com.iflytek.skillhub.dto.PageResponse<>(List.of(), 0, 0, 20) + new com.iflytek.skillhub.dto.PageResponse<>(List.of(), 0, 0, 20), + null ), List.of(new SkillVersionResponse(100L, "1.2.0", "PENDING_REVIEW", null, 1, 10L, null, true, null)), List.of(new SkillFileResponse(1L, "README.md", 123L, "text/markdown", "sha")), diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 60a6a64f..cb9eebf7 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -186,7 +186,9 @@ class SkillControllerTest { null, new com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService.VersionProjection(11L, "1.1.0", "PENDING_REVIEW"), null, - "OWNER_PREVIEW" + "OWNER_PREVIEW", + null, + null )); SkillSuiteReferenceResponse suiteReference = new SkillSuiteReferenceResponse( 9L, "team", "demo-suite", "Demo Suite", "2.0.0", 3, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java new file mode 100644 index 00000000..0a7b3a20 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AliyunAvatarModerationProviderTest.java @@ -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)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/XfyunAvatarModerationProviderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/XfyunAvatarModerationProviderTest.java new file mode 100644 index 00000000..56b2813b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/XfyunAvatarModerationProviderTest.java @@ -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); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java index 1188347f..7367345f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java @@ -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; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateRepository.java new file mode 100644 index 00000000..4085d7e5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillAvatarCandidateRepository.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.skill; + +import java.util.List; + +public interface SkillAvatarCandidateRepository { + List findBySkillIdOrderByCreatedAtDesc(Long skillId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java index 6308f753..616385c8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java @@ -9,6 +9,8 @@ import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.security.SecurityScanService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidate; +import com.iflytek.skillhub.domain.skill.SkillAvatarCandidateRepository; import com.iflytek.skillhub.domain.skill.SkillFile; import com.iflytek.skillhub.domain.skill.SkillFileRepository; import com.iflytek.skillhub.domain.skill.SkillRepository; @@ -49,6 +51,7 @@ public class SkillHardDeleteService { private final SkillRatingRepository skillRatingRepository; private final SkillReportRepository skillReportRepository; private final SkillVersionStatsRepository skillVersionStatsRepository; + private final SkillAvatarCandidateRepository avatarCandidates; private final ObjectStorageService objectStorageService; private final SkillStorageDeletionCompensationService compensationService; private final SecurityScanService securityScanService; @@ -65,6 +68,7 @@ public class SkillHardDeleteService { SkillRatingRepository skillRatingRepository, SkillReportRepository skillReportRepository, SkillVersionStatsRepository skillVersionStatsRepository, + SkillAvatarCandidateRepository avatarCandidates, ObjectStorageService objectStorageService, SkillStorageDeletionCompensationService compensationService, SecurityScanService securityScanService, @@ -80,6 +84,7 @@ public class SkillHardDeleteService { this.skillRatingRepository = skillRatingRepository; this.skillReportRepository = skillReportRepository; this.skillVersionStatsRepository = skillVersionStatsRepository; + this.avatarCandidates = avatarCandidates; this.objectStorageService = objectStorageService; this.compensationService = compensationService; this.securityScanService = securityScanService; @@ -117,6 +122,10 @@ public class SkillHardDeleteService { .forEach(storageKeys::add); storageKeys.add(buildBundleStorageKey(skill.getId(), version.getId())); } + avatarCandidates.findBySkillIdOrderByCreatedAtDesc(skill.getId()).stream() + .map(SkillAvatarCandidate::getObjectKey) + .filter(key -> key != null && !key.isBlank()) + .forEach(storageKeys::add); deleteStorageAfterCommit(skill, namespaceSlug, storageKeys); skill.setLatestVersionId(null); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index ddea24d0..3f57af1e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -116,7 +116,9 @@ public class SkillQueryService { SkillLifecycleProjectionService.VersionProjection publishedVersion, SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion, String ownerPreviewReviewComment, - String resolutionMode + String resolutionMode, + Long activeAvatarId, + SkillVisibility activeAvatarAudience ) {} public record SkillVersionDetailDTO( @@ -249,7 +251,9 @@ public class SkillQueryService { publishedVersion, ownerPreviewVersion, ownerPreviewReviewComment, - projection.resolutionMode().name() + projection.resolutionMode().name(), + skill.getActiveAvatarId(), + skill.getActiveAvatarAudience() ); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java index c500570e..56241774 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java @@ -64,6 +64,8 @@ class SkillHardDeleteServiceTest { @Mock private SkillVersionStatsRepository skillVersionStatsRepository; @Mock + private com.iflytek.skillhub.domain.skill.SkillAvatarCandidateRepository avatarCandidates; + @Mock private ObjectStorageService objectStorageService; @Mock private SkillStorageDeletionCompensationService compensationService; @@ -94,6 +96,7 @@ class SkillHardDeleteServiceTest { skillRatingRepository, skillReportRepository, skillVersionStatsRepository, + avatarCandidates, objectStorageService, compensationService, securityScanService, diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java index 1b188c44..cfc6971f 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillAvatarCandidateJpaRepository.java @@ -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 { +public interface SkillAvatarCandidateJpaRepository extends JpaRepository, SkillAvatarCandidateRepository { List findBySkillIdOrderByCreatedAtDesc(Long skillId); + List findByContentSha256AndMachineProviderAndMachineEnabledTrueAndManualEnabledAndMachineStatus( + String contentSha256, String machineProvider, boolean manualEnabled, + SkillAvatarCandidate.MachineStatus machineStatus); List findByStatusAndReviewStatusOrderByCreatedAtAsc( SkillAvatarCandidate.Status status, SkillAvatarCandidate.ReviewStatus reviewStatus); List findTop100ByDiscardAtBeforeAndObjectKeyIsNotNull(Instant before); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java index c9e623e2..a7cb8e40 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java @@ -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, SkillRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM Skill s WHERE s.id = :id") + Optional findLockedById(@Param("id") Long id); List findByIdIn(List ids); List findByNamespaceIdAndSlug(Long namespaceId, String slug); List findByNamespaceIdInAndSlugIn(List namespaceIds, List slugs);