This commit is contained in:
1 parent
2fee329002
commit
64eadf1446
52 files changed
+2590
-4
No files matched your search
+39
@@ -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;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
+10
@@ -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> {
|
||||
}
|
||||
+10
@@ -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> {
|
||||
}
|
||||
+10
@@ -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> {
|
||||
}
|
||||
+10
@@ -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> {
|
||||
}
|
||||
+10
@@ -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> {
|
||||
}
|
||||
+10
@@ -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> {
|
||||
}
|
||||
+10
@@ -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> {
|
||||
}
|
||||
+170
@@ -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;
|
||||
}
|
||||
}
|
||||
+140
@@ -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;
|
||||
}
|
||||
}
|
||||
+77
@@ -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();
|
||||
}
|
||||
}
|
||||
+306
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+79
@@ -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);
|
||||
}
|
||||
}
|
||||
+30
@@ -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();
|
||||
}
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+48
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+90
@@ -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();
|
||||
}
|
||||
}
|
||||
+56
@@ -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;
|
||||
}
|
||||
+1
@@ -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;
|
||||
|
||||
+131
@@ -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;
|
||||
}
|
||||
}
|
||||
+93
@@ -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;
|
||||
}
|
||||
}
|
||||
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user