459 lines
14 KiB
Markdown
459 lines
14 KiB
Markdown
|
|
# 邀请服务开发文档
|
|||
|
|
|
|||
|
|
## 一、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
|