Browse Source

补充用户端积分接口

master
glx 1 month ago
parent
commit
3520819371
  1. 1
      src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java
  2. 7
      src/main/java/com/youlai/boot/common/constant/CommonConstants.java
  3. 51
      src/main/java/com/youlai/boot/mini/controller/PointController.java
  4. 7
      src/main/java/com/youlai/boot/mini/converter/MiniPointRuleConverter.java
  5. 24
      src/main/java/com/youlai/boot/mini/mapper/MiniSignRecordMapper.java
  6. 79
      src/main/java/com/youlai/boot/mini/model/entity/MiniSignRecord.java
  7. 8
      src/main/java/com/youlai/boot/mini/model/form/AdjustUserPointForm.java
  8. 22
      src/main/java/com/youlai/boot/mini/model/vo/SignResultVO.java
  9. 22
      src/main/java/com/youlai/boot/mini/model/vo/SignStatusVO.java
  10. 24
      src/main/java/com/youlai/boot/mini/service/MiniPointRecordService.java
  11. 24
      src/main/java/com/youlai/boot/mini/service/MiniSignService.java
  12. 322
      src/main/java/com/youlai/boot/mini/service/impl/MiniPointRecordServiceImpl.java
  13. 173
      src/main/java/com/youlai/boot/mini/service/impl/MiniSignServiceImpl.java
  14. 30
      src/main/resources/mapper/mini/MiniSignRecordMapper.xml

1
src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java

@ -98,6 +98,7 @@ public class MyBatisPlusGenerator {
,new TableConfig("mini_point_account", IdType.AUTO, "mini") ,new TableConfig("mini_point_account", IdType.AUTO, "mini")
,new TableConfig("mini_point_record", IdType.AUTO, "mini") ,new TableConfig("mini_point_record", IdType.AUTO, "mini")
,new TableConfig("mini_point_rule", IdType.AUTO, "mini") ,new TableConfig("mini_point_rule", IdType.AUTO, "mini")
,new TableConfig("mini_sign_record", IdType.AUTO, "mini")
// ,new TableConfig("mini_stray_animal", IdType.AUTO, "mini") // ,new TableConfig("mini_stray_animal", IdType.AUTO, "mini")
// ,new TableConfig("mini_stray_animal", IdType.INPUT, "minitest") // ,new TableConfig("mini_stray_animal", IdType.INPUT, "minitest")

7
src/main/java/com/youlai/boot/common/constant/CommonConstants.java

@ -9,4 +9,11 @@ public class CommonConstants {
//geohash的level //geohash的level
public static final int GEOHASH_LEVEL = 12; public static final int GEOHASH_LEVEL = 12;
//每日签到幂等Key:占位符1=用户ID,占位符2=日期yyyyMMdd
public static final String SIGN_DAY_KEY = "sign:user:%s:%s";
//连续签到天数缓存Key:占位符=用户ID
public static final String SIGN_CONTINUOUS_KEY = "sign:continuous:%s";
//用户分享次数Key:占位符1=周期类型,占位符2=用户ID,占位符3=周期标识
public static final String SHARE_COUNT_KEY = "share:count:%s:%s:%s";
} }

51
src/main/java/com/youlai/boot/mini/controller/PointController.java

