feat(invite): 添加邀请码注册与验证功能

- 新增邀请码实体、Mapper、Service 及 XML 配置
- 注册接口支持填写邀请码并建立绑定关系
- 邀请码校验包含存在性、状态、过期及次数限制
- 补充相关错误码:INVITE_CODE_* 与 RECEIPT_ALREADY_PROCESSED
This commit is contained in:
2025-12-19 14:21:02 +08:00
parent 419878a607
commit 0ef7a7fd83
12 changed files with 237 additions and 6 deletions

View File

@@ -57,7 +57,12 @@ public enum ErrorCode {
LACK_ORIGIN_TRANSACTION_ID_ERROR(50019, "缺少原始交易id"), LACK_ORIGIN_TRANSACTION_ID_ERROR(50019, "缺少原始交易id"),
UNKNOWN_PRODUCT_TYPE(50020, "未知商品类型"), UNKNOWN_PRODUCT_TYPE(50020, "未知商品类型"),
PRODUCT_NOT_FOUND(50021, "商品不存在"), PRODUCT_NOT_FOUND(50021, "商品不存在"),
NO_QUOTA_AND_NOT_VIP(50022, "免费次数已用完请开通VIP"); NO_QUOTA_AND_NOT_VIP(50022, "免费次数已用完请开通VIP"),
INVITE_CODE_NOT_FOUND(50023, "邀请码不存在"),
INVITE_CODE_INVALID(50024, "邀请码无效"),
INVITE_CODE_EXPIRED(50025, "邀请码已过期"),
INVITE_CODE_USED_UP(50026, "邀请码使用次数已达上限"),
RECEIPT_ALREADY_PROCESSED(50027, "收据已处理");
/** /**
* 状态码 * 状态码

View File

@@ -102,7 +102,8 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/purchase/handle", "/purchase/handle",
"/apple/notification", "/apple/notification",
"/apple/receipt", "/apple/receipt",
"/apple/validate-receipt" "/apple/validate-receipt",
"/user/inviteCode"
}; };
} }

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardUserInvites;
/*
* @author: ziin
* @date: 2025/12/19 13:26
*/
public interface KeyboardUserInvitesMapper extends BaseMapper<KeyboardUserInvites> {
}

View File

@@ -24,4 +24,7 @@ public class UserRegisterDTO {
@Schema(description = "验证码") @Schema(description = "验证码")
private String verifyCode; private String verifyCode;
@Schema(description = "邀请码(可选)")
private String inviteCode;
} }

View File

@@ -0,0 +1,85 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/19 13:26
*/
/**
* 用户邀请关系绑定台账表,记录新用户最终归属的邀请人
*/
@Schema(description="用户邀请关系绑定台账表,记录新用户最终归属的邀请人")
@Data
@TableName(value = "keyboard_user_invites")
public class KeyboardUserInvites {
/**
* 邀请绑定记录主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="邀请绑定记录主键ID")
private Long id;
/**
* 邀请人用户ID
*/
@TableField(value = "inviter_user_id")
@Schema(description="邀请人用户ID")
private Long inviterUserId;
/**
* 被邀请人用户ID新注册用户
*/
@TableField(value = "invitee_user_id")
@Schema(description="被邀请人用户ID新注册用户")
private Long inviteeUserId;
/**
* 使用的邀请码ID
*/
@TableField(value = "invite_code_id")
@Schema(description="使用的邀请码ID")
private Long inviteCodeId;
/**
* 绑定时关联的点击Token通过邀请链接自动绑定时使用
*/
@TableField(value = "click_token")
@Schema(description="绑定时关联的点击Token通过邀请链接自动绑定时使用")
private String clickToken;
/**
* 绑定方式1=手动填写邀请码2=邀请链接自动绑定3=其他方式
*/
@TableField(value = "bind_type")
@Schema(description="绑定方式1=手动填写邀请码2=邀请链接自动绑定3=其他方式")
private Short bindType;
/**
* 邀请关系绑定完成时间
*/
@TableField(value = "bound_at")
@Schema(description="邀请关系绑定完成时间")
private Date boundAt;
/**
* 绑定 iP
*/
@TableField(value = "bind_ip")
@Schema(description="绑定 iP")
private String bindIp;
/**
* userAgent
*/
@TableField(value = "bind_user_agent")
@Schema(description="userAgent")
private String bindUserAgent;
}

