SaaS短链接系统-新手从零学习 4-短链接分组

eve2333 发布于 28 天前 22 次阅读


 短链接模块功能分析

  • 短链接跳转原理
  • 创建短链接表
  • 新增短链接
  • Host添加域名映射(短链接一般都是根据域名进行跳转,本地测试很难有那种域名,可以通过修改本地host的方式添加域名映射。)
  • 分页查询短链接集合
  • 编辑短链接
  • 将短链接删除(回收站)

我们在这个系统中选择创建一个短链接,首先输入一个原始地址,也就是我们要缩短的长链接。这时系统会默认把短链接的标题带出来,我们可以将这个短链接设置为永久有效,然后点击确认。

这样系统就会根据它的默认域名生成一个短链接,比如 url.cn,然后生成一个对应的路径,例如 /abc123。通过这个短链接访问时,系统会跳转到最初创建时填写的那个长链接,这就是短链接最核心的原理。

我们可以测试一下,搜索这个短链接,OK,跳转成功了。

除此之外,这里还有一些统计信息,比如访问次数、访问人数、IP数等监控数据。系统还提供了图表展示:可以看到每个时间段(如 24 小时内的每个小时)的访问量、访问地区、使用的操作系统和浏览器等信息。

这些监控功能非常丰富,所有的访问数据都会被记录下来。此外,还可以对短链接进行排序管理等功能。

可能有人会觉得这个功能看起来很简单,但实际上恰恰相反。这类短链接系统通常会面临海量并发请求 的挑战。

举个例子:如果你把一个短链接发布在微博上,一旦火起来,短时间内可能会有成千上万的人同时点击,这时候每秒的 TPS(Transactions Per Second)可能会达到几千甚至上万。而现实中,99.999% 的互联网系统都很难做到每秒处理上百次请求。

因此,能支撑这种高并发访问的系统设计是非常有难度也非常有价值的。

在这个基础上,还会涉及很多技术选型的问题:

  • 比如你是把短链接的热点数据放到 Redis 中,还是存储在 MySQL 里?
  • 如果放在 MySQL 中,当访问量激增时,会不会直接把数据库压垮?
  • 如果放在 Redis 中,又如何应对 Redis 宕机的情况?怎么保证即使 Redis 出现故障,数据依然不会丢失?或者说至少能尽量保证数据完整性?

在创建短链接时,如果用户恶意构造大量不存在的短链接请求,就会造成缓存穿透 。而在更新或删除短链接时,如果大量请求同时访问同一个失效的缓存键,就可能导致缓存击穿

为了防止短链接重复创建,难道每次都要去查一次数据库吗?这显然不可行。于是我们就引入了**布隆过滤器来快速判断某个短链接是否已经存在。 

除了缓存之外,还有大量的统计数据需要处理:

  • 地区访问分布
  • 使用的操作系统和浏览器类型
  • 这些信息是如何采集和分析的?

此外,还有两个关键的数据存储问题:

  1. 海量的短链接与原始链接的映射关系如何高效存储?
  2. 如何实现短链接的变更操作(如修改、删除)?变更时如何处理缓存与数据库之间的一致性?

创建短链接分组数据库表

短链接分组就像是谷歌浏览器的收藏栏,标识着不同网站不同的语义。如果大家使用短链接系统,创建不同语义的短链接需要在一个分页里查询,这是一件多么令人抓狂的事。

按照不同的思路的拆分开,这样查询的时候就会方便很多。也方便统计。

  • 增加短链接分组
  • 修改短链接分组(只能修改名称)
  • 查询短链接分组集合(短链接分组最多10个)
  • 删除短链接分组
  • 短链接分组排序