@ -1,18 +1,19 @@
package com.youlai.boot.mini.controller; package com.youlai.boot.mini.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.youlai.boot.common.result.PageResult; import com.youlai.boot.common.result.PageResult;
import com.youlai.boot.common.result.Result; import com.youlai.boot.common.result.Result;
import com.youlai.boot.framework.security.util.SecurityUtils; import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.common.result.PageResult;
import com.youlai.boot.mini.model.query.MyPointRecordQuery; import com.youlai.boot.mini.model.query.MyPointRecordQuery;
import com.youlai.boot.mini.model.query.PointAccountQuery;
import com.youlai.boot.mini.model.vo.MyPointRecordVO; import com.youlai.boot.mini.model.vo.MyPointRecordVO;
import com.youlai.boot.mini.model.vo.MyPointVO; import com.youlai.boot.mini.model.vo.MyPointVO;
import com.youlai.boot.mini.model.vo.SignResultVO;
import com.youlai.boot.mini.model.vo.SignStatusVO;
import com.youlai.boot.mini.service.MiniPointAccountService; import com.youlai.boot.mini.service.MiniPointAccountService;
import com.youlai.boot.mini.service.MiniPointRecordService; import com.youlai.boot.mini.service.MiniPointRecordService;
import com.youlai.boot.mini.service.MiniPointRuleService; import com.youlai.boot.mini.service.MiniPointRuleService;
import com.youlai.boot.mini.service.MiniSignService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject; import org.springdoc.core.annotations.ParameterObject;
@ -26,7 +27,8 @@ public class PointController {
private final MiniPointAccountService pointAccountService; private final MiniPointAccountService pointAccountService;
private final MiniPointRuleService pointRuleService; private final MiniPointRuleService pointRuleService;
private final MiniPointRecordService recordService; private final MiniPointRecordService pointRecordService;
private final MiniSignService signService;
@Operation(summary = "查询当前用户的积分账户信息") @Operation(summary = "查询当前用户的积分账户信息")
@GetMapping("/my") @GetMapping("/my")
@ -40,16 +42,45 @@ public class PointController {
@GetMapping("/records") @GetMapping("/records")
public PageResult<MyPointRecordVO> getMyPointRecords(@ParameterObject MyPointRecordQuery query) { public PageResult<MyPointRecordVO> getMyPointRecords(@ParameterObject MyPointRecordQuery query) {
Long userId = SecurityUtils.getUserId(); Long userId = SecurityUtils.getUserId();
return PageResult.success(recordService.pageMyPointRecord(userId, query)); return PageResult.success(pointRecordService.pageMyPointRecord(userId, query));
} }
//事件监听 实现 用户注册赠送积分 @Operation(summary = "用户签到")
@PostMapping("/sign")
public Result<SignResultVO> sign() {
return Result.success(signService.sign());
}
//每日签到,加签到表, @Operation(summary = "查询用户签到状态/当月签到日历")
@GetMapping("/sign/status")
public Result<SignStatusVO> getSignStatus() {
return Result.success(signService.getSignStatus());
}
@Operation(summary = "用户分享领取奖励")
@PostMapping("/share/reward")
public Result<Integer> shareReward() {
Long userId = SecurityUtils.getUserId();
Integer point = pointRecordService.giveShareReward(userId);
return Result.success(point);
}
//分享奖励,分享链接带上分享人ID,其他用户点击链接进入小程序后才给分享人发奖励 @Operation(summary = "登记流浪动物领取积分")
@PostMapping("/register-animal/reward")
public Result<Integer> registerAnimalReward(
@Parameter(description = "登记的流浪动物ID", required = true) @RequestParam Long animalId) {
Long userId = SecurityUtils.getUserId();
Integer point = pointRecordService.giveRegisterAnimalReward(userId, animalId);
return Result.success(point);
}
//AI生成图片扣费 @Operation(summary = "AI生成扣除积分")
@PostMapping("/ai-generate/deduct")
public Result<Integer> aiGenerateImageDeduct(
@Parameter(description = "AI生成任务唯一ID(用于幂等)", required = true) @RequestParam String taskId) {
Long userId = SecurityUtils.getUserId();
Integer deductPoint = pointRecordService.deductAiGenerateImagePoint(userId, taskId);
return Result.success(deductPoint);
}
//AI生成视频扣费
} }

7
src/main/java/com/youlai/boot/mini/converter/MiniPointRuleConverter.java

@ -6,6 +6,7 @@ import com.youlai.boot.mini.model.form.AddPointRuleForm;
import com.youlai.boot.mini.model.vo.RuleListVO; import com.youlai.boot.mini.model.vo.RuleListVO;
import org.mapstruct.Mapper; import org.mapstruct.Mapper;
import org.mapstruct.Mapping; import org.mapstruct.Mapping;
import org.mapstruct.Named;
/** /**
* 积分规则对象转换器 * 积分规则对象转换器
@ -21,8 +22,14 @@ public interface MiniPointRuleConverter {
/** /**
* 单个对象转换 * 单个对象转换
*/ */
@Mapping(source = "status", target = "status", qualifiedByName = "booleanToInt")
RuleListVO toRuleVo(MiniPointRule entity); RuleListVO toRuleVo(MiniPointRule entity);
@Named("booleanToInt")
default Integer booleanToInt(Boolean status) {
return status == null ? null : (status ? 1 : 0);
}
/** /**
* 新增表单转实体 * 新增表单转实体
*/ */

24
src/main/java/com/youlai/boot/mini/mapper/MiniSignRecordMapper.java

@ -0,0 +1,24 @@
package com.youlai.boot.mini.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.youlai.boot.mini.model.entity.MiniSignRecord;
import java.util.Date;
import java.util.List;
/**
* 签到记录Mapper
*/
public interface MiniSignRecordMapper extends BaseMapper<MiniSignRecord> {
/**
* 查询用户最近一次的签到记录
*/
MiniSignRecord selectLastSignRecord(Long userId);
/**
* 查询用户当月所有签到日期
* @param userId 用户ID
* @param yearMonth 年月格式yyyyMM
*/
List<Date> selectMonthSignDates(Long userId, String yearMonth);
}

79
src/main/java/com/youlai/boot/mini/model/entity/MiniSignRecord.java

