feat(commerce): 新增打赏任务中心与签到发奖
backend-ci / test (push) Failing after 4m53s

This commit is contained in:
Zhou Weidong committed 2026-11-09 10:32:53 +08:00
1 parent 2fee329002
commit 64eadf1446
52 files changed
+2590 -4

No files matched your search

@@ -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,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,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_REFUND = "REFUND";
public static final String BIZ_ADMIN = "ADMIN";
public static final String BIZ_REWARD = "REWARD";
private final WalletMapper walletMapper;
private final CoinLedgerMapper ledgerMapper;
@@ -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;
}
}