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

14 KiB
Raw Blame History

# 邀请服务开发文档

一、Entity 实体类

InviteCode.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

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

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

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

package com.openclaw.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.Data;

@Data
public class BindInviteDTO {
    @NotBlank(message = "邀请码不能为空")
    private String inviteCode;
}

InviteStatsVO.java

package com.openclaw.vo;

import lombok.Data;

@Data
public class InviteStatsVO {
    private Integer totalInvites;       // 累计邀请人数
    private Integer rewardedInvites;    // 已奖励次数
    private Integer totalEarnedPoints;  // 通过邀请获得的总积分
}

三、Repository

InviteCodeRepository.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

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

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

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

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()));
    }
}

六、配置参数

# 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