feat(promotion): operational promotion campaign management#447
feat(promotion): operational promotion campaign management#447paradoxgacsd wants to merge 1 commit into
Conversation
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.
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive operational promotion management system, including backend services for campaign lifecycles, database migrations, and a frontend management dashboard. Key features include campaign creation, an approval workflow, and a scheduled task for status transitions. Feedback focuses on several critical areas: the recordEvent endpoint is currently restricted to admins, which will block event tracking for regular users; the DefaultPromotionTargetGuard lacks a check to ensure a version belongs to the specified skill; the capacity validation logic only considers the current time instead of the full campaign window; and the createdAt field should be initialized using the provided clock-based timestamp to ensure test predictability.
| @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); | ||
| } |
| SkillVersion version = skillVersionRepository.findById(resolvedVersionId) | ||
| .orElseThrow(() -> new PromotionException("error.promotion.target.versionNotFound")); |
There was a problem hiding this comment.
在校验技能是否可推广时,如果请求中指定了 versionId,代码直接通过 skillVersionRepository.findById(resolvedVersionId) 获取版本,但没有校验该版本是否确实属于指定的 skillId。这可能导致逻辑漏洞,允许用户通过猜测 ID 来推广不属于该技能的版本。建议增加所属权校验。
| SkillVersion version = skillVersionRepository.findById(resolvedVersionId) | |
| .orElseThrow(() -> new PromotionException("error.promotion.target.versionNotFound")); | |
| SkillVersion version = skillVersionRepository.findById(resolvedVersionId) | |
| .orElseThrow(() -> new PromotionException("error.promotion.target.versionNotFound")); | |
| if (!version.getSkillId().equals(skillId)) { | |
| throw new PromotionException("error.promotion.target.invalid"); | |
| } |
| long currentActive = campaignRepository.countActiveBySlot(slot.getSlotCode(), now); | ||
| if (currentActive >= slot.getMaxActiveItems()) { | ||
| throw new PromotionException("error.promotion.campaign.timeConflict"); | ||
| } |
There was a problem hiding this comment.
| private Integer version = 1; | ||
|
|
||
| @Column(name = "created_at", nullable = false) | ||
| private Instant createdAt = Instant.now(); |
背景
为运营团队提供「推广位 + 推广计划」管理能力,覆盖详细设计文档中的 Promotion 模块需求:可视化挑选首页/分类页等推广位、配置目标技能或技能包、走「草稿 → 待审核 → 已排期 → 进行中 → 已结束/已驳回」状态机、并提供匿名读接口供前台展示。
改动概要
数据库(Flyway V42)
promotion_slot:推广位定义,含 7 条种子数据(首页 Banner、分类首屏、检索置顶等)。promotion_campaign:推广计划主表,含状态、目标类型、目标 ID、起止时间、version乐观锁字段。promotion_event_log:推广位曝光/点击事件流水。Server 端
skillhub-domain/.../promotion)PromotionCampaign、PromotionSlot、PromotionEventLog与对应仓储接口。PromotionCampaignService:状态机驱动(DRAFT → PENDING_REVIEW → SCHEDULED | REJECTED、SCHEDULED → ACTIVE → ENDED),同槽位容量校验、目标可见性校验、自审拒绝、乐观锁冲突识别。skillhub-infra):JPA 仓储实现,含分页查询与批量状态更新。skillhub-app)PromotionScheduler:定时扫描,按时间窗口推进SCHEDULED → ACTIVE → ENDED。/api/v1/admin/promotion-campaigns(管理端 CRUD + 审核),匿名/api/v1/promotion-slots/{slotCode}(前台展示)。Web 端
pages/promotion/promotion-campaigns.tsx:列表、筛选、提交审核、审批驳回。features/promotion/api.ts、hooks.ts:envelope-aware fetch 封装 + react-query 钩子。测试
PromotionCampaignServiceTest(领域):12 个用例覆盖状态机迁移、容量校验、自审拒绝、乐观锁。AdminPromotionCampaignControllerTest(HTTP / MockMvc):4 个用例覆盖管理端权限与典型流程。验证
./mvnw -pl skillhub-app -am:BUILD SUCCESS,所有相关模块编译通过。Checklist
V42/api/v1/...现有规范