Files
number/后端架构设计/06-积分服务开发文档.md
2026-03-17 12:09:43 +08:00

13 KiB
Raw Blame History

积分服务开发文档

一、Entity 实体类

UserPoints.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

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

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

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

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

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<PointsRecordVO> 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

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<PointsRecordVO> getRecords(Long userId, int pageNum, int pageSize) {
        Page<PointsRecord> page = new Page<>(pageNum, pageSize);
        IPage<PointsRecord> result = recordRepo.selectPage(page,
            new LambdaQueryWrapper<PointsRecord>()
                .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

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<PointsBalanceVO> getBalance() {
        return Result.ok(pointsService.getBalance(UserContext.getUserId()));
    }

    /** 获取积分流水 */
    @GetMapping("/records")
    public Result<IPage<PointsRecordVO>> 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<Integer> signIn() {
        int earned = pointsService.signIn(UserContext.getUserId());
        return Result.ok(earned);
    }
}

文档版本v1.0 创建日期2026-03-16