mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
feat(avatar): add owner and reviewer workflows
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
6d42bd6708
commit
f7e96bb8a4
36 changed files with 1180 additions and 14 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record AvatarReviewSettingsResponse(boolean machineEnabled, boolean manualEnabled,
|
||||
long version, Instant updatedAt) {}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record SkillAvatarDecisionRequest(boolean approved, @NotBlank String reason) {}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -30,5 +30,6 @@ public record SkillDetailResponse(
|
|||
String ownerPreviewReviewComment,
|
||||
String resolutionMode,
|
||||
List<SkillSuiteReferenceResponse> entryForSuites,
|
||||
PageResponse<SkillSuiteReferenceResponse> memberOfSuites
|
||||
PageResponse<SkillSuiteReferenceResponse> memberOfSuites,
|
||||
String avatarUrl
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() : "")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -274,7 +274,8 @@ public class SkillSearchAppService {
|
|||
headlineVersion != null
|
||||
? complianceSnapshotProjectionService.fromParsedMetadataJson(headlineVersion.parsedMetadataJson())
|
||||
: null,
|
||||
null
|
||||
null,
|
||||
SkillAvatarUrls.current(skill, namespaceSlug)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.skill;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface SkillAvatarCandidateRepository {
|
||||
List<SkillAvatarCandidate> findBySkillIdOrderByCreatedAtDesc(Long skillId);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue