Author SHA1 Message Date
jianghaifeng 2904e59e71 Merge branch 'develop' of https://git.reelshort2.com:80/jianghaifeng/FruitServer into develop
backend-ci / test (push) Failing after 4m42s
2026-11-13 16:55:07 +08:00
Zhou Weidong a5c9357a24 fix: 消除 JWT/签名密钥源码硬编码,全部改为环境变量注入
backend-ci / test (push) Failing after 4m39s
- JwtProperties.adminSecret/appSecret 默认值从 dev 密钥改为空字符串
- MediaProperties.signatureSecret 默认值从 dev 密钥改为空字符串
- app-api/admin-api application.yml 三处密钥回退值均改为空
- docker-compose.yml CORS_ALLOWED_ORIGINS 追加 ymb-site 3007 端口白名单
- .gitignore 追加 *.secrets.env / SSL 证书 / nginx 部署目录忽略规则
2026-11-13 16:53:53 +08:00
Zhou Weidong 12c28963f5 feat(commerce): 完成奖励活动管理接口 2026-11-13 13:43:16 +08:00
jianghaifeng 32cac38a0e feat(auth): Google 登录支持多客户端 audience 校验
- GoogleAuthProperties 的 clientId 改为 clientIds 列表
- GoogleIdTokenVerifier 命中任一 clientId 即通过,兼容 iOS 与 Android 不同的 aud
- app-api 注入三个客户端 id,生产可用 GOOGLE_CLIENT_IDS 覆盖
2026-11-13 12:30:55 +08:00
Zhou Weidong a706621786 fix(commerce): 奖励任务菜单改用46避免覆盖转码设置
backend-ci / test (push) Failing after 5m11s
2026-11-09 10:35:37 +08:00
Zhou Weidong 64eadf1446 feat(commerce): 新增打赏任务中心与签到发奖
backend-ci / test (push) Failing after 4m53s
2026-11-09 10:32:53 +08:00
Zhou Weidong 2fee329002 feat(creator): 内容审核返回创作者与分集信息 2026-11-09 10:32:53 +08:00
Zhou Weidong 9b2833dfd3 feat(creator): 后台录入创作者即通过并可直接开号 2026-11-09 10:32:53 +08:00
Zhou Weidong af0bf7f90e feat(creator): 新增一键发布与删除作品接口 2026-11-09 10:32:46 +08:00
Zhou Weidong a36276051d fix(system): 修复转码设置菜单误下发导致前端白屏 2026-11-09 10:32:46 +08:00
jianghaifeng 3df66c2f4c refactor(media): 抽出 MediaStorage 接口为对象存储切换铺路
backend-ci / test (push) Failing after 5m7s
本地磁盘实现 MediaStorageService 改为实现接口,五处注入点
改为依赖接口;行为不变。后期新增 S3 实现以配置切换即可。
2026-11-05 11:31:31 +08:00
jianghaifeng 32ada4c2bf feat(content): 短剧支持审核通过并上线一步流转
backend-ci / test (push) Failing after 5m8s
新增 APPROVE_PUBLISH 动作与 approve-publish 接口,草稿/审核中/已下线
可直接流转为已上线,事务内同步上线草稿分集并记录一条审核记录,
后台审核操作由三步减为一步。
2026-11-05 10:55:52 +08:00
Zhou Weidong eba7d400e1 fix(deploy): 看门狗飞书告警中文乱码改 UTF-8 发送
backend-ci / test (push) Failing after 4m39s
PS5 Invoke-RestMethod 对字符串 body 默认按 Latin-1 编码导致中文变 ???,
改为手动转 UTF-8 字节数组并声明 charset=utf-8。
2026-11-02 14:52:41 +08:00
Zhou Weidong 3faeba0589 fix(deploy): 补充 CORS 允许来源避免跨域被拒
app-api 增加 frostgiant 域名(含 nginx 非标准 80 端口)与公网 IP 来源;
admin-api 增加公网/局域网 IP 与 admin.frostgiant.vip。
Spring setAllowedOrigins 为精确字符串匹配,来源必须逐条列全。
2026-11-02 14:52:41 +08:00
93 changed files with 3528 additions and 71 deletions

No files matched your search

