From 64eadf1446099ef43a1addbce6758de99e011eb0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 9 Nov 2026 10:32:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(commerce):=20=E6=96=B0=E5=A2=9E=E6=89=93?= =?UTF-8?q?=E8=B5=8F=E4=BB=BB=E5=8A=A1=E4=B8=AD=E5=BF=83=E4=B8=8E=E7=AD=BE?= =?UTF-8?q?=E5=88=B0=E5=8F=91=E5=A5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-phase1.md | 60 ++++ .../controller/RewardAdminController.java | 126 ++++++++ .../AdminPermissionAnnotationTest.java | 4 + .../app/controller/AppCommerceController.java | 13 +- .../app/controller/AppMeController.java | 14 +- .../app/controller/AppRewardController.java | 78 +++++ .../app/controller/AppWatchController.java | 11 +- .../AppRewardAnonymousSecurityTest.java | 165 ++++++++++ .../framework/security/SecurityConfig.java | 3 + .../framework/security/SecurityUtils.java | 6 + .../reward/dal/entity/RewardActivity.java | 39 +++ .../dal/entity/RewardCheckinRecord.java | 28 ++ .../dal/entity/RewardCheckinRuleDay.java | 24 ++ .../reward/dal/entity/RewardEventLog.java | 25 ++ .../reward/dal/entity/RewardGrant.java | 33 ++ .../reward/dal/entity/RewardTask.java | 51 +++ .../reward/dal/entity/RewardTaskProgress.java | 31 ++ .../dal/mapper/RewardActivityMapper.java | 10 + .../dal/mapper/RewardCheckinRecordMapper.java | 10 + .../mapper/RewardCheckinRuleDayMapper.java | 10 + .../dal/mapper/RewardEventLogMapper.java | 10 + .../reward/dal/mapper/RewardGrantMapper.java | 10 + .../reward/dal/mapper/RewardTaskMapper.java | 10 + .../dal/mapper/RewardTaskProgressMapper.java | 10 + .../reward/service/RewardCheckinService.java | 170 ++++++++++ .../reward/service/RewardGrantService.java | 140 ++++++++ .../service/RewardTaskConfiguration.java | 77 +++++ .../reward/service/RewardTaskService.java | 306 ++++++++++++++++++ .../reward/service/RewardViewAssembler.java | 79 +++++ .../reward/service/WalletServiceFacade.java | 30 ++ .../commerce/reward/vo/RewardActivityVO.java | 19 ++ .../reward/vo/RewardCheckinRespVO.java | 18 ++ .../reward/vo/RewardCheckinRuleDayReqVO.java | 19 ++ .../reward/vo/RewardCheckinRuleSaveReqVO.java | 18 ++ .../commerce/reward/vo/RewardCheckinVO.java | 18 ++ .../commerce/reward/vo/RewardClaimRespVO.java | 16 + .../reward/vo/RewardGrantQueryReqVO.java | 18 ++ .../commerce/reward/vo/RewardGrantRespVO.java | 48 +++ .../reward/vo/RewardManualGrantReqVO.java | 24 ++ .../commerce/reward/vo/RewardOverviewVO.java | 19 ++ .../commerce/reward/vo/RewardRuleDayVO.java | 19 ++ .../commerce/reward/vo/RewardSectionVO.java | 20 ++ .../commerce/reward/vo/RewardTaskItemVO.java | 24 ++ .../reward/vo/RewardTaskQueryReqVO.java | 18 ++ .../commerce/reward/vo/RewardTaskRespVO.java | 90 ++++++ .../reward/vo/RewardTaskSaveReqVO.java | 56 ++++ .../commerce/service/WalletService.java | 1 + .../service/RewardCheckinServiceTest.java | 131 ++++++++ .../service/RewardGrantServiceTest.java | 93 ++++++ .../reward/service/RewardTaskServiceTest.java | 99 ++++++ sql/patch-2026-11-05-reward-center.sql | 237 ++++++++++++++ sql/schema-phase1.sql | 6 + 52 files changed, 2590 insertions(+), 4 deletions(-) create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/RewardAdminController.java create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppRewardController.java create mode 100644 fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppRewardAnonymousSecurityTest.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardActivity.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardCheckinRecord.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardCheckinRuleDay.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardEventLog.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardGrant.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardTask.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardTaskProgress.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardActivityMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardCheckinRecordMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardCheckinRuleDayMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardEventLogMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardGrantMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardTaskMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardTaskProgressMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardCheckinService.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardGrantService.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardTaskConfiguration.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardTaskService.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardViewAssembler.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/WalletServiceFacade.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardActivityVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinRespVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinRuleDayReqVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinRuleSaveReqVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardClaimRespVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardGrantQueryReqVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardGrantRespVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardManualGrantReqVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardOverviewVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardRuleDayVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardSectionVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskItemVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskQueryReqVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskRespVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskSaveReqVO.java create mode 100644 fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardCheckinServiceTest.java create mode 100644 fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardGrantServiceTest.java create mode 100644 fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardTaskServiceTest.java create mode 100644 sql/patch-2026-11-05-reward-center.sql diff --git a/docs/api-phase1.md b/docs/api-phase1.md index c262443..eee82b3 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -586,6 +586,53 @@ app.register.max-per-device-per-day 解锁后播放入口与下载入口会同时放行 —— 权益判定由交易模块的 `EntitlementPort` 实现提供, 播放域与下载域都不需要改代码。金币变动一律带幂等键并写 `coin_ledger`,钱包余额与流水同事务。 +### 1.8.1 奖励中心(已实现) + +奖励中心复用钱包和金币流水,首发时区固定为 `Asia/Jakarta`。任务配置、奖励金币、任务文案、动作、周期和上下线状态均由后台维护;客户端不能直接提交任务进度,观看进度由服务端在 `/app/watch/progress` 校验成功后累计。 + +| 方法 | 路径 | 鉴权 | 说明 | +| --- | --- | --- | --- | +| GET | `/app/rewards/overview` | 匿名 | 返回任务配置、任务分组、签到规则和活动卡片;未登录时余额为 `0`、用户进度为 `0`、签到状态为未签到;登录后补充用户数据;`locale` 可选 `id/en/zh` | +| GET | `/app/rewards/activities` | 匿名 | 返回当前生效活动卡片,活动一期只展示和跳转 | +| GET | `/app/rewards/check-in` | 登录 | 返回今日是否签到、连续天数、7 天规则 | +| POST | `/app/rewards/check-in` | 登录 | 按服务端日期和连续签到规则发放金币;同用户同日期幂等 | +| POST | `/app/rewards/tasks/{taskId}/claim` | 登录 | 领取已完成任务;同任务同周期幂等 | + +总览响应核心字段: + +```json +{ + "serverDate": "2026-11-05", + "timezone": "Asia/Jakarta", + "coinBalance": "135", + "checkin": { + "taskId": "202611050001", + "checkedInToday": false, + "streak": 2, + "currentDay": 3, + "days": [{"dayIndex": 1, "rewardCoin": "35", "checked": true, "current": false}] + }, + "sections": [{ + "code": "DAILY", + "title": "Tugas harian", + "tasks": [{ + "id": "202611050002", + "code": "watch_5_minutes", + "type": "WATCH_DURATION", + "progressValue": "120", + "targetValue": "300", + "rewardCoin": "20", + "status": "IN_PROGRESS" + }] + }], + "activities": [] +} +``` + +一期任务类型为 `DAILY_CHECK_IN`、`WATCH_DURATION`、`WATCH_EPISODES`、`COMPLETE_PROFILE`、`FIRST_PURCHASE`;`FIRST_LOGIN` 预留给登录事件接入。任务状态为 `IN_PROGRESS`、`COMPLETED`、`CLAIMED`。奖励发放使用 `coin_ledger.biz_type=REWARD`,任务领取幂等键为 `reward:{taskCode}:{userId}:{cycleKey}`,签到幂等键为 `reward:checkin:{userId}:{date}`。重复请求返回既有发放结果,不重复增加钱包余额。 + +失败场景:任务未完成或已停用返回 `200003`;任务、签到规则不存在返回 `200002`;奖励金币为 0 或请求参数非法返回 `400`;钱包余额和发放单不一致时整笔事务回滚。 + **`POST /app/orders/verify`(B1-1:Google Play 票据校验闭环)** 请求体: @@ -1233,6 +1280,19 @@ commentSummary,createdAt`。游客的 userId 为空、anonymous 为 true;评 > `id` 以字符串下发:见 **§0「ID 字段一律以字符串下发」**(本节不再单独规定)。 +### 2.11.1 奖励中心管理(已实现) + +| 方法 | 路径 | 权限点 | 说明 | +| --- | --- | --- | --- | +| 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 · 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`。 + **校验与错误码**: | 场景 | 结果 | 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 new file mode 100644 index 0000000..5de745f --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/RewardAdminController.java @@ -0,0 +1,126 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import com.fruitshorts.commerce.reward.service.RewardCheckinService; +import com.fruitshorts.commerce.reward.service.RewardGrantService; +import com.fruitshorts.commerce.reward.service.RewardTaskService; +import com.fruitshorts.commerce.reward.vo.RewardCheckinRuleSaveReqVO; +import com.fruitshorts.commerce.reward.vo.RewardGrantQueryReqVO; +import com.fruitshorts.commerce.reward.vo.RewardGrantRespVO; +import com.fruitshorts.commerce.reward.vo.RewardManualGrantReqVO; +import com.fruitshorts.commerce.reward.vo.RewardRuleDayVO; +import com.fruitshorts.commerce.reward.vo.RewardTaskQueryReqVO; +import com.fruitshorts.commerce.reward.vo.RewardTaskRespVO; +import com.fruitshorts.commerce.reward.vo.RewardTaskSaveReqVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.user.dal.mapper.AppUserMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +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.PutMapping; +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; + +import java.util.List; + +/** 后台奖励任务、签到规则和发放记录管理。 */ +@Tag(name = "后台-奖励中心") +@RestController +@RequestMapping("/admin/rewards") +@RequiredArgsConstructor +public class RewardAdminController { + + private final RewardTaskService taskService; + private final RewardCheckinService checkinService; + private final RewardGrantService grantService; + private final AppUserMapper appUserMapper; + + @Operation(summary = "奖励任务分页") + @GetMapping("/tasks/page") + @PreAuthorize("@ss.hasPermission('reward:list')") + public Result> page(@Validated RewardTaskQueryReqVO req) { + return Result.ok(taskService.page(req)); + } + + @Operation(summary = "奖励任务详情") + @GetMapping("/tasks/{id}") + @PreAuthorize("@ss.hasPermission('reward:list')") + public Result detail(@PathVariable Long id) { + return Result.ok(taskService.detail(id)); + } + + @Operation(summary = "新建奖励任务") + @PostMapping("/tasks") + @PreAuthorize("@ss.hasPermission('reward:update')") + @OperationLog(module = "奖励中心", action = "新建奖励任务") + public Result create(@Valid @RequestBody RewardTaskSaveReqVO req) { + return Result.ok(taskService.create(req)); + } + + @Operation(summary = "编辑奖励任务") + @PutMapping("/tasks/{id}") + @PreAuthorize("@ss.hasPermission('reward:update')") + @OperationLog(module = "奖励中心", action = "编辑奖励任务") + public Result update(@PathVariable Long id, @Valid @RequestBody RewardTaskSaveReqVO req) { + taskService.update(id, req); + return Result.ok(); + } + + @Operation(summary = "奖励任务上下线") + @PutMapping("/tasks/{id}/status") + @PreAuthorize("@ss.hasPermission('reward:update')") + @OperationLog(module = "奖励中心", action = "奖励任务上下线") + public Result updateStatus(@PathVariable Long id, @RequestParam Integer status) { + taskService.updateStatus(id, status); + return Result.ok(); + } + + @Operation(summary = "签到规则") + @GetMapping("/check-in/rules") + @PreAuthorize("@ss.hasPermission('reward:checkin')") + public Result> rules() { + return Result.ok(checkinService.rulesForAdmin()); + } + + @Operation(summary = "保存签到规则") + @PutMapping("/check-in/rules") + @PreAuthorize("@ss.hasPermission('reward:checkin')") + @OperationLog(module = "奖励中心", action = "保存签到规则") + public Result saveRules(@Valid @RequestBody RewardCheckinRuleSaveReqVO req) { + checkinService.saveRules(req); + return Result.ok(); + } + + @Operation(summary = "奖励发放记录分页") + @GetMapping("/grants/page") + @PreAuthorize("@ss.hasPermission('reward:grant')") + public Result> grants(@Validated RewardGrantQueryReqVO req) { + return Result.ok(grantService.page(req)); + } + + @Operation(summary = "人工补发奖励") + @PostMapping("/grants/manual") + @PreAuthorize("@ss.hasPermission('reward:grant')") + @OperationLog(module = "奖励中心", action = "人工补发奖励") + public Result manualGrant(@Valid @RequestBody RewardManualGrantReqVO req) { + if (appUserMapper.selectById(req.getUserId()) == null) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在,无法补发奖励"); + } + return Result.ok(grantService.manualGrant(req.getUserId(), req.getTaskId(), req.getAmount(), + req.getIdempotentKey(), SecurityUtils.getUserId())); + } +} 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 ab3ba57..7f7f6a3 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 @@ -32,6 +32,10 @@ class AdminPermissionAnnotationTest { assertThat(permissionsOf(NoticeAdminController.class)) .containsExactlyInAnyOrder("notice:list", "notice:list", "notice:update", "notice:update", "notice:update", "notice:update"); + assertThat(permissionsOf(RewardAdminController.class)) + .containsExactlyInAnyOrder("reward:list", "reward:list", "reward:update", + "reward:update", "reward:update", "reward:checkin", "reward:checkin", + "reward:grant", "reward:grant"); } /** 收集控制器里所有映射方法的 @PreAuthorize 权限点(缺失则记为 MISSING,便于断言直接看到)。 */ diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java index 279b242..bd36181 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java @@ -7,12 +7,14 @@ import com.fruitshorts.commerce.service.OrderService; import com.fruitshorts.commerce.service.ProductService; import com.fruitshorts.commerce.service.UnlockService; import com.fruitshorts.commerce.service.WalletService; +import com.fruitshorts.commerce.reward.service.RewardTaskService; import com.fruitshorts.commerce.vo.ProductRespVO; import com.fruitshorts.common.pojo.Result; import com.fruitshorts.framework.security.SecurityUtils; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -41,6 +43,8 @@ public class AppCommerceController { private final UnlockService unlockService; private final OrderService orderService; private final ProductService productService; + @Autowired(required = false) + private RewardTaskService rewardTaskService; /** 我的钱包:余额 + 最近流水,客户端一处渲染。 */ @Operation(summary = "我的钱包(余额 + 最近流水)") @@ -92,9 +96,14 @@ public class AppCommerceController { @Operation(summary = "提交商店票据(校验后建单并发权益,重复提交幂等)") @PostMapping("/orders/verify") public Result verify(@RequestBody Map body) { - return Result.ok(orderService.verifyAndDeliver(SecurityUtils.getAppUserId(), + Orders order = orderService.verifyAndDeliver(SecurityUtils.getAppUserId(), body.getOrDefault("platform", ""), body.getOrDefault("productId", ""), - body.getOrDefault("receipt", ""))); + body.getOrDefault("receipt", "")); + if (rewardTaskService != null && OrderService.STATUS_DELIVERED.equals(order.getStatus())) { + rewardTaskService.recordEvent(order.getUserId(), RewardTaskService.TYPE_FIRST_PURCHASE, + order.getOrderNo(), 1L); + } + return Result.ok(order); } } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppMeController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppMeController.java index 9b15318..1f39065 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppMeController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppMeController.java @@ -9,16 +9,19 @@ import com.fruitshorts.user.service.AppUserService; import com.fruitshorts.user.vo.AppUserRespVO; import com.fruitshorts.user.vo.DeactivateReqVO; import com.fruitshorts.user.vo.ProfileUpdateReqVO; +import com.fruitshorts.commerce.reward.service.RewardTaskService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.util.List; @@ -32,6 +35,8 @@ import java.util.Map; public class AppMeController { private final AppUserService userService; + @Autowired(required = false) + private RewardTaskService rewardTaskService; @Operation(summary = "我的资料") @GetMapping @@ -42,7 +47,14 @@ public class AppMeController { @Operation(summary = "修改资料(昵称 / 头像 / 语言等)") @PutMapping public Result update(@Valid @RequestBody ProfileUpdateReqVO req) { - return Result.ok(userService.updateProfile(SecurityUtils.getAppUserId(), req)); + Long userId = SecurityUtils.getAppUserId(); + AppUserRespVO profile = userService.updateProfile(userId, req); + if (rewardTaskService != null && StringUtils.hasText(profile.getNickname()) + && StringUtils.hasText(profile.getAvatarUrl())) { + rewardTaskService.recordEvent(userId, RewardTaskService.TYPE_COMPLETE_PROFILE, + userId.toString(), 1L); + } + return Result.ok(profile); } @Operation(summary = "我的登录设备列表") diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppRewardController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppRewardController.java new file mode 100644 index 0000000..7760871 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppRewardController.java @@ -0,0 +1,78 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.controller; + +import com.fruitshorts.commerce.reward.service.RewardCheckinService; +import com.fruitshorts.commerce.reward.service.RewardGrantService; +import com.fruitshorts.commerce.reward.service.RewardTaskService; +import com.fruitshorts.commerce.reward.vo.RewardActivityVO; +import com.fruitshorts.commerce.reward.vo.RewardCheckinRespVO; +import com.fruitshorts.commerce.reward.vo.RewardCheckinVO; +import com.fruitshorts.commerce.reward.vo.RewardClaimRespVO; +import com.fruitshorts.commerce.reward.vo.RewardOverviewVO; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.commerce.service.WalletService; +import com.fruitshorts.framework.security.SecurityUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** 客户端奖励中心:总览、签到、任务领取与活动。 */ +@Tag(name = "客户端-奖励中心") +@RestController +@RequestMapping("/app/rewards") +@RequiredArgsConstructor +public class AppRewardController { + + private final RewardTaskService taskService; + private final RewardCheckinService checkinService; + private final RewardGrantService grantService; + private final WalletService walletService; + + @Operation(summary = "奖励中心总览") + @GetMapping("/overview") + public Result overview(@RequestParam(required = false) String locale) { + Long userId = SecurityUtils.getAppUserIdOrNull(); + long coinBalance = userId == null ? 0L : walletService.getOrCreate(userId).getCoinBalance(); + RewardOverviewVO overview = taskService.overview( + userId, locale, coinBalance); + overview.setCheckin(checkinService.status(userId)); + return Result.ok(overview); + } + + @Operation(summary = "奖励活动列表") + @GetMapping("/activities") + public Result> activities(@RequestParam(required = false) String locale) { + return Result.ok(taskService.activities(locale)); + } + + @Operation(summary = "签到状态") + @GetMapping("/check-in") + public Result checkInStatus() { + return Result.ok(checkinService.status(SecurityUtils.getAppUserId())); + } + + @Operation(summary = "执行签到") + @PostMapping("/check-in") + public Result checkIn() { + return Result.ok(checkinService.checkIn(SecurityUtils.getAppUserId())); + } + + @Operation(summary = "领取已完成任务奖励") + @PostMapping("/tasks/{taskId}/claim") + public Result claim( + @Parameter(description = "奖励任务 ID") @PathVariable Long taskId) { + Long userId = SecurityUtils.getAppUserId(); + var task = taskService.getExisting(taskId); + return Result.ok(grantService.claim(userId, task, taskService.currentProgress(userId, task))); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppWatchController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppWatchController.java index c999a0c..1b30f02 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppWatchController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppWatchController.java @@ -9,6 +9,7 @@ import com.fruitshorts.app.service.AnonymousInteractionRateLimiter; import com.fruitshorts.framework.interaction.InteractionIdentityResolver; import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.playback.service.WatchProgressService; +import com.fruitshorts.commerce.reward.service.RewardTaskService; import com.fruitshorts.playback.vo.ProgressRespVO; import com.fruitshorts.playback.vo.ProgressSaveReqVO; import io.swagger.v3.oas.annotations.Operation; @@ -16,6 +17,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -33,6 +35,8 @@ import org.springframework.web.bind.annotation.RestController; public class AppWatchController { private final WatchProgressService progressService; + @Autowired(required = false) + private RewardTaskService rewardTaskService; private final InteractionIdentityResolver identityResolver; private final AnonymousInteractionRateLimiter anonymousRateLimiter; @@ -46,7 +50,12 @@ public class AppWatchController { request.getHeader("X-Device-Id"), req.getDeviceId()); anonymousRateLimiter.check(identity, "watch"); - return Result.ok(progressService.save(identity, req)); + ProgressRespVO saved = progressService.save(identity, req); + if (rewardTaskService != null && identity.authenticated()) { + rewardTaskService.recordWatch(identity.userId(), saved.getEpisodeId(), + saved.getPositionMs(), Boolean.TRUE.equals(saved.getFinished())); + } + return Result.ok(saved); } /** 某部剧的续看位置,进详情页时用它决定"续看第 N 集"。 */ diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppRewardAnonymousSecurityTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppRewardAnonymousSecurityTest.java new file mode 100644 index 0000000..09ce9c4 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppRewardAnonymousSecurityTest.java @@ -0,0 +1,165 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fruitshorts.commerce.reward.service.RewardCheckinService; +import com.fruitshorts.commerce.reward.service.RewardGrantService; +import com.fruitshorts.commerce.reward.service.RewardTaskService; +import com.fruitshorts.commerce.reward.vo.RewardCheckinVO; +import com.fruitshorts.commerce.reward.vo.RewardOverviewVO; +import com.fruitshorts.commerce.reward.vo.RewardActivityVO; +import com.fruitshorts.commerce.service.WalletService; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.framework.log.AccessDeniedRecorder; +import com.fruitshorts.framework.security.JwtAuthenticationFilter; +import com.fruitshorts.framework.security.JwtTokenProvider; +import com.fruitshorts.framework.security.RestAuthEntryPoint; +import com.fruitshorts.framework.security.SecurityConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.FilterChainProxy; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 奖励中心匿名浏览与写操作鉴权的安全链契约。 */ +@ExtendWith(SpringExtension.class) +@WebAppConfiguration +@ContextConfiguration(classes = AppRewardAnonymousSecurityTest.TestConfig.class) +class AppRewardAnonymousSecurityTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private RewardTaskService taskService; + @Autowired + private RewardCheckinService checkinService; + @Autowired + private WalletService walletService; + + @Test + void should_allow_anonymous_reward_overview_without_creating_wallet() throws Exception { + RewardOverviewVO overview = new RewardOverviewVO(); + overview.setCoinBalance(0L); + overview.setSections(List.of()); + overview.setActivities(List.of()); + given(taskService.overview(isNull(), org.mockito.ArgumentMatchers.eq("en"), + org.mockito.ArgumentMatchers.eq(0L))).willReturn(overview); + given(checkinService.status(isNull())).willReturn(new RewardCheckinVO()); + + mockMvc.perform(get("/app/rewards/overview").param("locale", "en")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.coinBalance").value(0)); + + verify(walletService, never()).getOrCreate(org.mockito.ArgumentMatchers.any()); + } + + @Test + void should_allow_anonymous_reward_activities() throws Exception { + given(taskService.activities("id")).willReturn(List.of(new RewardActivityVO())); + + mockMvc.perform(get("/app/rewards/activities").param("locale", "id")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data", org.hamcrest.Matchers.hasSize(1))); + } + + @Test + void should_keep_personal_reward_operations_login_required() throws Exception { + mockMvc.perform(get("/app/rewards/check-in")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode())); + mockMvc.perform(post("/app/rewards/check-in")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode())); + mockMvc.perform(post("/app/rewards/tasks/9/claim")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode())); + } + + @Configuration + @EnableWebMvc + @EnableWebSecurity + @Import(SecurityConfig.class) + static class TestConfig { + + @Bean + RewardTaskService rewardTaskService() { + return mock(RewardTaskService.class); + } + + @Bean + RewardCheckinService rewardCheckinService() { + return mock(RewardCheckinService.class); + } + + @Bean + RewardGrantService rewardGrantService() { + return mock(RewardGrantService.class); + } + + @Bean + WalletService walletService() { + return mock(WalletService.class); + } + + @Bean + AppRewardController appRewardController( + RewardTaskService taskService, + RewardCheckinService checkinService, + RewardGrantService grantService, + WalletService walletService) { + return new AppRewardController(taskService, checkinService, grantService, walletService); + } + + @Bean + JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter(mock(JwtTokenProvider.class), mock(ObjectProvider.class)); + } + + @Bean + RestAuthEntryPoint restAuthEntryPoint(ObjectMapper objectMapper) { + return new RestAuthEntryPoint(objectMapper, + new AccessDeniedRecorder(mock(ObjectProvider.class), mock(ObjectProvider.class))); + } + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + @Bean + MockMvc mockMvc(WebApplicationContext context) { + FilterChainProxy securityFilter = context.getBean( + "springSecurityFilterChain", FilterChainProxy.class); + return MockMvcBuilders.webAppContextSetup(context) + .addFilters(securityFilter) + .build(); + } + } +} 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 c637ada..a5c3307 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 @@ -50,6 +50,9 @@ public class SecurityConfig { /* 启动配置与公告:客户端登录前就要拿(POST /app/creator/apply 等仍要登录) */ "/app/config", "/app/notices", + /* 奖励中心只读配置:登录前可浏览任务与活动,签到/领取仍需登录。 */ + "/app/rewards/overview", + "/app/rewards/activities", /* 服务条款与隐私协议:登录前就要能打开,按语言返回 HTML */ "/privacy/**", "/terms/**", diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityUtils.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityUtils.java index 7f97c0e..3106882 100644 --- a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityUtils.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityUtils.java @@ -58,4 +58,10 @@ public final class SecurityUtils { } return loginUser.userId(); } + + /** 客户端匿名只读接口取当前用户,未登录或非客户端用户返回 null。 */ + public static Long getAppUserIdOrNull() { + LoginUser loginUser = getLoginUserOrNull(); + return isAppUser(loginUser) ? loginUser.userId() : null; + } } diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardActivity.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardActivity.java new file mode 100644 index 0000000..4588e4d --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardActivity.java @@ -0,0 +1,39 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.dal.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 奖励中心活动展示卡片。 */ +@Data +@TableName("reward_activity") +public class RewardActivity { + + @TableId(type = IdType.ASSIGN_ID) + 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 LocalDateTime startAt; + private LocalDateTime endAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Integer deleted; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardCheckinRecord.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardCheckinRecord.java new file mode 100644 index 0000000..8f1cbd9 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardCheckinRecord.java @@ -0,0 +1,28 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.dal.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** 用户签到事实,日期唯一用于幂等防刷。 */ +@Data +@TableName("reward_checkin_record") +public class RewardCheckinRecord { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long userId; + private Long taskId; + private LocalDate checkinDate; + private Integer dayIndex; + private Integer streak; + private Long rewardCoin; + private Long grantId; + private LocalDateTime createdAt; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardCheckinRuleDay.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardCheckinRuleDay.java new file mode 100644 index 0000000..f9ce8e1 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardCheckinRuleDay.java @@ -0,0 +1,24 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.dal.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 签到周期中某一天的金币奖励。 */ +@Data +@TableName("reward_checkin_rule_day") +public class RewardCheckinRuleDay { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long taskId; + private Integer dayIndex; + private Long rewardCoin; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardEventLog.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardEventLog.java new file mode 100644 index 0000000..2606487 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardEventLog.java @@ -0,0 +1,25 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.dal.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 服务端已验证奖励事件,用幂等键防止重复上报累计。 */ +@Data +@TableName("reward_event_log") +public class RewardEventLog { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long userId; + private String eventType; + private String sourceId; + private Long eventValue; + private String idempotentKey; + private LocalDateTime occurredAt; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardGrant.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardGrant.java new file mode 100644 index 0000000..73077b1 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardGrant.java @@ -0,0 +1,33 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.dal.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 奖励发放单,关联钱包流水并保留操作审计信息。 */ +@Data +@TableName("reward_grant") +public class RewardGrant { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long userId; + private Long taskId; + private String cycleKey; + private Integer claimSeq; + private Long amount; + private String status; + private String idempotentKey; + private Long coinLedgerId; + private String source; + private Long operatorId; + private String failureReason; + private LocalDateTime grantedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardTask.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardTask.java new file mode 100644 index 0000000..4c95e0c --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardTask.java @@ -0,0 +1,51 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.dal.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 奖励任务配置,后台维护后由客户端奖励页读取。 */ +@Data +@TableName("reward_task") +public class RewardTask { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String code; + private String sectionCode; + private String type; + private String verifyMode; + private Long targetValue; + private Long rewardCoin; + private String cycleType; + private Integer cycleLimit; + private String actionType; + private String actionUrl; + private String iconUrl; + private String titleId; + private String titleEn; + private String titleZh; + private String subtitleId; + private String subtitleEn; + private String subtitleZh; + private String ctaId; + private String ctaEn; + private String ctaZh; + private String sectionTitleId; + private String sectionTitleEn; + private String sectionTitleZh; + private String configJson; + private Integer sort; + private Integer status; + private LocalDateTime startAt; + private LocalDateTime endAt; + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Integer deleted; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardTaskProgress.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardTaskProgress.java new file mode 100644 index 0000000..db75074 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/entity/RewardTaskProgress.java @@ -0,0 +1,31 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.dal.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 用户任务在一个周期内的服务端进度。 */ +@Data +@TableName("reward_task_progress") +public class RewardTaskProgress { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long userId; + private Long taskId; + private String cycleKey; + private Long progressValue; + private Long targetValue; + private String status; + private Integer claimSeq; + private LocalDateTime completedAt; + private LocalDateTime claimedAt; + private LocalDateTime lastEventAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardActivityMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardActivityMapper.java new file mode 100644 index 0000000..b0ff254 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardActivityMapper.java @@ -0,0 +1,10 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.commerce.reward.dal.entity.RewardActivity; + +/** 奖励活动访问。 */ +public interface RewardActivityMapper extends BaseMapper { +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardCheckinRecordMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardCheckinRecordMapper.java new file mode 100644 index 0000000..45bfd6c --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardCheckinRecordMapper.java @@ -0,0 +1,10 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.commerce.reward.dal.entity.RewardCheckinRecord; + +/** 用户签到事实访问。 */ +public interface RewardCheckinRecordMapper extends BaseMapper { +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardCheckinRuleDayMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardCheckinRuleDayMapper.java new file mode 100644 index 0000000..6a19e89 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardCheckinRuleDayMapper.java @@ -0,0 +1,10 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.commerce.reward.dal.entity.RewardCheckinRuleDay; + +/** 签到规则访问。 */ +public interface RewardCheckinRuleDayMapper extends BaseMapper { +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardEventLogMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardEventLogMapper.java new file mode 100644 index 0000000..41f4170 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardEventLogMapper.java @@ -0,0 +1,10 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.commerce.reward.dal.entity.RewardEventLog; + +/** 服务端奖励事件访问。 */ +public interface RewardEventLogMapper extends BaseMapper { +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardGrantMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardGrantMapper.java new file mode 100644 index 0000000..0b33a2c --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardGrantMapper.java @@ -0,0 +1,10 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.commerce.reward.dal.entity.RewardGrant; + +/** 奖励发放单访问。 */ +public interface RewardGrantMapper extends BaseMapper { +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardTaskMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardTaskMapper.java new file mode 100644 index 0000000..db5f3f2 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardTaskMapper.java @@ -0,0 +1,10 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.commerce.reward.dal.entity.RewardTask; + +/** 奖励任务表访问。 */ +public interface RewardTaskMapper extends BaseMapper { +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardTaskProgressMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardTaskProgressMapper.java new file mode 100644 index 0000000..91c87fe --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/dal/mapper/RewardTaskProgressMapper.java @@ -0,0 +1,10 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.commerce.reward.dal.entity.RewardTaskProgress; + +/** 用户奖励进度表访问。 */ +public interface RewardTaskProgressMapper extends BaseMapper { +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardCheckinService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardCheckinService.java new file mode 100644 index 0000000..879977a --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardCheckinService.java @@ -0,0 +1,170 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.commerce.reward.dal.entity.RewardCheckinRecord; +import com.fruitshorts.commerce.reward.dal.entity.RewardCheckinRuleDay; +import com.fruitshorts.commerce.reward.dal.entity.RewardTask; +import com.fruitshorts.commerce.reward.dal.mapper.RewardCheckinRecordMapper; +import com.fruitshorts.commerce.reward.dal.mapper.RewardCheckinRuleDayMapper; +import com.fruitshorts.commerce.reward.dal.mapper.RewardTaskMapper; +import com.fruitshorts.commerce.reward.vo.RewardCheckinRespVO; +import com.fruitshorts.commerce.reward.vo.RewardCheckinRuleDayReqVO; +import com.fruitshorts.commerce.reward.vo.RewardCheckinRuleSaveReqVO; +import com.fruitshorts.commerce.reward.vo.RewardCheckinVO; +import com.fruitshorts.commerce.reward.vo.RewardRuleDayVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +/** 每日签到规则与签到发放。 */ +@Service +@RequiredArgsConstructor +public class RewardCheckinService { + + private static final String CHECKIN_CODE = "daily_check_in"; + private static final String CHECKIN_TYPE = RewardTaskService.TYPE_DAILY_CHECK_IN; + + private final RewardTaskMapper taskMapper; + private final RewardCheckinRuleDayMapper ruleMapper; + private final RewardCheckinRecordMapper recordMapper; + private final RewardGrantService grantService; + + /** 客户端签到状态。 */ + public RewardCheckinVO status(Long userId) { + RewardTask task = task(); + List rules = rules(task.getId()); + if (userId == null) { + return anonymousStatus(task, rules); + } + RewardCheckinRecord latest = latest(userId, task.getId()); + LocalDate today = LocalDate.now(RewardTaskService.REWARD_ZONE); + boolean checked = recordMapper.selectCount(Wrappers.lambdaQuery() + .eq(RewardCheckinRecord::getUserId, userId).eq(RewardCheckinRecord::getTaskId, task.getId()) + .eq(RewardCheckinRecord::getCheckinDate, today)) > 0; + int currentDay = latest == null ? 1 : Math.min(latest.getDayIndex() + 1, rules.size()); + RewardCheckinVO vo = new RewardCheckinVO(); + vo.setTaskId(task.getId()); + vo.setCheckedInToday(checked); + vo.setStreak(latest == null ? 0 : latest.getStreak()); + vo.setCurrentDay(currentDay); + vo.setDays(rules.stream().map(rule -> new RewardRuleDayVO(rule.getDayIndex(), rule.getRewardCoin(), + checked && latest != null && rule.getDayIndex() <= latest.getDayIndex(), + rule.getDayIndex() == currentDay)).toList()); + return vo; + } + + private RewardCheckinVO anonymousStatus(RewardTask task, List rules) { + RewardCheckinVO vo = new RewardCheckinVO(); + vo.setTaskId(task.getId()); + vo.setCheckedInToday(false); + vo.setStreak(0); + vo.setCurrentDay(1); + vo.setDays(rules.stream().map(rule -> new RewardRuleDayVO( + rule.getDayIndex(), rule.getRewardCoin(), false, rule.getDayIndex() == 1)).toList()); + return vo; + } + + /** 后台查看当前签到规则。 */ + public List rulesForAdmin() { + RewardTask task = task(); + return rules(task.getId()).stream() + .map(rule -> new RewardRuleDayVO(rule.getDayIndex(), rule.getRewardCoin(), false, false)) + .toList(); + } + + /** 客户端执行签到,按印尼时区计算连续天数。 */ + @Transactional(rollbackFor = Exception.class) + public RewardCheckinRespVO checkIn(Long userId) { + RewardTask task = task(); + LocalDate today = LocalDate.now(RewardTaskService.REWARD_ZONE); + RewardCheckinRecord existed = recordMapper.selectOne(Wrappers.lambdaQuery() + .eq(RewardCheckinRecord::getUserId, userId).eq(RewardCheckinRecord::getTaskId, task.getId()) + .eq(RewardCheckinRecord::getCheckinDate, today).last("limit 1")); + if (existed != null) return toResp(existed, true, userId); + List rules = rules(task.getId()); + RewardCheckinRecord latest = latest(userId, task.getId()); + int streak = latest != null && latest.getCheckinDate().plusDays(1).equals(today) + ? latest.getStreak() + 1 : 1; + int dayIndex = ((streak - 1) % rules.size()) + 1; + RewardCheckinRuleDay rule = rules.stream().filter(item -> item.getDayIndex() == dayIndex).findFirst() + .orElseThrow(() -> new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "签到规则未配置")); + String key = "reward:checkin:" + userId + ":" + today; + var grant = grantService.grant(userId, task.getId(), today.toString(), rule.getRewardCoin(), + RewardGrantService.SOURCE_APP, 0L, key); + RewardCheckinRecord record = new RewardCheckinRecord(); + record.setUserId(userId); + record.setTaskId(task.getId()); + record.setCheckinDate(today); + record.setDayIndex(dayIndex); + record.setStreak(streak); + record.setRewardCoin(rule.getRewardCoin()); + record.setGrantId(grant.getId()); + try { + recordMapper.insert(record); + } catch (DuplicateKeyException ex) { + RewardCheckinRecord raced = recordMapper.selectOne(Wrappers.lambdaQuery() + .eq(RewardCheckinRecord::getUserId, userId).eq(RewardCheckinRecord::getTaskId, task.getId()) + .eq(RewardCheckinRecord::getCheckinDate, today).last("limit 1")); + if (raced != null) return toResp(raced, true, userId); + throw ex; + } + return toResp(record, false, userId); + } + + /** 后台覆盖签到规则,按天数唯一校验。 */ + @Transactional(rollbackFor = Exception.class) + public void saveRules(RewardCheckinRuleSaveReqVO req) { + RewardTask task = task(); + long distinct = req.getDays().stream().map(RewardCheckinRuleDayReqVO::getDayIndex).distinct().count(); + if (distinct != req.getDays().size()) throw new ServiceException(ErrorCode.BAD_REQUEST, "签到天数不能重复"); + ruleMapper.delete(Wrappers.lambdaQuery().eq(RewardCheckinRuleDay::getTaskId, task.getId())); + for (RewardCheckinRuleDayReqVO item : req.getDays()) { + RewardCheckinRuleDay row = new RewardCheckinRuleDay(); + row.setTaskId(task.getId()); + row.setDayIndex(item.getDayIndex()); + row.setRewardCoin(item.getRewardCoin()); + ruleMapper.insert(row); + } + } + + private RewardTask task() { + RewardTask task = taskMapper.selectOne(Wrappers.lambdaQuery() + .eq(RewardTask::getCode, CHECKIN_CODE).eq(RewardTask::getType, CHECKIN_TYPE) + .eq(RewardTask::getStatus, 1).eq(RewardTask::getDeleted, 0).last("limit 1")); + if (task == null) throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "签到任务未配置"); + return task; + } + + private List rules(Long taskId) { + List rows = ruleMapper.selectList(Wrappers.lambdaQuery() + .eq(RewardCheckinRuleDay::getTaskId, taskId).orderByAsc(RewardCheckinRuleDay::getDayIndex)); + if (rows.isEmpty()) throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "签到规则未配置"); + return rows; + } + + private RewardCheckinRecord latest(Long userId, Long taskId) { + return recordMapper.selectOne(Wrappers.lambdaQuery() + .eq(RewardCheckinRecord::getUserId, userId).eq(RewardCheckinRecord::getTaskId, taskId) + .orderByDesc(RewardCheckinRecord::getCheckinDate).last("limit 1")); + } + + private RewardCheckinRespVO toResp(RewardCheckinRecord record, boolean existed, Long userId) { + RewardCheckinRespVO vo = new RewardCheckinRespVO(); + vo.setTaskId(record.getTaskId()); + vo.setGrantId(record.getGrantId()); + vo.setDayIndex(record.getDayIndex()); + vo.setStreak(record.getStreak()); + vo.setRewardCoin(record.getRewardCoin()); + vo.setCoinBalance(grantService.balance(userId)); + vo.setAlreadyCheckedIn(existed); + return vo; + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardGrantService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardGrantService.java new file mode 100644 index 0000000..6660132 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardGrantService.java @@ -0,0 +1,140 @@ +// 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.dal.entity.CoinLedger; +import com.fruitshorts.commerce.reward.dal.entity.RewardGrant; +import com.fruitshorts.commerce.reward.dal.entity.RewardTask; +import com.fruitshorts.commerce.reward.dal.entity.RewardTaskProgress; +import com.fruitshorts.commerce.reward.dal.mapper.RewardGrantMapper; +import com.fruitshorts.commerce.reward.dal.mapper.RewardTaskProgressMapper; +import com.fruitshorts.commerce.reward.vo.RewardClaimRespVO; +import com.fruitshorts.commerce.reward.vo.RewardGrantQueryReqVO; +import com.fruitshorts.commerce.reward.vo.RewardGrantRespVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; + +/** 奖励发放、任务领取和人工补发,统一复用钱包流水。 */ +@Service +@RequiredArgsConstructor +public class RewardGrantService { + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_GRANTED = "GRANTED"; + public static final String SOURCE_APP = "APP"; + public static final String SOURCE_ADMIN = "ADMIN"; + + private final RewardGrantMapper grantMapper; + private final RewardTaskProgressMapper progressMapper; + private final WalletServiceFacade wallet; + + /** 领取已完成任务。 */ + @Transactional(rollbackFor = Exception.class) + public RewardClaimRespVO claim(Long userId, RewardTask task, RewardTaskProgress progress) { + if (progress == null || (!RewardTaskService.STATUS_COMPLETED.equals(progress.getStatus()) + && !RewardTaskService.STATUS_CLAIMED.equals(progress.getStatus()))) { + throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "任务尚未完成"); + } + String key = "reward:" + task.getCode() + ":" + userId + ":" + progress.getCycleKey(); + boolean existed = grantMapper.selectCount(Wrappers.lambdaQuery() + .eq(RewardGrant::getIdempotentKey, key)) > 0; + RewardGrant grant = grant(userId, task.getId(), progress.getCycleKey(), task.getRewardCoin(), + SOURCE_APP, 0L, key); + RewardClaimRespVO result = new RewardClaimRespVO(); + result.setTaskId(task.getId()); + result.setGrantId(grant.getId()); + result.setRewardCoin(grant.getAmount()); + result.setCoinBalance(wallet.balance(userId)); + result.setAlreadyGranted(existed); + progress.setStatus(RewardTaskService.STATUS_CLAIMED); + progress.setClaimedAt(LocalDateTime.now()); + progressMapper.updateById(progress); + return result; + } + + /** 统一发放事务;先登记发放单,再写钱包流水,最后回填流水 id。 */ + @Transactional(rollbackFor = Exception.class) + public RewardGrant grant(Long userId, Long taskId, String cycleKey, Long amount, + String source, Long operatorId, String idempotentKey) { + if (amount == null || amount <= 0) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "奖励金币必须大于 0"); + } + RewardGrant existed = grantMapper.selectOne(Wrappers.lambdaQuery() + .eq(RewardGrant::getIdempotentKey, idempotentKey).last("limit 1")); + if (existed != null && STATUS_GRANTED.equals(existed.getStatus())) return existed; + RewardGrant row = existed == null ? newGrant(userId, taskId, cycleKey, amount, source, operatorId, idempotentKey) : existed; + if (existed == null) { + try { + grantMapper.insert(row); + } catch (DuplicateKeyException ex) { + RewardGrant raced = grantMapper.selectOne(Wrappers.lambdaQuery() + .eq(RewardGrant::getIdempotentKey, idempotentKey).last("limit 1")); + if (raced != null) return raced; + throw ex; + } + } + CoinLedger ledger = wallet.change(userId, amount, idempotentKey, source, operatorId); + row.setStatus(STATUS_GRANTED); + row.setCoinLedgerId(ledger.getId()); + row.setGrantedAt(LocalDateTime.now()); + row.setFailureReason(""); + grantMapper.updateById(row); + return row; + } + + /** 后台分页查看发放记录。 */ + public PageResult page(RewardGrantQueryReqVO req) { + Page page = new Page<>(req.getSafePageIndex(), req.getSafePageSize()); + IPage result = grantMapper.selectPage(page, Wrappers.lambdaQuery() + .eq(req.getUserId() != null, RewardGrant::getUserId, req.getUserId()) + .eq(req.getTaskId() != null, RewardGrant::getTaskId, req.getTaskId()) + .eq(StringUtils.hasText(req.getStatus()), RewardGrant::getStatus, req.getStatus()) + .eq(StringUtils.hasText(req.getSource()), RewardGrant::getSource, req.getSource()) + .orderByDesc(RewardGrant::getCreatedAt)); + return new PageResult<>(result.getTotal(), result.getRecords().stream() + .map(RewardGrantRespVO::of).toList()); + } + + /** 返回奖励发放后的钱包余额。 */ + public Long balance(Long userId) { + return wallet.balance(userId); + } + + /** 后台人工补发奖励,幂等键为空时生成一次性操作键。 */ + @Transactional(rollbackFor = Exception.class) + public RewardGrantRespVO manualGrant(Long userId, Long taskId, Long amount, + String idempotentKey, Long operatorId) { + String key = StringUtils.hasText(idempotentKey) ? idempotentKey.trim() + : "reward-admin:" + operatorId + ":" + userId + ":" + System.currentTimeMillis(); + RewardGrant grant = grant(userId, taskId, "MANUAL", amount, SOURCE_ADMIN, operatorId, key); + return RewardGrantRespVO.of(grant); + } + + private RewardGrant newGrant(Long userId, Long taskId, String cycleKey, Long amount, + String source, Long operatorId, String key) { + RewardGrant row = new RewardGrant(); + row.setUserId(userId); + row.setTaskId(taskId == null ? 0L : taskId); + row.setCycleKey(cycleKey == null ? "MANUAL" : cycleKey); + row.setClaimSeq(1); + row.setAmount(amount); + row.setStatus(STATUS_PENDING); + row.setIdempotentKey(key); + row.setCoinLedgerId(0L); + row.setSource(source); + row.setOperatorId(operatorId == null ? 0L : operatorId); + row.setFailureReason(""); + return row; + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardTaskConfiguration.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardTaskConfiguration.java new file mode 100644 index 0000000..aa0e26e --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardTaskConfiguration.java @@ -0,0 +1,77 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.commerce.reward.dal.entity.RewardTask; +import com.fruitshorts.commerce.reward.vo.RewardTaskQueryReqVO; +import com.fruitshorts.commerce.reward.vo.RewardTaskSaveReqVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import org.springframework.util.StringUtils; + +import java.util.Locale; + +/** 奖励任务后台配置的字段校验、查询与复制规则。 */ +final class RewardTaskConfiguration { + + private RewardTaskConfiguration() { + } + + static LambdaQueryWrapper taskQuery(RewardTaskQueryReqVO req) { + return Wrappers.lambdaQuery().eq(RewardTask::getDeleted, 0) + .eq(StringUtils.hasText(req.getType()), RewardTask::getType, req.getType()) + .eq(StringUtils.hasText(req.getSectionCode()), RewardTask::getSectionCode, req.getSectionCode()) + .eq(req.getStatus() != null, RewardTask::getStatus, req.getStatus()) + .and(StringUtils.hasText(req.getKeyword()), wrapper -> wrapper + .like(RewardTask::getCode, req.getKeyword()) + .or().like(RewardTask::getTitleId, req.getKeyword()) + .or().like(RewardTask::getTitleEn, req.getKeyword())) + .orderByAsc(RewardTask::getSort).orderByDesc(RewardTask::getId); + } + + static void validate(RewardTaskSaveReqVO req) { + if (req.getStartAt() != null && req.getEndAt() != null + && req.getStartAt().isAfter(req.getEndAt())) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "任务开始时间不能晚于结束时间"); + } + if (RewardTaskService.CYCLE_ONCE.equals(req.getCycleType()) + && req.getCycleLimit() != null && req.getCycleLimit() > 1) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "一次性任务的周期上限只能为 1"); + } + } + + static void apply(RewardTask task, RewardTaskSaveReqVO req) { + task.setSectionCode(req.getSectionCode().trim()); + task.setType(req.getType().trim().toUpperCase(Locale.ROOT)); + task.setVerifyMode(StringUtils.hasText(req.getVerifyMode()) ? req.getVerifyMode() : "SERVER"); + task.setTargetValue(req.getTargetValue()); + task.setRewardCoin(req.getRewardCoin()); + task.setCycleType(req.getCycleType().trim().toUpperCase(Locale.ROOT)); + task.setCycleLimit(req.getCycleLimit() == null ? 1 : req.getCycleLimit()); + task.setActionType(StringUtils.hasText(req.getActionType()) ? req.getActionType() : "NONE"); + task.setActionUrl(blank(req.getActionUrl())); + task.setIconUrl(blank(req.getIconUrl())); + task.setTitleId(req.getTitleId().trim()); + task.setTitleEn(blank(req.getTitleEn())); + task.setTitleZh(blank(req.getTitleZh())); + task.setSubtitleId(blank(req.getSubtitleId())); + task.setSubtitleEn(blank(req.getSubtitleEn())); + task.setSubtitleZh(blank(req.getSubtitleZh())); + task.setCtaId(blank(req.getCtaId())); + task.setCtaEn(blank(req.getCtaEn())); + task.setCtaZh(blank(req.getCtaZh())); + task.setSectionTitleId(req.getSectionTitleId().trim()); + task.setSectionTitleEn(blank(req.getSectionTitleEn())); + task.setSectionTitleZh(blank(req.getSectionTitleZh())); + task.setConfigJson(blank(req.getConfigJson())); + task.setSort(req.getSort() == null ? 0 : req.getSort()); + task.setStartAt(req.getStartAt()); + task.setEndAt(req.getEndAt()); + } + + private static String blank(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardTaskService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardTaskService.java new file mode 100644 index 0000000..44a1f36 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardTaskService.java @@ -0,0 +1,306 @@ +// 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.entity.RewardEventLog; +import com.fruitshorts.commerce.reward.dal.entity.RewardTask; +import com.fruitshorts.commerce.reward.dal.entity.RewardTaskProgress; +import com.fruitshorts.commerce.reward.dal.mapper.RewardActivityMapper; +import com.fruitshorts.commerce.reward.dal.mapper.RewardEventLogMapper; +import com.fruitshorts.commerce.reward.dal.mapper.RewardTaskMapper; +import com.fruitshorts.commerce.reward.dal.mapper.RewardTaskProgressMapper; +import com.fruitshorts.commerce.reward.vo.RewardActivityVO; +import com.fruitshorts.commerce.reward.vo.RewardOverviewVO; +import com.fruitshorts.commerce.reward.vo.RewardSectionVO; +import com.fruitshorts.commerce.reward.vo.RewardTaskItemVO; +import com.fruitshorts.commerce.reward.vo.RewardTaskQueryReqVO; +import com.fruitshorts.commerce.reward.vo.RewardTaskRespVO; +import com.fruitshorts.commerce.reward.vo.RewardTaskSaveReqVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.WeekFields; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** 奖励任务配置、用户进度与客户端总览。 */ +@Service +@RequiredArgsConstructor +public class RewardTaskService { + + public static final String TYPE_DAILY_CHECK_IN = "DAILY_CHECK_IN"; + public static final String TYPE_WATCH_DURATION = "WATCH_DURATION"; + public static final String TYPE_WATCH_EPISODES = "WATCH_EPISODES"; + public static final String TYPE_FIRST_LOGIN = "FIRST_LOGIN"; + public static final String TYPE_COMPLETE_PROFILE = "COMPLETE_PROFILE"; + public static final String TYPE_FIRST_PURCHASE = "FIRST_PURCHASE"; + public static final String STATUS_PROGRESS = "IN_PROGRESS"; + public static final String STATUS_COMPLETED = "COMPLETED"; + public static final String STATUS_CLAIMED = "CLAIMED"; + public static final String CYCLE_ONCE = "ONCE"; + public static final String CYCLE_DAILY = "DAILY"; + public static final String CYCLE_WEEKLY = "WEEKLY"; + public static final ZoneId REWARD_ZONE = ZoneId.of("Asia/Jakarta"); + + private final RewardTaskMapper taskMapper; + private final RewardTaskProgressMapper progressMapper; + private final RewardEventLogMapper eventLogMapper; + private final RewardActivityMapper activityMapper; + + /** 后台分页查询任务配置。 */ + public PageResult page(RewardTaskQueryReqVO req) { + Page page = new Page<>(req.getSafePageIndex(), req.getSafePageSize()); + IPage result = taskMapper.selectPage(page, RewardTaskConfiguration.taskQuery(req)); + return new PageResult<>(result.getTotal(), result.getRecords().stream() + .map(RewardTaskRespVO::of).toList()); + } + + /** 查询任务详情。 */ + public RewardTaskRespVO detail(Long id) { + return RewardTaskRespVO.of(getExisting(id)); + } + + /** 新建奖励任务,默认下线,避免半配置立即暴露给客户端。 */ + @Transactional(rollbackFor = Exception.class) + public Long create(RewardTaskSaveReqVO req) { + RewardTaskConfiguration.validate(req); + requireCodeFree(req.getCode().trim(), null); + RewardTask task = new RewardTask(); + task.setCode(req.getCode().trim()); + RewardTaskConfiguration.apply(task, req); + task.setStatus(0); + task.setVersion(1); + taskMapper.insert(task); + return task.getId(); + } + + /** 编辑奖励任务。已产生进度的任务保留原 id,版本递增。 */ + @Transactional(rollbackFor = Exception.class) + public void update(Long id, RewardTaskSaveReqVO req) { + RewardTaskConfiguration.validate(req); + RewardTask task = getExisting(id); + requireCodeFree(req.getCode().trim(), id); + task.setCode(req.getCode().trim()); + RewardTaskConfiguration.apply(task, req); + task.setVersion((task.getVersion() == null ? 0 : task.getVersion()) + 1); + taskMapper.updateById(task); + } + + /** 上线或下线任务。 */ + @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"); + } + RewardTask task = getExisting(id); + task.setStatus(status); + taskMapper.updateById(task); + } + + /** 客户端奖励总览。 */ + public RewardOverviewVO overview(Long userId, String locale, Long coinBalance) { + String language = RewardViewAssembler.normalizeLocale(locale); + List tasks = activeTasks(); + Map> grouped = new LinkedHashMap<>(); + for (RewardTask task : tasks) { + RewardTaskProgress progress = currentProgress(userId, task); + grouped.computeIfAbsent(task.getSectionCode(), key -> new ArrayList<>()) + .add(RewardViewAssembler.toItem(task, progress, language)); + } + RewardOverviewVO overview = new RewardOverviewVO(); + overview.setServerDate(LocalDate.now(REWARD_ZONE).toString()); + overview.setTimezone(REWARD_ZONE.getId()); + overview.setCoinBalance(coinBalance); + overview.setSections(grouped.entrySet().stream() + .map(entry -> new RewardSectionVO(entry.getKey(), + RewardViewAssembler.sectionTitle(tasks, entry.getKey(), language), + entry.getValue())).toList()); + overview.setActivities(activities(language)); + return overview; + } + + /** 服务端记录已验证事件并推进任务。 */ + @Transactional(rollbackFor = Exception.class) + public void recordEvent(Long userId, String eventType, String sourceId, long eventValue) { + if (userId == null || !StringUtils.hasText(eventType) || !StringUtils.hasText(sourceId)) { + return; + } + for (RewardTask task : activeTasks()) { + if (!eventType.equals(task.getType())) { + continue; + } + String eventKey = eventKey(task, userId, sourceId); + if (!insertEvent(userId, eventType, sourceId, eventValue, eventKey)) { + continue; + } + addProgress(userId, task, Math.max(eventValue, 0L)); + } + } + + /** 记录服务端校验后的观看进度,时长按单集最大值增量累计,完播集数按分集去重。 */ + @Transactional(rollbackFor = Exception.class) + public void recordWatch(Long userId, Long episodeId, Long positionMs, boolean finished) { + if (userId == null || episodeId == null) return; + for (RewardTask task : activeTasks()) { + if (TYPE_WATCH_DURATION.equals(task.getType())) { + recordWatchDuration(userId, episodeId, positionMs, task); + } else if (TYPE_WATCH_EPISODES.equals(task.getType()) && finished) { + recordWatchEpisode(userId, episodeId, task); + } + } + } + + /** 取现有任务实体。 */ + public RewardTask getExisting(Long id) { + RewardTask task = id == null ? null : taskMapper.selectById(id); + if (task == null || Integer.valueOf(1).equals(task.getDeleted())) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "奖励任务不存在"); + } + return task; + } + + /** 取当前周期进度。 */ + public RewardTaskProgress currentProgress(Long userId, RewardTask task) { + return findProgress(userId, task.getId(), cycleKey(task)); + } + + /** 周期键规则集中管理,保证进度和幂等键一致。 */ + public String cycleKey(RewardTask task) { + LocalDate today = LocalDate.now(REWARD_ZONE); + return switch (task.getCycleType()) { + case CYCLE_DAILY -> today.toString(); + case CYCLE_WEEKLY -> today.getYear() + "-W" + today.get(WeekFields.ISO.weekOfWeekBasedYear()); + default -> CYCLE_ONCE; + }; + } + + private List activeTasks() { + LocalDateTime now = LocalDateTime.now(REWARD_ZONE); + return taskMapper.selectList(Wrappers.lambdaQuery() + .eq(RewardTask::getStatus, 1).eq(RewardTask::getDeleted, 0) + .and(w -> w.isNull(RewardTask::getStartAt).or().le(RewardTask::getStartAt, now)) + .and(w -> w.isNull(RewardTask::getEndAt).or().ge(RewardTask::getEndAt, now)) + .orderByAsc(RewardTask::getSort).orderByAsc(RewardTask::getId)); + } + + /** 返回当前生效活动卡片。 */ + public List activities(String locale) { + String language = RewardViewAssembler.normalizeLocale(locale); + LocalDateTime now = LocalDateTime.now(REWARD_ZONE); + return activityMapper.selectList(Wrappers.lambdaQuery() + .eq(RewardActivity::getStatus, 1).eq(RewardActivity::getDeleted, 0) + .and(w -> w.isNull(RewardActivity::getStartAt).or().le(RewardActivity::getStartAt, now)) + .and(w -> w.isNull(RewardActivity::getEndAt).or().ge(RewardActivity::getEndAt, now)) + .orderByAsc(RewardActivity::getSort)).stream() + .map(row -> RewardViewAssembler.toActivity(row, language)).toList(); + } + + private RewardTaskProgress findProgress(Long userId, Long taskId, String cycleKey) { + if (userId == null) { + return null; + } + return progressMapper.selectOne(Wrappers.lambdaQuery() + .eq(RewardTaskProgress::getUserId, userId).eq(RewardTaskProgress::getTaskId, taskId) + .eq(RewardTaskProgress::getCycleKey, cycleKey).last("limit 1")); + } + + private RewardTaskProgress newProgress(Long userId, RewardTask task, String cycleKey) { + RewardTaskProgress progress = new RewardTaskProgress(); + progress.setUserId(userId); + progress.setTaskId(task.getId()); + progress.setCycleKey(cycleKey); + progress.setProgressValue(0L); + progress.setTargetValue(task.getTargetValue()); + progress.setStatus(STATUS_PROGRESS); + progress.setClaimSeq(0); + return progress; + } + + private boolean insertEvent(Long userId, String eventType, String sourceId, long value, String key) { + RewardEventLog event = new RewardEventLog(); + event.setUserId(userId); + event.setEventType(eventType); + event.setSourceId(sourceId); + event.setEventValue(value); + event.setIdempotentKey(key); + try { + eventLogMapper.insert(event); + return true; + } catch (DuplicateKeyException ex) { + return false; + } + } + + private void recordWatchDuration(Long userId, Long episodeId, Long positionMs, RewardTask task) { + long seconds = Math.max(positionMs == null ? 0L : positionMs / 1000L, 0L); + String key = eventKey(task, userId, episodeId.toString()); + RewardEventLog event = eventLogMapper.selectOne(Wrappers.lambdaQuery() + .eq(RewardEventLog::getIdempotentKey, key).last("limit 1")); + long previous = event == null || event.getEventValue() == null ? 0L : event.getEventValue(); + if (seconds <= previous) return; + if (event == null) { + event = new RewardEventLog(); + event.setUserId(userId); + event.setEventType(TYPE_WATCH_DURATION); + event.setSourceId(episodeId.toString()); + event.setEventValue(seconds); + event.setIdempotentKey(key); + eventLogMapper.insert(event); + } else { + event.setEventValue(seconds); + eventLogMapper.updateById(event); + } + addProgress(userId, task, seconds - previous); + } + + private void recordWatchEpisode(Long userId, Long episodeId, RewardTask task) { + String key = eventKey(task, userId, episodeId.toString()); + if (insertEvent(userId, TYPE_WATCH_EPISODES, episodeId.toString(), 1L, key)) { + addProgress(userId, task, 1L); + } + } + + private void addProgress(Long userId, RewardTask task, long delta) { + if (delta <= 0) return; + String cycleKey = cycleKey(task); + RewardTaskProgress progress = findProgress(userId, task.getId(), cycleKey); + if (progress == null) { + progress = newProgress(userId, task, cycleKey); + progressMapper.insert(progress); + } + long next = progress.getProgressValue() + delta; + progress.setProgressValue(Math.min(next, task.getTargetValue())); + progress.setLastEventAt(LocalDateTime.now()); + if (progress.getProgressValue() >= task.getTargetValue()) { + progress.setStatus(STATUS_COMPLETED); + progress.setCompletedAt(LocalDateTime.now()); + } + progressMapper.updateById(progress); + } + + private String eventKey(RewardTask task, Long userId, String sourceId) { + return "reward-event:" + task.getCode() + ":" + userId + ":" + cycleKey(task) + ":" + sourceId; + } + + private void requireCodeFree(String code, Long excludeId) { + Long count = taskMapper.selectCount(Wrappers.lambdaQuery().eq(RewardTask::getCode, code) + .eq(RewardTask::getDeleted, 0).ne(excludeId != null, RewardTask::getId, excludeId)); + if (count != null && count > 0) throw new ServiceException(ErrorCode.DATA_EXISTS, "任务编码已存在:" + code); + } + +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardViewAssembler.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardViewAssembler.java new file mode 100644 index 0000000..4b279e2 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/RewardViewAssembler.java @@ -0,0 +1,79 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.service; + +import com.fruitshorts.commerce.reward.dal.entity.RewardActivity; +import com.fruitshorts.commerce.reward.dal.entity.RewardTask; +import com.fruitshorts.commerce.reward.dal.entity.RewardTaskProgress; +import com.fruitshorts.commerce.reward.vo.RewardActivityVO; +import com.fruitshorts.commerce.reward.vo.RewardTaskItemVO; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Locale; + +/** 奖励中心多语言展示模型组装器。 */ +final class RewardViewAssembler { + + private RewardViewAssembler() { + } + + static RewardTaskItemVO toItem(RewardTask task, RewardTaskProgress progress, String locale) { + RewardTaskItemVO item = new RewardTaskItemVO(); + item.setId(task.getId()); + item.setCode(task.getCode()); + item.setType(task.getType()); + item.setTitle(text(task.getTitleId(), task.getTitleEn(), task.getTitleZh(), locale)); + item.setSubtitle(text(task.getSubtitleId(), task.getSubtitleEn(), task.getSubtitleZh(), locale)); + item.setCtaText(text(task.getCtaId(), task.getCtaEn(), task.getCtaZh(), locale)); + item.setIconUrl(task.getIconUrl()); + item.setActionType(task.getActionType()); + item.setActionUrl(task.getActionUrl()); + item.setTargetValue(task.getTargetValue()); + item.setRewardCoin(task.getRewardCoin()); + item.setProgressValue(progress == null ? 0L : progress.getProgressValue()); + item.setStatus(progress == null ? RewardTaskService.STATUS_PROGRESS : progress.getStatus()); + return item; + } + + static RewardActivityVO toActivity(RewardActivity row, String locale) { + RewardActivityVO vo = new RewardActivityVO(); + vo.setId(row.getId()); + vo.setCode(row.getCode()); + vo.setCoverUrl(row.getCoverUrl()); + vo.setTitle(text(row.getTitleId(), row.getTitleEn(), row.getTitleZh(), locale)); + vo.setSummary(text(row.getSummaryId(), row.getSummaryEn(), row.getSummaryZh(), locale)); + vo.setCtaText(text(row.getCtaId(), row.getCtaEn(), row.getCtaZh(), locale)); + vo.setActionType(row.getActionType()); + vo.setActionUrl(row.getActionUrl()); + return vo; + } + + static String sectionTitle(List tasks, String code, String locale) { + return tasks.stream().filter(task -> code.equals(task.getSectionCode())).findFirst() + .map(task -> text(task.getSectionTitleId(), task.getSectionTitleEn(), + task.getSectionTitleZh(), locale)) + .orElse(code); + } + + static String normalizeLocale(String locale) { + if (!StringUtils.hasText(locale)) { + return "id"; + } + String normalized = locale.trim().toLowerCase(Locale.ROOT); + if (normalized.startsWith("zh")) { + return "zh"; + } + return normalized.startsWith("en") ? "en" : "id"; + } + + private static String text(String id, String en, String zh, String locale) { + if ("zh".equals(locale) && StringUtils.hasText(zh)) { + return zh; + } + if ("en".equals(locale) && StringUtils.hasText(en)) { + return en; + } + return StringUtils.hasText(id) ? id : (StringUtils.hasText(en) ? en : zh); + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/WalletServiceFacade.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/WalletServiceFacade.java new file mode 100644 index 0000000..81311a5 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/service/WalletServiceFacade.java @@ -0,0 +1,30 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.service; + +import com.fruitshorts.commerce.dal.entity.CoinLedger; +import com.fruitshorts.commerce.dal.entity.Wallet; +import com.fruitshorts.commerce.service.WalletService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** 奖励域对钱包能力的最小适配,避免奖励服务泄露钱包实现细节。 */ +@Component +@RequiredArgsConstructor +public class WalletServiceFacade { + + private final WalletService walletService; + + /** 以 REWARD 类型写入金币流水。 */ + public CoinLedger change(Long userId, long amount, String idempotentKey, + String source, Long operatorId) { + return walletService.change(userId, amount, WalletService.BIZ_REWARD, + idempotentKey, idempotentKey, "奖励发放:" + source, false, operatorId); + } + + /** 查询当前钱包余额。 */ + public Long balance(Long userId) { + Wallet wallet = walletService.getOrCreate(userId); + return wallet.getCoinBalance(); + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardActivityVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardActivityVO.java new file mode 100644 index 0000000..123c67c --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardActivityVO.java @@ -0,0 +1,19 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import lombok.Data; + +/** 客户端奖励活动卡片。 */ +@Data +public class RewardActivityVO { + + private Long id; + private String code; + private String coverUrl; + private String title; + private String summary; + private String ctaText; + private String actionType; + private String actionUrl; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinRespVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinRespVO.java new file mode 100644 index 0000000..6ca9e18 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinRespVO.java @@ -0,0 +1,18 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import lombok.Data; + +/** 客户端签到结果。 */ +@Data +public class RewardCheckinRespVO { + + private Long taskId; + private Long grantId; + private Integer dayIndex; + private Integer streak; + private Long rewardCoin; + private Long coinBalance; + private Boolean alreadyCheckedIn; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinRuleDayReqVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinRuleDayReqVO.java new file mode 100644 index 0000000..032a7ba --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinRuleDayReqVO.java @@ -0,0 +1,19 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** 后台签到单日奖励参数。 */ +@Data +public class RewardCheckinRuleDayReqVO { + + @NotNull(message = "请填写签到天数") + @Min(value = 1, message = "签到天数必须大于 0") + private Integer dayIndex; + @NotNull(message = "请填写签到奖励") + @Min(value = 0, message = "签到奖励不能小于 0") + private Long rewardCoin; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinRuleSaveReqVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinRuleSaveReqVO.java new file mode 100644 index 0000000..9a9507e --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinRuleSaveReqVO.java @@ -0,0 +1,18 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +/** 后台保存签到周期奖励。 */ +@Data +public class RewardCheckinRuleSaveReqVO { + + @Valid + @NotEmpty(message = "签到规则不能为空") + private List days; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinVO.java new file mode 100644 index 0000000..2fc5312 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardCheckinVO.java @@ -0,0 +1,18 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import lombok.Data; + +import java.util.List; + +/** 客户端签到卡片状态。 */ +@Data +public class RewardCheckinVO { + + private Long taskId; + private Boolean checkedInToday; + private Integer streak; + private Integer currentDay; + private List days; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardClaimRespVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardClaimRespVO.java new file mode 100644 index 0000000..7d16b1a --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardClaimRespVO.java @@ -0,0 +1,16 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import lombok.Data; + +/** 客户端领取任务奖励结果。 */ +@Data +public class RewardClaimRespVO { + + private Long taskId; + private Long grantId; + private Long rewardCoin; + private Long coinBalance; + private Boolean alreadyGranted; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardGrantQueryReqVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardGrantQueryReqVO.java new file mode 100644 index 0000000..c9ef9eb --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardGrantQueryReqVO.java @@ -0,0 +1,18 @@ +// 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 RewardGrantQueryReqVO extends PageParam { + + private Long userId; + private Long taskId; + private String status; + private String source; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardGrantRespVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardGrantRespVO.java new file mode 100644 index 0000000..87cd46e --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardGrantRespVO.java @@ -0,0 +1,48 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import com.fruitshorts.commerce.reward.dal.entity.RewardGrant; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 奖励发放记录响应。 */ +@Data +public class RewardGrantRespVO { + + private Long id; + private Long userId; + private Long taskId; + private String cycleKey; + private Integer claimSeq; + private Long amount; + private String status; + private String idempotentKey; + private Long coinLedgerId; + private String source; + private Long operatorId; + private String failureReason; + private LocalDateTime grantedAt; + private LocalDateTime createdAt; + + /** 将发放实体转换为后台响应。 */ + public static RewardGrantRespVO of(RewardGrant grant) { + RewardGrantRespVO vo = new RewardGrantRespVO(); + vo.id = grant.getId(); + vo.userId = grant.getUserId(); + vo.taskId = grant.getTaskId(); + vo.cycleKey = grant.getCycleKey(); + vo.claimSeq = grant.getClaimSeq(); + vo.amount = grant.getAmount(); + vo.status = grant.getStatus(); + vo.idempotentKey = grant.getIdempotentKey(); + vo.coinLedgerId = grant.getCoinLedgerId(); + vo.source = grant.getSource(); + vo.operatorId = grant.getOperatorId(); + vo.failureReason = grant.getFailureReason(); + vo.grantedAt = grant.getGrantedAt(); + vo.createdAt = grant.getCreatedAt(); + return vo; + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardManualGrantReqVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardManualGrantReqVO.java new file mode 100644 index 0000000..a14030d --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardManualGrantReqVO.java @@ -0,0 +1,24 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** 后台人工补发奖励参数。 */ +@Data +public class RewardManualGrantReqVO { + + @NotNull(message = "请填写用户 ID") + private Long userId; + private Long taskId; + @NotNull(message = "请填写补发金币") + @Min(value = 1, message = "补发金币必须大于 0") + private Long amount; + @Size(max = 64, message = "幂等键过长") + private String idempotentKey; + @Size(max = 255, message = "补发原因过长") + private String reason; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardOverviewVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardOverviewVO.java new file mode 100644 index 0000000..5ac9e55 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardOverviewVO.java @@ -0,0 +1,19 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import lombok.Data; + +import java.util.List; + +/** 客户端奖励中心总览。 */ +@Data +public class RewardOverviewVO { + + private String serverDate; + private String timezone; + private Long coinBalance; + private RewardCheckinVO checkin; + private List sections; + private List activities; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardRuleDayVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardRuleDayVO.java new file mode 100644 index 0000000..0324b38 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardRuleDayVO.java @@ -0,0 +1,19 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** 签到周期某一天的配置与展示状态。 */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RewardRuleDayVO { + + private Integer dayIndex; + private Long rewardCoin; + private Boolean checked; + private Boolean current; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardSectionVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardSectionVO.java new file mode 100644 index 0000000..cdfb4d3 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardSectionVO.java @@ -0,0 +1,20 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** 客户端奖励任务分组。 */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RewardSectionVO { + + private String code; + private String title; + private List tasks; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskItemVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskItemVO.java new file mode 100644 index 0000000..69900c3 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskItemVO.java @@ -0,0 +1,24 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import lombok.Data; + +/** 客户端奖励任务卡片。 */ +@Data +public class RewardTaskItemVO { + + private Long id; + private String code; + private String type; + private String title; + private String subtitle; + private String ctaText; + private String iconUrl; + private String actionType; + private String actionUrl; + private Long progressValue; + private Long targetValue; + private Long rewardCoin; + private String status; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskQueryReqVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskQueryReqVO.java new file mode 100644 index 0000000..369513a --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskQueryReqVO.java @@ -0,0 +1,18 @@ +// 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 RewardTaskQueryReqVO extends PageParam { + + private String keyword; + private String type; + private String sectionCode; + private Integer status; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskRespVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskRespVO.java new file mode 100644 index 0000000..f713c6c --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskRespVO.java @@ -0,0 +1,90 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import com.fruitshorts.commerce.reward.dal.entity.RewardTask; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 后台奖励任务响应。 */ +@Data +public class RewardTaskRespVO { + + private Long id; + private String code; + private String sectionCode; + private String type; + private String verifyMode; + private Long targetValue; + private Long rewardCoin; + private String cycleType; + private Integer cycleLimit; + private String actionType; + private String actionUrl; + private String iconUrl; + private String titleId; + private String titleEn; + private String titleZh; + private String subtitleId; + private String subtitleEn; + private String subtitleZh; + private String ctaId; + private String ctaEn; + private String ctaZh; + private String sectionTitleId; + private String sectionTitleEn; + private String sectionTitleZh; + private String configJson; + private Integer sort; + private Integer status; + private String statusText; + private LocalDateTime startAt; + private LocalDateTime endAt; + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + /** 将任务实体转换为后台展示模型。 */ + public static RewardTaskRespVO of(RewardTask task) { + RewardTaskRespVO vo = new RewardTaskRespVO(); + vo.id = task.getId(); + vo.code = task.getCode(); + vo.sectionCode = task.getSectionCode(); + vo.type = task.getType(); + vo.verifyMode = task.getVerifyMode(); + vo.targetValue = task.getTargetValue(); + vo.rewardCoin = task.getRewardCoin(); + vo.cycleType = task.getCycleType(); + vo.cycleLimit = task.getCycleLimit(); + vo.actionType = task.getActionType(); + vo.actionUrl = task.getActionUrl(); + vo.iconUrl = task.getIconUrl(); + vo.copyTexts(task); + vo.configJson = task.getConfigJson(); + vo.sort = task.getSort(); + vo.status = task.getStatus(); + vo.statusText = task.getStatus() != null && task.getStatus() == 1 ? "已上线" : "已下线"; + vo.startAt = task.getStartAt(); + vo.endAt = task.getEndAt(); + vo.version = task.getVersion(); + vo.createdAt = task.getCreatedAt(); + vo.updatedAt = task.getUpdatedAt(); + return vo; + } + + private void copyTexts(RewardTask task) { + titleId = task.getTitleId(); + titleEn = task.getTitleEn(); + titleZh = task.getTitleZh(); + subtitleId = task.getSubtitleId(); + subtitleEn = task.getSubtitleEn(); + subtitleZh = task.getSubtitleZh(); + ctaId = task.getCtaId(); + ctaEn = task.getCtaEn(); + ctaZh = task.getCtaZh(); + sectionTitleId = task.getSectionTitleId(); + sectionTitleEn = task.getSectionTitleEn(); + sectionTitleZh = task.getSectionTitleZh(); + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskSaveReqVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskSaveReqVO.java new file mode 100644 index 0000000..87cedc0 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/reward/vo/RewardTaskSaveReqVO.java @@ -0,0 +1,56 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.reward.vo; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 后台奖励任务新增和编辑参数。 */ +@Data +public class RewardTaskSaveReqVO { + + @NotBlank(message = "请填写任务编码") + @Size(max = 64, message = "任务编码过长") + private String code; + @NotBlank(message = "请选择任务分组") + private String sectionCode; + @NotBlank(message = "请选择任务类型") + private String type; + private String verifyMode; + @NotNull(message = "请填写目标值") + @Min(value = 1, message = "目标值必须大于 0") + private Long targetValue; + @NotNull(message = "请填写奖励金币") + @Min(value = 0, message = "奖励金币不能小于 0") + private Long rewardCoin; + @NotBlank(message = "请选择任务周期") + private String cycleType; + @Min(value = 1, message = "周期领取上限必须大于 0") + private Integer cycleLimit; + private String actionType; + private String actionUrl; + private String iconUrl; + @NotBlank(message = "请填写印尼语标题") + private String titleId; + private String titleEn; + private String titleZh; + private String subtitleId; + private String subtitleEn; + private String subtitleZh; + private String ctaId; + private String ctaEn; + private String ctaZh; + @NotBlank(message = "请填写印尼语分组标题") + private String sectionTitleId; + private String sectionTitleEn; + private String sectionTitleZh; + private String configJson; + private Integer sort; + private LocalDateTime startAt; + private LocalDateTime endAt; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/WalletService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/WalletService.java index 1527a1a..72f3957 100644 --- a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/WalletService.java +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/WalletService.java @@ -33,6 +33,7 @@ public class WalletService { public static final String BIZ_UNLOCK = "UNLOCK"; public static final String BIZ_REFUND = "REFUND"; public static final String BIZ_ADMIN = "ADMIN"; + public static final String BIZ_REWARD = "REWARD"; private final WalletMapper walletMapper; private final CoinLedgerMapper ledgerMapper; diff --git a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardCheckinServiceTest.java b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardCheckinServiceTest.java new file mode 100644 index 0000000..a1072b3 --- /dev/null +++ b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardCheckinServiceTest.java @@ -0,0 +1,131 @@ +// 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.RewardCheckinRecord; +import com.fruitshorts.commerce.reward.dal.entity.RewardCheckinRuleDay; +import com.fruitshorts.commerce.reward.dal.entity.RewardGrant; +import com.fruitshorts.commerce.reward.dal.entity.RewardTask; +import com.fruitshorts.commerce.reward.dal.mapper.RewardCheckinRecordMapper; +import com.fruitshorts.commerce.reward.dal.mapper.RewardCheckinRuleDayMapper; +import com.fruitshorts.commerce.reward.dal.mapper.RewardTaskMapper; +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.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +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 RewardCheckinServiceTest { + + private RewardTaskMapper taskMapper; + private RewardCheckinRuleDayMapper ruleMapper; + private RewardCheckinRecordMapper recordMapper; + private RewardGrantService grantService; + private RewardCheckinService service; + + @BeforeAll + static void initTableInfo() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, RewardTask.class); + TableInfoHelper.initTableInfo(assistant, RewardCheckinRecord.class); + TableInfoHelper.initTableInfo(assistant, RewardCheckinRuleDay.class); + } + + @BeforeEach + void setUp() { + taskMapper = mock(RewardTaskMapper.class); + ruleMapper = mock(RewardCheckinRuleDayMapper.class); + recordMapper = mock(RewardCheckinRecordMapper.class); + grantService = mock(RewardGrantService.class); + service = new RewardCheckinService(taskMapper, ruleMapper, recordMapper, grantService); + given(taskMapper.selectOne(any())).willReturn(task()); + given(grantService.balance(7L)).willReturn(135L); + } + + @Test + void should_return_existing_result_when_user_checks_in_twice() { + RewardCheckinRecord existing = record(); + given(recordMapper.selectOne(any())).willReturn(existing); + + var result = service.checkIn(7L); + + assertThat(result.getAlreadyCheckedIn()).isTrue(); + assertThat(result.getCoinBalance()).isEqualTo(135L); + verify(grantService, never()).grant(any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void should_grant_configured_coins_when_first_check_in_today() { + given(recordMapper.selectOne(any())).willReturn(null); + RewardCheckinRuleDay day = new RewardCheckinRuleDay(); + day.setDayIndex(1); + day.setRewardCoin(35L); + given(ruleMapper.selectList(any())).willReturn(List.of(day)); + RewardGrant grant = new RewardGrant(); + grant.setId(91L); + given(grantService.grant(any(), any(), any(), any(), any(), any(), any())).willReturn(grant); + + var result = service.checkIn(7L); + + assertThat(result.getAlreadyCheckedIn()).isFalse(); + assertThat(result.getRewardCoin()).isEqualTo(35L); + verify(recordMapper).insert(any(RewardCheckinRecord.class)); + } + + @Test + void should_return_rule_defaults_without_reading_user_records_when_anonymous() { + RewardCheckinRuleDay first = new RewardCheckinRuleDay(); + first.setDayIndex(1); + first.setRewardCoin(35L); + RewardCheckinRuleDay second = new RewardCheckinRuleDay(); + second.setDayIndex(2); + second.setRewardCoin(50L); + given(ruleMapper.selectList(any())).willReturn(List.of(first, second)); + + var result = service.status(null); + + assertThat(result.getCheckedInToday()).isFalse(); + assertThat(result.getStreak()).isZero(); + assertThat(result.getCurrentDay()).isEqualTo(1); + assertThat(result.getDays()).hasSize(2); + assertThat(result.getDays().get(0).getCurrent()).isTrue(); + assertThat(result.getDays().get(1).getChecked()).isFalse(); + verify(recordMapper, never()).selectOne(any()); + verify(recordMapper, never()).selectCount(any()); + } + + private RewardTask task() { + RewardTask task = new RewardTask(); + task.setId(3L); + task.setCode("daily_check_in"); + task.setType(RewardTaskService.TYPE_DAILY_CHECK_IN); + task.setStatus(1); + task.setDeleted(0); + return task; + } + + private RewardCheckinRecord record() { + RewardCheckinRecord record = new RewardCheckinRecord(); + record.setId(6L); + record.setUserId(7L); + record.setTaskId(3L); + record.setCheckinDate(LocalDate.now(RewardTaskService.REWARD_ZONE)); + record.setDayIndex(1); + record.setStreak(1); + record.setRewardCoin(35L); + record.setGrantId(9L); + return record; + } +} diff --git a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardGrantServiceTest.java b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardGrantServiceTest.java new file mode 100644 index 0000000..ed41a8e --- /dev/null +++ b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardGrantServiceTest.java @@ -0,0 +1,93 @@ +// 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.dal.entity.CoinLedger; +import com.fruitshorts.commerce.reward.dal.entity.RewardGrant; +import com.fruitshorts.commerce.reward.dal.entity.RewardTask; +import com.fruitshorts.commerce.reward.dal.entity.RewardTaskProgress; +import com.fruitshorts.commerce.reward.dal.mapper.RewardGrantMapper; +import com.fruitshorts.commerce.reward.dal.mapper.RewardTaskProgressMapper; +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.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** 奖励领取幂等与钱包入账测试。 */ +class RewardGrantServiceTest { + + private RewardGrantMapper grantMapper; + private RewardTaskProgressMapper progressMapper; + private WalletServiceFacade wallet; + private RewardGrantService service; + + @BeforeAll + static void initTableInfo() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, RewardGrant.class); + TableInfoHelper.initTableInfo(assistant, RewardTaskProgress.class); + } + + @BeforeEach + void setUp() { + grantMapper = mock(RewardGrantMapper.class); + progressMapper = mock(RewardTaskProgressMapper.class); + wallet = mock(WalletServiceFacade.class); + service = new RewardGrantService(grantMapper, progressMapper, wallet); + } + + @Test + void should_grant_once_when_claim_is_repeated() { + AtomicReference stored = new AtomicReference<>(); + given(grantMapper.selectCount(any())).willAnswer(invocation -> stored.get() == null ? 0L : 1L); + given(grantMapper.selectOne(any())).willAnswer(invocation -> stored.get()); + given(grantMapper.insert(any(RewardGrant.class))).willAnswer(invocation -> { + RewardGrant row = invocation.getArgument(0); + row.setId(88L); + stored.set(row); + return 1; + }); + CoinLedger ledger = new CoinLedger(); + ledger.setId(99L); + given(wallet.change(any(), any(Long.class), any(), any(), any())).willReturn(ledger); + given(wallet.balance(7L)).willReturn(150L); + RewardTask task = task(); + RewardTaskProgress progress = progress(); + + var first = service.claim(7L, task, progress); + var second = service.claim(7L, task, progress); + + assertThat(first.getAlreadyGranted()).isFalse(); + assertThat(second.getAlreadyGranted()).isTrue(); + assertThat(progress.getStatus()).isEqualTo(RewardTaskService.STATUS_CLAIMED); + verify(wallet, times(1)).change(any(), any(Long.class), any(), any(), any()); + verify(progressMapper, times(2)).updateById(progress); + } + + private RewardTask task() { + RewardTask task = new RewardTask(); + task.setId(3L); + task.setCode("watch_5_minutes"); + task.setRewardCoin(20L); + return task; + } + + private RewardTaskProgress progress() { + RewardTaskProgress progress = new RewardTaskProgress(); + progress.setId(5L); + progress.setCycleKey("2026-11-05"); + progress.setStatus(RewardTaskService.STATUS_COMPLETED); + return progress; + } +} diff --git a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardTaskServiceTest.java b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardTaskServiceTest.java new file mode 100644 index 0000000..9379495 --- /dev/null +++ b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/reward/service/RewardTaskServiceTest.java @@ -0,0 +1,99 @@ +// 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.entity.RewardEventLog; +import com.fruitshorts.commerce.reward.dal.entity.RewardTask; +import com.fruitshorts.commerce.reward.dal.entity.RewardTaskProgress; +import com.fruitshorts.commerce.reward.dal.mapper.RewardActivityMapper; +import com.fruitshorts.commerce.reward.dal.mapper.RewardEventLogMapper; +import com.fruitshorts.commerce.reward.dal.mapper.RewardTaskMapper; +import com.fruitshorts.commerce.reward.dal.mapper.RewardTaskProgressMapper; +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.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** 服务端观看事件进度测试。 */ +class RewardTaskServiceTest { + + private RewardTaskMapper taskMapper; + private RewardTaskProgressMapper progressMapper; + private RewardEventLogMapper eventMapper; + private RewardTaskService service; + private AtomicReference event; + private AtomicReference progress; + + @BeforeAll + static void initTableInfo() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, RewardTask.class); + TableInfoHelper.initTableInfo(assistant, RewardTaskProgress.class); + TableInfoHelper.initTableInfo(assistant, RewardEventLog.class); + TableInfoHelper.initTableInfo(assistant, RewardActivity.class); + } + + @BeforeEach + void setUp() { + taskMapper = mock(RewardTaskMapper.class); + progressMapper = mock(RewardTaskProgressMapper.class); + eventMapper = mock(RewardEventLogMapper.class); + event = new AtomicReference<>(); + progress = new AtomicReference<>(); + service = new RewardTaskService(taskMapper, progressMapper, eventMapper, + mock(RewardActivityMapper.class)); + given(taskMapper.selectList(any())).willReturn(List.of(watchTask())); + given(eventMapper.selectOne(any())).willAnswer(invocation -> event.get()); + given(progressMapper.selectOne(any())).willAnswer(invocation -> progress.get()); + captureInserts(); + } + + @Test + void should_add_only_new_maximum_seconds_when_watch_progress_repeats() { + service.recordWatch(7L, 18L, 10_000L, false); + service.recordWatch(7L, 18L, 25_000L, false); + + assertThat(event.get().getEventValue()).isEqualTo(25L); + assertThat(progress.get().getProgressValue()).isEqualTo(25L); + verify(eventMapper).updateById(event.get()); + } + + private void captureInserts() { + given(eventMapper.insert(any(RewardEventLog.class))).willAnswer(invocation -> { + RewardEventLog row = invocation.getArgument(0); + row.setId(1L); + event.set(row); + return 1; + }); + given(progressMapper.insert(any(RewardTaskProgress.class))).willAnswer(invocation -> { + RewardTaskProgress row = invocation.getArgument(0); + row.setId(2L); + progress.set(row); + return 1; + }); + } + + private RewardTask watchTask() { + RewardTask task = new RewardTask(); + task.setId(3L); + task.setCode("watch_5_minutes"); + task.setType(RewardTaskService.TYPE_WATCH_DURATION); + task.setCycleType(RewardTaskService.CYCLE_DAILY); + task.setTargetValue(300L); + task.setStatus(1); + task.setDeleted(0); + return task; + } +} diff --git a/sql/patch-2026-11-05-reward-center.sql b/sql/patch-2026-11-05-reward-center.sql new file mode 100644 index 0000000..389b7e6 --- /dev/null +++ b/sql/patch-2026-11-05-reward-center.sql @@ -0,0 +1,237 @@ +-- Author: fruit.com.cn +-- 奖励中心:任务配置、签到、进度、发奖、活动与后台菜单(PostgreSQL,幂等)。 + +CREATE TABLE IF NOT EXISTS reward_task ( + id BIGINT PRIMARY KEY, + code VARCHAR(64) NOT NULL, + section_code VARCHAR(32) NOT NULL DEFAULT 'NEWBIE', + type VARCHAR(32) NOT NULL, + verify_mode VARCHAR(16) NOT NULL DEFAULT 'SERVER', + target_value BIGINT NOT NULL DEFAULT 1, + reward_coin BIGINT NOT NULL DEFAULT 0, + cycle_type VARCHAR(16) NOT NULL DEFAULT 'ONCE', + cycle_limit INT NOT NULL DEFAULT 1, + action_type VARCHAR(32) NOT NULL DEFAULT 'NONE', + action_url VARCHAR(512) NOT NULL DEFAULT '', + icon_url VARCHAR(512) NOT NULL DEFAULT '', + title_id VARCHAR(128) NOT NULL DEFAULT '', + title_en VARCHAR(128) NOT NULL DEFAULT '', + title_zh VARCHAR(128) NOT NULL DEFAULT '', + subtitle_id VARCHAR(255) NOT NULL DEFAULT '', + subtitle_en VARCHAR(255) NOT NULL DEFAULT '', + subtitle_zh VARCHAR(255) NOT NULL DEFAULT '', + cta_id VARCHAR(64) NOT NULL DEFAULT '', + cta_en VARCHAR(64) NOT NULL DEFAULT '', + cta_zh VARCHAR(64) NOT NULL DEFAULT '', + section_title_id VARCHAR(64) NOT NULL DEFAULT '', + section_title_en VARCHAR(64) NOT NULL DEFAULT '', + section_title_zh VARCHAR(64) NOT NULL DEFAULT '', + config_json TEXT NOT NULL DEFAULT '', + sort INT NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 0, + start_at TIMESTAMP(3), + end_at TIMESTAMP(3), + version INT NOT NULL DEFAULT 1, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_reward_task_code ON reward_task (code) WHERE deleted = 0; +CREATE INDEX IF NOT EXISTS idx_reward_task_active + ON reward_task (status, section_code, sort, start_at, end_at) WHERE deleted = 0; +COMMENT ON TABLE reward_task IS '奖励任务配置;文案、奖励、周期、动作和上下线均由后台维护'; + +CREATE TABLE IF NOT EXISTS reward_task_progress ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + task_id BIGINT NOT NULL, + cycle_key VARCHAR(32) NOT NULL, + progress_value BIGINT NOT NULL DEFAULT 0, + target_value BIGINT NOT NULL DEFAULT 1, + status VARCHAR(16) NOT NULL DEFAULT 'IN_PROGRESS', + claim_seq INT NOT NULL DEFAULT 0, + completed_at TIMESTAMP(3), + claimed_at TIMESTAMP(3), + last_event_at TIMESTAMP(3), + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_reward_progress_cycle + ON reward_task_progress (user_id, task_id, cycle_key); +CREATE INDEX IF NOT EXISTS idx_reward_progress_user + ON reward_task_progress (user_id, status, updated_at DESC); +COMMENT ON TABLE reward_task_progress IS '用户在任务周期内的服务端进度与领取状态'; + +CREATE TABLE IF NOT EXISTS reward_checkin_rule_day ( + id BIGINT PRIMARY KEY, + task_id BIGINT NOT NULL, + day_index INT NOT NULL, + reward_coin BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_reward_checkin_rule_day + ON reward_checkin_rule_day (task_id, day_index); +COMMENT ON TABLE reward_checkin_rule_day IS '签到任务每日奖励规则,修改后立即由奖励页读取'; + +CREATE TABLE IF NOT EXISTS reward_checkin_record ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + task_id BIGINT NOT NULL, + checkin_date DATE NOT NULL, + day_index INT NOT NULL, + streak INT NOT NULL DEFAULT 1, + reward_coin BIGINT NOT NULL DEFAULT 0, + grant_id BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_reward_checkin_date + ON reward_checkin_record (user_id, task_id, checkin_date); +CREATE INDEX IF NOT EXISTS idx_reward_checkin_user + ON reward_checkin_record (user_id, task_id, checkin_date DESC); +COMMENT ON TABLE reward_checkin_record IS '每日签到事实;用户、任务、日期唯一,防止重复签到'; + +CREATE TABLE IF NOT EXISTS reward_grant ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + task_id BIGINT NOT NULL, + cycle_key VARCHAR(32) NOT NULL, + claim_seq INT NOT NULL DEFAULT 1, + amount BIGINT NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'PENDING', + idempotent_key VARCHAR(64) NOT NULL, + coin_ledger_id BIGINT NOT NULL DEFAULT 0, + source VARCHAR(16) NOT NULL DEFAULT 'APP', + operator_id BIGINT NOT NULL DEFAULT 0, + failure_reason VARCHAR(255) NOT NULL DEFAULT '', + granted_at TIMESTAMP(3), + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_reward_grant_cycle + ON reward_grant (user_id, task_id, cycle_key, claim_seq); +CREATE UNIQUE INDEX IF NOT EXISTS uk_reward_grant_idem ON reward_grant (idempotent_key); +CREATE INDEX IF NOT EXISTS idx_reward_grant_user ON reward_grant (user_id, created_at DESC); +COMMENT ON TABLE reward_grant IS '奖励发放单;必须关联 coin_ledger,支持幂等与审计'; + +CREATE TABLE IF NOT EXISTS reward_activity ( + id BIGINT PRIMARY KEY, + code VARCHAR(64) NOT NULL, + cover_url VARCHAR(512) NOT NULL DEFAULT '', + action_type VARCHAR(32) NOT NULL DEFAULT 'NONE', + action_url VARCHAR(512) NOT NULL DEFAULT '', + title_id VARCHAR(128) NOT NULL DEFAULT '', + title_en VARCHAR(128) NOT NULL DEFAULT '', + title_zh VARCHAR(128) NOT NULL DEFAULT '', + summary_id VARCHAR(255) NOT NULL DEFAULT '', + summary_en VARCHAR(255) NOT NULL DEFAULT '', + summary_zh VARCHAR(255) NOT NULL DEFAULT '', + cta_id VARCHAR(64) NOT NULL DEFAULT '', + cta_en VARCHAR(64) NOT NULL DEFAULT '', + cta_zh VARCHAR(64) NOT NULL DEFAULT '', + sort INT NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 0, + start_at TIMESTAMP(3), + end_at TIMESTAMP(3), + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_reward_activity_code + ON reward_activity (code) WHERE deleted = 0; +CREATE INDEX IF NOT EXISTS idx_reward_activity_active + ON reward_activity (status, sort, start_at, end_at) WHERE deleted = 0; +COMMENT ON TABLE reward_activity IS '奖励中心活动卡片;一期只负责展示与跳转'; + +CREATE TABLE IF NOT EXISTS reward_event_log ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + event_type VARCHAR(32) NOT NULL, + source_id VARCHAR(128) NOT NULL DEFAULT '', + event_value BIGINT NOT NULL DEFAULT 0, + idempotent_key VARCHAR(128) NOT NULL, + occurred_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_reward_event_idem ON reward_event_log (idempotent_key); +CREATE INDEX IF NOT EXISTS idx_reward_event_user ON reward_event_log (user_id, occurred_at DESC); +COMMENT ON TABLE reward_event_log IS '服务端奖励事件去重日志,不接受客户端直接写入'; + +DROP TRIGGER IF EXISTS trg_reward_task_updated_at ON reward_task; +CREATE TRIGGER trg_reward_task_updated_at BEFORE UPDATE ON reward_task + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +DROP TRIGGER IF EXISTS trg_reward_task_progress_updated_at ON reward_task_progress; +CREATE TRIGGER trg_reward_task_progress_updated_at BEFORE UPDATE ON reward_task_progress + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +DROP TRIGGER IF EXISTS trg_reward_checkin_rule_day_updated_at ON reward_checkin_rule_day; +CREATE TRIGGER trg_reward_checkin_rule_day_updated_at BEFORE UPDATE ON reward_checkin_rule_day + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +DROP TRIGGER IF EXISTS trg_reward_grant_updated_at ON reward_grant; +CREATE TRIGGER trg_reward_grant_updated_at BEFORE UPDATE ON reward_grant + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +DROP TRIGGER IF EXISTS trg_reward_activity_updated_at ON reward_activity; +CREATE TRIGGER trg_reward_activity_updated_at BEFORE UPDATE ON reward_activity + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +INSERT INTO reward_task ( + id, code, section_code, type, verify_mode, target_value, reward_coin, cycle_type, + action_type, title_id, title_en, title_zh, subtitle_id, subtitle_en, subtitle_zh, + cta_id, cta_en, cta_zh, section_title_id, section_title_en, section_title_zh, sort, status +) VALUES + (202611050001, 'daily_check_in', 'CHECK_IN', 'DAILY_CHECK_IN', 'SERVER', 1, 35, 'DAILY', + 'NONE', 'Check-in harian', 'Daily check-in', '每日签到', + 'Check-in setiap hari untuk mendapatkan koin', 'Check in every day to earn coins', '每日签到领取金币', + 'Check-in', 'Check in', '签到', 'Hadiah check-in', 'Check-in rewards', '签到奖励', 10, 1), + (202611050002, 'watch_5_minutes', 'DAILY', 'WATCH_DURATION', 'SERVER', 300, 20, 'DAILY', + 'OPEN_HOME', 'Tonton selama 5 menit', 'Watch for 5 minutes', '观看 5 分钟', + 'Waktu menonton valid dihitung oleh server', 'Valid watch time is counted by the server', '有效观看时长由服务端统计', + 'Mulai menonton', 'Watch now', '去观看', 'Tugas harian', 'Daily tasks', '每日任务', 20, 1), + (202611050003, 'watch_3_episodes', 'DAILY', 'WATCH_EPISODES', 'SERVER', 3, 25, 'DAILY', + 'OPEN_HOME', 'Tonton 3 episode', 'Watch 3 episodes', '观看 3 集', + 'Episode yang berbeda dihitung satu kali', 'Each distinct episode counts once', '不同分集只计一次', + 'Mulai menonton', 'Watch now', '去观看', 'Tugas harian', 'Daily tasks', '每日任务', 30, 1), + (202611050004, 'complete_profile', 'NEWBIE', 'COMPLETE_PROFILE', 'SERVER', 1, 50, 'ONCE', + 'OPEN_PROFILE', 'Lengkapi profil', 'Complete your profile', '完善个人资料', + 'Tambahkan nama panggilan dan foto profil', 'Add a nickname and profile photo', '填写昵称并上传头像', + 'Lengkapi', 'Complete', '去完成', 'Tugas pengguna baru', 'New user tasks', '新手任务', 40, 1) +ON CONFLICT DO NOTHING; + +INSERT INTO reward_checkin_rule_day (id, task_id, day_index, reward_coin) VALUES + (202611050101, 202611050001, 1, 35), + (202611050102, 202611050001, 2, 35), + (202611050103, 202611050001, 3, 35), + (202611050104, 202611050001, 4, 35), + (202611050105, 202611050001, 5, 35), + (202611050106, 202611050001, 6, 55), + (202611050107, 202611050001, 7, 55) +ON CONFLICT DO NOTHING; + +INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES + (1170, 'reward:list', '查看奖励任务', '订单与权益', 'BUTTON', 170), + (1171, 'reward:update', '维护奖励任务', '订单与权益', 'BUTTON', 171), + (1172, 'reward:checkin', '配置签到规则', '订单与权益', 'BUTTON', 172), + (1173, 'reward:grant', '查看与补发奖励', '订单与权益', 'BUTTON', 173) +ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, group_name = EXCLUDED.group_name, type = EXCLUDED.type, sort = EXCLUDED.sort; + +INSERT INTO sys_role_permission (id, role_id, permission_id) +SELECT (11700000 + r.id * 100 + p.id % 100), r.id, p.id +FROM sys_role r +CROSS JOIN sys_permission p +WHERE r.code = 'super_admin' AND p.code IN ('reward:list', 'reward:update', 'reward:checkin', 'reward:grant') +ON CONFLICT (role_id, permission_id) DO NOTHING; + +INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, + permission_code, type, always_show, affix, sort) VALUES + (35, 22, '奖励任务', 'rewards', 'commerce-rewards', + 'views/commerce/RewardTaskManagementView', '', '', 'reward:list', 'MENU', 0, 0, 3) +ON CONFLICT (id) DO UPDATE SET + parent_id = EXCLUDED.parent_id, title = EXCLUDED.title, route_path = EXCLUDED.route_path, + route_name = EXCLUDED.route_name, component = EXCLUDED.component, + permission_code = EXCLUDED.permission_code, type = EXCLUDED.type, sort = EXCLUDED.sort; + +INSERT INTO sys_role_menu (id, role_id, menu_id) +SELECT (350000 + r.id), r.id, 35 +FROM sys_role r +WHERE r.code = 'super_admin' +ON CONFLICT (role_id, menu_id) DO NOTHING; + diff --git a/sql/schema-phase1.sql b/sql/schema-phase1.sql index de238f4..ef3c9a8 100644 --- a/sql/schema-phase1.sql +++ b/sql/schema-phase1.sql @@ -1045,3 +1045,9 @@ CREATE INDEX IF NOT EXISTS idx_app_event_log_time ON app_event_log (event_time D CREATE INDEX IF NOT EXISTS idx_app_event_log_user ON app_event_log (user_id); CREATE INDEX IF NOT EXISTS idx_app_event_log_name_time ON app_event_log (event_name, event_time DESC); COMMENT ON TABLE app_event_log IS '客户端埋点事件原始落库(匿名上报,供后续分析与漏斗)'; + +-- ============================================================ +-- 十一、奖励中心(任务配置、签到、进度、发奖与活动) +-- ============================================================ +-- 完整幂等建表、索引、触发器、种子任务与后台菜单见: +-- patch-2026-11-05-reward-center.sql。部署时执行该迁移,避免旧环境漏建奖励表。