Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
}
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);
}
Comment on lines +70 to +78
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 或其他允许匿名/普通用户访问的控制器中。

}
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));
}
}
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
) {}
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()
);
}
}
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
) {}
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
);
}
}
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
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");
}

if (version.getStatus() != SkillVersionStatus.PUBLISHED) {
throw new PromotionException("error.promotion.target.versionNotPublished");
}
}
}
Loading