# 邀请服务开发文档 ## 一、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 { @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 { @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 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 listInviteRecords(Long userId, int pageNum, int pageSize) { IPage page = inviteRecordRepo.selectPage( new Page<>(pageNum, pageSize), new LambdaQueryWrapper() .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().eq(InviteRecord::getInviterId, userId))); stats.setRewardedInvites((int) inviteRecordRepo.selectCount( new LambdaQueryWrapper() .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 getMyCode() { return Result.ok(inviteService.getMyInviteCode(UserContext.getUserId())); } /** 新用户绑定邀请码(注册时或注册后调用) */ @PostMapping("/bind") public Result bindCode(@Valid @RequestBody BindInviteDTO dto) { inviteService.bindInviteCode(UserContext.getUserId(), dto.getInviteCode()); return Result.ok(); } /** 邀请记录列表 */ @GetMapping("/records") public Result> records( @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize) { return Result.ok(inviteService.listInviteRecords(UserContext.getUserId(), pageNum, pageSize)); } /** 邀请统计概览 */ @GetMapping("/stats") public Result 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