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