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

432 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 积分服务开发文档
## 一、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<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
```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
```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