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

459 lines
14 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 实体类
### InviteCode.java
```java
package com.openclaw.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("invite_codes")
public class InviteCode {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String code; // 邀请码(唯一)
private Integer useCount; // 已使用次数
private Integer maxUseCount; // 最大使用次数(-1为不限
private Boolean isActive; // 是否启用
private LocalDateTime expiredAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
```
### InviteRecord.java
```java
package com.openclaw.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("invite_records")
public class InviteRecord {
@TableId(type = IdType.AUTO)
private Long id;
private Long inviterId; // 邀请人
private Long inviteeId; // 被邀请人
private String inviteCode; // 使用的邀请码
private String status; // pending / rewarded
private Integer inviterRewardPoints; // 邀请人获得积分
private Integer inviteeRewardPoints; // 被邀请人获得积分
private LocalDateTime rewardedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
```
---
## 二、DTO / VO
### InviteCodeVO.java
```java
package com.openclaw.vo;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class InviteCodeVO {
private String code;
private Integer useCount;
private Integer maxUseCount;
private Boolean isActive;
private LocalDateTime expiredAt;
// 邀请链接(前端拼接用)
private String inviteUrl;
}
```
### InviteRecordVO.java
```java
package com.openclaw.vo;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class InviteRecordVO {
private Long id;
private Long inviteeId;
private String inviteeNickname;
private String inviteeAvatar;
private String status;
private Integer inviterPoints; // 对应实体 inviterRewardPoints
private LocalDateTime createdAt;
private LocalDateTime rewardedAt;
}
```
### BindInviteDTO.java
```java
package com.openclaw.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class BindInviteDTO {
@NotBlank(message = "邀请码不能为空")
private String inviteCode;
}
```
### InviteStatsVO.java
```java
package com.openclaw.vo;
import lombok.Data;
@Data
public class InviteStatsVO {
private Integer totalInvites; // 累计邀请人数
private Integer rewardedInvites; // 已奖励次数
private Integer totalEarnedPoints; // 通过邀请获得的总积分
}
```
---
## 三、Repository
### InviteCodeRepository.java
```java
package com.openclaw.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.openclaw.entity.InviteCode;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface InviteCodeRepository extends BaseMapper<InviteCode> {
@Select("SELECT * FROM invite_codes WHERE code = #{code} AND is_active = 1 LIMIT 1")
InviteCode findActiveByCode(String code);
@Select("SELECT * FROM invite_codes WHERE user_id = #{userId} LIMIT 1")
InviteCode findByUserId(Long userId);
}
```
### InviteRecordRepository.java
```java
package com.openclaw.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.openclaw.entity.InviteRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface InviteRecordRepository extends BaseMapper<InviteRecord> {
@Select("SELECT * FROM invite_records WHERE inviter_id = #{inviterId} AND invitee_id = #{inviteeId} LIMIT 1")
InviteRecord findByInviterAndInvitee(Long inviterId, Long inviteeId);
@Select("SELECT COUNT(*) FROM invite_records WHERE invitee_id = #{inviteeId}")
int countByInviteeId(Long inviteeId);
@Select("SELECT SUM(inviter_reward_points) FROM invite_records WHERE inviter_id = #{inviterId} AND status = 'rewarded'")
Integer sumEarnedPoints(Long inviterId);
}
```
---
## 四、Service 接口 + 实现
### InviteService.java
```java
package com.openclaw.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.openclaw.dto.BindInviteDTO;
import com.openclaw.vo.*;
public interface InviteService {
/** 获取(或生成)我的邀请码 */
InviteCodeVO getMyInviteCode(Long userId);
/** 新用户注册后绑定邀请码(发放奖励) */
void bindInviteCode(Long inviteeId, String inviteCode);
/** 查询邀请记录列表 */
IPage<InviteRecordVO> listInviteRecords(Long userId, int pageNum, int pageSize);
/** 查询邀请统计数据 */
InviteStatsVO getInviteStats(Long userId);
}
```
### InviteServiceImpl.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.*;
import com.openclaw.repository.UserRepository;
import com.openclaw.vo.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class InviteServiceImpl implements InviteService {
private final InviteCodeRepository inviteCodeRepo;
private final InviteRecordRepository inviteRecordRepo;
private final UserRepository userRepo;
private final PointsService pointsService;
@Value("${invite.inviter-points:50}")
private int inviterPoints; // 邀请人奖励积分
@Value("${invite.invitee-points:30}")
private int inviteePoints; // 被邀请人奖励积分
@Value("${invite.url-prefix:https://app.openclaw.com/invite/}")
private String urlPrefix;
@Override
public InviteCodeVO getMyInviteCode(Long userId) {
InviteCode code = inviteCodeRepo.findByUserId(userId);
if (code == null) {
code = new InviteCode();
code.setUserId(userId);
code.setCode(generateUniqueCode());
code.setUseCount(0);
code.setMaxUseCount(-1); // 不限次数
code.setIsActive(true);
inviteCodeRepo.insert(code);
}
return toVO(code);
}
@Override
@Transactional
public void bindInviteCode(Long inviteeId, String inviteCode) {
// 1. 检查被邀请人是否已被邀请过
if (inviteRecordRepo.countByInviteeId(inviteeId) > 0) {
log.warn("用户 {} 已被邀请过,忽略重复绑定", inviteeId);
return;
}
// 2. 校验邀请码有效性
InviteCode code = inviteCodeRepo.findActiveByCode(inviteCode);
if (code == null) throw new BusinessException(ErrorCode.INVITE_CODE_INVALID);
// 3. 邀请人不能邀请自己
if (code.getUserId().equals(inviteeId))
throw new BusinessException(ErrorCode.INVITE_SELF_NOT_ALLOWED);
// 4. 检查使用次数上限
if (code.getMaxUseCount() > 0 && code.getUseCount() >= code.getMaxUseCount())
throw new BusinessException(ErrorCode.INVITE_CODE_EXHAUSTED);
// 5. 更新邀请码使用次数
code.setUseCount(code.getUseCount() + 1);
inviteCodeRepo.updateById(code);
// 6. 创建邀请记录
InviteRecord record = new InviteRecord();
record.setInviterId(code.getUserId());
record.setInviteeId(inviteeId);
record.setInviteCode(inviteCode);
record.setStatus("registered");
record.setInviterRewardPoints(inviterPoints);
record.setInviteeRewardPoints(inviteePoints);
record.setRewardedAt(LocalDateTime.now());
inviteRecordRepo.insert(record);
// 7. 发放积分
pointsService.addPointsDirectly(code.getUserId(), inviterPoints, "invite", record.getId(), "邀请好友奖励");
pointsService.addPointsDirectly(inviteeId, inviteePoints, "invited", record.getId(), "接受邀请奖励");
}
@Override
public IPage<InviteRecordVO> listInviteRecords(Long userId, int pageNum, int pageSize) {
IPage<InviteRecord> page = inviteRecordRepo.selectPage(
new Page<>(pageNum, pageSize),
new LambdaQueryWrapper<InviteRecord>()
.eq(InviteRecord::getInviterId, userId)
.orderByDesc(InviteRecord::getCreatedAt));
return page.convert(r -> {
InviteRecordVO vo = new InviteRecordVO();
vo.setId(r.getId());
vo.setInviteeId(r.getInviteeId());
// 查询被邀请人信息
User invitee = userRepo.selectById(r.getInviteeId());
if (invitee != null) {
vo.setInviteeNickname(invitee.getNickname());
vo.setInviteeAvatar(invitee.getAvatarUrl());
}
vo.setStatus(r.getStatus());
vo.setInviterPoints(r.getInviterRewardPoints()) // VO字段名保持简洁;
vo.setCreatedAt(r.getCreatedAt());
vo.setRewardedAt(r.getRewardedAt());
return vo;
});
}
@Override
public InviteStatsVO getInviteStats(Long userId) {
InviteStatsVO stats = new InviteStatsVO();
stats.setTotalInvites((int) inviteRecordRepo.selectCount(
new LambdaQueryWrapper<InviteRecord>().eq(InviteRecord::getInviterId, userId)));
stats.setRewardedInvites((int) inviteRecordRepo.selectCount(
new LambdaQueryWrapper<InviteRecord>()
.eq(InviteRecord::getInviterId, userId)
.eq(InviteRecord::getStatus, "registered")));
Integer earned = inviteRecordRepo.sumEarnedPoints(userId);
stats.setTotalEarnedPoints(earned == null ? 0 : earned);
return stats;
}
// --- 私有方法 ---
private String generateUniqueCode() {
// 取UUID前8位碰撞概率极低生产环境可加重试逻辑
return UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
}
private InviteCodeVO toVO(InviteCode code) {
InviteCodeVO vo = new InviteCodeVO();
vo.setCode(code.getCode());
vo.setUseCount(code.getUseCount());
vo.setMaxUseCount(code.getMaxUseCount());
vo.setIsActive(code.getIsActive());
vo.setExpiredAt(code.getExpiredAt());
vo.setInviteUrl(urlPrefix + code.getCode());
return vo;
}
}
```
---
## 五、Controller
### InviteController.java
```java
package com.openclaw.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.openclaw.common.Result;
import com.openclaw.dto.BindInviteDTO;
import com.openclaw.service.InviteService;
import com.openclaw.util.UserContext;
import com.openclaw.vo.*;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/invites")
@RequiredArgsConstructor
public class InviteController {
private final InviteService inviteService;
/** 获取我的邀请码 */
@GetMapping("/my-code")
public Result<InviteCodeVO> getMyCode() {
return Result.ok(inviteService.getMyInviteCode(UserContext.getUserId()));
}
/** 新用户绑定邀请码(注册时或注册后调用) */
@PostMapping("/bind")
public Result<Void> bindCode(@Valid @RequestBody BindInviteDTO dto) {
inviteService.bindInviteCode(UserContext.getUserId(), dto.getInviteCode());
return Result.ok();
}
/** 邀请记录列表 */
@GetMapping("/records")
public Result<IPage<InviteRecordVO>> records(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
return Result.ok(inviteService.listInviteRecords(UserContext.getUserId(), pageNum, pageSize));
}
/** 邀请统计概览 */
@GetMapping("/stats")
public Result<InviteStatsVO> stats() {
return Result.ok(inviteService.getInviteStats(UserContext.getUserId()));
}
}
```
---
## 六、配置参数
```yaml
# application.yml
invite:
inviter-points: 50 # 邀请人奖励积分
invitee-points: 30 # 被邀请人奖励积分
url-prefix: https://app.openclaw.com/invite/
```
---
## 七、邀请流程说明
```
邀请人 被邀请人 系统
| | |
| GET /invites/my-code |
|--------------->| |
|<-- InviteCodeVO含邀请链接 |
| | |
| 分享邀请链接 | |
|--------------->| |
| | 注册成功 |
| |-------------------->|
| | POST /invites/bind |
| |-------------------->|
| | 校验邀请码 |
| | 创建邀请记录 |
| | 发放双方积分 |
|<-- +50积分通知 |<-- +30积分通知 |
```
---
**文档版本**v1.0 | **创建日期**2026-03-16