Initial commit

This commit is contained in:
Developer
2026-03-17 12:09:43 +08:00
commit 70bedcf241
211 changed files with 31464 additions and 0 deletions

View 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