View File

@@ -23,4 +23,11 @@ public interface KeyboardUserInviteCodesService extends IService<KeyboardUserInv
*/ */
KeyboardUserInviteCodes createInviteCode(Long userId); KeyboardUserInviteCodes createInviteCode(Long userId);
/**
* 验证邀请码是否有效
* @param code 邀请码
* @return 邀请码实体,如果无效则抛出异常
*/
KeyboardUserInviteCodes validateInviteCode(String code);
} }

View File

@@ -0,0 +1,13 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.entity.KeyboardUserInvites;
import com.baomidou.mybatisplus.extension.service.IService;
/*
* @author: ziin
* @date: 2025/12/19 13:26
*/
public interface KeyboardUserInvitesService extends IService<KeyboardUserInvites>{
}

View File

@@ -94,7 +94,7 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
.exists(); .exists();
if (handled) { if (handled) {
log.info("Apple purchase already handled, transactionId={}", validationResult.getTransactionId()); log.info("Apple purchase already handled, transactionId={}", validationResult.getTransactionId());
return; throw new BusinessException(ErrorCode.RECEIPT_ALREADY_PROCESSED);
} }
// 4. 查询商品信息 // 4. 查询商品信息

View File

@@ -1,6 +1,8 @@
package com.yolo.keyborad.service.impl; package com.yolo.keyborad.service.impl;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import java.util.Date; import java.util.Date;
@@ -63,4 +65,34 @@ public class KeyboardUserInviteCodesServiceImpl extends ServiceImpl<KeyboardUser
return inviteCode; return inviteCode;
} }
@Override
public KeyboardUserInviteCodes validateInviteCode(String code) {
// 查询邀请码
QueryWrapper<KeyboardUserInviteCodes> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("code", code);
KeyboardUserInviteCodes inviteCode = this.getOne(queryWrapper);
// 邀请码不存在
if (inviteCode == null) {
throw new BusinessException(ErrorCode.INVITE_CODE_NOT_FOUND);
}
// 邀请码已停用
if (inviteCode.getStatus() != 1) {
throw new BusinessException(ErrorCode.INVITE_CODE_INVALID);
}
// 检查是否过期
if (inviteCode.getExpiresAt() != null && inviteCode.getExpiresAt().before(new Date())) {
throw new BusinessException(ErrorCode.INVITE_CODE_EXPIRED);
}
// 检查使用次数是否达到上限
if (inviteCode.getMaxUses() != null && inviteCode.getUsedCount() >= inviteCode.getMaxUses()) {
throw new BusinessException(ErrorCode.INVITE_CODE_USED_UP);
}
return inviteCode;
}
} }

View File

@@ -0,0 +1,18 @@
package com.yolo.keyborad.service.impl;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.model.entity.KeyboardUserInvites;
import com.yolo.keyborad.mapper.KeyboardUserInvitesMapper;
import com.yolo.keyborad.service.KeyboardUserInvitesService;
/*
* @author: ziin
* @date: 2025/12/19 13:26
*/
@Service
public class KeyboardUserInvitesServiceImpl extends ServiceImpl<KeyboardUserInvitesMapper, KeyboardUserInvites> implements KeyboardUserInvitesService{
}

View File

