Initial commit
This commit is contained in:
431
后端架构设计/06-积分服务开发文档.md
Normal file
431
后端架构设计/06-积分服务开发文档.md
Normal file
@@ -0,0 +1,431 @@
|
||||
# 积分服务开发文档
|
||||
|
||||
## 一、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
|
||||
Reference in New Issue
Block a user