432 lines
13 KiB
Markdown
432 lines
13 KiB
Markdown
# 积分服务开发文档
|
||
|
||
## 一、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
|