Skip to content

feat(promotion): operational promotion campaign management#447

Open
paradoxgacsd wants to merge 1 commit into
iflytek:mainfrom
paradoxgacsd:feature/promotion-management
Open

feat(promotion): operational promotion campaign management#447
paradoxgacsd wants to merge 1 commit into
iflytek:mainfrom
paradoxgacsd:feature/promotion-management

Conversation

@paradoxgacsd
Copy link
Copy Markdown

背景

为运营团队提供「推广位 + 推广计划」管理能力,覆盖详细设计文档中的 Promotion 模块需求:可视化挑选首页/分类页等推广位、配置目标技能或技能包、走「草稿 → 待审核 → 已排期 → 进行中 → 已结束/已驳回」状态机、并提供匿名读接口供前台展示。

改动概要

数据库(Flyway V42)

  • promotion_slot:推广位定义,含 7 条种子数据(首页 Banner、分类首屏、检索置顶等)。
  • promotion_campaign:推广计划主表,含状态、目标类型、目标 ID、起止时间、version 乐观锁字段。
  • promotion_event_log:推广位曝光/点击事件流水。

Server 端

  • 领域层skillhub-domain/.../promotion
    • 聚合:PromotionCampaignPromotionSlotPromotionEventLog 与对应仓储接口。
    • PromotionCampaignService:状态机驱动(DRAFT → PENDING_REVIEW → SCHEDULED | REJECTEDSCHEDULED → ACTIVE → ENDED),同槽位容量校验、目标可见性校验、自审拒绝、乐观锁冲突识别。
  • 基础设施层skillhub-infra):JPA 仓储实现,含分页查询与批量状态更新。
  • 应用层skillhub-app
    • PromotionScheduler:定时扫描,按时间窗口推进 SCHEDULED → ACTIVE → ENDED
    • REST:/api/v1/admin/promotion-campaigns(管理端 CRUD + 审核),匿名 /api/v1/promotion-slots/{slotCode}(前台展示)。

Web 端

  • 新页面 pages/promotion/promotion-campaigns.tsx:列表、筛选、提交审核、审批驳回。
  • 新增 features/promotion/api.tshooks.ts:envelope-aware fetch 封装 + react-query 钩子。

测试

  • PromotionCampaignServiceTest(领域):12 个用例覆盖状态机迁移、容量校验、自审拒绝、乐观锁。
  • AdminPromotionCampaignControllerTest(HTTP / MockMvc):4 个用例覆盖管理端权限与典型流程。
  • 前端 vitest:API 客户端 5 个用例(envelope 解码、错误码、CSRF 头)。

验证

  • JDK 21(Adoptium Temurin 21.0.10)+ ./mvnw -pl skillhub-app -am:BUILD SUCCESS,所有相关模块编译通过。
  • 领域 12 + Controller 4 = 16 个后端测试全部通过。
  • 前端 vitest 5 个用例全部通过。

Checklist

  • 单元测试已添加并通过
  • Flyway 迁移使用了仓库当前的下一个版本号 V42
  • 新增 REST 路径符合 /api/v1/... 现有规范
  • 不破坏既有 API

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.
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented May 16, 2026

CLA assistant check
All committers have signed the CLA.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +70 to +78
@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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

recordEvent 接口目前位于 AdminPromotionCampaignController 中,且受限于类级别的 @PreAuthorize 权限检查(仅限管理员)。然而,推广事件(如曝光 IMPRESSION 和点击 CLICK)通常是由普通用户或匿名用户在前台页面交互时触发的。将此接口放在管理端会导致普通用户因权限不足(403)而无法记录推广数据,从而使统计功能失效。建议将此接口移至 PromotionSlotController 或其他允许匿名/普通用户访问的控制器中。

Comment on lines +60 to +61
SkillVersion version = skillVersionRepository.findById(resolvedVersionId)
.orElseThrow(() -> new PromotionException("error.promotion.target.versionNotFound"));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

在校验技能是否可推广时,如果请求中指定了 versionId,代码直接通过 skillVersionRepository.findById(resolvedVersionId) 获取版本,但没有校验该版本是否确实属于指定的 skillId。这可能导致逻辑漏洞,允许用户通过猜测 ID 来推广不属于该技能的版本。建议增加所属权校验。

Suggested change
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");
}

Comment on lines +55 to +58
long currentActive = campaignRepository.countActiveBySlot(slot.getSlotCode(), now);
if (currentActive >= slot.getMaxActiveItems()) {
throw new PromotionException("error.promotion.campaign.timeConflict");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

容量校验逻辑 campaignRepository.countActiveBySlot(slot.getSlotCode(), now) 存在缺陷:

  1. 它仅检查了“当前时刻”的活跃计划。如果用户预约的是未来的推广计划,该检查将无法发现未来的时间冲突。
  2. PromotionCampaignScheduler 在激活 SCHEDULED 计划时没有进行容量检查。如果未来有多个计划在同一时间段重叠,它们都会被同时激活,从而绕过 maxActiveItems 的限制。

建议在创建或审核时,检查所请求的时间窗口 [startsAt, endsAt] 内的峰值占用情况,确保在任何时刻都不会超过槽位容量。

private Integer version = 1;

@Column(name = "created_at", nullable = false)
private Instant createdAt = Instant.now();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

createdAt 使用 Instant.now() 进行字段初始化,这会导致 PromotionCampaignService.createCampaign 方法中传入的 now 参数(基于 Clock)在设置创建时间时被忽略. 为了保证测试的可预测性(例如在单元测试中使用固定的 Mock Clock)以及领域逻辑的一致性,建议将 createdAt 的设置逻辑移至构造函数中,并使用传入的时间戳参数。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants