Compare commits

...

4 Commits

Author SHA1 Message Date
a510a4afcb fix(invite): 重命名字段并补充AGENT类型支持 2025-12-29 18:43:09 +08:00
c38f62c3c1 feat(theme): 新增主题列表Redis缓存机制
为提升查询性能,在KeyboardThemesServiceImpl中集成RedisTemplate,优先从缓存读取主题列表;新增ThemeCacheInitializer用于应用启动时预热缓存。
2025-12-29 15:13:50 +08:00
be921e144f feat(invite): 支持租户与用户两种邀请码类型 2025-12-29 15:04:30 +08:00
778cf4a0cb fix(entity): 补全用户邀请绑定台账字段与注释
为 KeyboardUserInvites 实体新增 clickToken、inviteType、profitTenantId、profitEmployeeId、inviterTenantId、inviteCode 等字段,并统一 Schema 注解空格格式,满足邀请链接归因、代理结算及审计需求。
2025-12-29 13:59:02 +08:00
6 changed files with 297 additions and 37 deletions

View File

@@ -0,0 +1,150 @@
package com.yolo.keyborad.listener;
import cn.hutool.core.bean.BeanUtil;
import com.yolo.keyborad.model.entity.KeyboardThemeStyles;
import com.yolo.keyborad.model.entity.KeyboardThemes;
import com.yolo.keyborad.model.vo.themes.KeyboardThemeStylesRespVO;
import com.yolo.keyborad.model.vo.themes.KeyboardThemesRespVO;
import com.yolo.keyborad.service.KeyboardThemeStylesService;
import com.yolo.keyborad.service.KeyboardThemesService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 主题缓存初始化器
* 在应用启动时按风格将主题缓存到Redis
*/
@Component
@Slf4j
public class ThemeCacheInitializer implements ApplicationRunner {
/**
* 主题按风格分组的缓存key前缀
*/
private static final String THEME_STYLE_KEY = "theme:style:";
/**
* 所有风格列表的缓存key
*/
private static final String THEME_STYLES_KEY = "theme:styles";
/**
* 所有主题列表的缓存key风格ID=9999表示全部
*/
private static final String THEME_ALL_KEY = "theme:style:all";
/**
* 缓存过期时间(天)
*/
private static final long CACHE_EXPIRE_DAYS = 1;
@Resource
private KeyboardThemesService themesService;
@Resource
private KeyboardThemeStylesService themeStylesService;
@Resource(name = "objectRedisTemplate")
private RedisTemplate<String, Object> redisTemplate;
@Override
public void run(ApplicationArguments args) {
try {
log.info("开始缓存主题数据到Redis...");
// 1. 缓存所有风格列表
cacheAllStyles();
// 2. 按风格分组缓存主题
cacheThemesByStyle();
log.info("主题数据缓存完成");
} catch (Exception e) {
log.error("缓存主题数据失败", e);
}
}
/**
* 缓存所有风格列表
*/
private void cacheAllStyles() {
List<KeyboardThemeStyles> stylesList = themeStylesService.lambdaQuery()
.eq(KeyboardThemeStyles::getDeleted, false)
.list();
List<KeyboardThemeStylesRespVO> stylesVOList = BeanUtil.copyToList(stylesList, KeyboardThemeStylesRespVO.class);
redisTemplate.opsForValue().set(THEME_STYLES_KEY, stylesVOList, CACHE_EXPIRE_DAYS, TimeUnit.DAYS);
log.info("已缓存 {} 种主题风格", stylesVOList.size());
}
/**
* 按风格分组缓存主题
*/
private void cacheThemesByStyle() {
// 查询所有有效主题
List<KeyboardThemes> allThemes = themesService.lambdaQuery()
.eq(KeyboardThemes::getDeleted, false)
.eq(KeyboardThemes::getThemeStatus, true)
.orderByAsc(KeyboardThemes::getSort)
.list();
// 转换为VO不设置购买状态缓存的是公共数据
List<KeyboardThemesRespVO> allThemesVO = allThemes.stream()
.map(theme -> BeanUtil.copyProperties(theme, KeyboardThemesRespVO.class))
.collect(Collectors.toList());
// 缓存所有主题风格ID=all
redisTemplate.opsForValue().set(THEME_ALL_KEY, allThemesVO, CACHE_EXPIRE_DAYS, TimeUnit.DAYS);
log.info("已缓存所有主题,共 {} 个", allThemesVO.size());
// 按风格分组
Map<Long, List<KeyboardThemesRespVO>> themesByStyle = allThemesVO.stream()
.collect(Collectors.groupingBy(KeyboardThemesRespVO::getThemeStyle));
// 按风格缓存主题
for (Map.Entry<Long, List<KeyboardThemesRespVO>> entry : themesByStyle.entrySet()) {
Long styleId = entry.getKey();
List<KeyboardThemesRespVO> themes = entry.getValue();
String key = THEME_STYLE_KEY + styleId;
redisTemplate.opsForValue().set(key, themes, CACHE_EXPIRE_DAYS, TimeUnit.DAYS);
log.info("已缓存风格ID={} 的主题,共 {} 个", styleId, themes.size());
}
}
/**
* 手动刷新缓存(可通过接口调用)
*/
public void refreshCache() {
log.info("手动刷新主题缓存...");
clearCache();
cacheAllStyles();
cacheThemesByStyle();
log.info("主题缓存刷新完成");
}
/**
* 清除主题相关缓存
*/
public void clearCache() {
// 删除风格列表缓存
redisTemplate.delete(THEME_STYLES_KEY);
redisTemplate.delete(THEME_ALL_KEY);
// 删除所有风格下的主题缓存
var keys = redisTemplate.keys(THEME_STYLE_KEY + "*");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
log.info("已清除主题相关缓存");
}
}

View File

@@ -1,9 +1,6 @@
package com.yolo.keyborad.model.entity; package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.*;
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 io.swagger.v3.oas.annotations.media.Schema;
import java.util.Date; import java.util.Date;
import lombok.Data; import lombok.Data;
@@ -18,6 +15,7 @@ import lombok.Data;
*/ */
@Schema(description="用户生成的邀请码表,用于邀请新用户注册/安装并建立邀请关系") @Schema(description="用户生成的邀请码表,用于邀请新用户注册/安装并建立邀请关系")
@Data @Data
@KeySequence("invite_codes_id_seq")
@TableName(value = "keyboard_user_invite_codes") @TableName(value = "keyboard_user_invite_codes")
public class KeyboardUserInviteCodes { public class KeyboardUserInviteCodes {
/** /**
@@ -75,4 +73,25 @@ public class KeyboardUserInviteCodes {
@TableField(value = "used_count") @TableField(value = "used_count")
@Schema(description="邀请码已使用次数") @Schema(description="邀请码已使用次数")
private Integer usedCount; private Integer usedCount;
/**
* 邀请码类型USER=普通用户邀请码TENANT=租户邀请码
*/
@TableField(value = "invite_type")
@Schema(description="邀请码类型USER=普通用户邀请码AGENT=租户邀请码")
private String inviteType;
/**
* 邀请码所属租户ID当inviteType=AGENT时使用
*/
@TableField(value = "owner_tenant_id")
@Schema(description="邀请码所属租户ID当inviteType=AGENT时使用")
private Long ownerTenantId;
/**
* 邀请码所属租户用户ID当inviteType=AGENT时使用
*/
@TableField(value = "owner_system_user_id")
@Schema(description="邀请码所属租户用户ID当inviteType=AGENT时使用")
private Long ownerSystemUserId;
} }

View File

@@ -10,13 +10,13 @@ import lombok.Data;
/* /*
* @author: ziin * @author: ziin
* @date: 2025/12/19 13:26 * @date: 2025/12/29 13:58
*/ */
/** /**
* 用户邀请关系绑定台账表,记录新用户最终归属的邀请人 * 用户邀请关系绑定台账表,记录新用户最终归属的邀请人
*/ */
@Schema(description="用户邀请关系绑定台账表,记录新用户最终归属的邀请人") @Schema(description = "用户邀请关系绑定台账表,记录新用户最终归属的邀请人")
@Data @Data
@TableName(value = "keyboard_user_invites") @TableName(value = "keyboard_user_invites")
public class KeyboardUserInvites { public class KeyboardUserInvites {
@@ -24,56 +24,97 @@ public class KeyboardUserInvites {
* 邀请绑定记录主键ID * 邀请绑定记录主键ID
*/ */
@TableId(value = "id", type = IdType.AUTO) @TableId(value = "id", type = IdType.AUTO)
@Schema(description="邀请绑定记录主键ID") @Schema(description = "邀请绑定记录主键ID")
private Long id; private Long id;
/** /**
* 邀请人用户ID * 邀请人用户ID
*/ */
@TableField(value = "inviter_user_id") @TableField(value = "inviter_user_id")
@Schema(description="邀请人用户ID") @Schema(description = "邀请人用户ID")
private Long inviterUserId; private Long inviterUserId;
/** /**
* 被邀请人用户ID新注册用户 * 被邀请人用户ID新注册用户
*/ */
@TableField(value = "invitee_user_id") @TableField(value = "invitee_user_id")
@Schema(description="被邀请人用户ID新注册用户") @Schema(description = "被邀请人用户ID新注册用户")
private Long inviteeUserId; private Long inviteeUserId;
/** /**
* 使用的邀请码ID * 使用的邀请码ID
*/ */
@TableField(value = "invite_code_id") @TableField(value = "invite_code_id")
@Schema(description="使用的邀请码ID") @Schema(description = "使用的邀请码ID")
private Long inviteCodeId; private Long inviteCodeId;
/**
* 绑定时关联的点击Token通过邀请链接自动绑定时使用
*/
@TableField(value = "click_token")
@Schema(description = "绑定时关联的点击Token通过邀请链接自动绑定时使用")
private String clickToken;
/** /**
* 绑定方式1=手动填写邀请码2=邀请链接自动绑定3=其他方式 * 绑定方式1=手动填写邀请码2=邀请链接自动绑定3=其他方式
*/ */
@TableField(value = "bind_type") @TableField(value = "bind_type")
@Schema(description="绑定方式1=手动填写邀请码2=邀请链接自动绑定3=其他方式") @Schema(description = "绑定方式1=手动填写邀请码2=邀请链接自动绑定3=其他方式")
private Short bindType; private Short bindType;
/** /**
* 邀请关系绑定完成时间 * 邀请关系绑定完成时间
*/ */
@TableField(value = "bound_at") @TableField(value = "bound_at")
@Schema(description="邀请关系绑定完成时间") @Schema(description = "邀请关系绑定完成时间")
private Date boundAt; private Date boundAt;
/** /**
* 绑定 iP * 绑定 iP
*/ */
@TableField(value = "bind_ip") @TableField(value = "bind_ip")
@Schema(description="绑定 iP") @Schema(description = "绑定 iP")
private String bindIp; private String bindIp;
/** /**
* userAgent * userAgent
*/ */
@TableField(value = "bind_user_agent") @TableField(value = "bind_user_agent")
@Schema(description="userAgent") @Schema(description = "userAgent")
private String bindUserAgent; private String bindUserAgent;
/**
* 邀请码类型快照USER=普通用户邀请AGENT=代理邀请
*/
@TableField(value = "invite_type")
@Schema(description = "邀请码类型快照USER=普通用户邀请AGENT=代理邀请")
private String inviteType;
/**
* 收益结算归属租户ID代理结算用绑定时固化
*/
@TableField(value = "profit_tenant_id")
@Schema(description = "收益结算归属租户ID代理结算用绑定时固化")
private Long profitTenantId;
/**
* 收益归因员工ID用于区分租户员工/渠道,绑定时固化)
*/
@TableField(value = "profit_employee_id")
@Schema(description = "收益归因员工ID用于区分租户员工/渠道,绑定时固化)")
private Long profitEmployeeId;
/**
* 邀请人所属租户ID快照便于审计/对账,可选)
*/
@TableField(value = "inviter_tenant_id")
@Schema(description = "邀请人所属租户ID快照便于审计/对账,可选)")
private Long inviterTenantId;
/**
* 邀请码字符串快照(便于排查,可选)
*/
@TableField(value = "invite_code")
@Schema(description = "邀请码字符串快照(便于排查,可选)")
private String inviteCode;
} }

View File

@@ -5,7 +5,9 @@ import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapp
import com.yolo.keyborad.model.entity.KeyboardThemePurchase; import com.yolo.keyborad.model.entity.KeyboardThemePurchase;
import com.yolo.keyborad.service.KeyboardThemePurchaseService; import com.yolo.keyborad.service.KeyboardThemePurchaseService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@@ -22,17 +24,25 @@ import com.yolo.keyborad.service.KeyboardThemesService;
*/ */
@Service @Service
@Slf4j
public class KeyboardThemesServiceImpl extends ServiceImpl<KeyboardThemesMapper, KeyboardThemes> implements KeyboardThemesService { public class KeyboardThemesServiceImpl extends ServiceImpl<KeyboardThemesMapper, KeyboardThemes> implements KeyboardThemesService {
private static final String THEME_STYLE_KEY = "theme:style:";
private static final String THEME_ALL_KEY = "theme:style:all";
@Resource @Resource
@Lazy // 延迟加载,打破循环依赖 @Lazy // 延迟加载,打破循环依赖
private KeyboardThemePurchaseService purchaseService; private KeyboardThemePurchaseService purchaseService;
@Resource(name = "objectRedisTemplate")
private RedisTemplate<String, Object> redisTemplate;
/** /**
* 根据风格查询主题列表 * 根据风格查询主题列表
* <p>查询规则:</p> * <p>查询规则:</p>
* <ul> * <ul>
* <li>优先从Redis缓存读取主题列表</li>
* <li>当themeStyle为9999时查询所有主题并按排序字段升序排列</li> * <li>当themeStyle为9999时查询所有主题并按排序字段升序排列</li>
* <li>其他情况下,查询指定风格的主题</li> * <li>其他情况下,查询指定风格的主题</li>
* <li>查询结果均过滤已删除和未启用的主题</li> * <li>查询结果均过滤已删除和未启用的主题</li>
@@ -44,23 +54,44 @@ public class KeyboardThemesServiceImpl extends ServiceImpl<KeyboardThemesMapper,
* @return 主题列表,包含主题详情和购买状态 * @return 主题列表,包含主题详情和购买状态
*/ */
@Override @Override
@SuppressWarnings("unchecked")
public List<KeyboardThemesRespVO> selectThemesByStyle(Long themeStyle, Long userId) { public List<KeyboardThemesRespVO> selectThemesByStyle(Long themeStyle, Long userId) {
// 根据风格参数查询主题列表 // 尝试从Redis缓存读取
List<KeyboardThemes> themesList; String cacheKey = themeStyle == 9999 ? THEME_ALL_KEY : THEME_STYLE_KEY + themeStyle;
if (themeStyle == 9999) { List<KeyboardThemesRespVO> themesList = null;
// 查询所有主题,按排序字段升序
themesList = this.lambdaQuery() try {
.eq(KeyboardThemes::getDeleted, false) Object cached = redisTemplate.opsForValue().get(cacheKey);
.eq(KeyboardThemes::getThemeStatus, true) if (cached != null) {
.orderByAsc(KeyboardThemes::getSort) themesList = (List<KeyboardThemesRespVO>) cached;
.list(); log.debug("从缓存读取风格{}的主题列表,共{}个", themeStyle, themesList.size());
} else { }
// 查询指定风格的主题 } catch (Exception e) {
themesList = this.lambdaQuery() log.warn("读取主题缓存失败,将从数据库查询", e);
.eq(KeyboardThemes::getDeleted, false) }
.eq(KeyboardThemes::getThemeStatus, true)
.eq(KeyboardThemes::getThemeStyle, themeStyle) // 缓存未命中,从数据库查询
.list(); if (themesList == null) {
List<KeyboardThemes> themesFromDb;
if (themeStyle == 9999) {
// 查询所有主题,按排序字段升序
themesFromDb = this.lambdaQuery()
.eq(KeyboardThemes::getDeleted, false)
.eq(KeyboardThemes::getThemeStatus, true)
.orderByAsc(KeyboardThemes::getSort)
.list();
} else {
// 查询指定风格的主题
themesFromDb = this.lambdaQuery()
.eq(KeyboardThemes::getDeleted, false)
.eq(KeyboardThemes::getThemeStatus, true)
.eq(KeyboardThemes::getThemeStyle, themeStyle)
.list();
}
themesList = themesFromDb.stream()
.map(theme -> BeanUtil.copyProperties(theme, KeyboardThemesRespVO.class))
.collect(Collectors.toList());
log.debug("从数据库读取风格{}的主题列表,共{}个", themeStyle, themesList.size());
} }
// 查询用户已购买的主题ID集合 // 查询用户已购买的主题ID集合
@@ -72,7 +103,7 @@ public class KeyboardThemesServiceImpl extends ServiceImpl<KeyboardThemesMapper,
.map(KeyboardThemePurchase::getThemeId) .map(KeyboardThemePurchase::getThemeId)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
// 转换为VO并设置购买状态 // 设置购买状态并返回
return themesList.stream().map(theme -> { return themesList.stream().map(theme -> {
KeyboardThemesRespVO vo = BeanUtil.copyProperties(theme, KeyboardThemesRespVO.class); KeyboardThemesRespVO vo = BeanUtil.copyProperties(theme, KeyboardThemesRespVO.class);
vo.setIsPurchased(purchasedThemeIds.contains(theme.getId())); vo.setIsPurchased(purchasedThemeIds.contains(theme.getId()));

View File

@@ -77,6 +77,7 @@ public class KeyboardUserInviteCodesServiceImpl extends ServiceImpl<KeyboardUser
inviteCode.setExpiresAt(null); // 永久有效 inviteCode.setExpiresAt(null); // 永久有效
inviteCode.setMaxUses(null); // 不限次数 inviteCode.setMaxUses(null); // 不限次数
inviteCode.setUsedCount(0); // 初始使用次数为0 inviteCode.setUsedCount(0); // 初始使用次数为0
inviteCode.setInviteType("USER"); // 默认为普通用户邀请码
// 保存到数据库 // 保存到数据库
this.save(inviteCode); this.save(inviteCode);

View File

@@ -270,14 +270,23 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
userInvite.setBoundAt(new Date()); userInvite.setBoundAt(new Date());
userInvite.setBindIp(request.getRemoteAddr()); userInvite.setBindIp(request.getRemoteAddr());
userInvite.setBindUserAgent(request.getHeader("User-Agent")); userInvite.setBindUserAgent(request.getHeader("User-Agent"));
// 记录邀请码类型快照(用户/租户)
userInvite.setInviteType(inviteCode.getInviteType());
userInvite.setInviteCode(inviteCode.getCode());
// 如果是租户邀请码记录租户ID
if ("AGENT".equals(inviteCode.getInviteType()) && inviteCode.getOwnerTenantId() != null) {
userInvite.setProfitTenantId(inviteCode.getOwnerTenantId());
userInvite.setInviterTenantId(inviteCode.getOwnerTenantId());
userInvite.setProfitEmployeeId(inviteCode.getOwnerSystemUserId());
}
userInvitesService.save(userInvite); userInvitesService.save(userInvite);
// 更新邀请码使用次数 // 更新邀请码使用次数
inviteCode.setUsedCount(inviteCode.getUsedCount() + 1); inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
inviteCodesService.updateById(inviteCode); inviteCodesService.updateById(inviteCode);
log.info("User bound to invite code, userId={}, inviteCodeId={}, inviterUserId={}", log.info("User bound to invite code, userId={}, inviteCodeId={}, inviterUserId={}, inviteType={}",
keyboardUser.getId(), inviteCode.getId(), inviteCode.getOwnerUserId()); keyboardUser.getId(), inviteCode.getId(), inviteCode.getOwnerUserId(), inviteCode.getInviteType());
} catch (BusinessException e) { } catch (BusinessException e) {
// 邀请码验证失败,记录日志但不影响注册流程 // 邀请码验证失败,记录日志但不影响注册流程
log.warn("Failed to bind invite code for user {}: {}", keyboardUser.getId(), e.getMessage()); log.warn("Failed to bind invite code for user {}: {}", keyboardUser.getId(), e.getMessage());
@@ -379,14 +388,23 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
userInvite.setBoundAt(new Date()); userInvite.setBoundAt(new Date());
userInvite.setBindIp(request.getRemoteAddr()); userInvite.setBindIp(request.getRemoteAddr());
userInvite.setBindUserAgent(request.getHeader("User-Agent")); userInvite.setBindUserAgent(request.getHeader("User-Agent"));
// 记录邀请码类型快照(用户/租户)
userInvite.setInviteType(inviteCode.getInviteType());
userInvite.setInviteCode(inviteCode.getCode());
// 如果是租户邀请码记录租户ID
if ("AGENT".equals(inviteCode.getInviteType()) && inviteCode.getOwnerTenantId() != null) {
userInvite.setProfitTenantId(inviteCode.getOwnerTenantId());
userInvite.setInviterTenantId(inviteCode.getOwnerTenantId());
userInvite.setProfitEmployeeId(inviteCode.getOwnerSystemUserId());
}
userInvitesService.save(userInvite); userInvitesService.save(userInvite);
// 更新邀请码使用次数 // 更新邀请码使用次数
inviteCode.setUsedCount(inviteCode.getUsedCount() + 1); inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
inviteCodesService.updateById(inviteCode); inviteCodesService.updateById(inviteCode);
log.info("User bound invite code, userId={}, inviteCodeId={}, inviterUserId={}", log.info("User bound invite code, userId={}, inviteCodeId={}, inviterUserId={}, inviteType={}",
userId, inviteCode.getId(), inviteCode.getOwnerUserId()); userId, inviteCode.getId(), inviteCode.getOwnerUserId(), inviteCode.getInviteType());
return true; return true;
} }