+12
View File
@@ -19,7 +19,19 @@ data/uploads/*
# 部署用的真实环境变量(含密钥),只提交 .env.example # 部署用的真实环境变量(含密钥),只提交 .env.example
.env .env
*.secrets.env
dump.rdb dump.rdb
# 看门狗飞书告警 Webhook,只存在于部署机 # 看门狗飞书告警 Webhook,只存在于部署机
deploy/feishu-webhook.txt deploy/feishu-webhook.txt
# SSL 证书与私钥(.crt/.cer/.pem/.key/.p12/.jks)—— 部署机本地文件,禁止入库
*.crt
*.cer
*.pem
*.key
*.p12
*.jks
# 部署用 nginx 配置目录(挂载卷的本地配置,随环境而异)
nginx/
+5 -2
View File
@@ -25,12 +25,15 @@ function Write-Log($msg) {
function Send-Alert($title, $msg) { function Send-Alert($title, $msg) {
if (-not $FeishuWebhook) { return } if (-not $FeishuWebhook) { return }
$body = @{ $json = @{
msg_type = "text" msg_type = "text"
content = @{ text = "[$title] $msg" } content = @{ text = "[$title] $msg" }
} | ConvertTo-Json -Depth 3 } | ConvertTo-Json -Depth 3
try { try {
Invoke-RestMethod -Uri $FeishuWebhook -Method Post -Body $body -ContentType "application/json" -TimeoutSec 10 # PS5 的 Invoke-RestMethod 对字符串 body 默认按 Latin-1 编码,中文会变成 ???,
# 必须手动转 UTF-8 字节数组发送
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
Invoke-RestMethod -Uri $FeishuWebhook -Method Post -Body $bytes -ContentType "application/json; charset=utf-8" -TimeoutSec 10
} catch { } catch {
Write-Log "飞书告警发送失败: $_" Write-Log "飞书告警发送失败: $_"
} }
+5 -1
View File
@@ -78,6 +78,9 @@ services:
# 限流/缓存用 Redis;容器内 127.0.0.1 是容器自身,必须指向编排里的 redis # 限流/缓存用 Redis;容器内 127.0.0.1 是容器自身,必须指向编排里的 redis
REDIS_HOST: ${REDIS_HOST:-redis} REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379} REDIS_PORT: ${REDIS_PORT:-6379}
# CORS:公网入口 nginx 把 SSL 跑在 80 端口上(腾讯云安全组只放 80),
# 浏览器 fetch 带 Origin 时端口不匹配会被 Spring CORS 拒(必须精确字符串匹配)。
CORS_ALLOWED_ORIGINS: "https://api.frostgiant.vip:80,https://api.frostgiant.vip,http://api.frostgiant.vip,https://39.162.50.99:8081,http://localhost:3005,http://127.0.0.1:3005,http://39.162.50.99:3007,http://192.168.31.250:3007"
ports: ports:
- "8081:8081" - "8081:8081"
volumes: volumes:
@@ -108,7 +111,8 @@ services:
SERVER_PORT: "8080" SERVER_PORT: "8080"
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: "2GB" SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: "2GB"
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: "2GB" SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: "2GB"
CORS_ALLOWED_ORIGINS: "http://localhost:3005,http://127.0.0.1:3005,http://39.162.50.99:3005,http://192.168.31.250:3005" # 浏览后台时 Origin 是实际访问地址,必须精确列全:本机、局域网、公网 IP、域名。
CORS_ALLOWED_ORIGINS: "http://localhost:3005,http://127.0.0.1:3005,http://39.162.50.99:3005,http://192.168.31.250:3005,https://api.frostgiant.vip,https://admin.frostgiant.vip"
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}} DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
# 管理端仅内网,页面上直接播放/下载裸媒资地址(不签播放签名), # 管理端仅内网,页面上直接播放/下载裸媒资地址(不签播放签名),
# 因此这里不启用 /files/episode/** 的签名校验;公开的 app-api 仍保持开启。 # 因此这里不启用 /files/episode/** 的签名校验;公开的 app-api 仍保持开启。
+65
View File
@@ -586,6 +586,53 @@ app.register.max-per-device-per-day
解锁后播放入口与下载入口会同时放行 —— 权益判定由交易模块的 `EntitlementPort` 实现提供, 解锁后播放入口与下载入口会同时放行 —— 权益判定由交易模块的 `EntitlementPort` 实现提供,
播放域与下载域都不需要改代码。金币变动一律带幂等键并写 `coin_ledger`,钱包余额与流水同事务。 播放域与下载域都不需要改代码。金币变动一律带幂等键并写 `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 票据校验闭环)** **`POST /app/orders/verify`(B1-1:Google Play 票据校验闭环)**
请求体: 请求体:
@@ -1233,6 +1280,24 @@ commentSummary,createdAt`。游客的 userId 为空、anonymous 为 true;评
> `id` 以字符串下发:见 **§0「ID 字段一律以字符串下发」**(本节不再单独规定)。 > `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 | `/admin/rewards/activities/page` · `/activities/{id}` | `reward:list` | 奖励活动分页与详情 |
| POST | `/admin/rewards/activities` | `reward:update` | 新建活动,默认下线 |
| PUT | `/admin/rewards/activities/{id}` · `/{id}/status` | `reward:update` | 编辑、上线 / 下线 |
| GET · PUT | `/admin/rewards/check-in/rules` | `reward:checkin` | 查看 / 保存签到每日奖励规则,天数不能重复 |
| GET | `/admin/rewards/grants/page` | `reward:grant` | 用户、任务、来源、状态筛选发放记录 |
| POST | `/admin/rewards/grants/manual` | `reward:grant` | 人工补发;仍写 `REWARD` 钱包流水并要求幂等键 |
后台任务保存字段包括 `code`、`sectionCode`、`type`、`verifyMode`、`targetValue`、`rewardCoin`、`cycleType`、`cycleLimit`、三语言文案、动作、排序和生效时间。任务配置不允许后台写任意脚本,扩展字段只能放在 `configJson`。
后台活动保存字段包括 `code`、`coverUrl`、`actionType`、`actionUrl`、三语言标题、三语言摘要、三语言按钮文案、排序和生效时间。活动与任务共用 `reward:update` 权限,新建活动默认下线。
**校验与错误码**: **校验与错误码**:
| 场景 | 结果 | | 场景 | 结果 |
@@ -70,8 +70,8 @@ public class CreatorAdminController {
return Result.ok(creatorService.audit(id, req, SecurityUtils.getUserId())); return Result.ok(creatorService.audit(id, req, SecurityUtils.getUserId()));
} }
/** 手工录入创作者主体(C 端申请入驻还没实现,先用它打通链路)。 */ /** 手工录入创作者主体:录入即通过,可直接开号。 */
@Operation(summary = "手工录入创作者主体") @Operation(summary = "手工录入创作者主体(录入即通过)")
@PostMapping @PostMapping
@PreAuthorize("@ss.hasPermission('creator:audit')") @PreAuthorize("@ss.hasPermission('creator:audit')")
@OperationLog(module = "创作者管理", action = "新增创作者") @OperationLog(module = "创作者管理", action = "新增创作者")
@@ -79,8 +79,8 @@ public class CreatorAdminController {
return Result.ok(creatorService.create(req)); return Result.ok(creatorService.create(req));
} }
/** 开通创作者账号:仅审核通过可开;已开通则幂等返回,不重复建号。 */ /** 开通创作者账号:不再校验入驻审核,仅冻结状态拒绝;已开通则幂等返回。 */
@Operation(summary = "开通创作者账号(仅审核通过可开通,幂等)") @Operation(summary = "开通创作者账号(仅冻结状态拒绝,幂等)")
@PostMapping("/{id}/account") @PostMapping("/{id}/account")
@PreAuthorize("@ss.hasPermission('creator:account')") @PreAuthorize("@ss.hasPermission('creator:account')")
@OperationLog(module = "创作者管理", action = "开通创作者账号") @OperationLog(module = "创作者管理", action = "开通创作者账号")
@@ -12,6 +12,8 @@ import com.fruitshorts.creator.service.CreatorContentService;
import com.fruitshorts.creator.vo.CreatorAttachReqVO; import com.fruitshorts.creator.vo.CreatorAttachReqVO;
import com.fruitshorts.creator.vo.CreatorDramaEditReqVO; import com.fruitshorts.creator.vo.CreatorDramaEditReqVO;
import com.fruitshorts.creator.vo.CreatorEpisodeReqVO; import com.fruitshorts.creator.vo.CreatorEpisodeReqVO;
import com.fruitshorts.creator.vo.CreatorPublishReqVO;
import com.fruitshorts.creator.vo.CreatorPublishRespVO;
import com.fruitshorts.creator.vo.CreatorSubmitReqVO; import com.fruitshorts.creator.vo.CreatorSubmitReqVO;
import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -53,6 +55,15 @@ public class CreatorContentController {
return Result.ok(creatorContentService.createDrama(req)); return Result.ok(creatorContentService.createDrama(req));
} }
/** 一键发布:建作品 + 建分集 + 绑定视频(可选同时提交审核),一个事务完成。 */
@Operation(summary = "一键发布(建作品 / 分集 / 绑定视频 / 可选提交审核)")
@PostMapping("/publish")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "一键发布作品")
public Result<CreatorPublishRespVO> publish(@Valid @RequestBody CreatorPublishReqVO req) {
return Result.ok(creatorContentService.publish(req));
}
/** 编辑作品信息(仅草稿 / 已下线可改)。 */ /** 编辑作品信息(仅草稿 / 已下线可改)。 */
@Operation(summary = "编辑作品信息(仅草稿 / 已下线可改)") @Operation(summary = "编辑作品信息(仅草稿 / 已下线可改)")
@PutMapping("/dramas/{id}") @PutMapping("/dramas/{id}")
@@ -101,6 +112,15 @@ public class CreatorContentController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "删除作品")
@DeleteMapping("/dramas/{id}")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "删除作品")
public Result<Void> deleteDrama(@PathVariable Long id) {
creatorContentService.deleteDrama(id);
return Result.ok();
}
/** 批量排序:body 里按新顺序给出分集 id。 */ /** 批量排序:body 里按新顺序给出分集 id。 */
@Operation(summary = "分集批量排序") @Operation(summary = "分集批量排序")
@PutMapping("/dramas/{id}/episodes/sort") @PutMapping("/dramas/{id}/episodes/sort")
@@ -31,14 +31,14 @@ public class DictController {
@Operation(summary = "短剧分类字典") @Operation(summary = "短剧分类字典")
@GetMapping("/drama-category") @GetMapping("/drama-category")
@PreAuthorize("@ss.hasPermission('drama:list')") @PreAuthorize("@ss.hasAnyPermission('drama:list','creator-center:view')")
public Result<List<DramaCategoryRespVO>> dramaCategories() { public Result<List<DramaCategoryRespVO>> dramaCategories() {
return Result.ok(dramaCategoryService.list()); return Result.ok(dramaCategoryService.list());
} }
@Operation(summary = "内容状态字典") @Operation(summary = "内容状态字典")
@GetMapping("/content-status") @GetMapping("/content-status")
@PreAuthorize("@ss.hasPermission('drama:list')") @PreAuthorize("@ss.hasAnyPermission('drama:list','creator-center:view')")
public Result<List<Map<String, String>>> contentStatus() { public Result<List<Map<String, String>>> contentStatus() {
return Result.ok(Arrays.stream(ContentStatus.values()) return Result.ok(Arrays.stream(ContentStatus.values())
.map(status -> Map.of("code", status.getCode(), "text", status.getText())) .map(status -> Map.of("code", status.getCode(), "text", status.getText()))
@@ -113,6 +113,15 @@ public class DramaController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "审核通过并上线")
@PostMapping("/{id}/approve-publish")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "审核通过并上线")
public Result<Void> approvePublish(@PathVariable Long id, @RequestBody(required = false) DramaStatusReqVO req) {
changeStatus(id, "APPROVE_PUBLISH", req);
return Result.ok();
}
@Operation(summary = "上线") @Operation(summary = "上线")
@PostMapping("/{id}/publish") @PostMapping("/{id}/publish")
@PreAuthorize("@ss.hasPermission('drama:review')") @PreAuthorize("@ss.hasPermission('drama:review')")
@@ -0,0 +1,171 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.commerce.reward.service.RewardCheckinService;
import com.fruitshorts.commerce.reward.service.RewardActivityAdminService;
import com.fruitshorts.commerce.reward.service.RewardGrantService;
import com.fruitshorts.commerce.reward.service.RewardTaskService;
import com.fruitshorts.commerce.reward.vo.RewardActivityQueryReqVO;
import com.fruitshorts.commerce.reward.vo.RewardActivityRespVO;
import com.fruitshorts.commerce.reward.vo.RewardActivitySaveReqVO;
import com.fruitshorts.commerce.reward.vo.RewardCheckinRuleSaveReqVO;
import com.fruitshorts.commerce.reward.vo.RewardGrantQueryReqVO;
import com.fruitshorts.commerce.reward.vo.RewardGrantRespVO;
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 RewardActivityAdminService activityService;
private final RewardCheckinService checkinService;
private final RewardGrantService grantService;
private final AppUserMapper appUserMapper;
@Operation(summary = "奖励任务分页")
@GetMapping("/tasks/page")
@PreAuthorize("@ss.hasPermission('reward:list')")
public Result<PageResult<RewardTaskRespVO>> page(@Validated RewardTaskQueryReqVO req) {
return Result.ok(taskService.page(req));
}
@Operation(summary = "奖励任务详情")
@GetMapping("/tasks/{id}")
@PreAuthorize("@ss.hasPermission('reward:list')")
public Result<RewardTaskRespVO> detail(@PathVariable Long id) {
return Result.ok(taskService.detail(id));
}
@Operation(summary = "新建奖励任务")
@PostMapping("/tasks")
@PreAuthorize("@ss.hasPermission('reward:update')")
@OperationLog(module = "奖励中心", action = "新建奖励任务")
public Result<Long> 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<Void> 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<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
taskService.updateStatus(id, status);
return Result.ok();
}
@Operation(summary = "奖励活动分页")
@GetMapping("/activities/page")
@PreAuthorize("@ss.hasPermission('reward:list')")
public Result<PageResult<RewardActivityRespVO>> activityPage(@Validated RewardActivityQueryReqVO req) {
return Result.ok(activityService.page(req));
}
@Operation(summary = "奖励活动详情")
@GetMapping("/activities/{id}")
@PreAuthorize("@ss.hasPermission('reward:list')")
public Result<RewardActivityRespVO> activityDetail(@PathVariable Long id) {
return Result.ok(activityService.detail(id));
}
@Operation(summary = "新建奖励活动")
@PostMapping("/activities")
@PreAuthorize("@ss.hasPermission('reward:update')")
@OperationLog(module = "奖励中心", action = "新建奖励活动")
public Result<Long> createActivity(@Valid @RequestBody RewardActivitySaveReqVO req) {
return Result.ok(activityService.create(req));
}
@Operation(summary = "编辑奖励活动")
@PutMapping("/activities/{id}")
@PreAuthorize("@ss.hasPermission('reward:update')")
@OperationLog(module = "奖励中心", action = "编辑奖励活动")
public Result<Void> updateActivity(@PathVariable Long id, @Valid @RequestBody RewardActivitySaveReqVO req) {
activityService.update(id, req);
return Result.ok();
}
@Operation(summary = "奖励活动上下线")
@PutMapping("/activities/{id}/status")
@PreAuthorize("@ss.hasPermission('reward:update')")
@OperationLog(module = "奖励中心", action = "奖励活动上下线")
public Result<Void> updateActivityStatus(@PathVariable Long id, @RequestParam Integer status) {
activityService.updateStatus(id, status);
return Result.ok();
}
@Operation(summary = "签到规则")
@GetMapping("/check-in/rules")
@PreAuthorize("@ss.hasPermission('reward:checkin')")
public Result<List<RewardRuleDayVO>> rules() {
return Result.ok(checkinService.rulesForAdmin());
}
@Operation(summary = "保存签到规则")
@PutMapping("/check-in/rules")
@PreAuthorize("@ss.hasPermission('reward:checkin')")
@OperationLog(module = "奖励中心", action = "保存签到规则")
public Result<Void> saveRules(@Valid @RequestBody RewardCheckinRuleSaveReqVO req) {
checkinService.saveRules(req);
return Result.ok();
}
@Operation(summary = "奖励发放记录分页")
@GetMapping("/grants/page")
@PreAuthorize("@ss.hasPermission('reward:grant')")
public Result<PageResult<RewardGrantRespVO>> 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<RewardGrantRespVO> 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()));
}
}
@@ -57,14 +57,14 @@ fruitshorts:
public-prefix: /files public-prefix: /files
chunk-size: 5242880 chunk-size: 5242880
max-file-size: 2147483648 max-file-size: 2147483648
signature-secret: ${MEDIA_SIGNATURE_SECRET:fruitshorts-media-dev-secret-change-me-in-production-32b} signature-secret: ${MEDIA_SIGNATURE_SECRET:}
signature-required: ${MEDIA_SIGNATURE_REQUIRED:false} signature-required: ${MEDIA_SIGNATURE_REQUIRED:false}
signature-ttl-minutes: 15 signature-ttl-minutes: 15
jwt: jwt:
# 双密钥都要外置:只配 admin 而漏配 app,生产会静默用 dev 默认值(prod fail-fast 会拦住) # ⚠️ 所有环境必须注入 JWT_ADMIN_SECRET / JWT_APP_SECRET,不设任何代码级默认值
admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b} admin-secret: ${JWT_ADMIN_SECRET:}
admin-ttl-hours: 12 admin-ttl-hours: 12
app-secret: ${JWT_APP_SECRET:fruitshorts-app-dev-secret-change-me-in-production-32bytes} app-secret: ${JWT_APP_SECRET:}
app-access-ttl-minutes: 120 app-access-ttl-minutes: 120
app-refresh-ttl-days: 30 app-refresh-ttl-days: 30
cors: cors:
@@ -32,6 +32,11 @@ class AdminPermissionAnnotationTest {
assertThat(permissionsOf(NoticeAdminController.class)) assertThat(permissionsOf(NoticeAdminController.class))
.containsExactlyInAnyOrder("notice:list", "notice:list", .containsExactlyInAnyOrder("notice:list", "notice:list",
"notice:update", "notice:update", "notice:update", "notice:update"); "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", "reward:list", "reward:list",
"reward:update", "reward:update", "reward:update");
} }
/** 收集控制器里所有映射方法的 @PreAuthorize 权限点(缺失则记为 MISSING,便于断言直接看到)。 */ /** 收集控制器里所有映射方法的 @PreAuthorize 权限点(缺失则记为 MISSING,便于断言直接看到)。 */
@@ -7,12 +7,14 @@ import com.fruitshorts.commerce.service.OrderService;
import com.fruitshorts.commerce.service.ProductService; import com.fruitshorts.commerce.service.ProductService;
import com.fruitshorts.commerce.service.UnlockService; import com.fruitshorts.commerce.service.UnlockService;
import com.fruitshorts.commerce.service.WalletService; import com.fruitshorts.commerce.service.WalletService;
import com.fruitshorts.commerce.reward.service.RewardTaskService;
import com.fruitshorts.commerce.vo.ProductRespVO; import com.fruitshorts.commerce.vo.ProductRespVO;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -41,6 +43,8 @@ public class AppCommerceController {
private final UnlockService unlockService; private final UnlockService unlockService;
private final OrderService orderService; private final OrderService orderService;
private final ProductService productService; private final ProductService productService;
@Autowired(required = false)
private RewardTaskService rewardTaskService;
/** 我的钱包:余额 + 最近流水,客户端一处渲染。 */ /** 我的钱包:余额 + 最近流水,客户端一处渲染。 */
@Operation(summary = "我的钱包(余额 + 最近流水)") @Operation(summary = "我的钱包(余额 + 最近流水)")
@@ -92,9 +96,14 @@ public class AppCommerceController {
@Operation(summary = "提交商店票据(校验后建单并发权益,重复提交幂等)") @Operation(summary = "提交商店票据(校验后建单并发权益,重复提交幂等)")
@PostMapping("/orders/verify") @PostMapping("/orders/verify")
public Result<Orders> verify(@RequestBody Map<String, String> body) { public Result<Orders> verify(@RequestBody Map<String, String> body) {
return Result.ok(orderService.verifyAndDeliver(SecurityUtils.getAppUserId(), Orders order = orderService.verifyAndDeliver(SecurityUtils.getAppUserId(),
body.getOrDefault("platform", ""), body.getOrDefault("platform", ""),
body.getOrDefault("productId", ""), 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);
} }
} }
@@ -9,16 +9,19 @@ import com.fruitshorts.user.service.AppUserService;
import com.fruitshorts.user.vo.AppUserRespVO; import com.fruitshorts.user.vo.AppUserRespVO;
import com.fruitshorts.user.vo.DeactivateReqVO; import com.fruitshorts.user.vo.DeactivateReqVO;
import com.fruitshorts.user.vo.ProfileUpdateReqVO; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@@ -32,6 +35,8 @@ import java.util.Map;
public class AppMeController { public class AppMeController {
private final AppUserService userService; private final AppUserService userService;
@Autowired(required = false)
private RewardTaskService rewardTaskService;
@Operation(summary = "我的资料") @Operation(summary = "我的资料")
@GetMapping @GetMapping
@@ -42,7 +47,14 @@ public class AppMeController {
@Operation(summary = "修改资料(昵称 / 头像 / 语言等)") @Operation(summary = "修改资料(昵称 / 头像 / 语言等)")
@PutMapping @PutMapping
public Result<AppUserRespVO> update(@Valid @RequestBody ProfileUpdateReqVO req) { public Result<AppUserRespVO> 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 = "我的登录设备列表") @Operation(summary = "我的登录设备列表")
@@ -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<RewardOverviewVO> 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<List<RewardActivityVO>> activities(@RequestParam(required = false) String locale) {
return Result.ok(taskService.activities(locale));
}
@Operation(summary = "签到状态")
@GetMapping("/check-in")
public Result<RewardCheckinVO> checkInStatus() {
return Result.ok(checkinService.status(SecurityUtils.getAppUserId()));
}
@Operation(summary = "执行签到")
@PostMapping("/check-in")
public Result<RewardCheckinRespVO> checkIn() {
return Result.ok(checkinService.checkIn(SecurityUtils.getAppUserId()));
}
@Operation(summary = "领取已完成任务奖励")
@PostMapping("/tasks/{taskId}/claim")
public Result<RewardClaimRespVO> 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)));
}
}
@@ -9,6 +9,7 @@ import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
import com.fruitshorts.framework.interaction.InteractionIdentityResolver; import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.playback.service.WatchProgressService; import com.fruitshorts.playback.service.WatchProgressService;
import com.fruitshorts.commerce.reward.service.RewardTaskService;
import com.fruitshorts.playback.vo.ProgressRespVO; import com.fruitshorts.playback.vo.ProgressRespVO;
import com.fruitshorts.playback.vo.ProgressSaveReqVO; import com.fruitshorts.playback.vo.ProgressSaveReqVO;
import io.swagger.v3.oas.annotations.Operation; 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.validation.Valid;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -33,6 +35,8 @@ import org.springframework.web.bind.annotation.RestController;
public class AppWatchController { public class AppWatchController {
private final WatchProgressService progressService; private final WatchProgressService progressService;
@Autowired(required = false)
private RewardTaskService rewardTaskService;
private final InteractionIdentityResolver identityResolver; private final InteractionIdentityResolver identityResolver;
private final AnonymousInteractionRateLimiter anonymousRateLimiter; private final AnonymousInteractionRateLimiter anonymousRateLimiter;
@@ -46,7 +50,12 @@ public class AppWatchController {
request.getHeader("X-Device-Id"), request.getHeader("X-Device-Id"),
req.getDeviceId()); req.getDeviceId());
anonymousRateLimiter.check(identity, "watch"); 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 集"。 */ /** 某部剧的续看位置,进详情页时用它决定"续看第 N 集"。 */
@@ -35,7 +35,9 @@ public class AppSearchService {
Page<Drama> page = new Page<>(safeIndex, safeSize); Page<Drama> page = new Page<>(safeIndex, safeSize);
Page<Drama> result = dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery() Page<Drama> result = dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode()) .eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode())
.and(w -> w.like(Drama::getTitle, keyword.trim()) .and(w -> w.like(Drama::getTitleLocales, keyword.trim())
.or().like(Drama::getSynopsisLocales, keyword.trim())
.or().like(Drama::getTitle, keyword.trim())
.or().like(Drama::getSynopsis, keyword.trim())) .or().like(Drama::getSynopsis, keyword.trim()))
.orderByDesc(Drama::getSort) .orderByDesc(Drama::getSort)
.orderByDesc(Drama::getId)); .orderByDesc(Drama::getId));
@@ -65,6 +65,11 @@ fruitshorts:
# 仅作 sys_config 缺失/非法时的兜底;运行时优先读取后台「系统配置」 # 仅作 sys_config 缺失/非法时的兜底;运行时优先读取后台「系统配置」
register-max-per-ip-per-day: ${REGISTER_MAX_PER_IP_PER_DAY:5} register-max-per-ip-per-day: ${REGISTER_MAX_PER_IP_PER_DAY:5}
register-max-per-device-per-day: ${REGISTER_MAX_PER_DEVICE_PER_DAY:3} register-max-per-device-per-day: ${REGISTER_MAX_PER_DEVICE_PER_DAY:3}
google:
# Google 登录允许的客户端 id(逗号分隔)。idToken 的 aud 随平台不同:
# iOS 为 iOS 客户端 id,Android 为 serverClientId 对应的 Web 客户端 id。
# 客户端 id 属公开信息,不是密钥;生产可用 GOOGLE_CLIENT_IDS 覆盖。
client-ids: ${GOOGLE_CLIENT_IDS:11115008580-51gv4sj2c78rud6n127g3shu4d2n96un.apps.googleusercontent.com,11115008580-dbqe47fhp5560nhrocrppiq7hcnlvl1s.apps.googleusercontent.com,11115008580-1v5pt5mfgbbuba74ebia6hngj618ne3a.apps.googleusercontent.com}
interaction: interaction:
# MACHINE_FIRST:机审通过即发布,命中 REVIEW 级敏感词才转人工 # MACHINE_FIRST:机审通过即发布,命中 REVIEW 级敏感词才转人工
# PRE_REVIEW :全部先审后发(生产建议) # PRE_REVIEW :全部先审后发(生产建议)
@@ -94,15 +99,15 @@ fruitshorts:
chunk-size: 5242880 chunk-size: 5242880
max-file-size: 2147483648 max-file-size: 2147483648
# 播放地址签名:生产必开(docs/12-上线检查清单),prod profile 里强制为 true # 播放地址签名:生产必开(docs/12-上线检查清单),prod profile 里强制为 true
signature-secret: ${MEDIA_SIGNATURE_SECRET:fruitshorts-media-dev-secret-change-me-in-production-32b} signature-secret: ${MEDIA_SIGNATURE_SECRET:}
signature-required: ${MEDIA_SIGNATURE_REQUIRED:false} signature-required: ${MEDIA_SIGNATURE_REQUIRED:false}
signature-ttl-minutes: 15 signature-ttl-minutes: 15
jwt: jwt:
# 双密钥:管理端与客户端物理隔离,token 不能互换(AGENTS.md 强制约束 2) # 双密钥:管理端与客户端物理隔离,token 不能互换(AGENTS.md 强制约束 2)
# 注意 app-secret 以前没写在这里,一直用代码里的 dev 默认值 —— 生产必须注入 JWT_APP_SECRET # ⚠️ 所有环境必须注入 JWT_ADMIN_SECRET / JWT_APP_SECRET,不设任何代码级默认值
admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b} admin-secret: ${JWT_ADMIN_SECRET:}
admin-ttl-hours: 12 admin-ttl-hours: 12
app-secret: ${JWT_APP_SECRET:fruitshorts-app-dev-secret-change-me-in-production-32bytes} app-secret: ${JWT_APP_SECRET:}
app-access-ttl-minutes: 120 app-access-ttl-minutes: 120
app-refresh-ttl-days: 30 app-refresh-ttl-days: 30
cors: cors:
@@ -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();
}
}
}
@@ -11,17 +11,17 @@ import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "fruitshorts.jwt") @ConfigurationProperties(prefix = "fruitshorts.jwt")
public class JwtProperties { public class JwtProperties {
/** 管理端签名密钥,长度必须 >= 32 字节。生产环境通过环境变量注入。 */ /** 管理端签名密钥,长度必须 >= 32 字节。**所有环境必须通过环境变量注入,不设任何默认值。** */
private String adminSecret = "fruitshorts-admin-dev-secret-change-me-in-production-32b"; private String adminSecret = "";
/** 管理端 token 有效期(小时)。 */ /** 管理端 token 有效期(小时)。 */
private int adminTtlHours = 12; private int adminTtlHours = 12;
/** /**
* 客户端签名密钥。与管理端物理隔离:管理端 token 拿到客户端接口上无法通过验签, * 客户端签名密钥。与管理端物理隔离:管理端 token 拿到客户端接口上无法通过验签,
* 反之亦然。生产环境必须通过环境变量注入,不要沿用默认值。 * 反之亦然。**所有环境必须通过环境变量注入,不设任何默认值。**
*/ */
private String appSecret = "fruitshorts-app-dev-secret-change-me-in-production-32bytes"; private String appSecret = "";
/** 客户端 access token 有效期(分钟)。 */ /** 客户端 access token 有效期(分钟)。 */
private int appAccessTtlMinutes = 120; private int appAccessTtlMinutes = 120;
@@ -50,6 +50,9 @@ public class SecurityConfig {
/* 启动配置与公告:客户端登录前就要拿(POST /app/creator/apply 等仍要登录) */ /* 启动配置与公告:客户端登录前就要拿(POST /app/creator/apply 等仍要登录) */
"/app/config", "/app/config",
"/app/notices", "/app/notices",
/* 奖励中心只读配置:登录前可浏览任务与活动,签到/领取仍需登录。 */
"/app/rewards/overview",
"/app/rewards/activities",
/* 服务条款与隐私协议:登录前就要能打开,按语言返回 HTML */ /* 服务条款与隐私协议:登录前就要能打开,按语言返回 HTML */
"/privacy/**", "/privacy/**",
"/terms/**", "/terms/**",
@@ -108,7 +111,7 @@ public class SecurityConfig {
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.stream(allowedOrigins.split(",")).map(String::trim).toList()); configuration.setAllowedOriginPatterns(Arrays.stream(allowedOrigins.split(",")).map(String::trim).toList());
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
configuration.setAllowedHeaders(List.of("*")); configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true); configuration.setAllowCredentials(true);
@@ -58,4 +58,10 @@ public final class SecurityUtils {
} }
return loginUser.userId(); return loginUser.userId();
} }
/** 客户端匿名只读接口取当前用户,未登录或非客户端用户返回 null。 */
public static Long getAppUserIdOrNull() {
LoginUser loginUser = getLoginUserOrNull();
return isAppUser(loginUser) ? loginUser.userId() : null;
}
} }
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<RewardActivity> {
}
@@ -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<RewardCheckinRecord> {
}
@@ -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<RewardCheckinRuleDay> {
}
@@ -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<RewardEventLog> {
}
@@ -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<RewardGrant> {
}
@@ -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<RewardTask> {
}
@@ -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<RewardTaskProgress> {
}
@@ -0,0 +1,136 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.reward.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.commerce.reward.dal.entity.RewardActivity;
import com.fruitshorts.commerce.reward.dal.mapper.RewardActivityMapper;
import com.fruitshorts.commerce.reward.vo.RewardActivityQueryReqVO;
import com.fruitshorts.commerce.reward.vo.RewardActivityRespVO;
import com.fruitshorts.commerce.reward.vo.RewardActivitySaveReqVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/** 奖励中心活动的后台配置服务。 */
@Service
@RequiredArgsConstructor
public class RewardActivityAdminService {
private final RewardActivityMapper activityMapper;
/** 分页查询活动配置。 */
public PageResult<RewardActivityRespVO> page(RewardActivityQueryReqVO req) {
Page<RewardActivity> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<RewardActivity> result = activityMapper.selectPage(page, Wrappers.<RewardActivity>lambdaQuery()
.eq(RewardActivity::getDeleted, 0)
.eq(req.getStatus() != null, RewardActivity::getStatus, req.getStatus())
.and(StringUtils.hasText(req.getKeyword()), w -> w
.like(RewardActivity::getCode, req.getKeyword())
.or().like(RewardActivity::getTitleId, req.getKeyword())
.or().like(RewardActivity::getTitleEn, req.getKeyword())
.or().like(RewardActivity::getTitleZh, req.getKeyword()))
.orderByAsc(RewardActivity::getSort)
.orderByDesc(RewardActivity::getId));
return new PageResult<>(result.getTotal(), result.getRecords().stream()
.map(RewardActivityRespVO::of).toList());
}
/** 查询活动详情。 */
public RewardActivityRespVO detail(Long id) {
return RewardActivityRespVO.of(getExisting(id));
}
/** 新建活动,默认下线,避免半配置内容立即展示。 */
@Transactional(rollbackFor = Exception.class)
public Long create(RewardActivitySaveReqVO req) {
validate(req);
String code = req.getCode().trim();
requireCodeFree(code, null);
RewardActivity activity = new RewardActivity();
activity.setCode(code);
apply(activity, req);
activity.setStatus(0);
activityMapper.insert(activity);
return activity.getId();
}
/** 编辑活动,保留现有上下线状态。 */
@Transactional(rollbackFor = Exception.class)
public void update(Long id, RewardActivitySaveReqVO req) {
validate(req);
RewardActivity activity = getExisting(id);
String code = req.getCode().trim();
requireCodeFree(code, id);
activity.setCode(code);
apply(activity, req);
activityMapper.updateById(activity);
}
/** 上线或下线活动。 */
@Transactional(rollbackFor = Exception.class)
public void updateStatus(Long id, Integer status) {
if (status == null || (status != 0 && status != 1)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "状态只能是 0 或 1");
}
RewardActivity activity = getExisting(id);
activity.setStatus(status);
activityMapper.updateById(activity);
}
private void validate(RewardActivitySaveReqVO req) {
if (req == null || !StringUtils.hasText(req.getCode()) || !StringUtils.hasText(req.getTitleId())) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请填写活动编码和印尼语标题");
}
}
private void apply(RewardActivity activity, RewardActivitySaveReqVO req) {
activity.setCoverUrl(valueOrEmpty(req.getCoverUrl()));
activity.setActionType(valueOrDefault(req.getActionType(), "NONE"));
activity.setActionUrl(valueOrEmpty(req.getActionUrl()));
activity.setTitleId(req.getTitleId().trim());
activity.setTitleEn(valueOrEmpty(req.getTitleEn()));
activity.setTitleZh(valueOrEmpty(req.getTitleZh()));
activity.setSummaryId(valueOrEmpty(req.getSummaryId()));
activity.setSummaryEn(valueOrEmpty(req.getSummaryEn()));
activity.setSummaryZh(valueOrEmpty(req.getSummaryZh()));
activity.setCtaId(valueOrEmpty(req.getCtaId()));
activity.setCtaEn(valueOrEmpty(req.getCtaEn()));
activity.setCtaZh(valueOrEmpty(req.getCtaZh()));
activity.setSort(req.getSort() == null ? 0 : req.getSort());
activity.setStartAt(req.getStartAt());
activity.setEndAt(req.getEndAt());
}
private RewardActivity getExisting(Long id) {
RewardActivity activity = id == null ? null : activityMapper.selectById(id);
if (activity == null || Integer.valueOf(1).equals(activity.getDeleted())) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "奖励活动不存在");
}
return activity;
}
private void requireCodeFree(String code, Long excludeId) {
Long count = activityMapper.selectCount(Wrappers.<RewardActivity>lambdaQuery()
.eq(RewardActivity::getCode, code)
.eq(RewardActivity::getDeleted, 0)
.ne(excludeId != null, RewardActivity::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "活动编码已存在:" + code);
}
}
private String valueOrEmpty(String value) {
return value == null ? "" : value.trim();
}
private String valueOrDefault(String value, String fallback) {
return StringUtils.hasText(value) ? value.trim() : fallback;
}
}
@@ -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<RewardCheckinRuleDay> 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.<RewardCheckinRecord>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<RewardCheckinRuleDay> 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<RewardRuleDayVO> 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.<RewardCheckinRecord>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<RewardCheckinRuleDay> 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.<RewardCheckinRecord>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.<RewardCheckinRuleDay>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.<RewardTask>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<RewardCheckinRuleDay> rules(Long taskId) {
List<RewardCheckinRuleDay> rows = ruleMapper.selectList(Wrappers.<RewardCheckinRuleDay>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.<RewardCheckinRecord>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;
}
}
@@ -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.<RewardGrant>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.<RewardGrant>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.<RewardGrant>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<RewardGrantRespVO> page(RewardGrantQueryReqVO req) {
Page<RewardGrant> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<RewardGrant> result = grantMapper.selectPage(page, Wrappers.<RewardGrant>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;
}
}
@@ -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<RewardTask> taskQuery(RewardTaskQueryReqVO req) {
return Wrappers.<RewardTask>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();
}
}
@@ -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<RewardTaskRespVO> page(RewardTaskQueryReqVO req) {
Page<RewardTask> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<RewardTask> 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<RewardTask> tasks = activeTasks();
Map<String, List<RewardTaskItemVO>> 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<RewardTask> activeTasks() {
LocalDateTime now = LocalDateTime.now(REWARD_ZONE);
return taskMapper.selectList(Wrappers.<RewardTask>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<RewardActivityVO> activities(String locale) {
String language = RewardViewAssembler.normalizeLocale(locale);
LocalDateTime now = LocalDateTime.now(REWARD_ZONE);
return activityMapper.selectList(Wrappers.<RewardActivity>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.<RewardTaskProgress>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.<RewardEventLog>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.<RewardTask>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);
}
}
@@ -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<RewardTask> 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);
}
}
@@ -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();
}
}
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.reward.vo;
import com.fruitshorts.common.pojo.PageParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** 后台奖励活动分页条件。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class RewardActivityQueryReqVO extends PageParam {
private String keyword;
private Integer status;
}
@@ -0,0 +1,62 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.reward.vo;
import com.fruitshorts.commerce.reward.dal.entity.RewardActivity;
import lombok.Data;
import java.time.LocalDateTime;
/** 后台奖励活动响应。 */
@Data
public class RewardActivityRespVO {
private Long id;
private String code;
private String coverUrl;
private String actionType;
private String actionUrl;
private String titleId;
private String titleEn;
private String titleZh;
private String summaryId;
private String summaryEn;
private String summaryZh;
private String ctaId;
private String ctaEn;
private String ctaZh;
private Integer sort;
private Integer status;
private String statusText;
private LocalDateTime startAt;
private LocalDateTime endAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** 将活动实体转换为后台展示模型。 */
public static RewardActivityRespVO of(RewardActivity activity) {
RewardActivityRespVO vo = new RewardActivityRespVO();
vo.id = activity.getId();
vo.code = activity.getCode();
vo.coverUrl = activity.getCoverUrl();
vo.actionType = activity.getActionType();
vo.actionUrl = activity.getActionUrl();
vo.titleId = activity.getTitleId();
vo.titleEn = activity.getTitleEn();
vo.titleZh = activity.getTitleZh();
vo.summaryId = activity.getSummaryId();
vo.summaryEn = activity.getSummaryEn();
vo.summaryZh = activity.getSummaryZh();
vo.ctaId = activity.getCtaId();
vo.ctaEn = activity.getCtaEn();
vo.ctaZh = activity.getCtaZh();
vo.sort = activity.getSort();
vo.status = activity.getStatus();
vo.statusText = activity.getStatus() != null && activity.getStatus() == 1 ? "已上线" : "已下线";
vo.startAt = activity.getStartAt();
vo.endAt = activity.getEndAt();
vo.createdAt = activity.getCreatedAt();
vo.updatedAt = activity.getUpdatedAt();
return vo;
}
}
@@ -0,0 +1,36 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.reward.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDateTime;
/** 后台奖励活动新增和编辑参数。 */
@Data
public class RewardActivitySaveReqVO {
@NotBlank(message = "请填写活动编码")
@Size(max = 64, message = "活动编码过长")
private String code;
@Size(max = 512, message = "封面地址过长")
private String coverUrl;
private String actionType;
@Size(max = 512, message = "跳转地址过长")
private String actionUrl;
@NotBlank(message = "请填写印尼语标题")
private String titleId;
private String titleEn;
private String titleZh;
private String summaryId;
private String summaryEn;
private String summaryZh;
private String ctaId;
private String ctaEn;
private String ctaZh;
private Integer sort;
private LocalDateTime startAt;
private LocalDateTime endAt;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<RewardCheckinRuleDayReqVO> days;
}
@@ -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<RewardRuleDayVO> days;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
}
@@ -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;
}
@@ -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<RewardSectionVO> sections;
private List<RewardActivityVO> activities;
}
@@ -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;
}
@@ -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<RewardTaskItemVO> tasks;
}
@@ -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;
}
@@ -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;
}
@@ -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();
}
}
@@ -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;
}
@@ -33,6 +33,7 @@ public class WalletService {
public static final String BIZ_UNLOCK = "UNLOCK"; public static final String BIZ_UNLOCK = "UNLOCK";
public static final String BIZ_REFUND = "REFUND"; public static final String BIZ_REFUND = "REFUND";
public static final String BIZ_ADMIN = "ADMIN"; public static final String BIZ_ADMIN = "ADMIN";
public static final String BIZ_REWARD = "REWARD";
private final WalletMapper walletMapper; private final WalletMapper walletMapper;
private final CoinLedgerMapper ledgerMapper; private final CoinLedgerMapper ledgerMapper;
@@ -0,0 +1,102 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.reward.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.commerce.reward.dal.entity.RewardActivity;
import com.fruitshorts.commerce.reward.dal.mapper.RewardActivityMapper;
import com.fruitshorts.commerce.reward.vo.RewardActivitySaveReqVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/** 奖励活动后台配置服务测试。 */
class RewardActivityAdminServiceTest {
private RewardActivityMapper activityMapper;
private RewardActivityAdminService service;
@BeforeAll
static void initTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""), RewardActivity.class);
}
@BeforeEach
void setUp() {
activityMapper = mock(RewardActivityMapper.class);
service = new RewardActivityAdminService(activityMapper);
}
@Test
void should_create_activity_offline_when_status_is_missing() {
given(activityMapper.selectCount(any())).willReturn(0L);
AtomicReference<RewardActivity> saved = captureInsert();
service.create(activity("spring_event"));
assertThat(saved.get().getCode()).isEqualTo("spring_event");
assertThat(saved.get().getStatus()).isZero();
}
@Test
void should_reject_when_activity_code_already_exists() {
given(activityMapper.selectCount(any())).willReturn(1L);
assertThatThrownBy(() -> service.create(activity("spring_event")))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.DATA_EXISTS.getCode()))
.hasMessageContaining("活动编码已存在");
verify(activityMapper, never()).insert(any(RewardActivity.class));
}
@Test
void should_update_status_when_status_is_valid() {
RewardActivity existing = new RewardActivity();
existing.setId(9L);
existing.setDeleted(0);
given(activityMapper.selectById(9L)).willReturn(existing);
service.updateStatus(9L, 1);
assertThat(existing.getStatus()).isOne();
verify(activityMapper).updateById(existing);
}
private AtomicReference<RewardActivity> captureInsert() {
AtomicReference<RewardActivity> saved = new AtomicReference<>();
given(activityMapper.insert(any(RewardActivity.class))).willAnswer(invocation -> {
RewardActivity activity = invocation.getArgument(0);
activity.setId(9L);
saved.set(activity);
return 1;
});
return saved;
}
private RewardActivitySaveReqVO activity(String code) {
RewardActivitySaveReqVO req = new RewardActivitySaveReqVO();
req.setCode(code);
req.setTitleId("Acara musim semi");
req.setTitleEn("Spring event");
req.setTitleZh("春季活动");
req.setSummaryId("Dapatkan koin tambahan");
req.setActionType("OPEN_HOME");
return req;
}
}
@@ -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;
}
}
@@ -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<RewardGrant> 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;
}
}
@@ -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<RewardEventLog> event;
private AtomicReference<RewardTaskProgress> 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;
}
}
@@ -61,6 +61,13 @@ public class DramaService {
.like(StringUtils.hasText(req.getKeyword()), Drama::getTitle, req.getKeyword()) .like(StringUtils.hasText(req.getKeyword()), Drama::getTitle, req.getKeyword())
.eq(StringUtils.hasText(req.getStatus()), Drama::getStatus, req.getStatus()) .eq(StringUtils.hasText(req.getStatus()), Drama::getStatus, req.getStatus())
.eq(req.getIsHot() != null, Drama::getIsHot, Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0); .eq(req.getIsHot() != null, Drama::getIsHot, Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0);
// admin 短剧管理默认隐藏创作者发的、尚在审核中/被驳回的剧——审核通过后才自动进入;
// admin 自己建的(creator_id IS NULL)不受影响;手动选了状态时也放行。
if (!StringUtils.hasText(req.getStatus())) {
wrapper.and(w -> w.isNull(Drama::getCreatorId)
.or()
.notIn(Drama::getStatus, List.of("reviewing", "rejected")));
}
if (!dramaTypes.isEmpty()) { if (!dramaTypes.isEmpty()) {
DramaMultiTagSupport.applyDramaTypeTokens(wrapper, dramaTypes); DramaMultiTagSupport.applyDramaTypeTokens(wrapper, dramaTypes);
} }
@@ -162,6 +169,12 @@ public class DramaService {
requireState(current, "APPROVE", Set.of(ContentStatus.REVIEWING.getCode())); requireState(current, "APPROVE", Set.of(ContentStatus.REVIEWING.getCode()));
yield ContentStatus.APPROVED.getCode(); yield ContentStatus.APPROVED.getCode();
} }
case "APPROVE_PUBLISH" -> {
// 一步审核通过并上线,减少后台操作步骤
requireState(current, "APPROVE_PUBLISH", Set.of(ContentStatus.DRAFT.getCode(),
ContentStatus.REVIEWING.getCode(), ContentStatus.OFFLINE.getCode()));
yield ContentStatus.PUBLISHED.getCode();
}
case "REJECT" -> { case "REJECT" -> {
requireState(current, "REJECT", Set.of(ContentStatus.REVIEWING.getCode())); requireState(current, "REJECT", Set.of(ContentStatus.REVIEWING.getCode()));
yield ContentStatus.DRAFT.getCode(); yield ContentStatus.DRAFT.getCode();
@@ -4,11 +4,13 @@ package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper; import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper; import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper; import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.content.vo.DramaSaveReqVO; import com.fruitshorts.content.vo.DramaSaveReqVO;
import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
@@ -25,6 +27,7 @@ import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@@ -159,6 +162,45 @@ class DramaServiceTest {
verify(dramaEpisodeMapper, never()).updateById(any(DramaEpisode.class)); verify(dramaEpisodeMapper, never()).updateById(any(DramaEpisode.class));
} }
@Test
void should_publish_directly_from_draft_when_approve_publish() {
Drama drama = givenDramaWithStatus(ContentStatus.DRAFT.getCode());
dramaService.changeStatus(DRAMA_ID, "APPROVE_PUBLISH", "", 1L, "admin");
assertThat(drama.getStatus()).isEqualTo(ContentStatus.PUBLISHED.getCode());
assertThat(drama.getPublishedAt()).isNotNull();
verify(dramaMapper).updateById(drama);
// 上线时同步把草稿分集发布
verify(dramaEpisodeMapper).update(any(), any());
}
@Test
void should_publish_directly_from_reviewing_when_approve_publish() {
Drama drama = givenDramaWithStatus(ContentStatus.REVIEWING.getCode());
dramaService.changeStatus(DRAMA_ID, "APPROVE_PUBLISH", "", 1L, "admin");
assertThat(drama.getStatus()).isEqualTo(ContentStatus.PUBLISHED.getCode());
}
@Test
void should_reject_approve_publish_when_already_published() {
givenDramaWithStatus(ContentStatus.PUBLISHED.getCode());
assertThatThrownBy(() -> dramaService.changeStatus(DRAMA_ID, "APPROVE_PUBLISH", "", 1L, "admin"))
.isInstanceOf(ServiceException.class);
}
private Drama givenDramaWithStatus(String status) {
Drama drama = new Drama();
drama.setId(DRAMA_ID);
drama.setStatus(status);
given(dramaMapper.selectById(DRAMA_ID)).willReturn(drama);
given(dramaMapper.updateById(any(Drama.class))).willReturn(1);
return drama;
}
private void givenExistingDrama(int freeEpisodes, int unlockCoin) { private void givenExistingDrama(int freeEpisodes, int unlockCoin) {
Drama drama = new Drama(); Drama drama = new Drama();
drama.setId(DRAMA_ID); drama.setId(DRAMA_ID);
@@ -11,6 +11,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** 创作者内容提交与审核流转,对应 content_submission 表。 */ /** 创作者内容提交与审核流转,对应 content_submission 表。 */
@Data @Data
@@ -38,4 +39,40 @@ public class ContentSubmission {
@TableLogic @TableLogic
private Integer deleted; private Integer deleted;
/** 非持久化:后台列表接口附加的创作者展示信息 */
@TableField(exist = false)
private String creatorName;
@TableField(exist = false)
private String creatorCode;
@TableField(exist = false)
private String creatorMobile;
/** 非持久化:后台列表/详情接口附加的短剧预览 */
@TableField(exist = false)
private String dramaTitle;
@TableField(exist = false)
private String dramaCover;
@TableField(exist = false)
private String dramaDescription;
@TableField(exist = false)
private String dramaGenre;
@TableField(exist = false)
private String dramaLanguage;
@TableField(exist = false)
private Integer dramaTotalEpisodes;
/** 非持久化:后台详情接口附加的分集预览(审核弹窗展示) */
@TableField(exist = false)
private List<EpisodePreview> episodePreviews;
@Data
public static class EpisodePreview {
private Long id;
private Integer epNo;
private String title;
private Integer durationSec;
private String coverUrl;
private String status;
}
} }
@@ -9,12 +9,15 @@ import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException; import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.enums.ContentStatus; import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.creator.dal.entity.ContentSubmission; import com.fruitshorts.creator.dal.entity.ContentSubmission;
import com.fruitshorts.creator.dal.entity.Creator; import com.fruitshorts.creator.dal.entity.Creator;
import com.fruitshorts.creator.dal.mapper.ContentSubmissionMapper; import com.fruitshorts.creator.dal.mapper.ContentSubmissionMapper;
import com.fruitshorts.creator.dal.mapper.CreatorMapper;
import com.fruitshorts.creator.vo.SubmissionAuditReqVO; import com.fruitshorts.creator.vo.SubmissionAuditReqVO;
import com.fruitshorts.creator.vo.SubmissionQueryReqVO; import com.fruitshorts.creator.vo.SubmissionQueryReqVO;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -24,8 +27,10 @@ import org.springframework.util.StringUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors;
/** 创作者内容提交:后台检索、审核,通过后按 type 落到短剧状态机。 */ /** 创作者内容提交:后台检索、审核,通过后按 type 落到短剧状态机。 */
@Service @Service
@@ -50,8 +55,10 @@ public class ContentSubmissionService {
public static final Set<String> PENDING = Set.of(STATUS_SUBMITTED, STATUS_REVIEWING); public static final Set<String> PENDING = Set.of(STATUS_SUBMITTED, STATUS_REVIEWING);
private final ContentSubmissionMapper submissionMapper; private final ContentSubmissionMapper submissionMapper;
private final CreatorMapper creatorMapper;
private final DramaService dramaService; private final DramaService dramaService;
private final EpisodeService episodeService; private final EpisodeService episodeService;
private final DramaEpisodeMapper dramaEpisodeMapper;
public PageResult<ContentSubmission> page(SubmissionQueryReqVO req) { public PageResult<ContentSubmission> page(SubmissionQueryReqVO req) {
Page<ContentSubmission> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize()); Page<ContentSubmission> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
@@ -62,7 +69,9 @@ public class ContentSubmissionService {
.eq(StringUtils.hasText(req.getStatus()), ContentSubmission::getStatus, req.getStatus()) .eq(StringUtils.hasText(req.getStatus()), ContentSubmission::getStatus, req.getStatus())
.eq(StringUtils.hasText(req.getType()), ContentSubmission::getType, req.getType()) .eq(StringUtils.hasText(req.getType()), ContentSubmission::getType, req.getType())
.orderByDesc(ContentSubmission::getId)); .orderByDesc(ContentSubmission::getId));
return new PageResult<>(result.getTotal(), result.getRecords()); List<ContentSubmission> rows = result.getRecords();
enrichForList(rows);
return new PageResult<>(result.getTotal(), rows);
} }
public ContentSubmission getExisting(Long id) { public ContentSubmission getExisting(Long id) {
@@ -70,9 +79,67 @@ public class ContentSubmissionService {
if (submission == null) { if (submission == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "内容提交记录不存在"); throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "内容提交记录不存在");
} }
enrichForList(List.of(submission));
enrichEpisodes(submission);
return submission; return submission;
} }
/** 列表/详情共用的回填:创作者信息 + 短剧预览(标题/封面/简介/题材/集数)。 */
private void enrichForList(List<ContentSubmission> rows) {
if (rows == null || rows.isEmpty()) return;
Set<Long> creatorIds = rows.stream().map(ContentSubmission::getCreatorId).filter(Objects::nonNull).collect(Collectors.toSet());
if (!creatorIds.isEmpty()) {
List<Creator> creators = creatorMapper.selectBatchIds(creatorIds);
Map<Long, Creator> byId = new java.util.HashMap<>();
creators.forEach(c -> byId.put(c.getId(), c));
rows.forEach(s -> {
Creator c = byId.get(s.getCreatorId());
if (c != null) {
s.setCreatorName(c.getName());
s.setCreatorCode(c.getCode());
s.setCreatorMobile(c.getContactMobile());
}
});
}
Set<Long> dramaIds = rows.stream().map(ContentSubmission::getDramaId).filter(Objects::nonNull).collect(Collectors.toSet());
if (!dramaIds.isEmpty()) {
List<Drama> dramas = dramaService.listByIds(List.copyOf(dramaIds));
Map<Long, Drama> byDrama = new java.util.HashMap<>();
dramas.forEach(d -> byDrama.put(d.getId(), d));
rows.forEach(s -> {
Drama d = byDrama.get(s.getDramaId());
if (d != null) {
s.setDramaTitle(d.getTitle());
s.setDramaCover(d.getCoverUrl());
s.setDramaDescription(d.getSynopsis());
s.setDramaGenre(d.getGenre());
s.setDramaLanguage(d.getLanguage());
s.setDramaTotalEpisodes(d.getTotalEpisodes());
}
});
}
}
/** 详情接口额外回填分集预览列表,供审核弹窗展示。 */
private void enrichEpisodes(ContentSubmission submission) {
if (submission.getDramaId() == null) return;
List<DramaEpisode> eps = dramaEpisodeMapper.selectList(
Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, submission.getDramaId())
.orderByAsc(DramaEpisode::getEpNo));
List<ContentSubmission.EpisodePreview> previews = eps.stream().map(e -> {
ContentSubmission.EpisodePreview p = new ContentSubmission.EpisodePreview();
p.setId(e.getId());
p.setEpNo(e.getEpNo());
p.setTitle(e.getTitle());
p.setDurationSec(e.getDurationSec());
p.setCoverUrl(e.getCoverUrl());
p.setStatus(e.getStatus());
return p;
}).toList();
submission.setEpisodePreviews(previews);
}
/** /**
* 创作者提交内容。 * 创作者提交内容。
* *
@@ -205,6 +272,14 @@ public class ContentSubmissionService {
} }
} }
/** 按剧清掉所有提交记录(删剧时用)。 */
@Transactional(rollbackFor = Exception.class)
public void deleteByDrama(Long dramaId) {
if (dramaId == null) return;
submissionMapper.delete(Wrappers.<ContentSubmission>lambdaQuery()
.eq(ContentSubmission::getDramaId, dramaId));
}
/** 审核:通过 / 驳回 / 撤回。 */ /** 审核:通过 / 驳回 / 撤回。 */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public ContentSubmission audit(Long id, SubmissionAuditReqVO req, Long operatorId, String operatorName) { public ContentSubmission audit(Long id, SubmissionAuditReqVO req, Long operatorId, String operatorName) {
@@ -21,10 +21,10 @@ import org.springframework.util.StringUtils;
import java.security.SecureRandom; import java.security.SecureRandom;
/** /**
* 创作者账号:后台给审核通过的创作者开一个**独立后台账号**,他登录后直接落创作者中心。 * 创作者账号:后台直接给创作者开一个**独立后台账号**,他登录后直接落创作者中心。
* *
* 约定: * 约定:
* - 只有 `approved` 的创作者能开通(没审核过就发账号等于绕过入驻); * - 账号由后台直接发放,不再校验入驻审核状态(仅冻结的创作者不能开号);
* - **幂等**:已经绑定了 `admin_user_id` 就不重复建号,返回 `created=false` 且不回明文密码, * - **幂等**:已经绑定了 `admin_user_id` 就不重复建号,返回 `created=false` 且不回明文密码,
* 运营要重发就用「重置密码」; * 运营要重发就用「重置密码」;
* - 初始密码是**一次性明文**:只在开通 / 重置的响应里出现一次,库里存 BCrypt 哈希, * - 初始密码是**一次性明文**:只在开通 / 重置的响应里出现一次,库里存 BCrypt 哈希,
@@ -49,10 +49,9 @@ public class CreatorAccountService {
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public CreatorAccountRespVO open(Long creatorId, String usernameOverride) { public CreatorAccountRespVO open(Long creatorId, String usernameOverride) {
Creator creator = requireCreator(creatorId); Creator creator = requireCreator(creatorId);
if (!CreatorService.STATUS_APPROVED.equals(creator.getStatus())) { if (CreatorService.STATUS_FROZEN.equals(creator.getStatus())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"只有审核通过的创作者才能开通账号,当前状态:" "创作者已冻结,请先解冻后再开通账号");
+ CreatorService.statusText(creator.getStatus()));
} }
if (creator.getAdminUserId() != null && creator.getAdminUserId() > 0) { if (creator.getAdminUserId() != null && creator.getAdminUserId() > 0) {
AdminUser existing = adminUserService.getExisting(creator.getAdminUserId()); AdminUser existing = adminUserService.getExisting(creator.getAdminUserId());
@@ -21,9 +21,12 @@ import com.fruitshorts.creator.dal.entity.ContentSubmission;
import com.fruitshorts.creator.dal.entity.Creator; import com.fruitshorts.creator.dal.entity.Creator;
import com.fruitshorts.creator.vo.CreatorDramaEditReqVO; import com.fruitshorts.creator.vo.CreatorDramaEditReqVO;
import com.fruitshorts.creator.vo.CreatorEpisodeReqVO; import com.fruitshorts.creator.vo.CreatorEpisodeReqVO;
import com.fruitshorts.creator.vo.CreatorPublishReqVO;
import com.fruitshorts.creator.vo.CreatorPublishRespVO;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.media.dal.entity.MediaAsset; import com.fruitshorts.media.dal.entity.MediaAsset;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper; import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.service.EpisodeTranscodeBindingService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -49,6 +52,7 @@ public class CreatorContentService {
private final PriceService priceService; private final PriceService priceService;
private final DramaEpisodeMapper episodeMapper; private final DramaEpisodeMapper episodeMapper;
private final MediaAssetMapper mediaAssetMapper; private final MediaAssetMapper mediaAssetMapper;
private final EpisodeTranscodeBindingService transcodeBindingService;
/** 新建草稿:归属写自己,状态固定 draft(是否上线由后续提交审核决定)。 */ /** 新建草稿:归属写自己,状态固定 draft(是否上线由后续提交审核决定)。 */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -59,6 +63,59 @@ public class CreatorContentService {
return dramaService.createForCreator(toSaveReq(req), creator.getId(), creator.getAdminUserId()); return dramaService.createForCreator(toSaveReq(req), creator.getId(), creator.getAdminUserId());
} }
/**
* 一键发布:建作品 + 建全部分集 + 绑定视频 + (可选)提交审核,一个事务做完。
*
* <p>抖音式创作流程的落点:创作者先选好视频(前端已按顺序排列并截好封面),
* 这里按数组顺序分配集号。任何一集绑定失败都整体回滚,不会留下半成品草稿。
*/
@Transactional(rollbackFor = Exception.class)
public CreatorPublishRespVO publish(CreatorPublishReqVO req) {
Creator creator = centerService.requireCurrentCreator();
centerService.requireWritable();
requireApprovedCreator(creator);
List<CreatorPublishReqVO.EpisodeItem> items = req.getEpisodes();
if (items == null || items.isEmpty()) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请至少上传一集视频");
}
CreatorDramaEditReqVO dramaReq = new CreatorDramaEditReqVO();
dramaReq.setTitle(req.getTitle());
dramaReq.setSubtitle(req.getSubtitle());
dramaReq.setCategoryId(req.getCategoryId());
dramaReq.setGenre(req.getGenre());
dramaReq.setDescription(req.getDescription());
dramaReq.setCover(req.getCover());
dramaReq.setLanguage(req.getLanguage());
if ((dramaReq.getCover() == null || dramaReq.getCover().isBlank())
&& items.get(0).getCover() != null) {
// 没单独传作品封面时,用第一集封面兜底:抖音式流程里用户通常只截了视频首帧
dramaReq.setCover(items.get(0).getCover());
}
Long dramaId = createDrama(dramaReq);
for (int i = 0; i < items.size(); i++) {
CreatorPublishReqVO.EpisodeItem item = items.get(i);
CreatorEpisodeReqVO ep = new CreatorEpisodeReqVO();
ep.setEpNo(i + 1);
ep.setTitle(item.getTitle() == null || item.getTitle().isBlank()
? "第 " + (i + 1) + " 集" : item.getTitle().trim());
ep.setDurationSec(item.getDurationSec());
ep.setCover(item.getCover());
Long episodeId = addEpisode(dramaId, ep);
attachAsset(dramaId, episodeId, item.getAssetId());
}
Long submissionId = null;
if (Boolean.TRUE.equals(req.getSubmit())) {
ContentSubmission submission = submissionService.submit(
creator, dramaId, ContentSubmissionService.TYPE_CREATE);
submissionId = submission.getId();
}
Drama created = dramaService.getExisting(dramaId);
return new CreatorPublishRespVO(dramaId, submissionId, created.getStatus());
}
/** 编辑作品信息。定价、热门、排序不在本接口的可改范围,原样回填,避免被默认值冲掉。 */ /** 编辑作品信息。定价、热门、排序不在本接口的可改范围,原样回填,避免被默认值冲掉。 */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void updateDrama(Long dramaId, CreatorDramaEditReqVO req) { public void updateDrama(Long dramaId, CreatorDramaEditReqVO req) {
@@ -195,7 +252,10 @@ public class CreatorContentService {
"只能把视频产物(bizType=EPISODE)挂到分集,当前是 " + asset.getBizType()); "只能把视频产物(bizType=EPISODE)挂到分集,当前是 " + asset.getBizType());
} }
String assetStatus = asset.getStatus() == null ? "" : asset.getStatus(); String assetStatus = asset.getStatus() == null ? "" : asset.getStatus();
if (!"ready".equals(assetStatus) && !"uploaded".equals(assetStatus)) { // transcoding 也要放行:转码任务在 upload complete 时就已入队,此时任务还没绑定分集,
// 必须先把分集挂上再让 bind 绑任务,否则 worker 一直跳过(episode_id 未绑定)。
if (!"ready".equals(assetStatus) && !"uploaded".equals(assetStatus)
&& !"transcoding".equals(assetStatus)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "产物尚未就绪,当前状态:" + assetStatus); throw new ServiceException(ErrorCode.BAD_REQUEST, "产物尚未就绪,当前状态:" + assetStatus);
} }
@@ -206,6 +266,8 @@ public class CreatorContentService {
episode.setDurationSec(asset.getDurationSec()); episode.setDurationSec(asset.getDurationSec());
} }
episodeMapper.updateById(episode); episodeMapper.updateById(episode);
// 绑定转码任务:把排队中的任务挂到这部剧 / 这一集,worker 才会开始转码并回写 HLS
transcodeBindingService.bind(episode.getId(), dramaId, episode.getVideoUrl());
} }
public PriceRespVO priceDetail(Long dramaId) { public PriceRespVO priceDetail(Long dramaId) {
@@ -260,6 +322,21 @@ public class CreatorContentService {
"「" + ContentStatus.textOf(drama.getStatus()) + "」" + reason); "「" + ContentStatus.textOf(drama.getStatus()) + "」" + reason);
} }
/** 草稿 / 已下线 / 审核中(允许撤掉)均可直接删;已上线的走 DramaService 自己拦。 */
@Transactional(rollbackFor = Exception.class)
public void deleteDrama(Long dramaId) {
Creator creator = centerService.requireCurrentCreator();
centerService.requireWritable();
Drama drama = dramaService.getExisting(dramaId);
if (!Objects.equals(creator.getId(), drama.getCreatorId())) {
throw new ServiceException(ErrorCode.FORBIDDEN, "无权删除该作品");
}
// 清 submissions(审核中的、撤回的、已审核的),否则残留 submission 挂在不存在的 drama 上
submissionService.deleteByDrama(dramaId);
// 再调 DramaService.delete(禁删 PUBLISHED、级联删 drama_episode)
dramaService.delete(dramaId);
}
private void requireApprovedCreator(Creator creator) { private void requireApprovedCreator(Creator creator) {
if (!CreatorService.STATUS_APPROVED.equals(creator.getStatus())) { if (!CreatorService.STATUS_APPROVED.equals(creator.getStatus())) {
throw new ServiceException(ErrorCode.FORBIDDEN, throw new ServiceException(ErrorCode.FORBIDDEN,
@@ -63,8 +63,8 @@ public class CreatorService {
} }
/** /**
* 后台手工录入创作者主体(C 端申请入驻还没做,先用它打通链路)。 * 后台手工录入创作者主体。
* 新录入的主体是 pending,必须走一次入驻审核通过才能开通账号。 * 录入即开通:直接置为 approved,不再走入驻审核(账号由后台直接发放)。
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Long create(CreatorSaveReqVO req) { public Long create(CreatorSaveReqVO req) {
@@ -83,9 +83,10 @@ public class CreatorService {
creator.setCountryCode(StringUtils.hasText(req.getCountryCode()) ? req.getCountryCode() : "ID"); creator.setCountryCode(StringUtils.hasText(req.getCountryCode()) ? req.getCountryCode() : "ID");
creator.setUserId(req.getUserId() == null ? 0L : req.getUserId()); creator.setUserId(req.getUserId() == null ? 0L : req.getUserId());
creator.setAdminUserId(0L); creator.setAdminUserId(0L);
creator.setStatus(STATUS_PENDING); creator.setStatus(STATUS_APPROVED);
creator.setAuditNote(""); creator.setAuditNote("后台直接开通");
creator.setAuditBy(0L); creator.setAuditBy(0L);
creator.setAuditAt(LocalDateTime.now());
creator.setSettlementCurrency("IDR"); creator.setSettlementCurrency("IDR");
creator.setBankAccount(""); creator.setBankAccount("");
creator.setTaxNo(""); creator.setTaxNo("");
@@ -0,0 +1,52 @@
// Author: fruit.com.cn
package com.fruitshorts.creator.vo;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* 创作者中心 · 一键发布(抖音式:选视频 → 填标题简介 → 发布)。
*
* 一次请求把「建作品 + 建分集 + 绑定视频」全部做完,避免前端分多次调用时
* 中途失败留下半成品草稿。视频必须先经分片上传拿到 assetId,再由这里绑定。
*/
@Data
public class CreatorPublishReqVO {
@NotBlank(message = "请填写作品名称")
private String title;
private String subtitle;
/** 短剧分类 id(创作者下拉选择,如都市 / 古装 / 甜宠) */
private Long categoryId;
/** 题材标签(可选,自由文本,如"闪婚"、"霸总") */
private String genre;
private String description;
/** 作品封面;不传时取第一集封面 */
private String cover;
private String language;
/** 创建后立即提交审核,默认 true(抖音式一键发布) */
private Boolean submit = true;
@Valid
@NotEmpty(message = "请至少上传一集视频")
private List<EpisodeItem> episodes;
@Data
public static class EpisodeItem {
@NotNull(message = "缺少媒资 id")
private Long assetId;
private String title;
private Integer durationSec;
private String cover;
}
}
@@ -0,0 +1,17 @@
// Author: fruit.com.cn
package com.fruitshorts.creator.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
/** 一键发布结果:作品 id 与本次提交的审核单 id(不提交审核时为空)。 */
@Data
@AllArgsConstructor
public class CreatorPublishRespVO {
private Long dramaId;
private Long submissionId;
/** 作品状态:提交审核后为 reviewing */
private String status;
}
@@ -23,8 +23,8 @@ public class MediaProperties {
/** 单个文件大小上限(字节),默认 2GB。 */ /** 单个文件大小上限(字节),默认 2GB。 */
private long maxFileSize = 2L * 1024 * 1024 * 1024; private long maxFileSize = 2L * 1024 * 1024 * 1024;
/** 播放地址签名密钥(不少于 32 字节)。生产环境必须用环境变量注入。 */ /** 播放地址签名密钥(不少于 32 字节)。**所有环境必须通过环境变量注入,不设任何默认值。** */
private String signatureSecret = "fruitshorts-media-dev-secret-change-me-in-production-32b"; private String signatureSecret = "";
/** 播放签名有效期(分钟)。默认 15 分钟,足够播完一集且不易被长期复用。 */ /** 播放签名有效期(分钟)。默认 15 分钟,足够播完一集且不易被长期复用。 */
private int signatureTtlMinutes = 15; private int signatureTtlMinutes = 15;
@@ -19,7 +19,7 @@ import org.springframework.util.StringUtils;
public class EpisodeTranscodeBindingService { public class EpisodeTranscodeBindingService {
private final MediaAssetMapper mediaAssetMapper; private final MediaAssetMapper mediaAssetMapper;
private final MediaStorageService storageService; private final MediaStorage storageService;
private final TranscodeService transcodeService; private final TranscodeService transcodeService;
/** /**
@@ -38,7 +38,7 @@ public class MediaService {
Set.of(".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".avif"); Set.of(".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".avif");
private final MediaProperties properties; private final MediaProperties properties;
private final MediaStorageService storageService; private final MediaStorage storageService;
private final MediaAssetMapper mediaAssetMapper; private final MediaAssetMapper mediaAssetMapper;
private final MediaUploadTaskMapper mediaUploadTaskMapper; private final MediaUploadTaskMapper mediaUploadTaskMapper;
private final TranscodeService transcodeService; private final TranscodeService transcodeService;
@@ -104,7 +104,7 @@ public class MediaService {
"分片未传完(" + chunks + "/" + task.getTotalChunks() + ")"); "分片未传完(" + chunks + "/" + task.getTotalChunks() + ")");
} }
MediaStorageService.MergeResult merged = MediaStorage.MergeResult merged =
storageService.merge(req.getUploadId(), task.getObjectKey(), task.getTotalChunks()); storageService.merge(req.getUploadId(), task.getObjectKey(), task.getTotalChunks());
String publicUrl = storageService.toPublicUrl(task.getObjectKey()); String publicUrl = storageService.toPublicUrl(task.getObjectKey());
@@ -0,0 +1,58 @@
// Author: fruit.com.cn
package com.fruitshorts.media.service;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Path;
/**
* 媒资存储抽象:当前实现为本地磁盘({@link MediaStorageService}),
* 后期新增 S3 实现后以配置切换,业务代码不感知。
*
* <p>S3 实现要点:分片走 multipart upload 或本地暂存后 putObject;
* {@link #fileFor(String)} 改成「下载到临时目录 / 上传回对象存储」的适配(ffmpeg 需要本地文件);
* {@link #toPublicUrl(String)} 返回 CDN / 对象存储域名地址。
*/
public interface MediaStorage {
/** 合并结果:落地路径、大小与 MD5。 */
record MergeResult(Path path, long size, String md5) {
}
/** 存储根目录(本地实现为磁盘目录;S3 实现为本地暂存目录)。 */
Path rootDir();
/**
* 把 objectKey 解析成本地可读写的路径。转码产物落地用它。
* 实现方必须校验 objectKey 不允许跑出根目录。
*/
Path fileFor(String objectKey);
/** 把对外 URL 反解成 objectKey(去掉前缀与签名查询串)。 */
String objectKeyOf(String publicUrl);
/** 分片临时目录。 */
Path chunkDir(String uploadId);
/** 保存一个分片。 */
void saveChunk(String uploadId, int index, MultipartFile file);
/** 已收到的分片数。 */
int countChunks(String uploadId);
/** 按分片序号顺序合并成正式文件,返回大小与 MD5。 */
MergeResult merge(String uploadId, String objectKey, int totalChunks);
/** 清理分片临时目录。 */
void cleanChunkDir(String uploadId);
/** 删除一个媒资文件。 */
void delete(String objectKey);
/** 生成对象键:bizType/yyyyMM/uuid.ext。 */
String buildObjectKey(String bizType, String originalName);
/** objectKey 转对外访问 URL。 */
String toPublicUrl(String objectKey);
}
@@ -28,20 +28,17 @@ import java.util.UUID;
/** /**
* 本地磁盘媒资存储:分片写临时目录,完成后合并成正式文件。 * 本地磁盘媒资存储:分片写临时目录,完成后合并成正式文件。
* 之后要换成对象存储时,只需替换本类实现。 * 是 {@link MediaStorage} 的本地实现;切对象存储时新增实现类即可,业务代码只依赖接口。
*/ */
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class MediaStorageService { public class MediaStorageService implements MediaStorage {
private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM"); private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM");
private final MediaProperties properties; private final MediaProperties properties;
public record MergeResult(Path path, long size, String md5) {
}
public Path rootDir() { public Path rootDir() {
Path root = Paths.get(properties.getRootDir()).toAbsolutePath().normalize(); Path root = Paths.get(properties.getRootDir()).toAbsolutePath().normalize();
try { try {
@@ -50,7 +50,7 @@ public class MediaTranscodeOutputService {
private final MediaAssetMapper mediaAssetMapper; private final MediaAssetMapper mediaAssetMapper;
private final TranscodeService transcodeService; private final TranscodeService transcodeService;
private final MediaStorageService storageService; private final MediaStorage storageService;
private final PlaybackSigner signer; private final PlaybackSigner signer;
public List<MediaRenditionRespVO> renditions(Long mediaAssetId) { public List<MediaRenditionRespVO> renditions(Long mediaAssetId) {
@@ -19,7 +19,7 @@ import java.util.Map;
public class ThumbnailUrlResolver { public class ThumbnailUrlResolver {
private final TranscodeService transcodeService; private final TranscodeService transcodeService;
private final MediaStorageService storageService; private final MediaStorage storageService;
private final PlaybackSigner signer; private final PlaybackSigner signer;
/** 单集播放使用。 */ /** 单集播放使用。 */
@@ -6,7 +6,7 @@ import com.fruitshorts.media.config.MediaTranscodeProfile;
import com.fruitshorts.media.dal.entity.MediaThumbnail; import com.fruitshorts.media.dal.entity.MediaThumbnail;
import com.fruitshorts.media.dal.entity.TranscodeTask; import com.fruitshorts.media.dal.entity.TranscodeTask;
import com.fruitshorts.media.service.MediaService; import com.fruitshorts.media.service.MediaService;
import com.fruitshorts.media.service.MediaStorageService; import com.fruitshorts.media.service.MediaStorage;
import com.fruitshorts.media.service.MediaTranscodeProfileService; import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.media.service.EpisodeTranscodeCompletionPort; import com.fruitshorts.media.service.EpisodeTranscodeCompletionPort;
import com.fruitshorts.media.service.TranscodeErrorTruncator; import com.fruitshorts.media.service.TranscodeErrorTruncator;
@@ -45,7 +45,7 @@ public class TranscodeWorker {
private final TranscodeService transcodeService; private final TranscodeService transcodeService;
private final MediaService mediaService; private final MediaService mediaService;
private final MediaStorageService storageService; private final MediaStorage storageService;
private final TranscodeRunner transcodeRunner; private final TranscodeRunner transcodeRunner;
private final MediaTranscodeProfileService profileService; private final MediaTranscodeProfileService profileService;
private final EpisodeTranscodeCompletionPort episodeCompletionPort; private final EpisodeTranscodeCompletionPort episodeCompletionPort;
@@ -137,15 +137,22 @@ public class MenuService {
List<MenuVO> roots = new ArrayList<>(); List<MenuVO> roots = new ArrayList<>();
for (Menu menu : menus) { for (Menu menu : menus) {
MenuVO node = nodes.get(menu.getId()); MenuVO node = nodes.get(menu.getId());
MenuVO parent = menu.getParentId() == null ? null : nodes.get(menu.getParentId()); long parentId = menu.getParentId() == null ? 0L : menu.getParentId();
if (parent == null) { if (parentId == 0L) {
roots.add(node); roots.add(node);
} else { continue;
if (parent.getChildren() == null) {
parent.setChildren(new ArrayList<>());
}
parent.getChildren().add(node);
} }
MenuVO parent = nodes.get(parentId);
if (parent == null) {
// 父级被权限/状态过滤掉时,不能把子菜单提升为一级路由:
// 子菜单的 routePath 是相对路径(如 transcode-settings),
// 提升后缺少前导 "/",前端注册动态路由会报错并白屏。
continue;
}
if (parent.getChildren() == null) {
parent.setChildren(new ArrayList<>());
}
parent.getChildren().add(node);
} }
return roots; return roots;
} }
@@ -6,20 +6,25 @@ import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/** /**
* Google 登录配置。 * Google 登录配置。
* *
* <p>凭据只从配置/环境变量读,不入库、不提交到仓库: * <p>凭据只从配置/环境变量读,不入库、不提交到仓库:
* <pre> * <pre>
* fruitshorts.auth.google.client-id: 1234567890-xxx.apps.googleusercontent.com * fruitshorts.auth.google.client-ids: 1234-ios.apps.googleusercontent.com,1234-web.apps.googleusercontent.com
* </pre> * </pre>
* 未配置 clientId 时,Google 登录接口明确返回"未配置"错误,不会静默放行。 * 客户端各平台拿到的 idToken,其 audience 并不相同:iOS 是 iOS 客户端 id,
* Android 是 serverClientId 对应的 Web 客户端 id,因此这里必须允许配置多个。
* 未配置时,Google 登录接口明确返回"未配置"错误,不会静默放行。
*/ */
@Data @Data
@Component @Component
@ConfigurationProperties(prefix = "fruitshorts.auth.google") @ConfigurationProperties(prefix = "fruitshorts.auth.google")
public class GoogleAuthProperties { public class GoogleAuthProperties {
/** Google OAuth 客户端 id,用于校验 idToken 的 audience。 */ /** 允许的 Google OAuth 客户端 id 列表,用于校验 idToken 的 audience。 */
private String clientId = ""; private List<String> clientIds = new ArrayList<>();
} }
@@ -19,6 +19,7 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.List;
/** /**
* Google idToken 校验器。 * Google idToken 校验器。
@@ -45,11 +46,14 @@ public class GoogleIdTokenVerifier {
/** /**
* 校验 idToken 并返回 Google 用户信息。 * 校验 idToken 并返回 Google 用户信息。
* *
* <p>audience 按平台不同而不同(iOS 用 iOS 客户端 id,Android 用 Web 客户端 id),
* 因此只要命中配置列表里的任意一个即视为合法,同时拦截其他应用盗用令牌。
*
* @param idToken 客户端从 Google 拿到的 idToken * @param idToken 客户端从 Google 拿到的 idToken
*/ */
public GoogleUser verify(String idToken) { public GoogleUser verify(String idToken) {
String clientId = properties.getClientId(); List<String> clientIds = properties.getClientIds();
if (!StringUtils.hasText(clientId)) { if (clientIds == null || clientIds.isEmpty()) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "Google 登录未配置"); throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "Google 登录未配置");
} }
if (!StringUtils.hasText(idToken)) { if (!StringUtils.hasText(idToken)) {
@@ -58,7 +62,7 @@ public class GoogleIdTokenVerifier {
JsonNode body = fetchTokenInfo(idToken); JsonNode body = fetchTokenInfo(idToken);
String aud = body.path("aud").asText(""); String aud = body.path("aud").asText("");
String sub = body.path("sub").asText(""); String sub = body.path("sub").asText("");
if (!clientId.equals(aud) || !StringUtils.hasText(sub)) { if (!clientIds.contains(aud) || !StringUtils.hasText(sub)) {
throw new ServiceException(ErrorCode.UNAUTHORIZED, "Google 令牌无效"); throw new ServiceException(ErrorCode.UNAUTHORIZED, "Google 令牌无效");
} }
return new GoogleUser(sub, body.path("email").asText(""), return new GoogleUser(sub, body.path("email").asText(""),
@@ -8,6 +8,7 @@ import com.fruitshorts.media.dal.entity.MediaUploadTask;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper; import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.dal.mapper.MediaUploadTaskMapper; import com.fruitshorts.media.dal.mapper.MediaUploadTaskMapper;
import com.fruitshorts.media.service.MediaService; import com.fruitshorts.media.service.MediaService;
import com.fruitshorts.media.service.MediaStorage;
import com.fruitshorts.media.service.MediaStorageService; import com.fruitshorts.media.service.MediaStorageService;
import com.fruitshorts.media.service.EpisodeVideoSourcePort; import com.fruitshorts.media.service.EpisodeVideoSourcePort;
import com.fruitshorts.media.service.TranscodeService; import com.fruitshorts.media.service.TranscodeService;
@@ -63,7 +64,7 @@ class MediaServiceTranscodePipelineTest {
givenCompletedUpload("video.mp4"); givenCompletedUpload("video.mp4");
given(storageService.countChunks("upload-1")).willReturn(1); given(storageService.countChunks("upload-1")).willReturn(1);
given(storageService.merge("upload-1", "episode/202610/video.mp4", 1)) given(storageService.merge("upload-1", "episode/202610/video.mp4", 1))
.willReturn(new MediaStorageService.MergeResult( .willReturn(new MediaStorage.MergeResult(
Path.of("/tmp/video.mp4"), 1024L, "md5")); Path.of("/tmp/video.mp4"), 1024L, "md5"));
given(storageService.toPublicUrl("episode/202610/video.mp4")) given(storageService.toPublicUrl("episode/202610/video.mp4"))
.willReturn(SOURCE_URL); .willReturn(SOURCE_URL);
@@ -79,7 +80,7 @@ class MediaServiceTranscodePipelineTest {
givenCompletedUpload("cover.jpg"); givenCompletedUpload("cover.jpg");
given(storageService.countChunks("upload-1")).willReturn(1); given(storageService.countChunks("upload-1")).willReturn(1);
given(storageService.merge("upload-1", "episode/202610/cover.jpg", 1)) given(storageService.merge("upload-1", "episode/202610/cover.jpg", 1))
.willReturn(new MediaStorageService.MergeResult( .willReturn(new MediaStorage.MergeResult(
Path.of("/tmp/cover.jpg"), 512L, "md5")); Path.of("/tmp/cover.jpg"), 512L, "md5"));
given(storageService.toPublicUrl("episode/202610/cover.jpg")) given(storageService.toPublicUrl("episode/202610/cover.jpg"))
.willReturn("/files/episode/202610/cover.jpg"); .willReturn("/files/episode/202610/cover.jpg");
+3 -1
View File
@@ -261,7 +261,9 @@ INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, r
(27, 26, '我的作品', 'workspace', 'creator-workspace', 'views/creator-center/WorkspaceView', '', '', 'creator-center:view', 'MENU', 0, 0, 1, 1), (27, 26, '我的作品', 'workspace', 'creator-workspace', 'views/creator-center/WorkspaceView', '', '', 'creator-center:view', 'MENU', 0, 0, 1, 1),
(28, 26, '作品编辑', 'dramas/:id', 'creator-drama-edit', 'views/creator-center/DramaEditView', '', '', 'creator-center:drama:edit', 'MENU', 0, 0, 2, 0), (28, 26, '作品编辑', 'dramas/:id', 'creator-drama-edit', 'views/creator-center/DramaEditView', '', '', 'creator-center:drama:edit', 'MENU', 0, 0, 2, 0),
(29, 26, '收费配置', 'prices', 'creator-prices', 'views/creator-center/PriceConfigView', '', '', 'creator-center:price:update', 'MENU', 0, 0, 3, 1), (29, 26, '收费配置', 'prices', 'creator-prices', 'views/creator-center/PriceConfigView', '', '', 'creator-center:price:update', 'MENU', 0, 0, 3, 1),
(30, 26, '数据看板', 'dashboard', 'creator-dashboard', 'views/creator-center/DashboardView', '', '', 'creator-center:stat:view', 'MENU', 0, 0, 4, 1) (30, 26, '数据看板', 'dashboard', 'creator-dashboard', 'views/creator-center/DashboardView', '', '', 'creator-center:stat:view', 'MENU', 0, 0, 4, 1),
-- 31 是隐藏路由(一键发布页,从「我的作品」页的发布按钮进入,不进侧边栏)
(31, 26, '发布作品', 'publish', 'creator-publish', 'views/creator-center/PublishView', '', '', 'creator-center:upload', 'MENU', 0, 0, 0, 0)
ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title, 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, route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name, component = EXCLUDED.component,
redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code, redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code,
+7 -1
View File
@@ -80,9 +80,15 @@ ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.
type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort; type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort;
-- 转码设置菜单(挂在后台管理下,仅超级管理员可见) -- 转码设置菜单(挂在后台管理下,仅超级管理员可见)
-- 权限码必须独立:早期误用 episode:upload,会让具备上传权限的创作者角色
-- 也下发这条子菜单,而父级「后台管理」不在其可见范围内,菜单被提升为一级路由
-- 后因路径缺少前导 "/" 导致前端路由注册报错、白屏。
INSERT INTO sys_permission (id, code, name, group_name, type, sort)
VALUES (50000105, 'system:transcode:settings', '转码设置', '后台管理', 'MENU', 7)
ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort) VALUES permission_code, type, always_show, affix, sort) VALUES
(35, 6, '转码设置', 'transcode-settings', 'transcode-settings', 'views/systemManagement/TranscodeSettingsView', '', '', 'episode:upload', 'MENU', 0, 0, 7) (35, 6, '转码设置', 'transcode-settings', 'transcode-settings', 'views/systemManagement/TranscodeSettingsView', '', '', 'system:transcode:settings', 'MENU', 0, 0, 7)
ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title, 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, route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name, component = EXCLUDED.component,
redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code, redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code,
@@ -1,11 +1,17 @@
-- Author: fruit.com.cn -- Author: fruit.com.cn
-- 后台「转码设置」独立菜单(幂等,仅提交文件,由发布流程执行) -- 后台「转码设置」独立菜单(幂等,仅提交文件,由发布流程执行)
-- 权限码独立于 episode:upload:否则创作者角色会误得该子菜单,父级不可见时
-- 会被提升为一级相对路径路由,前端注册报错。
INSERT INTO sys_permission (id, code, name, group_name, type, sort)
VALUES (50000105, 'system:transcode:settings', '转码设置', '后台管理', 'MENU', 7)
ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort) VALUES permission_code, type, always_show, affix, sort) VALUES
(35, 6, '转码设置', 'transcode-settings', 'transcode-settings', (35, 6, '转码设置', 'transcode-settings', 'transcode-settings',
'views/systemManagement/TranscodeSettingsView', '', '', 'views/systemManagement/TranscodeSettingsView', '', '',
'episode:upload', 'MENU', 0, 0, 7) 'system:transcode:settings', 'MENU', 0, 0, 7)
ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title, ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title,
route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name, route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name,
component = EXCLUDED.component, redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, component = EXCLUDED.component, redirect = EXCLUDED.redirect, icon = EXCLUDED.icon,
@@ -0,0 +1,13 @@
-- Author: fruit.com.cn
-- 创作者中心「一键发布」隐藏路由(幂等,由发布流程执行)
-- visible=0:只作为路由存在,从「我的作品」页的发布按钮进入,不进侧边栏。
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort, visible) VALUES
(31, 26, '发布作品', 'publish', 'creator-publish', 'views/creator-center/PublishView',
'', '', 'creator-center:upload', 'MENU', 0, 0, 0, 0)
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,
redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code,
type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix,
sort = EXCLUDED.sort, visible = EXCLUDED.visible;
+238
View File
@@ -0,0 +1,238 @@
-- 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;
-- 菜单 id 用 46:35 已被「后台管理 / 转码设置」占用,复用会互相覆盖
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort) VALUES
(46, 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 (460000 + r.id), r.id, 46
FROM sys_role r
WHERE r.code = 'super_admin'
ON CONFLICT (role_id, menu_id) DO NOTHING;
+6
View File
@@ -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_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); 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 '客户端埋点事件原始落库(匿名上报,供后续分析与漏斗)'; COMMENT ON TABLE app_event_log IS '客户端埋点事件原始落库(匿名上报,供后续分析与漏斗)';
-- ============================================================
-- 十一、奖励中心(任务配置、签到、进度、发奖与活动)
-- ============================================================
-- 完整幂等建表、索引、触发器、种子任务与后台菜单见:
-- patch-2026-11-05-reward-center.sql。部署时执行该迁移,避免旧环境漏建奖励表。