From 4624bb146ae689d820d2ceb3c7f5133c96ad9ae5 Mon Sep 17 00:00:00 2001 From: paradoxgacsd <3154222708@qq.com> Date: Sat, 16 May 2026 14:15:56 +0800 Subject: [PATCH] feat(promotion): operational promotion campaign management Implements the operational-promotion lane described in the design doc: - Three new tables (V42 migration): promotion_slot (seeded with 7 slots), promotion_campaign (status + optimistic-lock), promotion_event_log. - Domain layer: PromotionCampaign aggregate, status enum (DRAFT -> PENDING_REVIEW -> SCHEDULED|ACTIVE|ENDED|REJECTED), capacity- and target-aware PromotionCampaignService with self-review and optimistic-lock guards. - Infra layer: JPA repositories with paged status queries, slot-active lookup, and SCHEDULED->ACTIVE / ACTIVE->ENDED bulk transition queries. - App layer: DefaultPromotionTargetGuard validates skill visibility, publication state and version availability before promotion; PromotionCampaignAppService translates between DTOs and the domain service; per-minute scheduler sweep wired via @Scheduled. - HTTP: /api/v1/admin/promotion-campaigns (create/approve/reject/list, RBAC-guarded) and anonymous /api/v1/promotion-slots/{slotCode}. - Frontend: dashboard page promotion-campaigns.tsx, react-query hooks and a thin envelope-aware fetch wrapper for the new endpoints. - Tests: domain-level state-machine and capacity tests, MockMvc controller tests, and vitest coverage of the front-end fetch wrapper. --- .../skillhub/config/DomainBeanConfig.java | 13 + .../AdminPromotionCampaignController.java | 79 ++++++ .../portal/PromotionSlotController.java | 35 +++ .../CreatePromotionCampaignRequest.java | 28 +++ .../promotion/PromotionCampaignResponse.java | 44 ++++ .../PromotionCampaignReviewRequest.java | 10 + .../promotion/PromotionSlotItemResponse.java | 31 +++ .../DefaultPromotionTargetGuard.java | 66 +++++ .../PromotionCampaignAppService.java | 111 +++++++++ .../promotion/PromotionCampaignScheduler.java | 38 +++ .../V42__promotion_campaign_tables.sql | 67 +++++ .../AdminPromotionCampaignControllerTest.java | 140 +++++++++++ .../domain/promotion/PromotionCampaign.java | 125 ++++++++++ .../PromotionCampaignRepository.java | 36 +++ .../promotion/PromotionCampaignService.java | 159 ++++++++++++ .../promotion/PromotionCampaignStatus.java | 13 + .../domain/promotion/PromotionEventLog.java | 55 +++++ .../PromotionEventLogRepository.java | 10 + .../domain/promotion/PromotionEventType.java | 11 + .../domain/promotion/PromotionException.java | 12 + .../domain/promotion/PromotionSlot.java | 61 +++++ .../promotion/PromotionSlotRepository.java | 14 ++ .../promotion/PromotionTargetGuard.java | 12 + .../domain/promotion/PromotionTargetType.java | 9 + .../domain/promotion/package-info.java | 6 + .../PromotionCampaignServiceTest.java | 231 ++++++++++++++++++ .../jpa/PromotionCampaignJpaRepository.java | 90 +++++++ .../jpa/PromotionEventLogJpaRepository.java | 24 ++ .../infra/jpa/PromotionSlotJpaRepository.java | 21 ++ .../features/promotion-campaign/api.test.ts | 115 +++++++++ web/src/features/promotion-campaign/api.ts | 117 +++++++++ web/src/features/promotion-campaign/hooks.ts | 49 ++++ web/src/features/promotion-campaign/types.ts | 1 + .../pages/dashboard/promotion-campaigns.tsx | 130 ++++++++++ 34 files changed, 1963 insertions(+) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminPromotionCampaignController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionSlotController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/CreatePromotionCampaignRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionCampaignResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionCampaignReviewRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionSlotItemResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/promotion/DefaultPromotionTargetGuard.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/promotion/PromotionCampaignAppService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/promotion/PromotionCampaignScheduler.java create mode 100644 server/skillhub-app/src/main/resources/db/migration/V42__promotion_campaign_tables.sql create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminPromotionCampaignControllerTest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaign.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignStatus.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionEventLog.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionEventLogRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionEventType.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionException.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionSlot.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionSlotRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionTargetGuard.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionTargetType.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/package-info.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignServiceTest.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionCampaignJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionEventLogJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionSlotJpaRepository.java create mode 100644 web/src/features/promotion-campaign/api.test.ts create mode 100644 web/src/features/promotion-campaign/api.ts create mode 100644 web/src/features/promotion-campaign/hooks.ts create mode 100644 web/src/features/promotion-campaign/types.ts create mode 100644 web/src/pages/dashboard/promotion-campaigns.tsx diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java index 7b36265f0..034df4f82 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java @@ -1,5 +1,10 @@ package com.iflytek.skillhub.config; +import com.iflytek.skillhub.domain.promotion.PromotionCampaignRepository; +import com.iflytek.skillhub.domain.promotion.PromotionCampaignService; +import com.iflytek.skillhub.domain.promotion.PromotionEventLogRepository; +import com.iflytek.skillhub.domain.promotion.PromotionSlotRepository; +import com.iflytek.skillhub.domain.promotion.PromotionTargetGuard; import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; @@ -41,4 +46,12 @@ public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMeta public VisibilityChecker visibilityChecker() { return new VisibilityChecker(); } + + @Bean + public PromotionCampaignService promotionCampaignService(PromotionSlotRepository slotRepository, + PromotionCampaignRepository campaignRepository, + PromotionEventLogRepository eventLogRepository, + PromotionTargetGuard targetGuard) { + return new PromotionCampaignService(slotRepository, campaignRepository, eventLogRepository, targetGuard); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminPromotionCampaignController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminPromotionCampaignController.java new file mode 100644 index 000000000..acb4cfbc1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminPromotionCampaignController.java @@ -0,0 +1,79 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.promotion.PromotionCampaignStatus; +import com.iflytek.skillhub.domain.promotion.PromotionEventType; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.promotion.CreatePromotionCampaignRequest; +import com.iflytek.skillhub.dto.promotion.PromotionCampaignResponse; +import com.iflytek.skillhub.dto.promotion.PromotionCampaignReviewRequest; +import com.iflytek.skillhub.service.promotion.PromotionCampaignAppService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +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; + +/** + * Admin endpoints for operational promotion campaigns: create, approve, reject and list. + */ +@RestController +@RequestMapping("/api/v1/admin/promotion-campaigns") +@PreAuthorize("hasAnyRole('SKILL_ADMIN','SUPER_ADMIN')") +public class AdminPromotionCampaignController extends BaseApiController { + + private final PromotionCampaignAppService appService; + + public AdminPromotionCampaignController(PromotionCampaignAppService appService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.appService = appService; + } + + @PostMapping + public ApiResponse create(@Valid @RequestBody CreatePromotionCampaignRequest request, + @RequestAttribute("userId") String userId) { + return ok("response.success.created", appService.createCampaign(request, userId)); + } + + @PostMapping("/{id}/approve") + public ApiResponse approve(@PathVariable Long id, + @RequestBody(required = false) PromotionCampaignReviewRequest body, + @RequestAttribute("userId") String userId) { + String comment = body == null ? null : body.comment(); + return ok("response.success.updated", appService.approve(id, comment, userId)); + } + + @PostMapping("/{id}/reject") + public ApiResponse reject(@PathVariable Long id, + @RequestBody(required = false) PromotionCampaignReviewRequest body, + @RequestAttribute("userId") String userId) { + String comment = body == null ? null : body.comment(); + return ok("response.success.updated", appService.reject(id, comment, userId)); + } + + @GetMapping + public ApiResponse> list(@RequestParam(defaultValue = "PENDING_REVIEW") PromotionCampaignStatus status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ok("response.success.read", appService.listByStatus(status, page, size)); + } + + @PostMapping("/{id}/events/{eventType}") + public ApiResponse recordEvent(@PathVariable Long id, + @PathVariable PromotionEventType eventType, + @RequestAttribute(value = "userId", required = false) String userId, + HttpServletRequest httpRequest) { + String requestId = httpRequest.getHeader("X-Request-Id"); + appService.recordEvent(id, eventType, userId, null, requestId); + return ok("response.success.created", null); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionSlotController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionSlotController.java new file mode 100644 index 000000000..be300a455 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionSlotController.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.promotion.PromotionSlotItemResponse; +import com.iflytek.skillhub.service.promotion.PromotionCampaignAppService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * Anonymous-readable promotion slot lookup. Returns only ACTIVE campaigns whose + * windows are currently open. + */ +@RestController +@RequestMapping("/api/v1/promotion-slots") +public class PromotionSlotController extends BaseApiController { + + private final PromotionCampaignAppService promotionCampaignAppService; + + public PromotionSlotController(PromotionCampaignAppService promotionCampaignAppService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.promotionCampaignAppService = promotionCampaignAppService; + } + + @GetMapping("/{slotCode}") + public ApiResponse> listSlotItems(@PathVariable String slotCode) { + return ok("response.success.read", promotionCampaignAppService.listSlotItems(slotCode)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/CreatePromotionCampaignRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/CreatePromotionCampaignRequest.java new file mode 100644 index 000000000..71294a5bd --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/CreatePromotionCampaignRequest.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.dto.promotion; + +import com.iflytek.skillhub.domain.promotion.PromotionTargetType; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.Instant; + +/** + * Payload to create or submit a new promotion campaign. + */ +public record CreatePromotionCampaignRequest( + @NotNull PromotionTargetType targetType, + @NotNull Long targetId, + Long targetVersionId, + @NotBlank @Size(max = 64) String slotCode, + @NotBlank @Size(max = 128) String title, + @Size(max = 512) String subtitle, + Long coverMediaId, + Long demoMediaId, + @Min(0) @Max(100) int priority, + @NotNull Instant startsAt, + @NotNull Instant endsAt, + @Size(max = 1000) String reason +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionCampaignResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionCampaignResponse.java new file mode 100644 index 000000000..c4101cd6e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionCampaignResponse.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.dto.promotion; + +import com.iflytek.skillhub.domain.promotion.PromotionCampaign; +import com.iflytek.skillhub.domain.promotion.PromotionCampaignStatus; +import com.iflytek.skillhub.domain.promotion.PromotionTargetType; + +import java.time.Instant; + +/** + * Outbound representation of a {@link PromotionCampaign}. + */ +public record PromotionCampaignResponse( + Long id, + PromotionTargetType targetType, + Long targetId, + Long targetVersionId, + String slotCode, + String title, + String subtitle, + Long coverMediaId, + Long demoMediaId, + int priority, + PromotionCampaignStatus status, + Instant startsAt, + Instant endsAt, + String submittedBy, + String reviewedBy, + String reviewComment, + String reason, + Instant createdAt, + Instant updatedAt +) { + public static PromotionCampaignResponse from(PromotionCampaign c) { + return new PromotionCampaignResponse( + c.getId(), c.getTargetType(), c.getTargetId(), c.getTargetVersionId(), + c.getSlotCode(), c.getTitle(), c.getSubtitle(), + c.getCoverMediaId(), c.getDemoMediaId(), + c.getPriority(), c.getStatus(), + c.getStartsAt(), c.getEndsAt(), + c.getSubmittedBy(), c.getReviewedBy(), c.getReviewComment(), + c.getReason(), c.getCreatedAt(), c.getUpdatedAt() + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionCampaignReviewRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionCampaignReviewRequest.java new file mode 100644 index 000000000..516c93473 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionCampaignReviewRequest.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto.promotion; + +import jakarta.validation.constraints.Size; + +/** + * Common payload for review actions (approve / reject). + */ +public record PromotionCampaignReviewRequest( + @Size(max = 1000) String comment +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionSlotItemResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionSlotItemResponse.java new file mode 100644 index 000000000..8928ddc91 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionSlotItemResponse.java @@ -0,0 +1,31 @@ +package com.iflytek.skillhub.dto.promotion; + +import com.iflytek.skillhub.domain.promotion.PromotionCampaign; +import com.iflytek.skillhub.domain.promotion.PromotionTargetType; + +/** + * Public-facing item rendered into a promotion slot. Excludes audit fields and + * version sequence to avoid leaking review state to anonymous readers. + */ +public record PromotionSlotItemResponse( + Long campaignId, + String slotCode, + PromotionTargetType targetType, + Long targetId, + String title, + String subtitle, + String coverUrl, + String demoGifUrl, + String targetUrl +) { + public static PromotionSlotItemResponse from(PromotionCampaign c, String targetUrl) { + String coverUrl = c.getCoverMediaId() == null ? null : "/api/v1/media/" + c.getCoverMediaId(); + String demoUrl = c.getDemoMediaId() == null ? null : "/api/v1/media/" + c.getDemoMediaId(); + return new PromotionSlotItemResponse( + c.getId(), c.getSlotCode(), c.getTargetType(), c.getTargetId(), + c.getTitle(), c.getSubtitle(), + coverUrl, demoUrl, + targetUrl + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/promotion/DefaultPromotionTargetGuard.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/promotion/DefaultPromotionTargetGuard.java new file mode 100644 index 000000000..429d9dfdb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/promotion/DefaultPromotionTargetGuard.java @@ -0,0 +1,66 @@ +package com.iflytek.skillhub.service.promotion; + +import com.iflytek.skillhub.domain.promotion.PromotionException; +import com.iflytek.skillhub.domain.promotion.PromotionTargetGuard; +import com.iflytek.skillhub.domain.promotion.PromotionTargetType; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import org.springframework.stereotype.Component; + +/** + * Default {@link PromotionTargetGuard} implementation. For SKILL targets, checks + * the existence and PUBLISHED state of the bound version (or the skill's latest + * version when none was provided), and refuses non-PUBLIC, hidden, or archived + * skills. SKILL_BUNDLE targets are wired in once the bundle aggregate ships. + */ +@Component +public class DefaultPromotionTargetGuard implements PromotionTargetGuard { + + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + + public DefaultPromotionTargetGuard(SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository) { + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + } + + @Override + public void assertPromotable(PromotionTargetType targetType, Long targetId, Long targetVersionId) { + if (targetType == null || targetId == null) { + throw new PromotionException("error.promotion.target.invalid"); + } + switch (targetType) { + case SKILL -> assertSkillPromotable(targetId, targetVersionId); + case SKILL_BUNDLE -> { + // Skill bundle is delivered in branch feature/skill-bundle-management. + // Until that branch lands, the guard accepts SKILL_BUNDLE targets so the + // promotion module can be smoke-tested independently. + } + } + } + + private void assertSkillPromotable(Long skillId, Long versionId) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new PromotionException("error.promotion.target.notFound")); + if (skill.isHidden()) { + throw new PromotionException("error.promotion.target.hidden"); + } + if (skill.getVisibility() != SkillVisibility.PUBLIC) { + throw new PromotionException("error.promotion.target.notPublic"); + } + Long resolvedVersionId = versionId != null ? versionId : skill.getLatestVersionId(); + if (resolvedVersionId == null) { + throw new PromotionException("error.promotion.target.noPublishedVersion"); + } + SkillVersion version = skillVersionRepository.findById(resolvedVersionId) + .orElseThrow(() -> new PromotionException("error.promotion.target.versionNotFound")); + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + throw new PromotionException("error.promotion.target.versionNotPublished"); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/promotion/PromotionCampaignAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/promotion/PromotionCampaignAppService.java new file mode 100644 index 000000000..7ec35fd6c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/promotion/PromotionCampaignAppService.java @@ -0,0 +1,111 @@ +package com.iflytek.skillhub.service.promotion; + +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.promotion.PromotionCampaign; +import com.iflytek.skillhub.domain.promotion.PromotionCampaignRepository; +import com.iflytek.skillhub.domain.promotion.PromotionCampaignService; +import com.iflytek.skillhub.domain.promotion.PromotionCampaignStatus; +import com.iflytek.skillhub.domain.promotion.PromotionEventType; +import com.iflytek.skillhub.domain.promotion.PromotionTargetType; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.promotion.CreatePromotionCampaignRequest; +import com.iflytek.skillhub.dto.promotion.PromotionCampaignResponse; +import com.iflytek.skillhub.dto.promotion.PromotionSlotItemResponse; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.util.List; +import java.util.Optional; + +/** + * Application-level façade that exposes promotion campaign use cases to controllers. + * Hides the domain {@link PromotionCampaignService} behind DTO conversions, request + * paging, and target URL synthesis. + */ +@Service +public class PromotionCampaignAppService { + + private final PromotionCampaignService domainService; + private final PromotionCampaignRepository campaignRepository; + private final SkillRepository skillRepository; + private final NamespaceRepository namespaceRepository; + private final Clock clock; + + public PromotionCampaignAppService(PromotionCampaignService domainService, + PromotionCampaignRepository campaignRepository, + SkillRepository skillRepository, + NamespaceRepository namespaceRepository, + Clock clock) { + this.domainService = domainService; + this.campaignRepository = campaignRepository; + this.skillRepository = skillRepository; + this.namespaceRepository = namespaceRepository; + this.clock = clock; + } + + @Transactional + public PromotionCampaignResponse createCampaign(CreatePromotionCampaignRequest request, String submitter) { + PromotionCampaignService.CreateCampaignCommand command = new PromotionCampaignService.CreateCampaignCommand( + request.targetType(), request.targetId(), request.targetVersionId(), + request.slotCode(), request.title(), request.subtitle(), + request.coverMediaId(), request.demoMediaId(), + request.priority(), request.startsAt(), request.endsAt(), + request.reason() + ); + PromotionCampaign saved = domainService.createCampaign(command, submitter, clock.instant()); + return PromotionCampaignResponse.from(saved); + } + + @Transactional + public PromotionCampaignResponse approve(Long id, String comment, String reviewer) { + return PromotionCampaignResponse.from(domainService.approveCampaign(id, comment, reviewer, clock.instant())); + } + + @Transactional + public PromotionCampaignResponse reject(Long id, String comment, String reviewer) { + return PromotionCampaignResponse.from(domainService.rejectCampaign(id, comment, reviewer)); + } + + @Transactional(readOnly = true) + public List listSlotItems(String slotCode) { + return domainService.listSlotItems(slotCode, clock.instant()).stream() + .map(c -> PromotionSlotItemResponse.from(c, resolveTargetUrl(c))) + .toList(); + } + + @Transactional(readOnly = true) + public PageResponse listByStatus(PromotionCampaignStatus status, int page, int size) { + return PageResponse.from(campaignRepository.findByStatus(status, PageRequest.of(page, size)) + .map(PromotionCampaignResponse::from)); + } + + @Transactional + public void recordEvent(Long campaignId, PromotionEventType type, + String userId, String anonymousId, String requestId) { + domainService.recordEvent(campaignId, type, userId, anonymousId, requestId); + } + + @Transactional + public PromotionCampaignService.SchedulerSweepResult sweepLifecycle() { + return domainService.runScheduledSweep(clock.instant()); + } + + private String resolveTargetUrl(PromotionCampaign campaign) { + if (campaign.getTargetType() == PromotionTargetType.SKILL_BUNDLE) { + return "/bundles/" + campaign.getTargetId(); + } + Optional skill = skillRepository.findById(campaign.getTargetId()); + if (skill.isEmpty()) { + return null; + } + Skill s = skill.get(); + Optional ns = namespaceRepository.findById(s.getNamespaceId()); + String namespaceSlug = ns.map(Namespace::getSlug).orElse("global"); + return "/space/" + namespaceSlug + "/" + s.getSlug(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/promotion/PromotionCampaignScheduler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/promotion/PromotionCampaignScheduler.java new file mode 100644 index 000000000..49d0af988 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/promotion/PromotionCampaignScheduler.java @@ -0,0 +1,38 @@ +package com.iflytek.skillhub.service.promotion; + +import com.iflytek.skillhub.domain.promotion.PromotionCampaignService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Per-minute lifecycle sweep: SCHEDULED -> ACTIVE and ACTIVE -> ENDED. + * Disabled by default in tests via {@code skillhub.promotion.scheduler-enabled=false}. + */ +@Component +@ConditionalOnProperty(name = "skillhub.promotion.scheduler-enabled", havingValue = "true", matchIfMissing = true) +public class PromotionCampaignScheduler { + + private static final Logger log = LoggerFactory.getLogger(PromotionCampaignScheduler.class); + + private final PromotionCampaignAppService appService; + + public PromotionCampaignScheduler(PromotionCampaignAppService appService) { + this.appService = appService; + } + + @Scheduled(cron = "${skillhub.promotion.scheduler-cron:0 * * * * *}") + public void sweep() { + try { + PromotionCampaignService.SchedulerSweepResult result = appService.sweepLifecycle(); + if (result.activated() > 0 || result.ended() > 0) { + log.info("Promotion lifecycle sweep activated={}, ended={}", result.activated(), result.ended()); + } + } catch (RuntimeException ex) { + log.warn("Promotion lifecycle sweep failed", ex); + } + } +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V42__promotion_campaign_tables.sql b/server/skillhub-app/src/main/resources/db/migration/V42__promotion_campaign_tables.sql new file mode 100644 index 000000000..6ea41b374 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V42__promotion_campaign_tables.sql @@ -0,0 +1,67 @@ +-- Promotion management tables for operational promotion (slots/campaigns/events). +-- Distinct from existing promotion_request which represents global elevation governance. + +CREATE TABLE promotion_slot ( + id BIGSERIAL PRIMARY KEY, + slot_code VARCHAR(64) NOT NULL UNIQUE, + display_name VARCHAR(128) NOT NULL, + target_types JSONB NOT NULL DEFAULT '[]'::jsonb, + max_active_items INT NOT NULL DEFAULT 5, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE promotion_campaign ( + id BIGSERIAL PRIMARY KEY, + target_type VARCHAR(32) NOT NULL, + target_id BIGINT NOT NULL, + target_version_id BIGINT, + slot_code VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + subtitle VARCHAR(512), + cover_media_id BIGINT, + demo_media_id BIGINT, + priority INT NOT NULL DEFAULT 50, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + submitted_by VARCHAR(128) NOT NULL, + reviewed_by VARCHAR(128), + review_comment TEXT, + reason TEXT, + version INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT promotion_campaign_time_window_chk CHECK (ends_at > starts_at) +); + +CREATE INDEX idx_promotion_campaign_slot_status_window + ON promotion_campaign (slot_code, status, starts_at, ends_at, priority); +CREATE INDEX idx_promotion_campaign_target + ON promotion_campaign (target_type, target_id); +CREATE INDEX idx_promotion_campaign_status_created + ON promotion_campaign (status, created_at); + +CREATE TABLE promotion_event_log ( + id BIGSERIAL PRIMARY KEY, + campaign_id BIGINT NOT NULL, + event_type VARCHAR(32) NOT NULL, + user_id VARCHAR(128), + anonymous_id VARCHAR(128), + request_id VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_promotion_event_campaign ON promotion_event_log (campaign_id); +CREATE INDEX idx_promotion_event_type_created ON promotion_event_log (event_type, created_at); +CREATE INDEX idx_promotion_event_user ON promotion_event_log (user_id); + +INSERT INTO promotion_slot (slot_code, display_name, target_types, max_active_items, enabled) VALUES + ('HOME_HERO', '首页首屏', '["SKILL","SKILL_BUNDLE"]', 5, TRUE), + ('HOME_FEATURED_SKILLS', '首页精选技能', '["SKILL"]', 12, TRUE), + ('HOME_FEATURED_BUNDLES', '首页精选技能包', '["SKILL_BUNDLE"]', 8, TRUE), + ('SEARCH_PINNED', '搜索结果置顶', '["SKILL","SKILL_BUNDLE"]', 6, TRUE), + ('CATEGORY_FEATURED', '分类页精选', '["SKILL","SKILL_BUNDLE"]', 6, TRUE), + ('DETAIL_RELATED', '详情页相关推荐', '["SKILL","SKILL_BUNDLE"]', 8, TRUE), + ('CLI_RECOMMENDED', 'CLI 推荐位', '["SKILL","SKILL_BUNDLE"]', 12, TRUE); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminPromotionCampaignControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminPromotionCampaignControllerTest.java new file mode 100644 index 000000000..bbb6deeee --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminPromotionCampaignControllerTest.java @@ -0,0 +1,140 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.domain.promotion.PromotionCampaign; +import com.iflytek.skillhub.domain.promotion.PromotionCampaignStatus; +import com.iflytek.skillhub.domain.promotion.PromotionTargetType; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.promotion.PromotionCampaignResponse; +import com.iflytek.skillhub.service.promotion.PromotionCampaignAppService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.support.StaticMessageSource; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Locale; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Standalone MockMvc tests for {@link AdminPromotionCampaignController}. Pure HTTP boundary + * checks — domain authorization is exercised in {@code PromotionCampaignServiceTest}. + */ +class AdminPromotionCampaignControllerTest { + + private MockMvc mockMvc; + private PromotionCampaignAppService appService; + + @BeforeEach + void setUp() { + appService = mock(PromotionCampaignAppService.class); + StaticMessageSource messageSource = new StaticMessageSource(); + messageSource.addMessage("response.success.created", Locale.ROOT, "created"); + messageSource.addMessage("response.success.updated", Locale.ROOT, "updated"); + messageSource.addMessage("response.success.read", Locale.ROOT, "ok"); + ApiResponseFactory factory = new ApiResponseFactory(messageSource, Clock.systemUTC()); + + AdminPromotionCampaignController controller = new AdminPromotionCampaignController(appService, factory); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void listByStatus_returnsPagedResponse() throws Exception { + PromotionCampaignResponse resp = sampleResponse(1L); + given(appService.listByStatus(eq(PromotionCampaignStatus.PENDING_REVIEW), anyInt(), anyInt())) + .willReturn(new PageResponse<>(List.of(resp), 1, 0, 20)); + + mockMvc.perform(get("/api/v1/admin/promotion-campaigns") + .requestAttr("userId", "admin-1") + .param("status", "PENDING_REVIEW")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].id").value(1)) + .andExpect(jsonPath("$.data.total").value(1)); + } + + @Test + void create_passesPayloadToAppServiceAndReturnsCreated() throws Exception { + given(appService.createCampaign(any(), eq("admin-1"))) + .willReturn(sampleResponse(1L)); + + String body = """ + { + "targetType": "SKILL_BUNDLE", + "targetId": 88, + "targetVersionId": 120, + "slotCode": "HOME_HERO", + "title": "Featured", + "priority": 80, + "startsAt": "2026-06-01T00:00:00Z", + "endsAt": "2026-06-30T23:59:59Z" + } + """; + + mockMvc.perform(post("/api/v1/admin/promotion-campaigns") + .requestAttr("userId", "admin-1") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(1)); + } + + @Test + void approve_passesIdAndCommentToAppService() throws Exception { + given(appService.approve(eq(1L), anyString(), eq("admin-1"))) + .willReturn(sampleResponse(1L)); + + mockMvc.perform(post("/api/v1/admin/promotion-campaigns/1/approve") + .requestAttr("userId", "admin-1") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"comment\":\"ok\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + void reject_acceptsEmptyBody() throws Exception { + given(appService.reject(eq(1L), eq(null), eq("admin-1"))) + .willReturn(sampleResponse(1L)); + + mockMvc.perform(post("/api/v1/admin/promotion-campaigns/1/reject") + .requestAttr("userId", "admin-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private PromotionCampaign sampleCampaign(Long id) { + PromotionCampaign campaign = new PromotionCampaign( + PromotionTargetType.SKILL_BUNDLE, 88L, "HOME_HERO", + "Title", 80, Instant.parse("2026-06-01T00:00:00Z"), Instant.parse("2026-06-30T23:59:59Z"), + "alice"); + campaign.setStatus(PromotionCampaignStatus.PENDING_REVIEW); + try { + java.lang.reflect.Field f = PromotionCampaign.class.getDeclaredField("id"); + f.setAccessible(true); + f.set(campaign, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + return campaign; + } + + private PromotionCampaignResponse sampleResponse(Long id) { + return PromotionCampaignResponse.from(sampleCampaign(id)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaign.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaign.java new file mode 100644 index 000000000..63adbbde3 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaign.java @@ -0,0 +1,125 @@ +package com.iflytek.skillhub.domain.promotion; + +import jakarta.persistence.*; +import java.time.Instant; + +/** + * Operational promotion campaign placing a skill or skill bundle into a slot for a time window. + * + *

State transitions: DRAFT -> PENDING_REVIEW -> (REJECTED | SCHEDULED -> ACTIVE -> ENDED). + * Status transitions are guarded by the JPA @Version optimistic lock. + */ +@Entity +@Table(name = "promotion_campaign") +public class PromotionCampaign { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 32) + private PromotionTargetType targetType; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Column(name = "target_version_id") + private Long targetVersionId; + + @Column(name = "slot_code", nullable = false, length = 64) + private String slotCode; + + @Column(nullable = false, length = 128) + private String title; + + @Column(length = 512) + private String subtitle; + + @Column(name = "cover_media_id") + private Long coverMediaId; + + @Column(name = "demo_media_id") + private Long demoMediaId; + + @Column(nullable = false) + private int priority = 50; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private PromotionCampaignStatus status = PromotionCampaignStatus.DRAFT; + + @Column(name = "starts_at", nullable = false) + private Instant startsAt; + + @Column(name = "ends_at", nullable = false) + private Instant endsAt; + + @Column(name = "submitted_by", nullable = false, length = 128) + private String submittedBy; + + @Column(name = "reviewed_by", length = 128) + private String reviewedBy; + + @Column(name = "review_comment", columnDefinition = "TEXT") + private String reviewComment; + + @Column(columnDefinition = "TEXT") + private String reason; + + @Version + @Column(nullable = false) + private Integer version = 1; + + @Column(name = "created_at", nullable = false) + private Instant createdAt = Instant.now(); + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); + + protected PromotionCampaign() {} + + public PromotionCampaign(PromotionTargetType targetType, Long targetId, String slotCode, + String title, int priority, Instant startsAt, Instant endsAt, + String submittedBy) { + this.targetType = targetType; + this.targetId = targetId; + this.slotCode = slotCode; + this.title = title; + this.priority = priority; + this.startsAt = startsAt; + this.endsAt = endsAt; + this.submittedBy = submittedBy; + } + + public Long getId() { return id; } + public PromotionTargetType getTargetType() { return targetType; } + public Long getTargetId() { return targetId; } + public Long getTargetVersionId() { return targetVersionId; } + public String getSlotCode() { return slotCode; } + public String getTitle() { return title; } + public String getSubtitle() { return subtitle; } + public Long getCoverMediaId() { return coverMediaId; } + public Long getDemoMediaId() { return demoMediaId; } + public int getPriority() { return priority; } + public PromotionCampaignStatus getStatus() { return status; } + public Instant getStartsAt() { return startsAt; } + public Instant getEndsAt() { return endsAt; } + public String getSubmittedBy() { return submittedBy; } + public String getReviewedBy() { return reviewedBy; } + public String getReviewComment() { return reviewComment; } + public String getReason() { return reason; } + public Integer getVersion() { return version; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } + + public void setStatus(PromotionCampaignStatus status) { this.status = status; } + public void setSubtitle(String subtitle) { this.subtitle = subtitle; } + public void setCoverMediaId(Long coverMediaId) { this.coverMediaId = coverMediaId; } + public void setDemoMediaId(Long demoMediaId) { this.demoMediaId = demoMediaId; } + public void setReviewedBy(String reviewedBy) { this.reviewedBy = reviewedBy; } + public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; } + public void setReason(String reason) { this.reason = reason; } + public void setTargetVersionId(Long targetVersionId) { this.targetVersionId = targetVersionId; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignRepository.java new file mode 100644 index 000000000..ae3a50c30 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignRepository.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.domain.promotion; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for {@link PromotionCampaign}. + */ +public interface PromotionCampaignRepository { + PromotionCampaign save(PromotionCampaign campaign); + + Optional findById(Long id); + + Page findByStatus(PromotionCampaignStatus status, Pageable pageable); + + List findActiveBySlot(String slotCode, Instant now); + + long countActiveBySlot(String slotCode, Instant now); + + /** + * Optimistically transitions a campaign's status. Returns the number of rows updated; + * 0 means another writer beat us to the punch. + */ + int updateStatusWithVersion(Long id, + PromotionCampaignStatus newStatus, + String reviewedBy, + String reviewComment, + Integer expectedVersion); + + int markScheduledAsActive(Instant now); + + int markActiveAsEnded(Instant now); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignService.java new file mode 100644 index 000000000..9930f06eb --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignService.java @@ -0,0 +1,159 @@ +package com.iflytek.skillhub.domain.promotion; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Application service for operational promotion management. Encapsulates the campaign + * state machine (DRAFT -> PENDING_REVIEW -> SCHEDULED|ACTIVE|ENDED|REJECTED), slot + * capacity rules, and target reachability rules described in the design document. + * + *

The service is intentionally framework-agnostic so it can be unit-tested without + * Spring context — wiring lives in skillhub-app. + */ +public class PromotionCampaignService { + + private final PromotionSlotRepository slotRepository; + private final PromotionCampaignRepository campaignRepository; + private final PromotionEventLogRepository eventLogRepository; + private final PromotionTargetGuard targetGuard; + + public PromotionCampaignService(PromotionSlotRepository slotRepository, + PromotionCampaignRepository campaignRepository, + PromotionEventLogRepository eventLogRepository, + PromotionTargetGuard targetGuard) { + this.slotRepository = slotRepository; + this.campaignRepository = campaignRepository; + this.eventLogRepository = eventLogRepository; + this.targetGuard = targetGuard; + } + + public PromotionCampaign createCampaign(CreateCampaignCommand command, String submitter, Instant now) { + Objects.requireNonNull(command, "command"); + Objects.requireNonNull(submitter, "submitter"); + + if (command.startsAt() == null || command.endsAt() == null) { + throw new PromotionException("error.promotion.campaign.windowRequired"); + } + if (!command.endsAt().isAfter(command.startsAt())) { + throw new PromotionException("error.promotion.campaign.timeWindowInvalid"); + } + if (command.priority() < 0 || command.priority() > 100) { + throw new PromotionException("error.promotion.campaign.priorityOutOfRange"); + } + + PromotionSlot slot = slotRepository.findBySlotCode(command.slotCode()) + .orElseThrow(() -> new PromotionException("error.promotion.slot.notFound")); + if (!slot.isEnabled()) { + throw new PromotionException("error.promotion.slot.disabled"); + } + + targetGuard.assertPromotable(command.targetType(), command.targetId(), command.targetVersionId()); + + long currentActive = campaignRepository.countActiveBySlot(slot.getSlotCode(), now); + if (currentActive >= slot.getMaxActiveItems()) { + throw new PromotionException("error.promotion.campaign.timeConflict"); + } + + PromotionCampaign campaign = new PromotionCampaign( + command.targetType(), + command.targetId(), + slot.getSlotCode(), + command.title(), + command.priority(), + command.startsAt(), + command.endsAt(), + submitter + ); + campaign.setSubtitle(command.subtitle()); + campaign.setCoverMediaId(command.coverMediaId()); + campaign.setDemoMediaId(command.demoMediaId()); + campaign.setReason(command.reason()); + campaign.setTargetVersionId(command.targetVersionId()); + campaign.setStatus(PromotionCampaignStatus.PENDING_REVIEW); + return campaignRepository.save(campaign); + } + + public PromotionCampaign approveCampaign(Long id, String comment, String reviewer, Instant now) { + PromotionCampaign campaign = campaignRepository.findById(id) + .orElseThrow(() -> new PromotionException("error.promotion.campaign.notFound")); + if (campaign.getStatus() != PromotionCampaignStatus.PENDING_REVIEW) { + throw new PromotionException("error.promotion.campaign.notPendingReview"); + } + if (campaign.getSubmittedBy().equals(reviewer)) { + throw new PromotionException("error.promotion.campaign.selfReview"); + } + targetGuard.assertPromotable(campaign.getTargetType(), campaign.getTargetId(), campaign.getTargetVersionId()); + + PromotionCampaignStatus next = computeApprovedState(campaign, now); + int updated = campaignRepository.updateStatusWithVersion( + campaign.getId(), next, reviewer, comment, campaign.getVersion()); + if (updated == 0) { + throw new PromotionException("error.promotion.campaign.concurrentUpdate"); + } + return campaignRepository.findById(id).orElseThrow(); + } + + public PromotionCampaign rejectCampaign(Long id, String comment, String reviewer) { + PromotionCampaign campaign = campaignRepository.findById(id) + .orElseThrow(() -> new PromotionException("error.promotion.campaign.notFound")); + if (campaign.getStatus() != PromotionCampaignStatus.PENDING_REVIEW) { + throw new PromotionException("error.promotion.campaign.notPendingReview"); + } + int updated = campaignRepository.updateStatusWithVersion( + campaign.getId(), PromotionCampaignStatus.REJECTED, reviewer, comment, campaign.getVersion()); + if (updated == 0) { + throw new PromotionException("error.promotion.campaign.concurrentUpdate"); + } + return campaignRepository.findById(id).orElseThrow(); + } + + public List listSlotItems(String slotCode, Instant now) { + if (slotRepository.findBySlotCode(slotCode).isEmpty()) { + throw new PromotionException("error.promotion.slot.notFound"); + } + return campaignRepository.findActiveBySlot(slotCode, now); + } + + public PromotionEventLog recordEvent(Long campaignId, PromotionEventType type, + String userId, String anonymousId, String requestId) { + Optional campaign = campaignRepository.findById(campaignId); + if (campaign.isEmpty()) { + throw new PromotionException("error.promotion.campaign.notFound"); + } + return eventLogRepository.save(new PromotionEventLog(campaignId, type, userId, anonymousId, requestId)); + } + + public SchedulerSweepResult runScheduledSweep(Instant now) { + int activated = campaignRepository.markScheduledAsActive(now); + int ended = campaignRepository.markActiveAsEnded(now); + return new SchedulerSweepResult(activated, ended); + } + + private PromotionCampaignStatus computeApprovedState(PromotionCampaign campaign, Instant now) { + if (now.isBefore(campaign.getStartsAt())) { + return PromotionCampaignStatus.SCHEDULED; + } + if (now.isBefore(campaign.getEndsAt())) { + return PromotionCampaignStatus.ACTIVE; + } + return PromotionCampaignStatus.ENDED; + } + + public record CreateCampaignCommand(PromotionTargetType targetType, + Long targetId, + Long targetVersionId, + String slotCode, + String title, + String subtitle, + Long coverMediaId, + Long demoMediaId, + int priority, + Instant startsAt, + Instant endsAt, + String reason) {} + + public record SchedulerSweepResult(int activated, int ended) {} +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignStatus.java new file mode 100644 index 000000000..e2c668b70 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignStatus.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.domain.promotion; + +/** + * Lifecycle states for a promotion campaign. + */ +public enum PromotionCampaignStatus { + DRAFT, + PENDING_REVIEW, + SCHEDULED, + ACTIVE, + ENDED, + REJECTED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionEventLog.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionEventLog.java new file mode 100644 index 000000000..7fd06c761 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionEventLog.java @@ -0,0 +1,55 @@ +package com.iflytek.skillhub.domain.promotion; + +import jakarta.persistence.*; +import java.time.Instant; + +/** + * Append-only log row capturing user interactions with a promotion campaign + * (impressions, clicks, downloads, installs) for effectiveness reporting. + */ +@Entity +@Table(name = "promotion_event_log") +public class PromotionEventLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "campaign_id", nullable = false) + private Long campaignId; + + @Enumerated(EnumType.STRING) + @Column(name = "event_type", nullable = false, length = 32) + private PromotionEventType eventType; + + @Column(name = "user_id", length = 128) + private String userId; + + @Column(name = "anonymous_id", length = 128) + private String anonymousId; + + @Column(name = "request_id", length = 64) + private String requestId; + + @Column(name = "created_at", nullable = false) + private Instant createdAt = Instant.now(); + + protected PromotionEventLog() {} + + public PromotionEventLog(Long campaignId, PromotionEventType eventType, String userId, + String anonymousId, String requestId) { + this.campaignId = campaignId; + this.eventType = eventType; + this.userId = userId; + this.anonymousId = anonymousId; + this.requestId = requestId; + } + + public Long getId() { return id; } + public Long getCampaignId() { return campaignId; } + public PromotionEventType getEventType() { return eventType; } + public String getUserId() { return userId; } + public String getAnonymousId() { return anonymousId; } + public String getRequestId() { return requestId; } + public Instant getCreatedAt() { return createdAt; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionEventLogRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionEventLogRepository.java new file mode 100644 index 000000000..35f636621 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionEventLogRepository.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.promotion; + +/** + * Append-only event sink for promotion analytics. + */ +public interface PromotionEventLogRepository { + PromotionEventLog save(PromotionEventLog event); + + long countByCampaignAndType(Long campaignId, PromotionEventType type); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionEventType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionEventType.java new file mode 100644 index 000000000..83d7641e0 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionEventType.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.domain.promotion; + +/** + * Recorded user-facing actions for analytics on a campaign. + */ +public enum PromotionEventType { + IMPRESSION, + CLICK, + DOWNLOAD, + INSTALL +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionException.java new file mode 100644 index 000000000..d528c74e3 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionException.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.domain.promotion; + +/** + * Domain-level checked-style runtime exception for promotion state machine violations. + * The {@link #getMessage()} is the i18n message code defined in the design document + * (e.g. {@code error.promotion.target.notPublic}). + */ +public class PromotionException extends RuntimeException { + public PromotionException(String messageCode) { + super(messageCode); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionSlot.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionSlot.java new file mode 100644 index 000000000..f8b9a0c00 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionSlot.java @@ -0,0 +1,61 @@ +package com.iflytek.skillhub.domain.promotion; + +import jakarta.persistence.*; +import java.time.Instant; + +/** + * Represents a configured promotion placement (e.g., HOME_HERO, SEARCH_PINNED). + * The slot bounds operational rules: which target types may appear, the maximum + * number of concurrently active campaigns, and whether the placement is enabled. + */ +@Entity +@Table(name = "promotion_slot") +public class PromotionSlot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "slot_code", nullable = false, unique = true, length = 64) + private String slotCode; + + @Column(name = "display_name", nullable = false, length = 128) + private String displayName; + + @Column(name = "target_types", nullable = false, columnDefinition = "jsonb") + private String targetTypesJson = "[]"; + + @Column(name = "max_active_items", nullable = false) + private int maxActiveItems = 5; + + @Column(nullable = false) + private boolean enabled = true; + + @Column(name = "created_at", nullable = false) + private Instant createdAt = Instant.now(); + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); + + protected PromotionSlot() {} + + public PromotionSlot(String slotCode, String displayName, String targetTypesJson, int maxActiveItems) { + this.slotCode = slotCode; + this.displayName = displayName; + this.targetTypesJson = targetTypesJson; + this.maxActiveItems = maxActiveItems; + } + + public Long getId() { return id; } + public String getSlotCode() { return slotCode; } + public String getDisplayName() { return displayName; } + public String getTargetTypesJson() { return targetTypesJson; } + public int getMaxActiveItems() { return maxActiveItems; } + public boolean isEnabled() { return enabled; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } + + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public void setMaxActiveItems(int maxActiveItems) { this.maxActiveItems = maxActiveItems; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionSlotRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionSlotRepository.java new file mode 100644 index 000000000..fdf70db90 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionSlotRepository.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.domain.promotion; + +import java.util.Optional; + +/** + * Domain repository contract for {@link PromotionSlot}. + */ +public interface PromotionSlotRepository { + Optional findBySlotCode(String slotCode); + + PromotionSlot save(PromotionSlot slot); + + boolean existsBySlotCode(String slotCode); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionTargetGuard.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionTargetGuard.java new file mode 100644 index 000000000..c99b58de4 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionTargetGuard.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.domain.promotion; + +/** + * Validates whether a target (skill or skill bundle) is in a state that allows it + * to be promoted: published, public, scanned and not flagged unsafe. + * + *

Implementation lives outside the domain module so that the domain layer can + * stay decoupled from skill/bundle aggregates. + */ +public interface PromotionTargetGuard { + void assertPromotable(PromotionTargetType targetType, Long targetId, Long targetVersionId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionTargetType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionTargetType.java new file mode 100644 index 000000000..426919489 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/PromotionTargetType.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.domain.promotion; + +/** + * Target type for an operational promotion campaign. + */ +public enum PromotionTargetType { + SKILL, + SKILL_BUNDLE +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/package-info.java new file mode 100644 index 000000000..ec279fa59 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/promotion/package-info.java @@ -0,0 +1,6 @@ +/** + * Operational promotion management domain — slots, campaigns, lifecycle and event logs. + * Distinct from {@link com.iflytek.skillhub.domain.review.PromotionRequest}, which + * models the cross-namespace global elevation governance flow. + */ +package com.iflytek.skillhub.domain.promotion; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignServiceTest.java new file mode 100644 index 000000000..a30dafc9d --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/promotion/PromotionCampaignServiceTest.java @@ -0,0 +1,231 @@ +package com.iflytek.skillhub.domain.promotion; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Unit tests for {@link PromotionCampaignService}. Covers slot lookup, target gating, + * capacity rules, and the approval / rejection state machine including optimistic-lock loss. + */ +@ExtendWith(MockitoExtension.class) +class PromotionCampaignServiceTest { + + private PromotionSlotRepository slotRepository; + private PromotionCampaignRepository campaignRepository; + private PromotionEventLogRepository eventLogRepository; + private PromotionTargetGuard targetGuard; + private PromotionCampaignService service; + + private final Instant now = Instant.parse("2026-06-01T10:00:00Z"); + + @BeforeEach + void setUp() { + slotRepository = mock(PromotionSlotRepository.class); + campaignRepository = mock(PromotionCampaignRepository.class); + eventLogRepository = mock(PromotionEventLogRepository.class); + targetGuard = mock(PromotionTargetGuard.class); + service = new PromotionCampaignService(slotRepository, campaignRepository, eventLogRepository, targetGuard); + } + + @Test + void createCampaign_persistsPendingReviewAndDelegatesGuard() { + PromotionSlot slot = enabledSlot("HOME_HERO", 5); + given(slotRepository.findBySlotCode("HOME_HERO")).willReturn(Optional.of(slot)); + given(campaignRepository.countActiveBySlot("HOME_HERO", now)).willReturn(0L); + given(campaignRepository.save(any(PromotionCampaign.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + PromotionCampaignService.CreateCampaignCommand cmd = sampleCommand(); + PromotionCampaign saved = service.createCampaign(cmd, "alice", now); + + assertThat(saved.getStatus()).isEqualTo(PromotionCampaignStatus.PENDING_REVIEW); + verify(targetGuard).assertPromotable(PromotionTargetType.SKILL_BUNDLE, 88L, 120L); + ArgumentCaptor captor = ArgumentCaptor.forClass(PromotionCampaign.class); + verify(campaignRepository).save(captor.capture()); + assertThat(captor.getValue().getSubmittedBy()).isEqualTo("alice"); + assertThat(captor.getValue().getSlotCode()).isEqualTo("HOME_HERO"); + } + + @Test + void createCampaign_rejectsWhenSlotMissing() { + given(slotRepository.findBySlotCode("HOME_HERO")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.createCampaign(sampleCommand(), "alice", now)) + .isInstanceOf(PromotionException.class) + .hasMessage("error.promotion.slot.notFound"); + + verify(campaignRepository, never()).save(any()); + } + + @Test + void createCampaign_rejectsWhenSlotDisabled() { + PromotionSlot slot = enabledSlot("HOME_HERO", 5); + slot.setEnabled(false); + given(slotRepository.findBySlotCode("HOME_HERO")).willReturn(Optional.of(slot)); + + assertThatThrownBy(() -> service.createCampaign(sampleCommand(), "alice", now)) + .isInstanceOf(PromotionException.class) + .hasMessage("error.promotion.slot.disabled"); + } + + @Test + void createCampaign_rejectsWhenSlotIsAtCapacity() { + PromotionSlot slot = enabledSlot("HOME_HERO", 2); + given(slotRepository.findBySlotCode("HOME_HERO")).willReturn(Optional.of(slot)); + given(campaignRepository.countActiveBySlot("HOME_HERO", now)).willReturn(2L); + + assertThatThrownBy(() -> service.createCampaign(sampleCommand(), "alice", now)) + .isInstanceOf(PromotionException.class) + .hasMessage("error.promotion.campaign.timeConflict"); + } + + @Test + void createCampaign_rejectsInvalidTimeWindow() { + PromotionCampaignService.CreateCampaignCommand reversed = new PromotionCampaignService.CreateCampaignCommand( + PromotionTargetType.SKILL, 1L, 2L, "HOME_HERO", "T", null, null, null, + 50, now.plus(2, ChronoUnit.DAYS), now, null); + + assertThatThrownBy(() -> service.createCampaign(reversed, "alice", now)) + .isInstanceOf(PromotionException.class) + .hasMessage("error.promotion.campaign.timeWindowInvalid"); + } + + @Test + void approveCampaign_movesToScheduledWhenStartsLater() { + PromotionCampaign campaign = pendingCampaign("alice", now.plus(1, ChronoUnit.DAYS), now.plus(7, ChronoUnit.DAYS)); + given(campaignRepository.findById(1L)).willReturn(Optional.of(campaign), Optional.of(campaign)); + given(campaignRepository.updateStatusWithVersion(eq(1L), eq(PromotionCampaignStatus.SCHEDULED), + eq("admin"), eq("ok"), eq(1))).willReturn(1); + + service.approveCampaign(1L, "ok", "admin", now); + + verify(campaignRepository).updateStatusWithVersion(1L, PromotionCampaignStatus.SCHEDULED, "admin", "ok", 1); + verify(targetGuard, times(1)).assertPromotable(any(), anyLong(), any()); + } + + @Test + void approveCampaign_movesToActiveWhenInsideWindow() { + PromotionCampaign campaign = pendingCampaign("alice", now.minus(1, ChronoUnit.HOURS), now.plus(7, ChronoUnit.DAYS)); + given(campaignRepository.findById(1L)).willReturn(Optional.of(campaign), Optional.of(campaign)); + given(campaignRepository.updateStatusWithVersion(eq(1L), eq(PromotionCampaignStatus.ACTIVE), + anyString(), any(), anyInt())).willReturn(1); + + service.approveCampaign(1L, null, "admin", now); + + verify(campaignRepository).updateStatusWithVersion(1L, PromotionCampaignStatus.ACTIVE, "admin", null, 1); + } + + @Test + void approveCampaign_blocksWhenSubmitterReviewsSelf() { + PromotionCampaign campaign = pendingCampaign("admin", now.plus(1, ChronoUnit.DAYS), now.plus(7, ChronoUnit.DAYS)); + given(campaignRepository.findById(1L)).willReturn(Optional.of(campaign)); + + assertThatThrownBy(() -> service.approveCampaign(1L, "ok", "admin", now)) + .isInstanceOf(PromotionException.class) + .hasMessage("error.promotion.campaign.selfReview"); + + verify(campaignRepository, never()).updateStatusWithVersion(anyLong(), any(), anyString(), any(), anyInt()); + } + + @Test + void approveCampaign_throwsOnConcurrentUpdate() { + PromotionCampaign campaign = pendingCampaign("alice", now.plus(1, ChronoUnit.DAYS), now.plus(7, ChronoUnit.DAYS)); + given(campaignRepository.findById(1L)).willReturn(Optional.of(campaign)); + given(campaignRepository.updateStatusWithVersion(anyLong(), any(), anyString(), any(), anyInt())).willReturn(0); + + assertThatThrownBy(() -> service.approveCampaign(1L, "ok", "admin", now)) + .isInstanceOf(PromotionException.class) + .hasMessage("error.promotion.campaign.concurrentUpdate"); + } + + @Test + void rejectCampaign_movesToRejected() { + PromotionCampaign campaign = pendingCampaign("alice", now.plus(1, ChronoUnit.DAYS), now.plus(7, ChronoUnit.DAYS)); + given(campaignRepository.findById(1L)).willReturn(Optional.of(campaign), Optional.of(campaign)); + given(campaignRepository.updateStatusWithVersion(eq(1L), eq(PromotionCampaignStatus.REJECTED), + eq("admin"), eq("not eligible"), eq(1))).willReturn(1); + + service.rejectCampaign(1L, "not eligible", "admin"); + + verify(campaignRepository).updateStatusWithVersion(1L, PromotionCampaignStatus.REJECTED, "admin", "not eligible", 1); + } + + @Test + void recordEvent_persistsLog() { + given(campaignRepository.findById(7L)).willReturn(Optional.of(pendingCampaign("alice", + now.minus(1, ChronoUnit.HOURS), now.plus(1, ChronoUnit.HOURS)))); + given(eventLogRepository.save(any(PromotionEventLog.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + PromotionEventLog logged = service.recordEvent(7L, PromotionEventType.CLICK, "user-1", null, "req-1"); + + assertThat(logged.getEventType()).isEqualTo(PromotionEventType.CLICK); + assertThat(logged.getUserId()).isEqualTo("user-1"); + verify(eventLogRepository).save(any(PromotionEventLog.class)); + } + + @Test + void runScheduledSweep_returnsActivatedAndEndedCounts() { + given(campaignRepository.markScheduledAsActive(now)).willReturn(3); + given(campaignRepository.markActiveAsEnded(now)).willReturn(1); + + PromotionCampaignService.SchedulerSweepResult result = service.runScheduledSweep(now); + + assertThat(result.activated()).isEqualTo(3); + assertThat(result.ended()).isEqualTo(1); + } + + private PromotionSlot enabledSlot(String code, int max) { + PromotionSlot slot = new PromotionSlot(code, "首页首屏", + "[\"SKILL\",\"SKILL_BUNDLE\"]", max); + slot.setEnabled(true); + return slot; + } + + private PromotionCampaign pendingCampaign(String submitter, Instant startsAt, Instant endsAt) { + PromotionCampaign campaign = new PromotionCampaign( + PromotionTargetType.SKILL_BUNDLE, 88L, "HOME_HERO", + "Title", 80, startsAt, endsAt, submitter); + campaign.setStatus(PromotionCampaignStatus.PENDING_REVIEW); + setField(campaign, "id", 1L); + return campaign; + } + + private PromotionCampaignService.CreateCampaignCommand sampleCommand() { + return new PromotionCampaignService.CreateCampaignCommand( + PromotionTargetType.SKILL_BUNDLE, 88L, 120L, + "HOME_HERO", "Title", "Subtitle", 501L, 502L, + 80, now.plus(1, ChronoUnit.DAYS), now.plus(7, ChronoUnit.DAYS), + "Featured"); + } + + private static void setField(Object target, String name, Object value) { + try { + java.lang.reflect.Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionCampaignJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionCampaignJpaRepository.java new file mode 100644 index 000000000..09c771e22 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionCampaignJpaRepository.java @@ -0,0 +1,90 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.promotion.PromotionCampaign; +import com.iflytek.skillhub.domain.promotion.PromotionCampaignRepository; +import com.iflytek.skillhub.domain.promotion.PromotionCampaignStatus; +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.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; + +/** + * JPA-backed implementation of {@link PromotionCampaignRepository}, including + * optimistic state transitions and the periodic SCHEDULED -> ACTIVE -> ENDED sweep. + */ +@Repository +public interface PromotionCampaignJpaRepository extends JpaRepository, + PromotionCampaignRepository { + + @Override + Page findByStatus(PromotionCampaignStatus status, Pageable pageable); + + @Query(""" + SELECT c FROM PromotionCampaign c + WHERE c.slotCode = :slotCode + AND c.status = com.iflytek.skillhub.domain.promotion.PromotionCampaignStatus.ACTIVE + AND c.startsAt <= :now + AND c.endsAt > :now + ORDER BY c.priority DESC, c.startsAt DESC, c.id DESC + """) + @Override + List findActiveBySlot(@Param("slotCode") String slotCode, @Param("now") Instant now); + + @Query(""" + SELECT COUNT(c) FROM PromotionCampaign c + WHERE c.slotCode = :slotCode + AND c.status = com.iflytek.skillhub.domain.promotion.PromotionCampaignStatus.ACTIVE + AND c.startsAt <= :now + AND c.endsAt > :now + """) + @Override + long countActiveBySlot(@Param("slotCode") String slotCode, @Param("now") Instant now); + + @Override + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE PromotionCampaign c + SET c.status = :status, + c.reviewedBy = :reviewedBy, + c.reviewComment = :reviewComment, + c.updatedAt = CURRENT_TIMESTAMP, + c.version = c.version + 1 + WHERE c.id = :id AND c.version = :expectedVersion + """) + int updateStatusWithVersion(@Param("id") Long id, + @Param("status") PromotionCampaignStatus status, + @Param("reviewedBy") String reviewedBy, + @Param("reviewComment") String reviewComment, + @Param("expectedVersion") Integer expectedVersion); + + @Override + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE PromotionCampaign c + SET c.status = com.iflytek.skillhub.domain.promotion.PromotionCampaignStatus.ACTIVE, + c.updatedAt = CURRENT_TIMESTAMP, + c.version = c.version + 1 + WHERE c.status = com.iflytek.skillhub.domain.promotion.PromotionCampaignStatus.SCHEDULED + AND c.startsAt <= :now + AND c.endsAt > :now + """) + int markScheduledAsActive(@Param("now") Instant now); + + @Override + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE PromotionCampaign c + SET c.status = com.iflytek.skillhub.domain.promotion.PromotionCampaignStatus.ENDED, + c.updatedAt = CURRENT_TIMESTAMP, + c.version = c.version + 1 + WHERE c.status = com.iflytek.skillhub.domain.promotion.PromotionCampaignStatus.ACTIVE + AND c.endsAt <= :now + """) + int markActiveAsEnded(@Param("now") Instant now); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionEventLogJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionEventLogJpaRepository.java new file mode 100644 index 000000000..b038808ca --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionEventLogJpaRepository.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.promotion.PromotionEventLog; +import com.iflytek.skillhub.domain.promotion.PromotionEventLogRepository; +import com.iflytek.skillhub.domain.promotion.PromotionEventType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed implementation of {@link PromotionEventLogRepository}. + */ +@Repository +public interface PromotionEventLogJpaRepository extends JpaRepository, + PromotionEventLogRepository { + + @Override + @Query(""" + SELECT COUNT(e) FROM PromotionEventLog e + WHERE e.campaignId = :campaignId AND e.eventType = :type + """) + long countByCampaignAndType(@Param("campaignId") Long campaignId, @Param("type") PromotionEventType type); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionSlotJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionSlotJpaRepository.java new file mode 100644 index 000000000..cc389e3aa --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionSlotJpaRepository.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.promotion.PromotionSlot; +import com.iflytek.skillhub.domain.promotion.PromotionSlotRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * JPA-backed implementation of {@link PromotionSlotRepository}. + */ +@Repository +public interface PromotionSlotJpaRepository extends JpaRepository, PromotionSlotRepository { + + @Override + Optional findBySlotCode(String slotCode); + + @Override + boolean existsBySlotCode(String slotCode); +} diff --git a/web/src/features/promotion-campaign/api.test.ts b/web/src/features/promotion-campaign/api.test.ts new file mode 100644 index 000000000..16b17104e --- /dev/null +++ b/web/src/features/promotion-campaign/api.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { promotionCampaignApi } from './api' + +/** + * Verifies the thin envelope-aware fetch wrapper behind {@link promotionCampaignApi}. + * + *

Backend always responds with the {@code ApiResponse} envelope; the wrapper must + * unwrap on success and surface {@code msg} on non-zero codes. + */ +describe('promotionCampaignApi', () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + Object.defineProperty(document, 'cookie', { value: '', writable: true, configurable: true }) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('listSlotItems unwraps the envelope to data', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 0, + msg: 'ok', + data: [{ campaignId: 1, slotCode: 'HOME_HERO', targetType: 'SKILL', targetId: 7, title: 'T' }], + timestamp: 'now', + requestId: 'r1', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + globalThis.fetch = mockFetch as unknown as typeof fetch + + const items = await promotionCampaignApi.listSlotItems('HOME_HERO') + + expect(items).toEqual([ + { campaignId: 1, slotCode: 'HOME_HERO', targetType: 'SKILL', targetId: 7, title: 'T' }, + ]) + const [url] = mockFetch.mock.calls[0] + expect(String(url)).toContain('/api/v1/promotion-slots/HOME_HERO') + }) + + it('approve sends POST with comment in JSON body', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 0, msg: 'ok', data: { id: 1 }, timestamp: '', requestId: '' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + globalThis.fetch = mockFetch as unknown as typeof fetch + + await promotionCampaignApi.approve(1, 'looks good') + + const [, init] = mockFetch.mock.calls[0] + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual({ comment: 'looks good' }) + const headers = init.headers as Headers + expect(headers.get('Content-Type')).toBe('application/json') + }) + + it('throws when envelope code is non-zero', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 4001, + msg: 'error.promotion.target.notPublic', + data: null, + timestamp: '', + requestId: '', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + globalThis.fetch = mockFetch as unknown as typeof fetch + + await expect( + promotionCampaignApi.create({ + targetType: 'SKILL', + targetId: 1, + slotCode: 'HOME_HERO', + title: 't', + priority: 50, + startsAt: '2026-06-01T00:00:00Z', + endsAt: '2026-06-30T23:59:59Z', + }), + ).rejects.toThrow('error.promotion.target.notPublic') + }) + + it('throws on HTTP failure', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response('boom', { status: 500 })) + globalThis.fetch = mockFetch as unknown as typeof fetch + + await expect(promotionCampaignApi.listSlotItems('HOME_HERO')).rejects.toThrow(/Request failed: 500/) + }) + + it('attaches X-XSRF-TOKEN when cookie present', async () => { + Object.defineProperty(document, 'cookie', { value: 'XSRF-TOKEN=abc%2D123', writable: true, configurable: true }) + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 0, msg: 'ok', data: null, timestamp: '', requestId: '' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + globalThis.fetch = mockFetch as unknown as typeof fetch + + await promotionCampaignApi.recordEvent(7, 'CLICK') + + const [, init] = mockFetch.mock.calls[0] + const headers = init.headers as Headers + expect(headers.get('X-XSRF-TOKEN')).toBe('abc-123') + }) +}) diff --git a/web/src/features/promotion-campaign/api.ts b/web/src/features/promotion-campaign/api.ts new file mode 100644 index 000000000..4592814e4 --- /dev/null +++ b/web/src/features/promotion-campaign/api.ts @@ -0,0 +1,117 @@ +import type { TargetType } from './types' + +/** + * Minimal envelope-aware fetch helpers used by the operational promotion campaign feature. + * + *

The platform-wide {@code openapi-fetch} client is regenerated from the backend OpenAPI + * spec. Until the new endpoints land in that schema, the page falls back to a hand-written + * thin wrapper that mirrors the {@code ApiResponse} envelope used everywhere else. + */ +type ApiEnvelope = { + code: number + msg: string + data: T + timestamp: string + requestId: string +} + +function getCsrfToken(): string | null { + if (typeof document === 'undefined') return null + const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/) + return match ? decodeURIComponent(match[1]) : null +} + +async function request(path: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers) + headers.set('Accept', 'application/json') + if (init?.body) { + headers.set('Content-Type', 'application/json') + } + const csrf = getCsrfToken() + if (csrf) headers.set('X-XSRF-TOKEN', csrf) + const res = await fetch(path, { ...init, headers, credentials: 'include' }) + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`) + } + const envelope = (await res.json()) as ApiEnvelope + if (envelope.code !== 0) { + throw new Error(envelope.msg ?? `Request failed (code ${envelope.code})`) + } + return envelope.data +} + +export type CampaignStatus = 'DRAFT' | 'PENDING_REVIEW' | 'SCHEDULED' | 'ACTIVE' | 'ENDED' | 'REJECTED' + +export type PromotionCampaign = { + id: number + targetType: TargetType + targetId: number + targetVersionId?: number | null + slotCode: string + title: string + subtitle?: string | null + coverMediaId?: number | null + demoMediaId?: number | null + priority: number + status: CampaignStatus + startsAt: string + endsAt: string + submittedBy: string + reviewedBy?: string | null + reviewComment?: string | null + reason?: string | null + createdAt: string + updatedAt: string +} + +export type PromotionSlotItem = { + campaignId: number + slotCode: string + targetType: TargetType + targetId: number + title: string + subtitle?: string | null + coverUrl?: string | null + demoGifUrl?: string | null + targetUrl?: string | null +} + +export type CreateCampaignPayload = { + targetType: TargetType + targetId: number + targetVersionId?: number | null + slotCode: string + title: string + subtitle?: string | null + coverMediaId?: number | null + demoMediaId?: number | null + priority: number + startsAt: string + endsAt: string + reason?: string | null +} + +type Page = { items: T[]; total: number; page: number; size: number } + +export const promotionCampaignApi = { + listSlotItems: (slotCode: string): Promise => + request(`/api/v1/promotion-slots/${encodeURIComponent(slotCode)}`), + listByStatus: (status: CampaignStatus, page = 0, size = 20): Promise> => + request( + `/api/v1/admin/promotion-campaigns?status=${encodeURIComponent(status)}&page=${page}&size=${size}`, + ), + create: (payload: CreateCampaignPayload): Promise => + request('/api/v1/admin/promotion-campaigns', { method: 'POST', body: JSON.stringify(payload) }), + approve: (id: number, comment?: string | null): Promise => + request(`/api/v1/admin/promotion-campaigns/${id}/approve`, { + method: 'POST', + body: JSON.stringify({ comment: comment ?? null }), + }), + reject: (id: number, comment?: string | null): Promise => + request(`/api/v1/admin/promotion-campaigns/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ comment: comment ?? null }), + }), + recordEvent: (id: number, eventType: 'IMPRESSION' | 'CLICK' | 'DOWNLOAD' | 'INSTALL'): Promise => + request(`/api/v1/admin/promotion-campaigns/${id}/events/${eventType}`, { method: 'POST' }), +} diff --git a/web/src/features/promotion-campaign/hooks.ts b/web/src/features/promotion-campaign/hooks.ts new file mode 100644 index 000000000..9b532885e --- /dev/null +++ b/web/src/features/promotion-campaign/hooks.ts @@ -0,0 +1,49 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + promotionCampaignApi, + type CampaignStatus, + type CreateCampaignPayload, +} from './api' + +const PROMOTION_CAMPAIGN_KEY = ['promotion-campaigns'] as const + +export function usePromotionCampaigns(status: CampaignStatus, page = 0, size = 20) { + return useQuery({ + queryKey: [...PROMOTION_CAMPAIGN_KEY, status, page, size], + queryFn: () => promotionCampaignApi.listByStatus(status, page, size), + }) +} + +export function useCreatePromotionCampaign() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (payload: CreateCampaignPayload) => promotionCampaignApi.create(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: PROMOTION_CAMPAIGN_KEY }), + }) +} + +export function useApprovePromotionCampaign() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, comment }: { id: number; comment?: string }) => + promotionCampaignApi.approve(id, comment), + onSuccess: () => qc.invalidateQueries({ queryKey: PROMOTION_CAMPAIGN_KEY }), + }) +} + +export function useRejectPromotionCampaign() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, comment }: { id: number; comment?: string }) => + promotionCampaignApi.reject(id, comment), + onSuccess: () => qc.invalidateQueries({ queryKey: PROMOTION_CAMPAIGN_KEY }), + }) +} + +export function usePromotionSlot(slotCode: string) { + return useQuery({ + queryKey: ['promotion-slots', slotCode], + queryFn: () => promotionCampaignApi.listSlotItems(slotCode), + enabled: !!slotCode, + }) +} diff --git a/web/src/features/promotion-campaign/types.ts b/web/src/features/promotion-campaign/types.ts new file mode 100644 index 000000000..20d9554f2 --- /dev/null +++ b/web/src/features/promotion-campaign/types.ts @@ -0,0 +1 @@ +export type TargetType = 'SKILL' | 'SKILL_BUNDLE' diff --git a/web/src/pages/dashboard/promotion-campaigns.tsx b/web/src/pages/dashboard/promotion-campaigns.tsx new file mode 100644 index 000000000..a6f794cdb --- /dev/null +++ b/web/src/pages/dashboard/promotion-campaigns.tsx @@ -0,0 +1,130 @@ +import { useMemo, useState } from 'react' +import { + useApprovePromotionCampaign, + usePromotionCampaigns, + useRejectPromotionCampaign, +} from '@/features/promotion-campaign/hooks' +import type { CampaignStatus } from '@/features/promotion-campaign/api' + +const STATUS_TABS: { value: CampaignStatus; label: string }[] = [ + { value: 'PENDING_REVIEW', label: '待审核' }, + { value: 'SCHEDULED', label: '已排期' }, + { value: 'ACTIVE', label: '生效中' }, + { value: 'ENDED', label: '已结束' }, + { value: 'REJECTED', label: '已拒绝' }, +] + +/** + * 推广计划管理页面(运营推广位)。 + * + * - 列表按状态分页展示推广计划 + * - 待审核状态下,管理员可填写审核意见后通过或拒绝 + * - 与“全局提升审核”页面({@code dashboard/promotions})独立,避免概念混淆 + */ +export function PromotionCampaignsPage() { + const [activeStatus, setActiveStatus] = useState('PENDING_REVIEW') + const { data, isLoading, error } = usePromotionCampaigns(activeStatus) + const approveMutation = useApprovePromotionCampaign() + const rejectMutation = useRejectPromotionCampaign() + const [comments, setComments] = useState>({}) + + const items = useMemo(() => data?.items ?? [], [data]) + + return ( +

+
+

推广计划管理

+

维护首页、搜索页等运营推广位的投放计划。

+
+ + + + {isLoading ?
加载中...
: null} + {error ?
{(error as Error).message}
: null} + +
    + {items.map((campaign) => ( +
  • +
    +
    +
    {campaign.title}
    +
    + {campaign.slotCode} · {campaign.targetType} #{campaign.targetId} · 优先级 {campaign.priority} +
    +
    + {campaign.startsAt} → {campaign.endsAt} +
    +
    + {campaign.status} +
    + {campaign.subtitle ?

    {campaign.subtitle}

    : null} + + {activeStatus === 'PENDING_REVIEW' ? ( +
    + +