-
Notifications
You must be signed in to change notification settings - Fork 429
feat(promotion): operational promotion campaign management #447
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
paradoxgacsd
wants to merge
1
commit into
iflytek:main
Choose a base branch
from
paradoxgacsd:feature/promotion-management
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
79 changes: 79 additions & 0 deletions
79
...src/main/java/com/iflytek/skillhub/controller/admin/AdminPromotionCampaignController.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<PromotionCampaignResponse> create(@Valid @RequestBody CreatePromotionCampaignRequest request, | ||
| @RequestAttribute("userId") String userId) { | ||
| return ok("response.success.created", appService.createCampaign(request, userId)); | ||
| } | ||
|
|
||
| @PostMapping("/{id}/approve") | ||
| public ApiResponse<PromotionCampaignResponse> 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<PromotionCampaignResponse> 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<PageResponse<PromotionCampaignResponse>> 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<Void> 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); | ||
| } | ||
| } | ||
35 changes: 35 additions & 0 deletions
35
...hub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionSlotController.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<List<PromotionSlotItemResponse>> listSlotItems(@PathVariable String slotCode) { | ||
| return ok("response.success.read", promotionCampaignAppService.listSlotItems(slotCode)); | ||
| } | ||
| } |
28 changes: 28 additions & 0 deletions
28
...-app/src/main/java/com/iflytek/skillhub/dto/promotion/CreatePromotionCampaignRequest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) {} |
44 changes: 44 additions & 0 deletions
44
...llhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionCampaignResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| ); | ||
| } | ||
| } |
10 changes: 10 additions & 0 deletions
10
...-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionCampaignReviewRequest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) {} |
31 changes: 31 additions & 0 deletions
31
...llhub-app/src/main/java/com/iflytek/skillhub/dto/promotion/PromotionSlotItemResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ); | ||
| } | ||
| } |
66 changes: 66 additions & 0 deletions
66
...app/src/main/java/com/iflytek/skillhub/service/promotion/DefaultPromotionTargetGuard.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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")); | ||||||||||||||||
|
Comment on lines
+60
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 在校验技能是否可推广时,如果请求中指定了
Suggested change
|
||||||||||||||||
| if (version.getStatus() != SkillVersionStatus.PUBLISHED) { | ||||||||||||||||
| throw new PromotionException("error.promotion.target.versionNotPublished"); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
recordEvent接口目前位于AdminPromotionCampaignController中,且受限于类级别的@PreAuthorize权限检查(仅限管理员)。然而,推广事件(如曝光IMPRESSION和点击CLICK)通常是由普通用户或匿名用户在前台页面交互时触发的。将此接口放在管理端会导致普通用户因权限不足(403)而无法记录推广数据,从而使统计功能失效。建议将此接口移至PromotionSlotController或其他允许匿名/普通用户访问的控制器中。