diff --git a/docs/api-phase1.md b/docs/api-phase1.md index eee82b3..ff520c5 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -1287,12 +1287,17 @@ commentSummary,createdAt`。游客的 userId 为空、anonymous 为 true;评 | GET | `/admin/rewards/tasks/page` · `/tasks/{id}` | `reward:list` | 奖励任务分页与详情 | | POST | `/admin/rewards/tasks` | `reward:update` | 新建任务,默认下线 | | PUT | `/admin/rewards/tasks/{id}` · `/{id}/status` | `reward:update` | 编辑、上线 / 下线;编辑会递增版本 | +| GET | `/admin/rewards/activities/page` · `/activities/{id}` | `reward:list` | 奖励活动分页与详情 | +| POST | `/admin/rewards/activities` | `reward:update` | 新建活动,默认下线 | +| PUT | `/admin/rewards/activities/{id}` · `/{id}/status` | `reward:update` | 编辑、上线 / 下线 | | GET · PUT | `/admin/rewards/check-in/rules` | `reward:checkin` | 查看 / 保存签到每日奖励规则,天数不能重复 | | GET | `/admin/rewards/grants/page` | `reward:grant` | 用户、任务、来源、状态筛选发放记录 | | POST | `/admin/rewards/grants/manual` | `reward:grant` | 人工补发;仍写 `REWARD` 钱包流水并要求幂等键 | 后台任务保存字段包括 `code`、`sectionCode`、`type`、`verifyMode`、`targetValue`、`rewardCoin`、`cycleType`、`cycleLimit`、三语言文案、动作、排序和生效时间。任务配置不允许后台写任意脚本,扩展字段只能放在 `configJson`。 +后台活动保存字段包括 `code`、`coverUrl`、`actionType`、`actionUrl`、三语言标题、三语言摘要、三语言按钮文案、排序和生效时间。活动与任务共用 `reward:update` 权限,新建活动默认下线。 + **校验与错误码**: | 场景 | 结果 | diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/RewardAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/RewardAdminController.java index 5de745f..3d87fbc 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/RewardAdminController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/RewardAdminController.java @@ -3,8 +3,12 @@ package com.fruitshorts.admin.controller; import com.fruitshorts.commerce.reward.service.RewardCheckinService; +import com.fruitshorts.commerce.reward.service.RewardActivityAdminService; import com.fruitshorts.commerce.reward.service.RewardGrantService; import com.fruitshorts.commerce.reward.service.RewardTaskService; +import com.fruitshorts.commerce.reward.vo.RewardActivityQueryReqVO; +import com.fruitshorts.commerce.reward.vo.RewardActivityRespVO; +import com.fruitshorts.commerce.reward.vo.RewardActivitySaveReqVO; import com.fruitshorts.commerce.reward.vo.RewardCheckinRuleSaveReqVO; import com.fruitshorts.commerce.reward.vo.RewardGrantQueryReqVO; import com.fruitshorts.commerce.reward.vo.RewardGrantRespVO; @@ -45,6 +49,7 @@ import java.util.List; public class RewardAdminController { private final RewardTaskService taskService; + private final RewardActivityAdminService activityService; private final RewardCheckinService checkinService; private final RewardGrantService grantService; private final AppUserMapper appUserMapper; @@ -89,6 +94,46 @@ public class RewardAdminController { return Result.ok(); } + @Operation(summary = "奖励活动分页") + @GetMapping("/activities/page") + @PreAuthorize("@ss.hasPermission('reward:list')") + public Result> activityPage(@Validated RewardActivityQueryReqVO req) { + return Result.ok(activityService.page(req)); + } + + @Operation(summary = "奖励活动详情") + @GetMapping("/activities/{id}") + @PreAuthorize("@ss.hasPermission('reward:list')") + public Result activityDetail(@PathVariable Long id) { + return Result.ok(activityService.detail(id)); + } + + @Operation(summary = "新建奖励活动") + @PostMapping("/activities") + @PreAuthorize("@ss.hasPermission('reward:update')") + @OperationLog(module = "奖励中心", action = "新建奖励活动") + public Result createActivity(@Valid @RequestBody RewardActivitySaveReqVO req) { + return Result.ok(activityService.create(req)); + } + + @Operation(summary = "编辑奖励活动") + @PutMapping("/activities/{id}") + @PreAuthorize("@ss.hasPermission('reward:update')") + @OperationLog(module = "奖励中心", action = "编辑奖励活动") + public Result updateActivity(@PathVariable Long id, @Valid @RequestBody RewardActivitySaveReqVO req) { + activityService.update(id, req); + return Result.ok(); + } + + @Operation(summary = "奖励活动上下线") + @PutMapping("/activities/{id}/status") + @PreAuthorize("@ss.hasPermission('reward:update')") + @OperationLog(module = "奖励中心", action = "奖励活动上下线") + public Result updateActivityStatus(@PathVariable Long id, @RequestParam Integer status) { + activityService.updateStatus(id, status); + return Result.ok(); + } + @Operation(summary = "签到规则") @GetMapping("/check-in/rules") @PreAuthorize("@ss.hasPermission('reward:checkin')") diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/AdminPermissionAnnotationTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/AdminPermissionAnnotationTest.java index 7f7f6a3..56880d0 100644 --- a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/AdminPermissionAnnotationTest.java +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/AdminPermissionAnnotationTest.java @@ -35,7 +35,8 @@ class AdminPermissionAnnotationTest { assertThat(permissionsOf(RewardAdminController.class)) .containsExactlyInAnyOrder("reward:list", "reward:list", "reward:update", "reward:update", "reward:update", "reward:checkin", "reward:checkin", - "reward:grant", "reward:grant"); + "reward:grant", "reward:grant", "reward:list", "reward:list", + "reward:update", "reward:update", "reward:update"); } /** 收集控制器里所有映射方法的 @PreAuthorize 权限点(缺失则记为 MISSING,便于断言直接看到)。 */ diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppSearchService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppSearchService.java index 557b737..d4f77fa 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppSearchService.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppSearchService.java @@ -35,7 +35,9 @@ public class AppSearchService { Page page = new Page<>(safeIndex, safeSize); Page result = dramaMapper.selectPage(page, Wrappers.lambdaQuery() .eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode()) - .and(w -> w.like(Drama::getTitle, keyword.trim()) + .and(w -> w.like(Drama::getTitleLocales, keyword.trim()) + .or().like(Drama::getSynopsisLocales, keyword.trim()) + .or().like(Drama::getTitle, keyword.trim()) .or().like(Drama::getSynopsis, keyword.trim())) .orderByDesc(Drama::getSort) .orderByDesc(Drama::getId)); diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java index a5c3307..c422890 100644 --- a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java @@ -111,7 +111,7 @@ public class SecurityConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(Arrays.stream(allowedOrigins.split(",")).map(String::trim).toList()); + configuration.setAllowedOriginPatterns(Arrays.stream(allowedOrigins.split(",")).map(String::trim).toList()); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardActivityAdminService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardActivityAdminService.java new file mode 100644 index 0000000..717ffcc --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardActivityAdminService.java @@ -0,0 +1,136 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fruitshorts.commerce.reward.dal.entity.RewardActivity; +import com.fruitshorts.commerce.reward.dal.mapper.RewardActivityMapper; +import com.fruitshorts.commerce.reward.vo.RewardActivityQueryReqVO; +import com.fruitshorts.commerce.reward.vo.RewardActivityRespVO; +import com.fruitshorts.commerce.reward.vo.RewardActivitySaveReqVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +/** 奖励中心活动的后台配置服务。 */ +@Service +@RequiredArgsConstructor +public class RewardActivityAdminService { + + private final RewardActivityMapper activityMapper; + + /** 分页查询活动配置。 */ + public PageResult page(RewardActivityQueryReqVO req) { + Page page = new Page<>(req.getSafePageIndex(), req.getSafePageSize()); + IPage result = activityMapper.selectPage(page, Wrappers.lambdaQuery() + .eq(RewardActivity::getDeleted, 0) + .eq(req.getStatus() != null, RewardActivity::getStatus, req.getStatus()) + .and(StringUtils.hasText(req.getKeyword()), w -> w + .like(RewardActivity::getCode, req.getKeyword()) + .or().like(RewardActivity::getTitleId, req.getKeyword()) + .or().like(RewardActivity::getTitleEn, req.getKeyword()) + .or().like(RewardActivity::getTitleZh, req.getKeyword())) + .orderByAsc(RewardActivity::getSort) + .orderByDesc(RewardActivity::getId)); + return new PageResult<>(result.getTotal(), result.getRecords().stream() + .map(RewardActivityRespVO::of).toList()); + } + + /** 查询活动详情。 */ + public RewardActivityRespVO detail(Long id) { + return RewardActivityRespVO.of(getExisting(id)); + } + + /** 新建活动,默认下线,避免半配置内容立即展示。 */ + @Transactional(rollbackFor = Exception.class) + public Long create(RewardActivitySaveReqVO req) { + validate(req); + String code = req.getCode().trim(); + requireCodeFree(code, null); + RewardActivity activity = new RewardActivity(); + activity.setCode(code); + apply(activity, req); + activity.setStatus(0); + activityMapper.insert(activity); + return activity.getId(); + } + + /** 编辑活动,保留现有上下线状态。 */ + @Transactional(rollbackFor = Exception.class) + public void update(Long id, RewardActivitySaveReqVO req) { + validate(req); + RewardActivity activity = getExisting(id); + String code = req.getCode().trim(); + requireCodeFree(code, id); + activity.setCode(code); + apply(activity, req); + activityMapper.updateById(activity); + } + + /** 上线或下线活动。 */ + @Transactional(rollbackFor = Exception.class) + public void updateStatus(Long id, Integer status) { + if (status == null || (status != 0 && status != 1)) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "状态只能是 0 或 1"); + } + RewardActivity activity = getExisting(id); + activity.setStatus(status); + activityMapper.updateById(activity); + } + + private void validate(RewardActivitySaveReqVO req) { + if (req == null || !StringUtils.hasText(req.getCode()) || !StringUtils.hasText(req.getTitleId())) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "请填写活动编码和印尼语标题"); + } + } + + private void apply(RewardActivity activity, RewardActivitySaveReqVO req) { + activity.setCoverUrl(valueOrEmpty(req.getCoverUrl())); + activity.setActionType(valueOrDefault(req.getActionType(), "NONE")); + activity.setActionUrl(valueOrEmpty(req.getActionUrl())); + activity.setTitleId(req.getTitleId().trim()); + activity.setTitleEn(valueOrEmpty(req.getTitleEn())); + activity.setTitleZh(valueOrEmpty(req.getTitleZh())); + activity.setSummaryId(valueOrEmpty(req.getSummaryId())); + activity.setSummaryEn(valueOrEmpty(req.getSummaryEn())); + activity.setSummaryZh(valueOrEmpty(req.getSummaryZh())); + activity.setCtaId(valueOrEmpty(req.getCtaId())); + activity.setCtaEn(valueOrEmpty(req.getCtaEn())); + activity.setCtaZh(valueOrEmpty(req.getCtaZh())); + activity.setSort(req.getSort() == null ? 0 : req.getSort()); + activity.setStartAt(req.getStartAt()); + activity.setEndAt(req.getEndAt()); + } + + private RewardActivity getExisting(Long id) { + RewardActivity activity = id == null ? null : activityMapper.selectById(id); + if (activity == null || Integer.valueOf(1).equals(activity.getDeleted())) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "奖励活动不存在"); + } + return activity; + } + + private void requireCodeFree(String code, Long excludeId) { + Long count = activityMapper.selectCount(Wrappers.lambdaQuery() + .eq(RewardActivity::getCode, code) + .eq(RewardActivity::getDeleted, 0) + .ne(excludeId != null, RewardActivity::getId, excludeId)); + if (count != null && count > 0) { + throw new ServiceException(ErrorCode.DATA_EXISTS, "活动编码已存在:" + code); + } + } + + private String valueOrEmpty(String value) { + return value == null ? "" : value.trim(); + } + + private String valueOrDefault(String value, String fallback) { + return StringUtils.hasText(value) ? value.trim() : fallback; + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardActivityQueryReqVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardActivityQueryReqVO.java new file mode 100644 index 0000000..3988a8f --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardActivityQueryReqVO.java @@ -0,0 +1,16 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import com.fruitshorts.common.pojo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** 后台奖励活动分页条件。 */ +@Data +@EqualsAndHashCode(callSuper = true) +public class RewardActivityQueryReqVO extends PageParam { + + private String keyword; + private Integer status; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardActivityRespVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardActivityRespVO.java new file mode 100644 index 0000000..24abe37 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardActivityRespVO.java @@ -0,0 +1,62 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import com.fruitshorts.commerce.reward.dal.entity.RewardActivity; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 后台奖励活动响应。 */ +@Data +public class RewardActivityRespVO { + + private Long id; + private String code; + private String coverUrl; + private String actionType; + private String actionUrl; + private String titleId; + private String titleEn; + private String titleZh; + private String summaryId; + private String summaryEn; + private String summaryZh; + private String ctaId; + private String ctaEn; + private String ctaZh; + private Integer sort; + private Integer status; + private String statusText; + private LocalDateTime startAt; + private LocalDateTime endAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + /** 将活动实体转换为后台展示模型。 */ + public static RewardActivityRespVO of(RewardActivity activity) { + RewardActivityRespVO vo = new RewardActivityRespVO(); + vo.id = activity.getId(); + vo.code = activity.getCode(); + vo.coverUrl = activity.getCoverUrl(); + vo.actionType = activity.getActionType(); + vo.actionUrl = activity.getActionUrl(); + vo.titleId = activity.getTitleId(); + vo.titleEn = activity.getTitleEn(); + vo.titleZh = activity.getTitleZh(); + vo.summaryId = activity.getSummaryId(); + vo.summaryEn = activity.getSummaryEn(); + vo.summaryZh = activity.getSummaryZh(); + vo.ctaId = activity.getCtaId(); + vo.ctaEn = activity.getCtaEn(); + vo.ctaZh = activity.getCtaZh(); + vo.sort = activity.getSort(); + vo.status = activity.getStatus(); + vo.statusText = activity.getStatus() != null && activity.getStatus() == 1 ? "已上线" : "已下线"; + vo.startAt = activity.getStartAt(); + vo.endAt = activity.getEndAt(); + vo.createdAt = activity.getCreatedAt(); + vo.updatedAt = activity.getUpdatedAt(); + return vo; + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardActivitySaveReqVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardActivitySaveReqVO.java new file mode 100644 index 0000000..5b05f17 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardActivitySaveReqVO.java @@ -0,0 +1,36 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 后台奖励活动新增和编辑参数。 */ +@Data +public class RewardActivitySaveReqVO { + + @NotBlank(message = "请填写活动编码") + @Size(max = 64, message = "活动编码过长") + private String code; + @Size(max = 512, message = "封面地址过长") + private String coverUrl; + private String actionType; + @Size(max = 512, message = "跳转地址过长") + private String actionUrl; + @NotBlank(message = "请填写印尼语标题") + private String titleId; + private String titleEn; + private String titleZh; + private String summaryId; + private String summaryEn; + private String summaryZh; + private String ctaId; + private String ctaEn; + private String ctaZh; + private Integer sort; + private LocalDateTime startAt; + private LocalDateTime endAt; +} diff --git a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardActivityAdminServiceTest.java b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardActivityAdminServiceTest.java new file mode 100644 index 0000000..a34ca91 --- /dev/null +++ b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardActivityAdminServiceTest.java @@ -0,0 +1,102 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fruitshorts.commerce.reward.dal.entity.RewardActivity; +import com.fruitshorts.commerce.reward.dal.mapper.RewardActivityMapper; +import com.fruitshorts.commerce.reward.vo.RewardActivitySaveReqVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicReference; + +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.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** 奖励活动后台配置服务测试。 */ +class RewardActivityAdminServiceTest { + + private RewardActivityMapper activityMapper; + private RewardActivityAdminService service; + + @BeforeAll + static void initTableInfo() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), RewardActivity.class); + } + + @BeforeEach + void setUp() { + activityMapper = mock(RewardActivityMapper.class); + service = new RewardActivityAdminService(activityMapper); + } + + @Test + void should_create_activity_offline_when_status_is_missing() { + given(activityMapper.selectCount(any())).willReturn(0L); + AtomicReference saved = captureInsert(); + + service.create(activity("spring_event")); + + assertThat(saved.get().getCode()).isEqualTo("spring_event"); + assertThat(saved.get().getStatus()).isZero(); + } + + @Test + void should_reject_when_activity_code_already_exists() { + given(activityMapper.selectCount(any())).willReturn(1L); + + assertThatThrownBy(() -> service.create(activity("spring_event"))) + .isInstanceOf(ServiceException.class) + .satisfies(ex -> assertThat(((ServiceException) ex).getCode()) + .isEqualTo(ErrorCode.DATA_EXISTS.getCode())) + .hasMessageContaining("活动编码已存在"); + verify(activityMapper, never()).insert(any(RewardActivity.class)); + } + + @Test + void should_update_status_when_status_is_valid() { + RewardActivity existing = new RewardActivity(); + existing.setId(9L); + existing.setDeleted(0); + given(activityMapper.selectById(9L)).willReturn(existing); + + service.updateStatus(9L, 1); + + assertThat(existing.getStatus()).isOne(); + verify(activityMapper).updateById(existing); + } + + private AtomicReference captureInsert() { + AtomicReference saved = new AtomicReference<>(); + given(activityMapper.insert(any(RewardActivity.class))).willAnswer(invocation -> { + RewardActivity activity = invocation.getArgument(0); + activity.setId(9L); + saved.set(activity); + return 1; + }); + return saved; + } + + private RewardActivitySaveReqVO activity(String code) { + RewardActivitySaveReqVO req = new RewardActivitySaveReqVO(); + req.setCode(code); + req.setTitleId("Acara musim semi"); + req.setTitleEn("Spring event"); + req.setTitleZh("春季活动"); + req.setSummaryId("Dapatkan koin tambahan"); + req.setActionType("OPEN_HOME"); + return req; + } +}