@ -0,0 +1,79 @@
package com.youlai.boot.mini.model.entity;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
@Getter
@Setter
@ToString
@Accessors(chain = true)
@TableName("mini_sign_record")
@Schema(description = "签到记录表")
public class MiniSignRecord implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "签到记录表主键id")
private Long id;
@TableField("uuid")
@Schema(description = "uuid唯一标识,前后端用这个进行数据交互")
private String uuid;
@TableField("user_id")
@Schema(description = "用户id")
private Long userId;
@TableField("continuous_days")
@Schema(description = "本次签到后的连续天数")
private Integer continuousDays;
@TableField("points")
@Schema(description = "本次获得积分")
private Integer points;
@TableField("sign_date")
@Schema(description = "签到日期唯一约束用")
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date signDate;
@TableField("create_time")
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
@TableField("create_timestamp")
@Schema(description = "创建时间毫秒级时间戳")
private Long createTimestamp;
@TableField("create_by")
@Schema(description = "创建人ID")
private Long createBy;
@TableField("update_time")
@Schema(description = "更新时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updateTime;
@TableField("update_timestamp")
@Schema(description = "更新时间毫秒级时间戳")
private Long updateTimestamp;
@TableField("update_by")
@Schema(description = "修改人ID")
private Long updateBy;
@TableField("is_deleted")
@Schema(description = "逻辑删除标识(0-未删除 1-已删除)")
private Boolean deleted;
}

8
src/main/java/com/youlai/boot/mini/model/form/AdjustUserPointForm.java

@ -18,7 +18,7 @@ public class AdjustUserPointForm {
private Long userId; private Long userId;
@NotBlank(message = "业务类型不能为空") @NotBlank(message = "业务类型不能为空")
@EnumValid(enumClass = AdjustUserPointEnum.class, message = "业务类型不合法") // @EnumValid(enumClass = AdjustUserPointEnum.class, message = "业务类型不合法")
@Schema(description = "业务类型 system_increase system_reduce", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "业务类型 system_increase system_reduce", requiredMode = Schema.RequiredMode.REQUIRED)
private String bizType; private String bizType;
@ -26,4 +26,10 @@ public class AdjustUserPointForm {
@Schema(description = "变化值(+增加,-扣减)", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "变化值(+增加,-扣减)", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer changeAmount; private Integer changeAmount;
@Schema(description = "业务唯一ID,幂等用,比如AI生成任务ID")
private String bizId;
@Schema(description = "备注,存业务关联信息")
private String remark;
} }

22
src/main/java/com/youlai/boot/mini/model/vo/SignResultVO.java

@ -0,0 +1,22 @@
package com.youlai.boot.mini.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 签到结果返回VO
*/
@Data
@Schema(description = "签到结果返回")
public class SignResultVO {
@Schema(description = "是否签到成功")
private Boolean success;
@Schema(description = "本次总共获得积分")
private Integer totalPoint;
@Schema(description = "当前连续签到天数")
private Integer continuousDays;
}

22
src/main/java/com/youlai/boot/mini/model/vo/SignStatusVO.java

@ -0,0 +1,22 @@
package com.youlai.boot.mini.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
* 签到状态返回VO
*/
@Data
@Schema(description = "签到状态返回")
public class SignStatusVO {
@Schema(description = "今日是否已签到")
private Boolean todaySigned;
@Schema(description = "当前连续签到天数")
private Integer continuousDays;
@Schema(description = "当月已签到日期列表,格式yyyy-MM-dd,用于前端渲染签到日历")
private List<String> signedDates;
}

24
src/main/java/com/youlai/boot/mini/service/MiniPointRecordService.java

@ -32,4 +32,28 @@ public interface MiniPointRecordService extends IService<MiniPointRecord> {
*/ */
IPage<MyPointRecordVO> pageMyPointRecord(Long userId, MyPointRecordQuery query); IPage<MyPointRecordVO> pageMyPointRecord(Long userId, MyPointRecordQuery query);
/**
* 发放分享奖励
* @param userId 用户ID
* @return 获得的积分
*/
Integer giveShareReward(Long userId);
/**
* 登记流浪动物领取积分
* @param userId 用户ID
* @param animalId 登记的动物ID幂等用防止同一动物重复领积分
* @return 获得的积分0表示没有获得次数超限/已经领过
*/
Integer giveRegisterAnimalReward(Long userId, Long animalId);
/**
* AI生成图片扣除用户积分
* @param userId 用户ID
* @param taskId AI生成任务唯一ID幂等用防止重复扣费
* @return 扣除的积分值负数大于等于0表示扣除失败积分不足/已经扣过
*/
Integer deductAiGenerateImagePoint(Long userId, String taskId);
} }

24
src/main/java/com/youlai/boot/mini/service/MiniSignService.java

@ -0,0 +1,24 @@
package com.youlai.boot.mini.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.mini.model.entity.MiniSignRecord;
import com.youlai.boot.mini.model.vo.SignResultVO;
import com.youlai.boot.mini.model.vo.SignStatusVO;
/**
* 签到服务接口
*/
public interface MiniSignService extends IService<MiniSignRecord> {
/**
* 用户签到
* @return 签到结果
*/
SignResultVO sign();
/**
* 查询用户签到状态包括今日是否签到连续天数当月签到日历
* @return 签到状态
*/
SignStatusVO getSignStatus();
}