CREATE TABLE `t_group` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `gid` varchar(32) DEFAULT NULL COMMENT '分组标识',
  `name` varchar(64) DEFAULT NULL COMMENT '分组名称',
  `username` varchar(256) DEFAULT NULL COMMENT '创建分组用户名',
  `sort_order` int(3) DEFAULT NULL COMMENT '分组排序',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '修改时间',
  `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;;

跟用户这种肯定是比不了的,所以说我们这边就直接使用什么?直接使用一个分表结构,直接提供 group 表。
然后 ID 我们先用第一个 INT 类型,尽量因为分组表我们假设后续可能不会有特别大的数据量,但我们也不能排除这种情况。
当你对业务增长曲线未知时,一切都要按照分表的流程去做。你可以先不分,但后续分表时不要有太多阻碍。

举个实际的例子:如果我们用了 INT(11),将来做分布式组件时需要修改,还要清洗之前的数据,这非常不友好。
不如我们直接使用 Snowflake(或类似分布式ID) ,直接用分布式ID就可以了。我们打上标识,首先想一下短链接分组需要哪些元素?

首先要有 gid(分组ID),然后 hr(可能是字段名,原词需确认),设成 VARCHAR(32) 其实就够了。
dad 是什么标识?短链接分组标识,直接叫分组标识。什么意思?相当于分组ID可能会在业务中使用,我们尽量不要用纯数字ID(ID 字段不具备语义标识性),可以用一个较短的字符串标识,比如6位随机码,这样可以帮助用户更好地区分。
比如在业务沟通时,可以明确说 "gid 下的哪个短链接" 或 "哪个分组",起到类似用户名的标识作用。

接下来是短链接的 name,设置为 VARCHAR(64),基本不可能出现过长的分组名称。
然后还有一个字段,应该是创建人(用户)。是哪个用户创建的?
用户的用户名字段我们直接叫 username 好了,取值设为 VARCHAR(256)
为什么要加 username?是因为这些分组需要保持唯一性,即同一个用户下不能有重复的分组名称,但同一个分组名称可以在不同用户下存在。

接下来是创建时间(create_time)和创建用户名(creator_username)。
为了保证唯一性,我们需要创建一个唯一索引:
UNIQUE INDEX idx_name_user (name, username),使用 B+Tree 类型。

这里需要注意最左匹配原则:很多情况下是直接通过 gid 查询,而 name 可能不会单独使用。
因此索引字段顺序应优先放常用查询字段(如 gid),再放其他字段(如 username)。

我们可以先造一条测试数据:
RANDOM 生成随机中英文或数字组合的标识码(如 123456),短链接实例名为 短链接实例,用户名为 马丁,关闭时间 close_time 等。

最后验证唯一索引是否生效:如果尝试插入重复的 name + username 组合,会触发索引冲突,证明约束已生效。

新增短链接分组功能

创建GroupDO

package com.nageoffer.shortlink.admin.dao.entiry;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Date;

/**
 * 短链接分组实体
 */
@Data
@TableName("t_group")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GroupDO{

    /**
     * id
     */
    private Long id;

    /**
     * 分组标识
     */
    private String gid;

    /**
     * 分组名称
     */
    private String name;

    /**
     * 创建分组用户名
     */
    private String username;

   
    /**
     * 创建时间
     */
    @TableField(fill = FieldFill.INSERT)
    private Date createTime;
    /**
     * 修改时间
     */
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Date updateTime;
    /**
     * 删除标识0:未删除1:已删除
     */
    @TableField(fill = FieldFill.INSERT)
    private Integer delFlag;
}
package com.nageoffer.shortlink.admin.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.nageoffer.shortlink.admin.dao.entiry.GroupDO;


import java.util.List;

/**
 * 短链接分组接口层
 */
public interface GroupService extends IService<GroupDO> {
    /**
     * 新增短链接分组
     * @param groupName 短链接分组名
     */
    void saveGroup(String groupName);
}
package com.nageoffer.shortlink.admin.dao.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.service.IService;
import com.nageoffer.shortlink.admin.dao.entiry.GroupDO;


/**
 * 短链接分组持久层
 */
public interface GroupMapper extends BaseMapper<GroupDO> {
}
package com.nageoffer.shortlink.admin.controller;

import com.nageoffer.shortlink.admin.service.GroupService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.RestController;

/**
 * 短链接分组控制层
 */
@RestController
@RequiredArgsConstructor
public class GroupController {
    private final GroupService groupService;
}

Java实现一个随机函数,最终输出的是包含数字和英文字母的6位随机数,给我一个工具类,将utils改名toolkits,新建类如下:

package com.nageoffer.shortlink.admin.toolkit;

import java.security.SecureRandom;
/**
 * 分组ID随机生成器
 */
public final class RandomGenerator {

    private static final String CHARACTERS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    private static final SecureRandom RANDOM = new SecureRandom();

    /**
     * 生成随机分组ID
     *
     * @return 分组ID
     */
    public static String generateRandom() {
        return generateRandom(6);
    }

    /**
     * 生成随机分组ID
     *
     * @param length 生成多少位
     * @return 分组ID
     */
    public static String generateRandom(int length) {
        StringBuilder sb = new StringBuilder(length);
        for (int i = 0; i < length; i++) {
            int randomIndex = RANDOM.nextInt(CHARACTERS.length());
            sb.append(CHARACTERS.charAt(randomIndex));
        }
        return sb.toString();
    }
}

在dto的req中新建ShortLinkGroupSaveReqDTO

package com.nageoffer.shortlink.admin.dto.req;

import lombok.Data;

/**
 * 短链接分组创建参数
 */
@Data
public class ShortLinkGroupSaveReqDTO {
    private String name;
}

 相应的GroupController,GroupService和GroupServiceImpl

package com.nageoffer.shortlink.admin.controller;

import com.nageoffer.shortlink.admin.common.convention.result.Results;
import com.nageoffer.shortlink.admin.dto.req.ShortLinkGroupSaveReqDTO;
import com.nageoffer.shortlink.admin.service.GroupService;
import lombok.RequiredArgsConstructor;
import com.nageoffer.shortlink.admin.common.convention.result.Result;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 * 短链接分组控制层
 */
@RestController
@RequiredArgsConstructor
public class GroupController {
    private final GroupService groupService;

    @PostMapping("/api/short-link/v1/group")
    public Result<Void>save(@RequestBody ShortLinkGroupSaveReqDTO requestParam){
        groupService.saveGroup(requestParam.getName());
        return Results.success();
    }

}

package com.nageoffer.shortlink.admin.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.nageoffer.shortlink.admin.dao.entiry.GroupDO;


import java.util.List;

/**
 * 短链接分组接口层
 */
public interface GroupService extends IService<GroupDO> {
    /**
     * 新增短链接分组
     * @param groupName 短链接分组名
     */
    void saveGroup(String groupName);
}

 在每个DO里面都mp的自动填充很麻烦,在common包下建立database包,新建BaseDO文件

package com.nageoffer.shortlink.admin.common.database;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;

import java.util.Date;

@Data
public class BaseDO {
        /**
         * 创建时间
         */
        @TableField(fill = FieldFill.INSERT)
        private Date createTime;
        /**
         * 修改时间
         */
        @TableField(fill = FieldFill.INSERT_UPDATE)
        private Date updateTime;
        /**
         * 删除标识0:未删除1:已删除
         */
        @TableField(fill = FieldFill.INSERT)
        private Integer delFlag;
}

将GroupDO和UserDO都extends BaseDO 

public class GroupDO extends BaseDO 
..............................................................
public class UserDO extends BaseDO

 是的我们成功生成了

查询短链接分组功能

短链接分组中应该支持自定义 sort_order,这个在创建数据库就已经定义好了。有1和0两个数据,1在前面。但是呢就是很多情况下我们初始化的时候把这个 sort_order给添加进来默认是0,
因此要在后面加一个update_time,用两个字段做排序。相当于如果说你的 sort_order是相同的,那么再用updatetime去做一层排序

/**
 * 短链接分组实体
 */
@Data
@TableName("t_group")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GroupDO extends BaseDO {

    /**
     * id
     */
    private Long id;

    /**
     * 分组标识
     */
    private String gid;

    /**
     * 分组名称
     */
    private String name;

    /**
     * 创建分组用户名
     */
    private String username;

    /**
     * 分组排序
     */
    private Integer sortOrder;
}

impl中设置初始化

@Override
public void saveGroup(String groupName) {
    String gid;
    do {
        gid = RandomGenerator.generateRandom();
    } while (!hasGid(gid));
    GroupDO groupDO = GroupDO.builder()
            .gid(gid)
            .sortOrder(0)//默认是0
            .name(groupName)
            .build();
    baseMapper.insert(groupDO);
}

 在resq下新建ShortLinkGroupRespDTO

package com.nageoffer.shortlink.admin.dto.resq;

import lombok.Data;

/**
 * 短链接分组返回实体对象
 */
@Data
public class ShortLinkGroupRespDTO {
    /**
     * 分组标识
     */
    private String gid;
    /**
     * 分组名称
     */
    private String name;
    /**
     * 创建分组用户名
     */
    private String username;
    /**
     * 分组排序
     */
    private Integer sortOrder;
}

 书写Service和controller

@GetMapping("/api/short-link/v1/group")
public Result<List<ShortLinkGroupRespDTO>>listGroup(){
    return Results.success(groupService.listGroup());
}
/**
 * 查询用户短链接分组集合
 * @return 用户短链接分组集合
 */
List<ShortLinkGroupRespDTO> listGroup();

 我们要干什么?要从我们当前的请求里面获取用户名对吧?因为这里我们现在用户名这里还没做,所以说直接就返回一个null就可以了,我们现在相当于新增插入的时候是null,然后你用now去获取它一样的获取得出来好吧?然后我们这里用一个维克斯 PPS website第二number query。然后用一个girl给他丢。第二这里还不行,点Q盖起来。 Get your name,然后先设个然后这里的话我们要设置order by ask,然后这里的话第一个就是盖特应该是salt sort of,然后还要有一层叫盖 update time。然后这里不要忘了一个东西,那就是我们的EQ这里要给它加上一个的delflag都需要是生效中的,然后做一个查询,然后baseMapper.selectList(queryWrapper) 。List<GroupDO>groupDOList然后给他返回BeanUtil。

    @Override
    public List<ShortLinkGroupRespDTO> listGroup(){
    // TODO 获取用户名
        LambdaQueryWrapper<GroupDO> queryWrapper = Wrappers.lambdaQuery(GroupDO.class)
                .eq(GroupDO::getDelFlag, 0)
                .eq(GroupDO::getUsername,null)
                .orderByDesc(GroupDO::getSortOrder, GroupDO::getUpdateTime);
        List<GroupDO> groupDOList = baseMapper.selectList(queryWrapper);
        return BeanUtil.copyToList(groupDOList, ShortLinkGroupRespDTO.class);
    }

apifox没有如预料所示返回数据库里的你刚刚注册的记录 ,因此找bug

排序顺序返回 在数据库先填写数,这时如期返回,并且可以

1.数据库中的null不等于任何值,包括自身,eq传null底层会把sql写成where username = null,所以获取不到值,只能用 where username is null。代码里面的话写成wrapper.isNull(username)就可以获取到了 

2.这样就没警告了 .orderByDesc(List.of(GroupDO::getSortOrder, GroupDO::getUpdateTime));事实上升级 mybatis-plus 就好了,你改下pom版本,不要3.5.3.1

使用了orderByDesc()或者orderByAsc(),ide会提示:Unchecked generics array creation for varargs parameter · Issue #3720 · baomidou/mybatis-plus

拦截器封装用户上下文功能

就是封装我们的用户上下文,打算跟网关一起做的,我们有很多的方法都需要依赖这个从当前请求里面获取上下文

 common中新建biz.user,UserInfoDTO文件

package com.nageoffer.shortlink.admin.common.biz.user;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 用户信息实体
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserInfoDTO {

    /**
     * 用户 ID
     */
    private String userId;

    /**
     * 用户名
     */
    private String username;

    /**
     * 真实姓名
     */
    private String realName;

    /**
     * 用户 Token
     */
    private String token;
}

 创建USerContext文件

package com.nageoffer.shortlink.admin.common.biz.user;
import com.alibaba.ttl.TransmittableThreadLocal;

import java.util.Optional;

/**
 * 用户上下文
 */
public final class UserContext {

    private static final ThreadLocal<UserInfoDTO> USER_THREAD_LOCAL = new TransmittableThreadLocal<>();

    /**
     * 设置用户至上下文
     *
     * @param user 用户详情信息
     */
    public static void setUser(UserInfoDTO user) {
        USER_THREAD_LOCAL.set(user);
    }

    /**
     * 获取上下文中用户 ID
     *
     * @return 用户 ID
     */
    public static String getUserId() {
        UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get();
        return Optional.ofNullable(userInfoDTO).map(UserInfoDTO::getUserId).orElse(null);
    }

    /**
     * 获取上下文中用户名称
     *
     * @return 用户名称
     */
    public static String getUsername() {
        UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get();
        return Optional.ofNullable(userInfoDTO).map(UserInfoDTO::getUsername).orElse(null);
    }

    /**
     * 获取上下文中用户真实姓名
     *
     * @return 用户真实姓名
     */
    public static String getRealName() {
        UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get();
        return Optional.ofNullable(userInfoDTO).map(UserInfoDTO::getRealName).orElse(null);
    }

    /**
     * 获取上下文中用户 Token
     *
     * @return 用户 Token
     */
    public static String getToken() {
        UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get();
        return Optional.ofNullable(userInfoDTO).map(UserInfoDTO::getToken).orElse(null);
    }

    /**
     * 清理用户上下文
     */
    public static void removeUser() {
        USER_THREAD_LOCAL.remove();
    }
}

用户上下文指的是当你在这个我们呢拦截器里面,在你方法进入到我们springboot之后,我们拦截到你的TOKEN,就是那个查询用户名是否可用,如果有的话就放到当前的THREAD就是这个请求里面,请求不管是controller还是service都可以通过这些函数获得信息:一种就是你要获取当年登录信息,还要通过里面各种方法调用

接下来写一个过滤器 

package com.nageoffer.shortlink.admin.common.biz.user;

import com.alibaba.fastjson2.JSON;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.util.StringUtils;

import java.io.IOException;
import java.net.URLDecoder;

import static java.nio.charset.StandardCharsets.UTF_8;

/**
 * 用户信息传输过滤器
 */
@RequiredArgsConstructor
public class UserTransmitFilter implements Filter {
    private final StringRedisTemplate stringRedisTemplate;

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String username = httpServletRequest.getHeader("username");
        String token = httpServletRequest.getHeader("token");
        Object userInfoJsonStr =stringRedisTemplate.opsForHash().get("login_"+username,token);
        if (userInfoJsonStr != null) {
            UserInfoDTO userInfoDTO = JSON.parseObject(userInfoJsonStr.toString(), UserInfoDTO.class);
            UserContext.setUser(userInfoDTO);
        }
        try {
            filterChain.doFilter(servletRequest, servletResponse);
        } finally {
            UserContext.removeUser();
        }
    }
}

在config中添加相应的声明UserConfiguration 

package com.nageoffer.shortlink.admin.config;

import com.nageoffer.shortlink.admin.common.biz.user.UserTransmitFilter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;

/**
 * 用户配置自动装配
 */
@Configuration
public class UserConfiguration {

    /**
     * 用户信息传递过滤器
     */
    @Bean
    public FilterRegistrationBean<UserTransmitFilter> globalUserTransmitFilter(StringRedisTemplate stringRedisTemplate) {
        FilterRegistrationBean<UserTransmitFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(new UserTransmitFilter(stringRedisTemplate));
        registration.addUrlPatterns("/*");
        registration.setOrder(0);
        return registration;
    }
}

GroupServicelmpl.java 设置用户名,使用getUsername获得

    @Override
    public void saveGroup(String groupName) {
        String gid;
        do {
            gid = RandomGenerator.generateRandom();
        } while (!hasGid(gid));
        GroupDO groupDO = GroupDO.builder()
                .gid(gid)
                .sortOrder(0)
                .username(UserContext.getUsername())
                .name(groupName)
                .build();
        baseMapper.insert(groupDO);
    }

    @Override
    public List<ShortLinkGroupRespDTO> listGroup(){
    // TODO 从当前上下文获取用户名
        LambdaQueryWrapper<GroupDO> queryWrapper = Wrappers.lambdaQuery(GroupDO.class)
                .eq(GroupDO::getDelFlag, 0)
                .eq(GroupDO::getUsername,UserContext.getUsername())
                .orderByDesc(GroupDO::getSortOrder, GroupDO::getUpdateTime);
        List<GroupDO> groupDOList = baseMapper.selectList(queryWrapper);
        return BeanUtil.copyToList(groupDOList, ShortLinkGroupRespDTO.class);
    }

    private boolean hasGid(String gid) {
        LambdaQueryWrapper<GroupDO> queryWrapper = Wrappers.lambdaQuery(GroupDO.class)
                .eq(GroupDO::getGid, gid)
        .eq(GroupDO::getUsername, UserContext.getUsername());
        GroupDO hasGroupFlag = baseMapper.selectOne(queryWrapper);
        return hasGroupFlag == null;
    }

 UserInfoDTO设置成这个,相对于反序列化的时候,不要userid直接id去做

@JSONField(name="id")
private String userId;

 把它给设置到一个ThreadLocal,相当于只要你是在当前的这个线程,不管你在哪一个地方获取,都可以获取到当前的用户上下文,并且使用阿里巴巴的安全线程TransmittableThreadLocal
我们默认的ThreadLocal跨线程就失效了,如果是父子线程的话,还勉强有一个时间类可以传递,但是没有关系的跨线程的传递不能,ThreadLocal父子线程传递实现方案 - 知乎

 可以看看TransmittableThreadLocal原理,大概源码

先新增之后然后继续执行后续的过滤器,然后执行完了之后,他在最后的话会把给remove掉,相当于把从用户信息从当前的线程上下文里面给它抹掉,防止内存泄露,直接给他调remove

UserTransmitFilter.java用户信息=null,那他肯定是需要登录的 ,要不然你不就业务可能会出问题吗,我们代码里面这么用了,实际情况是我们是要在网关层面去做这个事;为了后续测试方便,

 作者提到时间太长,换成30DAYs比较好,UserServiceImpl里面

stringRedisTemplate.expire("login_" + requestParam.getUsername(), 30L, TimeUnit.DAYS);

1.大家可能在这一节遇到过滤器的报错问题,这是因为没有过滤器没有将登录接口放行,所以会出现问题。下一节视频会解决,请正常向后看一节就好。

当前过滤器配置为拦截所有路径(/*),包括登录接口。未登录时,Redis中无用户信息,导致过滤器无法设置用户上下文,进而阻止登录请求。

解决方案 :需在过滤器中排除登录接口的URL,例如
或在 doFilter 方法中添加路径检查逻辑。

registration.addUrlPatterns("/*");
registration.addInitParameter("excludedUrls", "/api/short-link/v1/user/login");

2.过滤器设置了registration.addUrlPatterns("/*"); 是不是登录接口也被拦住了,Redis里没有数据时咋登录呢。

  • 过滤器强制要求请求携带合法Token(从Redis中获取用户信息),但登录接口本身需要允许匿名访问。
  • 修改过滤器路径 :将 addUrlPatterns("/*") 改为 addUrlPatterns("/api/*"),排除 /login 等公共接口。
  • 动态放行逻辑 :在 doFilter 中判断URL是否为登录接口,若匹配则直接放行:if ("/api/short-link/v1/user/login".equals(httpServletRequest.getRequestURI())) { filterChain.doFilter(servletRequest, servletResponse); return; }

3.这里用mvc的拦截器是不是会更好一点,感觉拦截器更好

对比维度过滤器(Filter)拦截器(Interceptor)
作用层级Servlet规范,作用于所有请求(包括静态资源)Spring MVC,仅作用于控制器方法
适用场景全局请求处理(如日志、跨域、用户上下文)控制器级别的逻辑(如权限校验、参数绑定)
灵活性需手动处理URL匹配可结合@RequestMapping注解精确匹配
  • 当前场景适用过滤器 :用户上下文需在请求早期设置,且可能涉及非控制器的请求处理(如WebSocket)。
  • 拦截器优势 :若仅需在控制器方法执行前设置上下文,拦截器更轻量且与Spring集成更紧密。

4.从后面回来的,这里拦截器的作用有点局限,仅仅只验证了用户是否登录,但只要一个用户登陆了,他就可以访问别的用户的短链接信息

修复方案
业务层校验 :在数据库查询时强制加入 username 条件(如代码中 UserContext.getUsername()),确保用户仅能访问自身数据。
统一权限校验 :使用Spring Security或自定义注解(如 @CheckUserAccess)在方法级别校验权限。

5.admin.common.biz.user中UserInfoDTO的用户id是String类型,而admin.dao.entity中UserDO的用户id是Long类型,会有影响吗?我记得做了转换,最终不会有问题

  • 潜在风险 :若数据库字段为 BIGINT(对应Java Long),而UserInfoDTO使用 String,需确保序列化/反序列化时正确转换。
  • 正确性条件
    • 存储时 :将 Long 转换为 String(如 user.setId(userDO.getId().toString()))。
    • 读取时 :确保 JSON.parseObject(...) 能正确解析 StringLong(FastJSON2默认支持)。

6.为什么不用session来管理呢,有现成的库可用。spring security就可以。因为Spring权限框架比较复杂,短链接系统重点不在用户,所以用一种简单方案实现登录。市场上的Spring权限框架以及SaToken都挺不错的

  • Session的局限性
    • 单机部署 :Session依赖服务器内存,无法横向扩展。
    • 分布式部署 :需引入 Spring Session + Redis,复杂度与当前方案相当。
  • Spring Security优势
    • 提供完整的认证、授权机制(如RBAC、OAuth2)。
    • 安全性更高(如防CSRF、XSS攻击)。
  • 项目选择合理性
    • 短期目标 :快速实现基础功能,适合教学或原型开发。
    • 长期建议 :生产环境应使用Spring Security或SaToken等成熟框架。

7.这里为什么用过滤器不用拦截器,还是说都可以?

过滤器是javaweb的,拦截器是spring的有区别

  • 过滤器(Filter)
    • 优点 :作用于所有请求,包括非Spring MVC的资源(如静态文件、WebSocket)。
    • 缺点 :需手动处理URL匹配,与Spring耦合度低。
  • 拦截器(Interceptor)
    • 优点 :与Spring MVC集成紧密,可访问 HandlerMethodModelAndView
    • 缺点 :无法拦截非控制器请求。
  • 结论
    • 当前场景合理 :用户上下文需全局生效,过滤器更合适。
    • 替代方案 :若仅需在控制器方法中使用用户信息,拦截器更简洁。

修改短链接分组功能

在dto中的req创建修改的参数,实际上就是个名字

package com.nageoffer.shortlink.admin.dto.req;

import lombok.Data;

/**
 * 短链接分组修改参数
 */
@Data
public class ShortLinkGroupUpdateReqDTO {
    /**
     * 分组名
     */
    private String name;
    /**
     * 分组标注
     */
    private String gid;
}

 在Controller和Service添加,还有impl

@Override
public void updateGroup(ShortLinkGroupUpdateReqDTO requestParam){
    LambdaUpdateWrapper<GroupDO> updateWrapper = Wrappers.lambdaUpdate(GroupDO.class)
            .eq(GroupDO::getUsername, UserContext.getUsername())
            .eq(GroupDO::getGid, requestParam.getGid())
            .eq(GroupDO::getDelFlag, 0);
    GroupDO groupDO = new GroupDO();
    groupDO.setName(requestParam.getName());
    baseMapper.update(groupDO, updateWrapper);
}
/**
 * 修改短链接分组名称
 */
@PutMapping("/api/short-link/v1/group")
public Result<Void>update(@RequestBody ShortLinkGroupUpdateReqDTO requestParam){
    groupService.updateGroup(requestParam);
    return Results.success();
}
/**
 * 修改短链接分组
 * @param requestParam 短链接
 */
void updateGroup(ShortLinkGroupUpdateReqDTO requestParam);

 我们在UserConfiguration设置排除的url好像没有这个语法?直接在USerTransmitFilter里面:
(实际上是用户登录拦截器中忽略登录接口)

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
    String requestURI = httpServletRequest.getRequestURI();
    if(!Objects.equals(requestURI,"/api/short-link/v1/user/login")){
        String username = httpServletRequest.getHeader("username");
        String token = httpServletRequest.getHeader("token");
        Object userInfoJsonStr =stringRedisTemplate.opsForHash().get("login_"+username,token);
        if (userInfoJsonStr != null) {
            UserInfoDTO userInfoDTO = JSON.parseObject(userInfoJsonStr.toString(), UserInfoDTO.class);
            UserContext.setUser(userInfoDTO);
        }
    }
    try {
        filterChain.doFilter(servletRequest, servletResponse);
    } finally {
        UserContext.removeUser();
    }
}

右上角可以选择添加全局参数,记得先登录,因为设置的30min过期,现在已经改成30days了

 这里可能会有报错,请看下一篇文章的”修正错误“部分

 阿里的TTL应该只是解决了在同一台JVM下,不同线程间ThreadLocal的复用吧,可是看到现在发现这个项目应该是微服务架构,是在分布式环境下的,那么在服务互相调用的过程中TTL就不能维护ThreadLocal保存的信息了吧,这个时候怎么解决?

用JWT token来代替当前这个登录方式不知道行不行,这样就是stateless了。

 好麻烦啊,现在创建一个用户

{

    "username":"markdown",

    "password":"114514",

    "realName":"弱水三千",

    "phone":"13869696969",

    "mail":"1256789@qq.com"

}
这个号在user15里面,"token": "23cb2507-040b-438d-aefe-1e253ce2ac9e"

删除短链接分组功能

 删除一般都是软删除,所以一般是update去做,改改标识就好

/**
 * 删除短链接分组
 */
@DeleteMapping("/api/short-link/v1/group")
public Result<Void>delete(@RequestParam String gid){
    groupService.deleteGroup(gid);
    return Results.success();
}
/**
 * 删除短链接分组
 * @param gid 短链接分组标识
 */
void deleteGroup(String gid);
@Override
public void deleteGroup(String gid) {
    LambdaUpdateWrapper<GroupDO> updateWrapper = Wrappers.lambdaUpdate(GroupDO.class)
            .eq(GroupDO::getUsername, UserContext.getUsername())
            .eq(GroupDO::getGid, gid)
            .eq(GroupDO::getDelFlag, 0);
    GroupDO groupDO = new GroupDO();
    groupDO.setDelFlag(1);
    baseMapper.update(groupDO, updateWrapper);
}

当前用户下的gid被删除前是正常的0,现在set是1了

 

1.这里删除分组的时候,不需要将分组下的所有短链接也删除吗?

2.会不会有这种情况出现:分组已经删除了但是分组下的短链接没有删除。后续新增分组时随机的gid与之前已删除分组的gid相同,导致新增的这个分组中出现了原来已被删除分组里的短链接?

问题1:删除分组时是否需要删除其下的短链接?
回答正确性:✅ 正确(需根据业务需求判断)
分析 :
当前逻辑仅对分组进行软删除(del_flag = 1),但未处理分组下的短链接。这种设计可能存在以下问题:

数据冗余 :短链接仍关联到已删除的分组GID,可能占用存储空间。
数据混乱风险 :
若后续新增分组时复用相同的GID(例如通过随机生成),新分组会意外包含旧分组的短链接(尤其是未删除的短链接)。
短链接查询逻辑若未校验分组状态(如 del_flag = 0),可能导致业务错误(如显示已删除分组的短链接)。
解决方案 :

软删除分组 + 短链接隔离 :
在删除分组时,同步更新短链接的 group_id 或 del_flag,确保其与已删除分组解绑。例如:

// 删除分组时,同步更新该分组下的所有短链接
LambdaUpdateWrapper<ShortLinkDO> shortLinkWrapper = Wrappers.lambdaUpdate(ShortLinkDO.class)
    .eq(ShortLinkDO::getGroupId, gid);
ShortLinkDO shortLinkDO = new ShortLinkDO();
shortLinkDO.setDelFlag(1); // 软删除短链接
shortLinkMapper.update(shortLinkDO, shortLinkWrapper);

硬删除分组 + 级联删除 :
若业务允许物理删除,可直接删除分组及其关联的短链接(需数据库外键约束支持)。

问题2:新增分组复用GID是否会导致短链接归属混乱?
回答正确性:✅ 正确(存在风险)
分析 :

GID生成策略缺陷 :当前使用随机生成的GID(如 RandomGenerator.generateRandom()),未保证全局唯一性。若复用已删除分组的GID,可能导致新分组包含旧分组的短链接(尤其是未删除的短链接)。
风险场景 :
分组A(GID=abc123)被软删除,其短链接未清理。
新建分组B,GID恰好也为abc123。
查询分组B时,短链接列表中包含分组A的短链接(因GID相同且未删除)。
修复方案 :

GID全局唯一性校验 :
在生成GID时,检查数据库中是否已存在相同GID(包括已删除的记录),避免复用:

String newGid;
do {
    newGid = RandomGenerator.generateRandom();
} while (groupMapper.existsGid(newGid)); // 自定义方法校验GID是否已存在

逻辑删除短链接 :
在删除分组时,同步软删除其关联的短链接(如 del_flag = 1),确保新分组不会继承旧数据。
短链接归属校验 :
查询短链接时,强制校验其关联的分组状态:

LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
    .eq(ShortLinkDO::getGroupId, gid)
    .eq(ShortLinkDO::getDelFlag, 0)
    .exists(GroupDO.class, groupWrapper -> groupWrapper
        .eq(GroupDO::getGid, gid)
        .eq(GroupDO::getDelFlag, 0)); // 确保分组未被删除

短链接分组排序功能

比如用户在前排拖动了分组的位置,也就是重新排序,那么前端就应该传一个顺序过来,也就是这个接口进行修改sortOrder字段

让前端把所有的短链接分组传过来,然后去把它排序过的那个分组给我们保存到数据库即可

在dto的req中新建ShortLinkGroupSortReqDTO

package com.nageoffer.shortlink.admin.dto.req;

import lombok.Data;

/**
 * 短链接分组排序参数
 */
@Data
public class ShortLinkGroupSortReqDTO {
    /**
     * 分组id
     */
    private String gid;
    /**
     * 排序
     */
    private Integer sortOrder;
}
    /**
     * 排序短链接分组
     * @param requestParam 短链接分组排序标识
     */
    void sortGroup(List<ShortLinkGroupSortReqDTO> requestParam);
@Override
public void sortGroup(List<ShortLinkGroupSortReqDTO> requestParam)
{
    requestParam.forEach(each -> {
        GroupDO groupDO =GroupDO.builder()
                .sortOrder(each.getSortOrder())
                .build();
        LambdaUpdateWrapper<GroupDO>updateWrapper = Wrappers.lambdaUpdate(GroupDO.class)
                .eq(GroupDO::getUsername, UserContext.getUsername())
                .eq(GroupDO::getGid, each.getGid())
                .eq(GroupDO::getDelFlag, 0);
        baseMapper.update(groupDO, updateWrapper);
    });
}

 我们在数据库中新建两个数据

1924109143078203393    Vp1eSP    乔思佳    markdown    1    2025-05-18 22:25:43    2025-05-18 22:25:43    0
1924109143078203390    Vp1eSZ    乔思佳    markdown    1    2025-05-18 22:25:43    2025-05-18 22:25:43    0

 我们这个参数就让前端去跟我们传就好了,就在前端这边把排序sortOrder弄好,把参数给我们一个数组的然后我们把这些全部都给当前用户的全部复制即可

能否批量去做呢???

1.

心态要好昂:mybatis-plus有个lambdaupdate方法可以直接set sortOrder,感觉没必要再去写wrapper和构造一个新的对象

2024-02-03 12:08

马丁 回复 心态要好昂:如果都能实现类似的效果的话,实现过程可能有多个,看大家的习惯就好

2024-03-26 12:49

不会pop不改名 回复 心态要好昂:感谢分享思路,代码如下 public void sortGroup(List<ShortLinkGroupSortReqDTO> requestParam) { requestParam.forEach(each -> { LambdaUpdateWrapper<GroupDO> updateWrapper = Wrappers.lambdaUpdate(GroupDO.class) .set(GroupDO::getSortOrder, each.getSortOrder()) .eq(GroupDO::getGid, each.getGid()) .eq(GroupDO::getDelFlag, 0); update(updateWrapper); }); }

2024-03-30 13:54

lovqq 回复 心态要好昂:``` list.forEach(groupSortReqDto -> lambdaUpdate() .set(Group::getSortOrder, groupSortReqDto.getSortOrder()) .eq(Group::getUsername, UserContext.getUsername()) .eq(Group::getDelFlag, 0) .eq(Group::getGid, groupSortReqDto.getGid()) .update()); ```

2025-02-05 23:16

陌慕辞 回复 心态要好昂:自动填充字段(如 createTime, updateTime, createBy, updateBy 等)通常依赖于 MetaObjectHandler 接口来进行统一处理,而它的触发机制只在 BaseMapper 的 insert/update 方法 中自动启用。 自动填充字段的机制,是由 MyBatis-Plus 内部拦截器 在执行 BaseMapper.insert() 和 BaseMapper.updateById() 等方法时触发的。 如果你调用的是自定义的 IService.save() 或 update() 方法,MyBatis-Plus 会进一步传递给 BaseMapper,此时仍能触发自动填充。 但如果你在 IService 实现类中使用了 自定义 SQL,或者调用的是 非 MyBatis-Plus 的更新逻辑,就不会触发自动填充机制。 this.lambdaUpdate() .eq(...) .set(...) .update(); // ❌ 此方法构建的是一个 UPDATE SQL,不会自动填充

2.Wzzzzz.🍫:Done,稍微修改了下:// list转为map Map<String, Integer> sortMap = linkGroupSortReqDTOS.stream() .collect(Collectors.toMap(ShortLinkGroupSortReqDTO::getGid, ShortLinkGroupSortReqDTO::getSortOrder)); // 这里查询一次是为了后续批量更新时能够根据id进行匹配更新 List<GroupDO> groupList = this.lambdaQuery().eq(GroupDO::getUsername, UserContext.getUsername()) .in(GroupDO::getGid, sortMap.keySet()) .eq(GroupDO::getDelFlag, 0) .list(); // 修改排序值 groupList.forEach(group -> group.setSortOrder(sortMap.getOrDefault(group.getGid(), 0))); // 批量更新 this.updateBatchById(groupList);

2025-02-07 17:28

肚圆圆 回复 Wzzzzz.🍫:最后一行自调用会导致事务失效

问题1:MyBatis-Plus的LambdaUpdateWrapper是否可直接设置字段值?

回答正确性:✅ 正确(需注意自动填充逻辑)
分析

  • 原代码逻辑 GroupDO groupDO = GroupDO.builder().sortOrder(each.getSortOrder()).build(); LambdaUpdateWrapper<GroupDO> updateWrapper = Wrappers.lambdaUpdate(GroupDO.class) .eq(GroupDO::getUsername, UserContext.getUsername()) .eq(GroupDO::getGid, each.getGid()) .eq(GroupDO::getDelFlag, 0); baseMapper.update(groupDO, updateWrapper); 该方式通过构造 GroupDO 对象并传入 update() 方法,MyBatis-Plus 会自动将非空字段更新到数据库。
  • 优化方案 this.lambdaUpdate() .set(Group::getSortOrder, groupSortReqDto.getSortOrder()) .eq(Group::getGid, groupSortReqDto.getGid()) .eq(Group::getUsername, UserContext.getUsername()) .eq(Group::getDelFlag, 0) .update();
    • 优点 :无需构造 GroupDO 对象,代码更简洁。
    • 注意事项
      • 自动填充字段失效 :若 sortOrder 更新依赖 MetaObjectHandler 的自动填充逻辑(如 @TableField(fill = FieldFill.INSERT_UPDATE)),此方式不会触发自动填充。
      • 性能差异 :两种方式生成的 SQL 语句相同,性能无显著差异。

结论 :两种方式均正确,但需根据是否依赖自动填充逻辑选择合适方案。


问题2:批量更新时的事务管理是否失效?

回答正确性:✅ 正确(事务失效风险存在)
分析

优化代码逻辑

// 1. 转换为 Map
Map<String, Integer> sortMap = linkGroupSortReqDTOS.stream()
    .collect(Collectors.toMap(ShortLinkGroupSortReqDTO::getGid, ShortLinkGroupSortReqDTO::getSortOrder));

// 2. 查询所有相关分组
List<GroupDO> groupList = this.lambdaQuery()
    .eq(GroupDO::getUsername, UserContext.getUsername())
    .in(GroupDO::getGid, sortMap.keySet())
    .eq(GroupDO::getDelFlag, 0)
    .list();

// 3. 更新排序值
groupList.forEach(group -> group.setSortOrder(sortMap.getOrDefault(group.getGid(), 0)));

// 4. 批量更新
this.updateBatchById(groupList);

事务失效风险

  • 原因 :若 updateBatchById 是自定义方法且在同一个类中被直接调用(如 this.updateBatchById(...)),Spring 的事务代理(基于 AOP)不会生效。
  • 解决方案
    1. 注入自身 Bean @Autowired private GroupService groupService; groupService.updateBatchById(groupList); // 通过代理对象调用
    2. 使用 AopContext ((GroupService) AopContext.currentProxy()).updateBatchById(groupList);
    3. 事务注解添加到方法 :确保 updateBatchById 方法本身带有 @Transactional 注解,并在 Spring 配置中启用事务管理。

结论 :肚圆圆的回复正确,自调用确实会导致事务失效,需通过代理对象调用以确保事务生效。 ​