@@ -12,9 +12,7 @@ import com.yolo.keyborad.config.NacosAppConfigCenter;
import com.yolo.keyborad.exception.BusinessException; import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.mapper.KeyboardUserMapper; import com.yolo.keyborad.mapper.KeyboardUserMapper;
import com.yolo.keyborad.model.dto.user.*; import com.yolo.keyborad.model.dto.user.*;
import com.yolo.keyborad.model.entity.KeyboardUser; import com.yolo.keyborad.model.entity.*;
import com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal;
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO; import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
import com.yolo.keyborad.service.*; import com.yolo.keyborad.service.*;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -65,6 +63,12 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
@Resource @Resource
private KeyboardUserInviteCodesService inviteCodesService; private KeyboardUserInviteCodesService inviteCodesService;
@Resource
private KeyboardUserInvitesService userInvitesService;
@Resource
private HttpServletRequest request;
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder; private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
public UserServiceImpl(NacosAppConfigCenter.DynamicAppConfig cfgHolder) { public UserServiceImpl(NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
@@ -255,6 +259,35 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
// 初始化用户邀请码 // 初始化用户邀请码
inviteCodesService.createInviteCode(keyboardUser.getId()); inviteCodesService.createInviteCode(keyboardUser.getId());
// 处理邀请码绑定
if (userRegisterDTO.getInviteCode() != null && !userRegisterDTO.getInviteCode().trim().isEmpty()) {
try {
// 验证邀请码
KeyboardUserInviteCodes inviteCode = inviteCodesService.validateInviteCode(userRegisterDTO.getInviteCode().trim());
// 创建邀请关系绑定记录
KeyboardUserInvites userInvite = new KeyboardUserInvites();
userInvite.setInviterUserId(inviteCode.getOwnerUserId());
userInvite.setInviteeUserId(keyboardUser.getId());
userInvite.setInviteCodeId(inviteCode.getId());
userInvite.setBindType((short) 1); // 1=手动填写邀请码
userInvite.setBoundAt(new Date());
userInvite.setBindIp(request.getRemoteAddr());
userInvite.setBindUserAgent(request.getHeader("User-Agent"));
userInvitesService.save(userInvite);
// 更新邀请码使用次数
inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
inviteCodesService.updateById(inviteCode);
log.info("User bound to invite code, userId={}, inviteCodeId={}, inviterUserId={}",
keyboardUser.getId(), inviteCode.getId(), inviteCode.getOwnerUserId());
} catch (BusinessException e) {
// 邀请码验证失败,记录日志但不影响注册流程
log.warn("Failed to bind invite code for user {}: {}", keyboardUser.getId(), e.getMessage());
}
}
log.info("User registered with email, userId={}, email={}, freeQuota={}", log.info("User registered with email, userId={}, email={}, freeQuota={}",
keyboardUser.getId(), keyboardUser.getEmail(), appConfig.getUserRegisterProperties().getFreeTrialQuota()); keyboardUser.getId(), keyboardUser.getEmail(), appConfig.getUserRegisterProperties().getFreeTrialQuota());
} }

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yolo.keyborad.mapper.KeyboardUserInvitesMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardUserInvites">
<!--@mbg.generated-->
<!--@Table keyboard_user_invites-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="inviter_user_id" jdbcType="BIGINT" property="inviterUserId" />
<result column="invitee_user_id" jdbcType="BIGINT" property="inviteeUserId" />
<result column="invite_code_id" jdbcType="BIGINT" property="inviteCodeId" />
<result column="click_token" jdbcType="VARCHAR" property="clickToken" />
<result column="bind_type" jdbcType="SMALLINT" property="bindType" />
<result column="bound_at" jdbcType="TIMESTAMP" property="boundAt" />
<result column="bind_ip" jdbcType="VARCHAR" property="bindIp" />
<result column="bind_user_agent" jdbcType="VARCHAR" property="bindUserAgent" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, inviter_user_id, invitee_user_id, invite_code_id, click_token, bind_type, bound_at,
bind_ip, bind_user_agent
</sql>
</mapper>