# 积分服务开发文档 ## 一、Entity 实体类 ### UserPoints.java ```java package com.openclaw.entity; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDate; import java.time.LocalDateTime; @Data @TableName("user_points") public class UserPoints { @TableId(type = IdType.AUTO) private Long id; private Long userId; private Integer availablePoints; private Integer frozenPoints; private Integer totalEarned; private Integer totalConsumed; private LocalDate lastSignInDate; private Integer signInStreak; private LocalDateTime updatedAt; } ``` ### PointsRecord.java ```java package com.openclaw.entity; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; @Data @TableName("points_records") public class PointsRecord { @TableId(type = IdType.AUTO) private Long id; private Long userId; private String pointsType; // earn / consume / freeze / unfreeze private String source; // register/sign_in/invite/... private Integer amount; private Integer balance; private String description; private Long relatedId; private String relatedType; private LocalDateTime createdAt; } ``` ### PointsRule.java ```java package com.openclaw.entity; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; @Data @TableName("points_rules") public class PointsRule { @TableId(type = IdType.AUTO) private Integer id; private String ruleName; private String source; private Integer pointsAmount; private Integer frequencyLimit; private String frequencyPeriod; // daily/weekly/monthly/unlimited private Boolean enabled; private LocalDateTime createdAt; private LocalDateTime updatedAt; } ``` ## 二、VO ### PointsBalanceVO.java ```java package com.openclaw.vo; import lombok.Data; import java.time.LocalDate; @Data public class PointsBalanceVO { private Integer availablePoints; private Integer frozenPoints; private Integer totalEarned; private Integer totalConsumed; private LocalDate lastSignInDate; private Integer signInStreak; private Boolean signedInToday; // 今日是否已签到 } ``` ### PointsRecordVO.java ```java package com.openclaw.vo; import lombok.Data; import java.time.LocalDateTime; @Data public class PointsRecordVO { private Long id; private String pointsType; private String source; private String sourceLabel; // 中文描述 private Integer amount; private Integer balance; private String description; private LocalDateTime createdAt; } ``` ## 三、Service 接口 ### PointsService.java ```java package com.openclaw.service; import com.baomidou.mybatisplus.core.metadata.IPage; import com.openclaw.vo.*; public interface PointsService { /** 初始化用户积分账户(注册时调用) */ void initUserPoints(Long userId); /** 获取积分余额 */ PointsBalanceVO getBalance(Long userId); /** 获取积分流水(分页) */ IPage getRecords(Long userId, int pageNum, int pageSize); /** 每日签到 */ int signIn(Long userId); /** 按规则发放积分(注册/邀请/加群/评价等) */ void earnPoints(Long userId, String source, Long relatedId, String relatedType); /** 消耗积分(购买Skill) */ void consumePoints(Long userId, int amount, Long relatedId, String relatedType); /** 冻结积分(下单时) */ void freezePoints(Long userId, int amount, Long orderId); /** 解冻积分(取消订单时) */ void unfreezePoints(Long userId, int amount, Long orderId); /** 检查积分是否充足 */ boolean hasEnoughPoints(Long userId, int required); } ``` ## 四、Service 实现 ### PointsServiceImpl.java ```java package com.openclaw.service.impl; 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.openclaw.constant.ErrorCode; import com.openclaw.entity.*; import com.openclaw.exception.BusinessException; import com.openclaw.repository.*; import com.openclaw.service.PointsService; import com.openclaw.vo.*; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; @Service @RequiredArgsConstructor public class PointsServiceImpl implements PointsService { private final UserPointsRepository userPointsRepo; private final PointsRecordRepository recordRepo; private final PointsRuleRepository ruleRepo; @Override @Transactional public void initUserPoints(Long userId) { UserPoints up = new UserPoints(); up.setUserId(userId); up.setAvailablePoints(0); up.setFrozenPoints(0); up.setTotalEarned(0); up.setTotalConsumed(0); up.setSignInStreak(0); userPointsRepo.insert(up); } @Override public PointsBalanceVO getBalance(Long userId) { UserPoints up = userPointsRepo.findByUserId(userId); PointsBalanceVO vo = new PointsBalanceVO(); if (up == null) return vo; vo.setAvailablePoints(up.getAvailablePoints()); vo.setFrozenPoints(up.getFrozenPoints()); vo.setTotalEarned(up.getTotalEarned()); vo.setTotalConsumed(up.getTotalConsumed()); vo.setLastSignInDate(up.getLastSignInDate()); vo.setSignInStreak(up.getSignInStreak()); vo.setSignedInToday(LocalDate.now().equals(up.getLastSignInDate())); return vo; } @Override public IPage getRecords(Long userId, int pageNum, int pageSize) { Page page = new Page<>(pageNum, pageSize); IPage result = recordRepo.selectPage(page, new LambdaQueryWrapper() .eq(PointsRecord::getUserId, userId) .orderByDesc(PointsRecord::getCreatedAt)); return result.convert(this::toRecordVO); } @Override @Transactional public int signIn(Long userId) { UserPoints up = userPointsRepo.findByUserId(userId); LocalDate today = LocalDate.now(); // 今日已签到 if (today.equals(up.getLastSignInDate())) { throw new BusinessException(ErrorCode.ALREADY_SIGNED_IN); } // 计算连续签到天数 boolean consecutive = up.getLastSignInDate() != null && today.minusDays(1).equals(up.getLastSignInDate()); int streak = consecutive ? up.getSignInStreak() + 1 : 1; // 签到积分:连续签到递增,最高20分 int points = Math.min(5 + (streak - 1) * 1, 20); up.setLastSignInDate(today); up.setSignInStreak(streak); userPointsRepo.updateById(up); addPoints(userId, "earn", "sign_in", points, points, "每日签到", null, null); return points; } @Override @Transactional public void earnPoints(Long userId, String source, Long relatedId, String relatedType) { PointsRule rule = ruleRepo.findBySource(source); if (rule == null || !rule.getEnabled()) return; UserPoints up = userPointsRepo.findByUserId(userId); int newBalance = up.getAvailablePoints() + rule.getPointsAmount(); addPoints(userId, "earn", source, rule.getPointsAmount(), newBalance, rule.getRuleName(), relatedId, relatedType); } @Override @Transactional public void consumePoints(Long userId, int amount, Long relatedId, String relatedType) { UserPoints up = userPointsRepo.findByUserId(userId); if (up.getAvailablePoints() < amount) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH); int newBalance = up.getAvailablePoints() - amount; addPoints(userId, "consume", "skill_purchase", -amount, newBalance, "兑换Skill", relatedId, relatedType); } @Override @Transactional public void freezePoints(Long userId, int amount, Long orderId) { userPointsRepo.freezePoints(userId, amount); addPoints(userId, "freeze", "skill_purchase", -amount, userPointsRepo.findByUserId(userId).getAvailablePoints(), "积分冻结-订单" + orderId, orderId, "order"); } @Override @Transactional public void unfreezePoints(Long userId, int amount, Long orderId) { userPointsRepo.unfreezePoints(userId, amount); addPoints(userId, "unfreeze", "skill_purchase", amount, userPointsRepo.findByUserId(userId).getAvailablePoints(), "积分解冻-订单取消" + orderId, orderId, "order"); } @Override public boolean hasEnoughPoints(Long userId, int required) { UserPoints up = userPointsRepo.findByUserId(userId); return up != null && up.getAvailablePoints() >= required; } private void addPoints(Long userId, String type, String source, int amount, int balance, String desc, Long relatedId, String relatedType) { // 更新账户 if ("earn".equals(type)) { userPointsRepo.addAvailablePoints(userId, amount); } else if ("consume".equals(type)) { userPointsRepo.addAvailablePoints(userId, amount); // amount为负数 userPointsRepo.addTotalConsumed(userId, -amount); } userPointsRepo.addTotalEarned(userId, "earn".equals(type) ? amount : 0); // 记录流水 PointsRecord r = new PointsRecord(); r.setUserId(userId); r.setPointsType(type); r.setSource(source); r.setAmount(amount); r.setBalance(balance); r.setDescription(desc); r.setRelatedId(relatedId); r.setRelatedType(relatedType); recordRepo.insert(r); } private PointsRecordVO toRecordVO(PointsRecord r) { PointsRecordVO vo = new PointsRecordVO(); vo.setId(r.getId()); vo.setPointsType(r.getPointsType()); vo.setSource(r.getSource()); vo.setSourceLabel(getSourceLabel(r.getSource())); vo.setAmount(r.getAmount()); vo.setBalance(r.getBalance()); vo.setDescription(r.getDescription()); vo.setCreatedAt(r.getCreatedAt()); return vo; } @Override @Transactional public void addPointsDirectly(Long userId, int amount, String source, Long relatedId, String desc) { UserPoints up = userPointsRepo.findByUserId(userId); if (up == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND); int newBalance = up.getAvailablePoints() + amount; if (newBalance < 0) throw new BusinessException(ErrorCode.POINTS_NOT_ENOUGH); String type = amount >= 0 ? "earn" : "consume"; addPoints(userId, type, source, amount, newBalance, desc, relatedId, null); } @Override public void ensureNotNegative(Long userId) { UserPoints up = userPointsRepo.findByUserId(userId); if (up != null && up.getAvailablePoints() < 0) { // 强制归零,记录一条修正流水 int diff = -up.getAvailablePoints(); addPoints(userId, "admin_correct", "admin_adjust", diff, 0, "积分余额修正(防负)", null, null); } } private String getSourceLabel(String source) { return switch (source) { case "register" -> "新用户注册"; case "sign_in" -> "每日签到"; case "invite" -> "邀请好友"; case "join_community" -> "加入社群"; case "recharge" -> "充值赠送"; case "skill_purchase" -> "兑换Skill"; case "review" -> "发表评价"; case "activity" -> "活动奖励"; case "admin_adjust" -> "管理员调整"; default -> source; }; } } ``` ## 五、Controller ### PointsController.java ```java package com.openclaw.controller; import com.baomidou.mybatisplus.core.metadata.IPage; import com.openclaw.common.Result; import com.openclaw.service.PointsService; import com.openclaw.util.UserContext; import com.openclaw.vo.*; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/points") @RequiredArgsConstructor public class PointsController { private final PointsService pointsService; /** 获取积分余额 */ @GetMapping("/balance") public Result getBalance() { return Result.ok(pointsService.getBalance(UserContext.getUserId())); } /** 获取积分流水 */ @GetMapping("/records") public Result> getRecords( @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "20") int pageSize) { return Result.ok(pointsService.getRecords(UserContext.getUserId(), pageNum, pageSize)); } /** 每日签到 */ @PostMapping("/sign-in") public Result signIn() { int earned = pointsService.signIn(UserContext.getUserId()); return Result.ok(earned); } } ``` --- **文档版本**:v1.0 **创建日期**:2026-03-16