322
src/main/java/com/youlai/boot/mini/service/impl/MiniPointRecordServiceImpl.java

@ -1,14 +1,19 @@
package com.youlai.boot.mini.service.impl; package com.youlai.boot.mini.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.common.constant.CommonConstants;
import com.youlai.boot.common.exception.MsgException; import com.youlai.boot.common.exception.MsgException;
import com.youlai.boot.framework.security.util.SecurityUtils; import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.mini.mapper.MiniPointAccountMapper;
import com.youlai.boot.mini.mapper.MiniPointRecordMapper; import com.youlai.boot.mini.mapper.MiniPointRecordMapper;
import com.youlai.boot.mini.model.entity.MiniPointAccount; import com.youlai.boot.mini.model.entity.MiniPointAccount;
import com.youlai.boot.mini.model.entity.MiniPointRecord; import com.youlai.boot.mini.model.entity.MiniPointRecord;
import com.youlai.boot.mini.model.entity.MiniPointRule;
import com.youlai.boot.mini.model.form.AdjustUserPointForm; import com.youlai.boot.mini.model.form.AdjustUserPointForm;
import com.youlai.boot.mini.model.query.MyPointRecordQuery; import com.youlai.boot.mini.model.query.MyPointRecordQuery;
import com.youlai.boot.mini.model.query.PointRecordQuery; import com.youlai.boot.mini.model.query.PointRecordQuery;
@ -16,13 +21,24 @@ import com.youlai.boot.mini.model.vo.MyPointRecordVO;
import com.youlai.boot.mini.model.vo.PointRecordVO; import com.youlai.boot.mini.model.vo.PointRecordVO;
import com.youlai.boot.mini.service.MiniPointAccountService; import com.youlai.boot.mini.service.MiniPointAccountService;
import com.youlai.boot.mini.service.MiniPointRecordService; import com.youlai.boot.mini.service.MiniPointRecordService;
import com.youlai.boot.mini.service.MiniPointRuleService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.time.temporal.WeekFields;
import java.util.Date; import java.util.Date;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@ -30,6 +46,9 @@ import java.util.UUID;
public class MiniPointRecordServiceImpl extends ServiceImpl<MiniPointRecordMapper, MiniPointRecord> implements MiniPointRecordService { public class MiniPointRecordServiceImpl extends ServiceImpl<MiniPointRecordMapper, MiniPointRecord> implements MiniPointRecordService {
private final MiniPointAccountService miniPointAccountService; private final MiniPointAccountService miniPointAccountService;
private final MiniPointRuleService ruleService;
private final StringRedisTemplate redisTemplate;
private final MiniPointAccountMapper miniPointAccountMapper;
@Override @Override
@ -70,7 +89,9 @@ public class MiniPointRecordServiceImpl extends ServiceImpl<MiniPointRecordMappe
record.setChangeAmount(form.getChangeAmount()); record.setChangeAmount(form.getChangeAmount());
record.setBalanceAfter(afterBalance); record.setBalanceAfter(afterBalance);
record.setBizType(form.getBizType()); record.setBizType(form.getBizType());
record.setBizId(UUID.randomUUID().toString().replace("-", "")); // 业务唯一ID,保证幂等 // record.setRemark(form.getRemark());
// 优先用传进来的业务唯一ID(比如AI任务ID),没有的话自动生成,保证幂等
record.setBizId(StrUtil.isNotBlank(form.getBizId()) ? form.getBizId() : UUID.randomUUID().toString().replace("-", ""));
record.setCreateBy(SecurityUtils.getUserId()); record.setCreateBy(SecurityUtils.getUserId());
record.setCreateTime(new Date()); record.setCreateTime(new Date());
record.setCreateTimestamp(System.currentTimeMillis()); record.setCreateTimestamp(System.currentTimeMillis());
@ -89,4 +110,303 @@ public class MiniPointRecordServiceImpl extends ServiceImpl<MiniPointRecordMappe
return baseMapper.pageMyPointRecord(page, userId, query); return baseMapper.pageMyPointRecord(page, userId, query);
} }
/**
* 简单封装通用周期校验
* @param ruleCode 规则编码
* @param userId 用户ID
* @param bizPrefix 业务前缀避免不同业务Key冲突
* @return 校验结果[是否允许, 奖励积分, 计数Key, 过期天数, 周期类型, 超限提示, Redis是否正常]
*/
private Object[] checkPeriodLimit(String ruleCode, Long userId, String bizPrefix) {
// 1. 查询规则
MiniPointRule rule = ruleService.getOne(new LambdaQueryWrapper<MiniPointRule>()
.eq(MiniPointRule::getRuleCode, ruleCode)
.eq(MiniPointRule::getStatus, false)
.last("LIMIT 1"));
if (rule == null || rule.getPoints() <= 0) {
return new Object[]{false, 0, null, 0L, null, "活动暂未开放", true};
}
int rewardPoint = rule.getPoints();
String limitPeriod = rule.getLimitPeriod();
int limitCount = rule.getLimitCount();
LocalDate now = LocalDate.now();
// 2. 分情况处理周期逻辑
if ("ALL".equals(limitPeriod)) {
// ALL周期:永久限制次数,无时间范围,适合注册送分这类一生一次的奖励
String countKey = String.format("reward:count:%s:all:%s:all", bizPrefix, userId);
long currentCount = 0;
boolean redisNormal = true;
try {
currentCount = Optional.ofNullable(redisTemplate.opsForValue().get(countKey)).map(Long::parseLong).orElse(0L);
} catch (Exception e) {
redisNormal = false;
log.warn("Redis不可用,触发数据库计数兜底,ruleCode={}, userId={}", ruleCode, userId, e);
currentCount = countRecordByPeriod(userId, bizPrefix, limitPeriod, now);
}
// 校验永久次数上限
if (currentCount >= limitCount) {
return new Object[]{false, 0, null, 0L, limitPeriod, "该奖励仅可领取" + limitCount + "次", redisNormal};
}
// ALL周期缓存设置10年过期,足够覆盖用户生命周期
return new Object[]{true, rewardPoint, countKey, 3650L, limitPeriod, null, redisNormal};
}
// limit_period = null 才是完全无限制,不做任何次数校验
if (limitPeriod == null) {
return new Object[]{true, rewardPoint, null, 0L, limitPeriod, null, true};
}
// 3. 周期计算
String periodKey;
long expireDays = 0;
String limitTip = "";
switch (limitPeriod) {
case "DAY":
periodKey = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
expireDays = 1L;
limitTip = "今日奖励已达上限,请明天再来";
break;
case "WEEK":
//用本周一日期作为周期key,解决跨年周计数分裂问题
LocalDate monday = now.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
periodKey = monday.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
expireDays = 8L;
limitTip = "本周奖励已达上限,请下周再来";
break;
case "MONTH":
periodKey = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
expireDays = 32L;
limitTip = "本月奖励已达上限,请下月再来";
break;
case "YEAR":
periodKey = String.valueOf(now.getYear());
expireDays = 366L;
limitTip = "本年奖励已达上限,请明年再来";
break;
default:
return new Object[]{true, rewardPoint, null, 0L, limitPeriod, null, true};
}
// 4. 计数校验:优先读Redis,异常时走数据库兜底
String countKey = String.format("reward:count:%s:%s:%s:%s", bizPrefix, limitPeriod.toLowerCase(), userId, periodKey);
long currentCount = 0;
boolean redisNormal = true;
try {
currentCount = Optional.ofNullable(redisTemplate.opsForValue().get(countKey)).map(Long::parseLong).orElse(0L);
} catch (Exception e) {
// Redis异常,走数据库兜底
redisNormal = false;
log.warn("Redis不可用,触发数据库计数兜底,ruleCode={}, userId={}", ruleCode, userId, e);
currentCount = countRecordByPeriod(userId, bizPrefix, limitPeriod, now);
}
if (currentCount >= limitCount) {
return new Object[]{false, 0, null, 0L, limitPeriod, limitTip, redisNormal};
}
return new Object[]{true, rewardPoint, countKey, expireDays, limitPeriod, null, redisNormal};
}
/**
* 按周期从积分流水表统计用户领取次数100%准确作为Redis兜底
*/
private long countRecordByPeriod(Long userId, String bizType, String limitPeriod, LocalDate now) {
// ALL周期:统计该用户该业务类型的所有流水,无时间范围
if ("ALL".equals(limitPeriod)) {
return count(new LambdaQueryWrapper<MiniPointRecord>()
.eq(MiniPointRecord::getUserId, userId)
.eq(MiniPointRecord::getBizType, bizType.toUpperCase())
.eq(MiniPointRecord::getDeleted, 0));
}
LocalDateTime startTime, endTime;
switch (limitPeriod) {
case "DAY":
startTime = now.atStartOfDay();
endTime = now.plusDays(1).atStartOfDay().minusNanos(1);
break;
case "WEEK":
LocalDate monday = now.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
startTime = monday.atStartOfDay();
endTime = monday.plusWeeks(1).atStartOfDay().minusNanos(1);
break;
case "MONTH":
LocalDate firstDay = now.with(TemporalAdjusters.firstDayOfMonth());
startTime = firstDay.atStartOfDay();
endTime = firstDay.plusMonths(1).atStartOfDay().minusNanos(1);
break;
case "YEAR":
LocalDate firstYearDay = LocalDate.of(now.getYear(), 1, 1);
startTime = firstYearDay.atStartOfDay();
endTime = firstYearDay.plusYears(1).atStartOfDay().minusNanos(1);
break;
default:
return 0;
}
// 统计当前周期内该业务类型的流水数量
return count(new LambdaQueryWrapper<MiniPointRecord>()
.eq(MiniPointRecord::getUserId, userId)
.eq(MiniPointRecord::getBizType, bizType.toUpperCase()) // 和流水的biz_type保持一致
.ge(MiniPointRecord::getCreateTime, startTime)
.le(MiniPointRecord::getCreateTime, endTime)
.eq(MiniPointRecord::getDeleted, 0));
}
@Override
@Transactional(rollbackFor = Exception.class)
public Integer giveShareReward(Long userId) {
// 调用通用周期校验方法
Object[] checkResult = checkPeriodLimit("SHARE_CONTENT", userId, "share");
boolean allow = (boolean) checkResult[0];
int rewardPoint = (int) checkResult[1];
String countKey = (String) checkResult[2];
long expireDays = (long) checkResult[3];
String limitPeriod = (String) checkResult[4];
String limitTip = (String) checkResult[5];
boolean redisNormal = (boolean) checkResult[6];
// 校验不通过直接抛异常
if (!allow) {
throw new MsgException(limitTip);
}
// 发放积分
AdjustUserPointForm adjustForm = new AdjustUserPointForm();
adjustForm.setUserId(userId);
adjustForm.setBizType("SHARE_CONTENT");
adjustForm.setChangeAmount(rewardPoint);
adjustPoint(adjustForm);
// 有周期限制且Redis正常的情况下才更新缓存,Redis异常时跳过不报错
if (countKey != null && expireDays > 0 && redisNormal) {
redisTemplate.opsForValue().increment(countKey, 1);
// 日周期特殊存25小时,避免跨天服务器时间差问题
if ("DAY".equals(limitPeriod)) {
redisTemplate.expire(countKey, 25, TimeUnit.HOURS);
} else {
redisTemplate.expire(countKey, expireDays, TimeUnit.DAYS);
}
}
return rewardPoint;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Integer deductAiGenerateImagePoint(Long userId, String taskId) {
try {
// 1. 幂等校验:同一个任务ID只能扣一次费,防止重试重复扣费
String idempotentKey = String.format("reward:ai_image:idempotent:%s", taskId);
boolean alreadyDeducted = false;
try {
alreadyDeducted = Boolean.TRUE.equals(redisTemplate.hasKey(idempotentKey));
} catch (Exception e) {
// Redis异常查数据库兜底:查bizId等于taskId的流水
long count = count(new LambdaQueryWrapper<MiniPointRecord>()
.eq(MiniPointRecord::getBizType, "AI_GENERATE_IMAGE")
.eq(MiniPointRecord::getBizId, taskId)
.eq(MiniPointRecord::getDeleted, 0));
alreadyDeducted = count > 0;
}
if (alreadyDeducted) {
throw new MsgException("任务已处理");
}
// 2. 调用通用周期校验,规则编码AI_GENERATE_IMAGE,业务前缀ai_generate_image
Object[] checkResult = checkPeriodLimit("AI_GENERATE_IMAGE", userId, "ai_generate_image");
boolean allow = (boolean) checkResult[0];
int deductPoint = (int) checkResult[1]; // 规则配置的扣除积分数,应该是负数
String countKey = (String) checkResult[2];
long expireDays = (long) checkResult[3];
String limitPeriod = (String) checkResult[4];
boolean redisNormal = (boolean) checkResult[6];
// 校验不通过(次数超限/规则未配置)或者扣除的积分>=0(规则配置错误),返回0
if (!allow || deductPoint >= 0) {
throw new MsgException("积分规则配置错误");
}
// 3. 检查用户积分是否足够
LambdaQueryWrapper<MiniPointAccount> pointAccountQuery = new LambdaQueryWrapper<>();
pointAccountQuery.eq(MiniPointAccount::getUserId, userId);
pointAccountQuery.last("LIMIT 1");
MiniPointAccount account = miniPointAccountMapper.selectOne(pointAccountQuery);
// 账户不存在积分就是0,扣除后为负说明积分不足
if (account == null || account.getPoints() + deductPoint < 0) {
throw new MsgException("用户积分不足");
}
// 4. 执行扣费
AdjustUserPointForm adjustForm = new AdjustUserPointForm();
adjustForm.setUserId(userId);
adjustForm.setBizType("AI_GENERATE_IMAGE");
adjustForm.setChangeAmount(deductPoint);
adjustForm.setBizId(taskId);
adjustForm.setRemark("AI生成图片扣费,任务ID:" + taskId);
adjustPoint(adjustForm);
// 5. 更新周期计数缓存
if (countKey != null && expireDays > 0 && redisNormal) {
redisTemplate.opsForValue().increment(countKey, 1);
if ("DAY".equals(limitPeriod)) {
redisTemplate.expire(countKey, 25, TimeUnit.HOURS);
} else {
redisTemplate.expire(countKey, expireDays, TimeUnit.DAYS);
}
}
// 6. 写入幂等标记,存1年足够
if (redisNormal) {
redisTemplate.opsForValue().set(idempotentKey, "1", 365, TimeUnit.DAYS);
}
return deductPoint; // 返回扣除的积分数,负数表示扣除成功
} catch (Exception e) {
log.error("用户{}AI生成图片任务{}扣费失败", userId, taskId, e);
return 0;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public Integer giveRegisterAnimalReward(Long userId, Long animalId) {
try {
// 1. 调用通用周期校验,规则编码REGISTER_ANIMAL,业务前缀register_animal
Object[] checkResult = checkPeriodLimit("REGISTER_ANIMAL", userId, "register_animal");
boolean allow = (boolean) checkResult[0];
int rewardPoint = (int) checkResult[1];
String countKey = (String) checkResult[2];
long expireDays = (long) checkResult[3];
String limitPeriod = (String) checkResult[4];
boolean redisNormal = (boolean) checkResult[6];
// 校验不通过(次数超限),返回0,不抛异常,不影响主登记流程
if (!allow || rewardPoint <= 0) {
return 0;
}
// 2. 发放积分
AdjustUserPointForm adjustForm = new AdjustUserPointForm();
adjustForm.setUserId(userId);
adjustForm.setBizType("REGISTER_ANIMAL");
adjustForm.setChangeAmount(rewardPoint);
adjustPoint(adjustForm);
// 3. 更新周期计数缓存
if (countKey != null && expireDays > 0 && redisNormal) {
redisTemplate.opsForValue().increment(countKey, 1);
if ("DAY".equals(limitPeriod)) {
redisTemplate.expire(countKey, 25, TimeUnit.HOURS);
} else {
redisTemplate.expire(countKey, expireDays, TimeUnit.DAYS);
}
}
return rewardPoint;
} catch (Exception e) {
// 积分发放失败打日志,不抛异常,不影响登记主流程
log.error("用户{}登记流浪动物{}发放积分失败", userId, animalId, e);
return 0;
}
}
} }

173
src/main/java/com/youlai/boot/mini/service/impl/MiniSignServiceImpl.java

@ -0,0 +1,173 @@
package com.youlai.boot.mini.service.impl;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.common.constant.CommonConstants;
import com.youlai.boot.common.exception.MsgException;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.mini.mapper.MiniSignRecordMapper;
import com.youlai.boot.mini.model.entity.MiniPointRule;
import com.youlai.boot.mini.model.entity.MiniSignRecord;
import com.youlai.boot.mini.model.form.AdjustUserPointForm;
import com.youlai.boot.mini.model.vo.SignResultVO;
import com.youlai.boot.mini.model.vo.SignStatusVO;
import com.youlai.boot.mini.service.MiniPointRecordService;
import com.youlai.boot.mini.service.MiniPointRuleService;
import com.youlai.boot.mini.service.MiniSignService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 签到服务实现类
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MiniSignServiceImpl extends ServiceImpl<MiniSignRecordMapper, MiniSignRecord> implements MiniSignService {
private final StringRedisTemplate redisTemplate;
private final MiniPointRuleService ruleService;
private final MiniPointRecordService pointRecordService;
@Transactional(rollbackFor = Exception.class)
@Override
public SignResultVO sign() {
Long userId = SecurityUtils.getUserId();
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String dayKey = String.format(CommonConstants.SIGN_DAY_KEY, userId, today);
// 1. 幂等校验
// Redis快速校验
if (Boolean.TRUE.equals(redisTemplate.hasKey(dayKey))) {
throw new MsgException("今日已签到,请勿重复提交");
}
// 数据库唯一索引兜底校验
long existCount = count(new LambdaQueryWrapper<MiniSignRecord>()
.eq(MiniSignRecord::getUserId, userId)
.eq(MiniSignRecord::getSignDate, Date.from(LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant())));
if (existCount > 0) {
throw new MsgException("今日已签到");
}
try {
// 2. 计算连续签到天数
MiniSignRecord lastSign = baseMapper.selectLastSignRecord(userId);
int continuousDays = 1;
if (lastSign != null) {
LocalDate lastSignDate = lastSign.getSignDate().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
LocalDate yesterday = LocalDate.now().minusDays(1);
// 昨天签到了,连续天数+1
if (lastSignDate.equals(yesterday)) {
continuousDays = lastSign.getContinuousDays() + 1;
}
// 断签重置为1(lastSignDate不是昨天也不是今天的情况)
else if (!lastSignDate.equals(LocalDate.now())) {
continuousDays = 1;
}
}
// 3. 发放固定每日签到奖励
MiniPointRule baseRule = ruleService.getOne(new LambdaQueryWrapper<MiniPointRule>()
.eq(MiniPointRule::getRuleCode, "SIGN_IN_BASE")
.eq(MiniPointRule::getStatus, false)
.last("LIMIT 1"));
int basePoint = baseRule == null ? 0 : baseRule.getPoints();
int totalPoint = basePoint;
// 复用现有积分调整方法,自动生成流水,biz_type=SIGN_IN
if (totalPoint > 0) {
AdjustUserPointForm adjustForm = new AdjustUserPointForm();
adjustForm.setUserId(userId);
adjustForm.setBizType("SIGN_IN");
adjustForm.setChangeAmount(totalPoint);
pointRecordService.adjustPoint(adjustForm);
}
//4. 保存签到记录
MiniSignRecord signRecord = new MiniSignRecord();
signRecord.setUuid(IdUtil.fastSimpleUUID());
signRecord.setUserId(userId);
signRecord.setContinuousDays(continuousDays);
signRecord.setPoints(totalPoint);
signRecord.setSignDate(Date.from(LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant()));
signRecord.setCreateBy(userId);
signRecord.setCreateTime(new Date());
signRecord.setCreateTimestamp(System.currentTimeMillis());
save(signRecord);
//5. 更新缓存
redisTemplate.opsForValue().set(dayKey, "1", 25, TimeUnit.HOURS);
redisTemplate.opsForValue().set(String.format(CommonConstants.SIGN_CONTINUOUS_KEY, userId), String.valueOf(continuousDays), 30, TimeUnit.DAYS);
// 6. 构造返回结果
SignResultVO result = new SignResultVO();
result.setSuccess(true);
result.setTotalPoint(totalPoint);
result.setContinuousDays(continuousDays);
return result;
} catch (DuplicateKeyException e) {
// 唯一索引冲突,说明已经签到过了
throw new MsgException("今日已签到");
} catch (Exception e) {
// 异常删除Redis幂等key,允许用户重试
redisTemplate.delete(dayKey);
log.error("用户{}签到失败", userId, e);
throw new MsgException("签到失败,请稍后重试");
}
}
@Override
public SignStatusVO getSignStatus() {
Long userId = SecurityUtils.getUserId();
LocalDate today = LocalDate.now();
SignStatusVO status = new SignStatusVO();
// 1. 今日是否已签到
String dayKey = String.format(CommonConstants.SIGN_DAY_KEY, userId, today.format(DateTimeFormatter.ofPattern("yyyyMMdd")));
status.setTodaySigned(Boolean.TRUE.equals(redisTemplate.hasKey(dayKey)));
// 2. 连续签到天数,优先读缓存,缓存没有查数据库
String continuousKey = String.format(CommonConstants.SIGN_CONTINUOUS_KEY, userId);
String cacheDays = redisTemplate.opsForValue().get(continuousKey);
int continuousDays = 0;
if (StrUtil.isNotBlank(cacheDays)) {
continuousDays = Integer.parseInt(cacheDays);
} else {
MiniSignRecord lastSign = baseMapper.selectLastSignRecord(userId);
if (lastSign != null) {
LocalDate lastSignDate = lastSign.getSignDate().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
if (lastSignDate.equals(today) || lastSignDate.equals(today.minusDays(1))) {
continuousDays = lastSign.getContinuousDays();
}
}
// 更新缓存
redisTemplate.opsForValue().set(continuousKey, String.valueOf(continuousDays), 30, TimeUnit.DAYS);
}
status.setContinuousDays(continuousDays);
// 3. 当月已签到日期列表,用于前端渲染签到日历
String yearMonth = today.format(DateTimeFormatter.ofPattern("yyyyMM"));
List<Date> signDates = baseMapper.selectMonthSignDates(userId, yearMonth);
List<String> formatDates = signDates.stream()
.map(date -> date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
.collect(Collectors.toList());
status.setSignedDates(formatDates);
return status;
}
}

30
src/main/resources/mapper/mini/MiniSignRecordMapper.xml

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.youlai.boot.mini.mapper.MiniSignRecordMapper">
<!-- 查询用户最近一次的签到记录 -->
<select id="selectLastSignRecord" resultType="com.youlai.boot.mini.model.entity.MiniSignRecord">
SELECT *
FROM mini_sign_record
WHERE user_id = #{userId}
AND is_deleted = 0
ORDER BY sign_date DESC
LIMIT 1
</select>
<!-- 查询用户当月所有签到日期 -->
<select id="selectMonthSignDates" resultType="java.util.Date">
SELECT
sign_date
FROM mini_sign_record
WHERE
user_id = #{userId}
AND is_deleted = 0
AND DATE_FORMAT(sign_date, '%Y%m') = #{yearMonth}
ORDER BY sign_date
</select>
</mapper>
Loading…
Cancel
Save