diff --git a/src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java b/src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java index 0a01a23..be19bc6 100644 --- a/src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java +++ b/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_record", 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.INPUT, "minitest") diff --git a/src/main/java/com/youlai/boot/common/constant/CommonConstants.java b/src/main/java/com/youlai/boot/common/constant/CommonConstants.java index fd18c9f..cf1063f 100644 --- a/src/main/java/com/youlai/boot/common/constant/CommonConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/CommonConstants.java @@ -9,4 +9,11 @@ public class CommonConstants { //geohash的level 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"; + } diff --git a/src/main/java/com/youlai/boot/mini/controller/PointController.java b/src/main/java/com/youlai/boot/mini/controller/PointController.java index 14e88a3..f1e2a19 100644 --- a/src/main/java/com/youlai/boot/mini/controller/PointController.java +++ b/src/main/java/com/youlai/boot/mini/controller/PointController.java @@ -1,18 +1,19 @@ 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.Result; 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.PointAccountQuery; import com.youlai.boot.mini.model.vo.MyPointRecordVO; 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.MiniPointRecordService; 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.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springdoc.core.annotations.ParameterObject; @@ -26,7 +27,8 @@ public class PointController { private final MiniPointAccountService pointAccountService; private final MiniPointRuleService pointRuleService; - private final MiniPointRecordService recordService; + private final MiniPointRecordService pointRecordService; + private final MiniSignService signService; @Operation(summary = "查询当前用户的积分账户信息") @GetMapping("/my") @@ -40,16 +42,45 @@ public class PointController { @GetMapping("/records") public PageResult getMyPointRecords(@ParameterObject MyPointRecordQuery query) { Long userId = SecurityUtils.getUserId(); - return PageResult.success(recordService.pageMyPointRecord(userId, query)); + return PageResult.success(pointRecordService.pageMyPointRecord(userId, query)); } - //事件监听 实现 用户注册赠送积分 + @Operation(summary = "用户签到") + @PostMapping("/sign") + public Result sign() { + return Result.success(signService.sign()); + } - //每日签到,加签到表, + @Operation(summary = "查询用户签到状态/当月签到日历") + @GetMapping("/sign/status") + public Result getSignStatus() { + return Result.success(signService.getSignStatus()); + } + + @Operation(summary = "用户分享领取奖励") + @PostMapping("/share/reward") + public Result shareReward() { + Long userId = SecurityUtils.getUserId(); + Integer point = pointRecordService.giveShareReward(userId); + return Result.success(point); + } - //分享奖励,分享链接带上分享人ID,其他用户点击链接进入小程序后才给分享人发奖励 + @Operation(summary = "登记流浪动物领取积分") + @PostMapping("/register-animal/reward") + public Result 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 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生成视频扣费 } diff --git a/src/main/java/com/youlai/boot/mini/converter/MiniPointRuleConverter.java b/src/main/java/com/youlai/boot/mini/converter/MiniPointRuleConverter.java index 7f7005f..5ec0160 100644 --- a/src/main/java/com/youlai/boot/mini/converter/MiniPointRuleConverter.java +++ b/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 org.mapstruct.Mapper; 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); + @Named("booleanToInt") + default Integer booleanToInt(Boolean status) { + return status == null ? null : (status ? 1 : 0); + } + /** * 新增表单转实体 */ diff --git a/src/main/java/com/youlai/boot/mini/mapper/MiniSignRecordMapper.java b/src/main/java/com/youlai/boot/mini/mapper/MiniSignRecordMapper.java new file mode 100644 index 0000000..8894fc2 --- /dev/null +++ b/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 selectLastSignRecord(Long userId); + + /** + * 查询用户当月所有签到日期 + * @param userId 用户ID + * @param yearMonth 年月,格式yyyyMM + */ + List selectMonthSignDates(Long userId, String yearMonth); +} diff --git a/src/main/java/com/youlai/boot/mini/model/entity/MiniSignRecord.java b/src/main/java/com/youlai/boot/mini/model/entity/MiniSignRecord.java new file mode 100644 index 0000000..3383164 --- /dev/null +++ b/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; + + +} diff --git a/src/main/java/com/youlai/boot/mini/model/form/AdjustUserPointForm.java b/src/main/java/com/youlai/boot/mini/model/form/AdjustUserPointForm.java index bf7cde1..86dcd68 100644 --- a/src/main/java/com/youlai/boot/mini/model/form/AdjustUserPointForm.java +++ b/src/main/java/com/youlai/boot/mini/model/form/AdjustUserPointForm.java @@ -18,7 +18,7 @@ public class AdjustUserPointForm { private Long userId; @NotBlank(message = "业务类型不能为空") - @EnumValid(enumClass = AdjustUserPointEnum.class, message = "业务类型不合法") +// @EnumValid(enumClass = AdjustUserPointEnum.class, message = "业务类型不合法") @Schema(description = "业务类型 system_increase system_reduce", requiredMode = Schema.RequiredMode.REQUIRED) private String bizType; @@ -26,4 +26,10 @@ public class AdjustUserPointForm { @Schema(description = "变化值(+增加,-扣减)", requiredMode = Schema.RequiredMode.REQUIRED) private Integer changeAmount; + @Schema(description = "业务唯一ID,幂等用,比如AI生成任务ID") + private String bizId; + + @Schema(description = "备注,存业务关联信息") + private String remark; + } diff --git a/src/main/java/com/youlai/boot/mini/model/vo/SignResultVO.java b/src/main/java/com/youlai/boot/mini/model/vo/SignResultVO.java new file mode 100644 index 0000000..2f8923f --- /dev/null +++ b/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; + +} diff --git a/src/main/java/com/youlai/boot/mini/model/vo/SignStatusVO.java b/src/main/java/com/youlai/boot/mini/model/vo/SignStatusVO.java new file mode 100644 index 0000000..dd15dcf --- /dev/null +++ b/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 signedDates; +} diff --git a/src/main/java/com/youlai/boot/mini/service/MiniPointRecordService.java b/src/main/java/com/youlai/boot/mini/service/MiniPointRecordService.java index 60f76a8..a23fef8 100644 --- a/src/main/java/com/youlai/boot/mini/service/MiniPointRecordService.java +++ b/src/main/java/com/youlai/boot/mini/service/MiniPointRecordService.java @@ -32,4 +32,28 @@ public interface MiniPointRecordService extends IService { */ IPage 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); + + } diff --git a/src/main/java/com/youlai/boot/mini/service/MiniSignService.java b/src/main/java/com/youlai/boot/mini/service/MiniSignService.java new file mode 100644 index 0000000..057683e --- /dev/null +++ b/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 { + + /** + * 用户签到 + * @return 签到结果 + */ + SignResultVO sign(); + + /** + * 查询用户签到状态,包括今日是否签到、连续天数、当月签到日历 + * @return 签到状态 + */ + SignStatusVO getSignStatus(); +} diff --git a/src/main/java/com/youlai/boot/mini/service/impl/MiniPointRecordServiceImpl.java b/src/main/java/com/youlai/boot/mini/service/impl/MiniPointRecordServiceImpl.java index d9b5b9f..df5c23d 100644 --- a/src/main/java/com/youlai/boot/mini/service/impl/MiniPointRecordServiceImpl.java +++ b/src/main/java/com/youlai/boot/mini/service/impl/MiniPointRecordServiceImpl.java @@ -1,14 +1,19 @@ 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.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; 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.MiniPointAccountMapper; import com.youlai.boot.mini.mapper.MiniPointRecordMapper; import com.youlai.boot.mini.model.entity.MiniPointAccount; 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.query.MyPointRecordQuery; 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.service.MiniPointAccountService; import com.youlai.boot.mini.service.MiniPointRecordService; +import com.youlai.boot.mini.service.MiniPointRuleService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; 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.Locale; +import java.util.Optional; import java.util.UUID; +import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor @@ -30,6 +46,9 @@ import java.util.UUID; public class MiniPointRecordServiceImpl extends ServiceImpl implements MiniPointRecordService { private final MiniPointAccountService miniPointAccountService; + private final MiniPointRuleService ruleService; + private final StringRedisTemplate redisTemplate; + private final MiniPointAccountMapper miniPointAccountMapper; @Override @@ -70,7 +89,9 @@ public class MiniPointRecordServiceImpl extends ServiceImpl() + .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() + .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() + .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() + .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 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; + } + } + } diff --git a/src/main/java/com/youlai/boot/mini/service/impl/MiniSignServiceImpl.java b/src/main/java/com/youlai/boot/mini/service/impl/MiniSignServiceImpl.java new file mode 100644 index 0000000..92bf71d --- /dev/null +++ b/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 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() + .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() + .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 signDates = baseMapper.selectMonthSignDates(userId, yearMonth); + List 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; + } +} diff --git a/src/main/resources/mapper/mini/MiniSignRecordMapper.xml b/src/main/resources/mapper/mini/MiniSignRecordMapper.xml new file mode 100644 index 0000000..2b6199a --- /dev/null +++ b/src/main/resources/mapper/mini/MiniSignRecordMapper.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + +