feat(invite): 添加邀请码注册与验证功能
- 新增邀请码实体、Mapper、Service 及 XML 配置 - 注册接口支持填写邀请码并建立绑定关系 - 邀请码校验包含存在性、状态、过期及次数限制 - 补充相关错误码:INVITE_CODE_* 与 RECEIPT_ALREADY_PROCESSED
This commit is contained in:
@@ -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, "收据已处理");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态码
|
* 状态码
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -24,4 +24,7 @@ public class UserRegisterDTO {
|
|||||||
|
|
||||||
@Schema(description = "验证码")
|
@Schema(description = "验证码")
|
||||||
private String verifyCode;
|
private String verifyCode;
|
||||||
|
|
||||||
|
@Schema(description = "邀请码(可选)")
|
||||||
|
private String inviteCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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. 查询商品信息
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/main/resources/mapper/KeyboardUserInvitesMapper.xml
Normal file
22
src/main/resources/mapper/KeyboardUserInvitesMapper.xml
Normal 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>
|
||||||
Reference in New Issue
Block a user