当前位置: 首页 > article >正文

基于Redis实现的手机短信登入功能

目录

开发准备

注册阿里短信服务

依赖坐标

阿里短信 依赖

mybatis-plus 依赖 

redis 依赖 

配置文件

导入数据库表

短信发送工具类

生成随机验证码的工具类

校验合法手机号的工具类

ThreadLocal 线程工具类

消息工具类

基于 session 的短信登录的问题

开发教程

Redis 结构设计

实现效果


代码地址:手机短信认证

开发准备


注册阿里短信服务

        首先我们需要获取阿里的短信服务,进入之后点击国内消息按照流程申请模板即可。详细教程可以参考这篇文章:如何注册阿里短信服务

依赖坐标

阿里短信 依赖

        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>aliyun-java-sdk-core</artifactId>
            <version>4.5.16</version>
        </dependency>
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>aliyun-java-sdk-dysmsapi</artifactId>
            <version>2.1.0</version>
        </dependency>

mybatis-plus 依赖 

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.3</version>
        </dependency>

redis 依赖 

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <version>2.4.0</version>
</dependency>

配置文件

server:
  port: 8081
spring:
  application:
    name: hmdp
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/hmdp?useSSL=false&useUnicode=true&characterEncoding=utf8
    username: root
    password:
  redis:
    host: localhost
    port: 6379
    password:
    lettuce:
      pool:
        max-active: 10
        max-idle: 10
        min-idle: 1
        time-between-eviction-runs: 10s
  jackson:
    default-property-inclusion: non_null # JSON 处理时忽略非空字段
mybatis-plus:
  type-aliases-package: com.hmdp.entity # 别名扫描包
logging:
  level:
    com.hmdp: debug # 打印日志

        注意数据库的信息需要填成你自己的。

导入数据库表

DROP TABLE IF EXISTS `tb_user`;
CREATE TABLE `tb_user`  (
  `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
  `phone` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '手机号码',
  `password` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '密码,加密存储',
  `nick_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '昵称,默认是用户id',
  `icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '人物头像',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `uniqe_key_phone`(`phone`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1010 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Compact;

短信发送工具类

/**
 * 短信发送工具类
 */
public class SMSUtils {

    /**
     * 发送短信
     * @param signName 签名
     * @param templateCode 模板
     * @param phoneNumbers 手机号
     * @param param 参数
     */
    public static void sendMessage(String signName, String templateCode,String phoneNumbers,String param){
        // 此处需要替换成开发者自己的(在阿里云访问控制台寻找)
        DefaultProfile profile = DefaultProfile.getProfile("cn-hangzhou", "自己的AccessKeyId", "自己的accesskeySecret");

        IAcsClient client = new DefaultAcsClient(profile);

        SendSmsRequest request = new SendSmsRequest();

        request.setSysRegionId("cn-hangzhou");
        // 要发送给那个人的电话号码
        request.setPhoneNumbers(phoneNumbers);
        // 我们在阿里云设置的签名
        request.setSignName(signName);
        // 我们在阿里云设置的模板
        request.setTemplateCode(templateCode);
        // 在设置模板的时候有一个占位符
        request.setTemplateParam("{\"code\":\""+param+"\"}");

        try {
            SendSmsResponse response = client.getAcsResponse(request);
            System.out.println("短信发送成功");
        }catch (ClientException e) {
            e.printStackTrace();
        }
    }

}

生成随机验证码的工具类

public class RandomUtil {
    public static final String BASE_NUMBER = "0123456789";
    public static final String BASE_CHAR = "abcdefghijklmnopqrstuvwxyz";
    public static final String BASE_CHAR_NUMBER = "abcdefghijklmnopqrstuvwxyz0123456789";

    public RandomUtil() {
    }

    public static ThreadLocalRandom getRandom() {
        return ThreadLocalRandom.current();
    }

    public static SecureRandom createSecureRandom(byte[] seed) {
        return null == seed ? new SecureRandom() : new SecureRandom(seed);
    }

    public static SecureRandom getSecureRandom() {
        return getSecureRandom((byte[])null);
    }

    public static SecureRandom getSecureRandom(byte[] seed) {
        return createSecureRandom(seed);
    }

    public static SecureRandom getSHA1PRNGRandom(byte[] seed) {
        SecureRandom random;
        try {
            random = SecureRandom.getInstance("SHA1PRNG");
        } catch (NoSuchAlgorithmException var3) {
            NoSuchAlgorithmException e = var3;
            throw new UtilException(e);
        }

        if (null != seed) {
            random.setSeed(seed);
        }

        return random;
    }

    public static SecureRandom getSecureRandomStrong() {
        try {
            return SecureRandom.getInstanceStrong();
        } catch (NoSuchAlgorithmException var1) {
            NoSuchAlgorithmException e = var1;
            throw new UtilException(e);
        }
    }

    public static Random getRandom(boolean isSecure) {
        return (Random)(isSecure ? getSecureRandom() : getRandom());
    }

    public static boolean randomBoolean() {
        return 0 == randomInt(2);
    }

    public static char randomChinese() {
        return (char)randomInt(19968, 40959);
    }

    public static int randomInt(int min, int max) {
        return getRandom().nextInt(min, max);
    }

    public static int randomInt() {
        return getRandom().nextInt();
    }

    public static int randomInt(int limit) {
        return getRandom().nextInt(limit);
    }

    public static long randomLong(long min, long max) {
        return getRandom().nextLong(min, max);
    }

    public static long randomLong() {
        return getRandom().nextLong();
    }

    public static long randomLong(long limit) {
        return getRandom().nextLong(limit);
    }

    public static double randomDouble(double min, double max) {
        return getRandom().nextDouble(min, max);
    }

    public static double randomDouble(double min, double max, int scale, RoundingMode roundingMode) {
        return NumberUtil.round(randomDouble(min, max), scale, roundingMode).doubleValue();
    }

    public static double randomDouble() {
        return getRandom().nextDouble();
    }

    public static double randomDouble(int scale, RoundingMode roundingMode) {
        return NumberUtil.round(randomDouble(), scale, roundingMode).doubleValue();
    }

    public static double randomDouble(double limit) {
        return getRandom().nextDouble(limit);
    }

    public static double randomDouble(double limit, int scale, RoundingMode roundingMode) {
        return NumberUtil.round(randomDouble(limit), scale, roundingMode).doubleValue();
    }

    public static BigDecimal randomBigDecimal() {
        return NumberUtil.toBigDecimal(getRandom().nextDouble());
    }

    public static BigDecimal randomBigDecimal(BigDecimal limit) {
        return NumberUtil.toBigDecimal(getRandom().nextDouble(limit.doubleValue()));
    }

    public static BigDecimal randomBigDecimal(BigDecimal min, BigDecimal max) {
        return NumberUtil.toBigDecimal(getRandom().nextDouble(min.doubleValue(), max.doubleValue()));
    }

    public static byte[] randomBytes(int length) {
        byte[] bytes = new byte[length];
        getRandom().nextBytes(bytes);
        return bytes;
    }

    public static <T> T randomEle(List<T> list) {
        return randomEle(list, list.size());
    }

    public static <T> T randomEle(List<T> list, int limit) {
        if (list.size() < limit) {
            limit = list.size();
        }

        return list.get(randomInt(limit));
    }

    public static <T> T randomEle(T[] array) {
        return randomEle(array, array.length);
    }

    public static <T> T randomEle(T[] array, int limit) {
        if (array.length < limit) {
            limit = array.length;
        }

        return array[randomInt(limit)];
    }

    public static <T> List<T> randomEles(List<T> list, int count) {
        List<T> result = new ArrayList(count);
        int limit = list.size();

        while(result.size() < count) {
            result.add(randomEle(list, limit));
        }

        return result;
    }

    public static <T> List<T> randomEleList(List<T> source, int count) {
        if (count >= source.size()) {
            return ListUtil.toList(source);
        } else {
            int[] randomList = ArrayUtil.sub(randomInts(source.size()), 0, count);
            List<T> result = new ArrayList();
            int[] var4 = randomList;
            int var5 = randomList.length;

            for(int var6 = 0; var6 < var5; ++var6) {
                int e = var4[var6];
                result.add(source.get(e));
            }

            return result;
        }
    }

    public static <T> Set<T> randomEleSet(Collection<T> collection, int count) {
        ArrayList<T> source = CollUtil.distinct(collection);
        if (count > source.size()) {
            throw new IllegalArgumentException("Count is larger than collection distinct size !");
        } else {
            Set<T> result = new LinkedHashSet(count);
            int limit = source.size();

            while(result.size() < count) {
                result.add(randomEle((List)source, limit));
            }

            return result;
        }
    }

    public static int[] randomInts(int length) {
        int[] range = ArrayUtil.range(length);

        for(int i = 0; i < length; ++i) {
            int random = randomInt(i, length);
            ArrayUtil.swap(range, i, random);
        }

        return range;
    }

    public static String randomString(int length) {
        return randomString("abcdefghijklmnopqrstuvwxyz0123456789", length);
    }

    public static String randomStringUpper(int length) {
        return randomString("abcdefghijklmnopqrstuvwxyz0123456789", length).toUpperCase();
    }

    public static String randomStringWithoutStr(int length, String elemData) {
        String baseStr = "abcdefghijklmnopqrstuvwxyz0123456789";
        baseStr = StrUtil.removeAll(baseStr, elemData.toCharArray());
        return randomString(baseStr, length);
    }

    public static String randomNumbers(int length) {
        return randomString("0123456789", length);
    }

    public static String randomString(String baseString, int length) {
        if (StrUtil.isEmpty(baseString)) {
            return "";
        } else {
            StringBuilder sb = new StringBuilder(length);
            if (length < 1) {
                length = 1;
            }

            int baseLength = baseString.length();

            for(int i = 0; i < length; ++i) {
                int number = randomInt(baseLength);
                sb.append(baseString.charAt(number));
            }

            return sb.toString();
        }
    }

    public static char randomNumber() {
        return randomChar("0123456789");
    }

    public static char randomChar() {
        return randomChar("abcdefghijklmnopqrstuvwxyz0123456789");
    }

    public static char randomChar(String baseString) {
        return baseString.charAt(randomInt(baseString.length()));
    }

    /** @deprecated */
    @Deprecated
    public static Color randomColor() {
        Random random = getRandom();
        return new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256));
    }

    public static <T> WeightRandom<T> weightRandom(WeightRandom.WeightObj<T>[] weightObjs) {
        return new WeightRandom(weightObjs);
    }

    public static <T> WeightRandom<T> weightRandom(Iterable<WeightRandom.WeightObj<T>> weightObjs) {
        return new WeightRandom(weightObjs);
    }

    public static DateTime randomDay(int min, int max) {
        return randomDate(DateUtil.date(), DateField.DAY_OF_YEAR, min, max);
    }

    public static DateTime randomDate(Date baseDate, DateField dateField, int min, int max) {
        if (null == baseDate) {
            baseDate = DateUtil.date();
        }

        return DateUtil.offset((Date)baseDate, dateField, randomInt(min, max));
    }
}

校验合法手机号的工具类

public abstract class RegexPatterns {
    /**
     * 手机号正则
     */
    public static final String PHONE_REGEX = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$";
    /**
     * 邮箱正则
     */
    public static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
    /**
     * 密码正则。4~32位的字母、数字、下划线
     */
    public static final String PASSWORD_REGEX = "^\\w{4,32}$";
    /**
     * 验证码正则, 6位数字或字母
     */
    public static final String VERIFY_CODE_REGEX = "^[a-zA-Z\\d]{6}$";

}
public class RegexUtils {
    /**
     * 是否是无效手机格式
     * @param phone 要校验的手机号
     * @return true:符合,false:不符合
     */
    public static boolean isPhoneInvalid(String phone){
        return mismatch(phone, RegexPatterns.PHONE_REGEX);
    }
    /**
     * 是否是无效邮箱格式
     * @param email 要校验的邮箱
     * @return true:符合,false:不符合
     */
    public static boolean isEmailInvalid(String email){
        return mismatch(email, RegexPatterns.EMAIL_REGEX);
    }

    /**
     * 是否是无效验证码格式
     * @param code 要校验的验证码
     * @return true:符合,false:不符合
     */
    public static boolean isCodeInvalid(String code){
        return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX);
    }

    // 校验是否不符合正则格式
    private static boolean mismatch(String str, String regex){
        if (StrUtil.isBlank(str)) {
            return true;
        }
        return !str.matches(regex);
    }
}

ThreadLocal 线程工具类

public class UserHolder {
    private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();

    public static void saveUser(UserDTO user){
        tl.set(user);
    }

    public static UserDTO getUser(){
        return tl.get();
    }

    public static void removeUser(){
        tl.remove();
    }
}

消息工具类

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
    private Boolean success;
    private String errorMsg;
    private Object data;
    private Long total;

    public static Result ok(){
        return new Result(true, null, null, null);
    }
    public static Result ok(Object data){
        return new Result(true, null, data, null);
    }
    public static Result ok(List<?> data, Long total){
        return new Result(true, null, data, total);
    }
    public static Result fail(String errorMsg){
        return new Result(false, errorMsg, null, null);
    }
}

基于 session 的短信登录的问题

        多台 tomcat 并不共享 session 存储空间,当请求切换到不同服务器会导致数据丢失问题。虽然可以用 tomcat 间的数据同步解决这个问题,但还是会出现数据不一致和占用内存问题。
session 代替方案应该满足:

数据共享
内存存储
key,value结构

使用 redis 代替 session 是完全可以的

开发教程


Redis 结构设计

(1)之前的生成验证码后,我们会将其存放在 Session 中,每一个不同的请求就会对应一个 Session,它们 SessionID 肯定是唯一的。因为我们的程序上线面对的是庞大用户量。在高并发的场景下,生成的验证码是有可能相同的,如果使用验证码作为 key,那么就会造成数据覆盖。


        所以我们使用手机号作为key,验证码作为value,这样保证key的唯一性。从而保证我们的数据安全。

(2)登入注册时创建用户,用户信息需要保存到 Redis 中,此时的 key 建议用一个随机的 token(建议不要用手机号码,因为之后这个 token 会传入前端,有泄露风险),value 存放用户信息。所以这里返回 token 给客户端,为的就是之后在校验的时候可以拿着 token 去 Redis 中取数据进行校验。

(3)之前的登入校验是 Session 与 Cookie,当使用了 Session,它会自动将 SessionID 传入 Cookie 中,每次请求都会携带 Cookie,就相当于带着 SessionID 查找用户


        而现在是用请求中携带的 token  Redis 中获取用户数据。

 (4)


Redis 中的 String 与 Hash 结构都能对用户信息进行存储;但是考虑到内存的占用,Hash 结构无疑是最好的。

(5)Redis 是基于内存的,而我们知道内存的空间是十分有限的;那么用户退出系统了还需要保留用户的 token 吗。答案肯定是不需要的。那么我们就需要设置 token 的有效期。我们不能设置了 token 有效期就高枕无忧了,还需要判断当前用户是否还在使用。如果用户还在使用,而你的 token 有限期恰恰失效了,那么这就是一个不合格的系统。我们可以使用一个拦截器判断当前用户是否还在使用,如果正在使用就刷新 token。

 Controller

    /**
     * 发送手机验证码
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        // 发送短信验证码并保存验证码
        return userService.sendCode(phone,session);
    }

    /**
     * 登录功能
     * @param 登录参数,包含手机号、验证码;或者手机号、密码
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
        // 实现登录功能
        return userService.login(loginForm,session);
    }

Service

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result sendCode(String phone, HttpSession session) {
        // 校验手机号
        if(RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机格式错误");
        }
        // 校验符合,生成验证码
        String code = RandomUtil.randomNumbers(6);
        Map<String,Object> param = new HashMap<>();
        param.put("code", code);
        // 保存验证码到 redis
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
        // 发送验证码
        log.debug("发送验证码成功,验证码为:"+code);
        String templateCode = "【验证码】您的验证码为:" + code + "。3分钟内有效,请勿泄露和转发。如非本人操作,请忽略此短信。";
        SMSUtils.sendMessage("东方",templateCode,phone,code);
        // 返回成功
        return Result.ok();
    }

    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 校验手机号
        String phone = loginForm.getPhone();
        if(RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机格式错误");
        }

        // 输入验证码
        String code = loginForm.getCode();
        // 生成的验证码
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
        // 校验验证码
        if(cacheCode == null || !cacheCode.equals(code)){
            // 验证码不一致
            return Result.fail("验证码不一致");
        }

        // 如果一致,判断用户是否存在
        User user = query().eq("phone", phone).one();
        // 用户不存在
        if(user == null){
            // 创建一个新用户,并保存用户信息
            user = createUserWithPhone(phone);
        }

        // 随机生成 token 作为登入令牌
        String token = UUID.randomUUID().toString();
        // 将user对象转化成hashmap存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
        // 存储
        String tokenKey  = LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
        // 设置 token 的有效期
        stringRedisTemplate.expire(tokenKey,CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(token);
    }
}

 拦截器

刷新拦截器

public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            return true;
        }
        // 2.基于 token 获取 redis 中的用户
        String key  = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }
        // 5.将查询到的hash数据转为UserDTO
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 6.存在,保存用户信息到 ThreadLocal
        UserHolder.saveUser(userDTO);
        // 7.刷新token有效期
        stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}

登入拦截器

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.判断是否需要拦截(ThreadLocal中是否有用户)
        if (UserHolder.getUser() == null) {
            // 没有,需要拦截,设置状态码
            response.setStatus(401);
            // 拦截
            return false;
        }
        // 有用户,则放行
        return true;
    }
}

 

 简单说一下刷新功能:

        假设此时用户的 token 失效了,那么就会被刷新拦截器拦截,因为当前用户是在线状态。所以前两条 return 都不会返回,直到刷新了有效期才能返回。

配置拦截器

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/user/code",
                        "/user/login"
                ).order(1);
        // token 刷新的拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

当项目中存在多个拦截器,那么的执行顺序是根据方法而定的,拦截器所有的 preHandle 方法是按照拦截器的 order 升序执行的,如果 order  一致,则按照添加顺序执行。

静态变量

    public static final String LOGIN_CODE_KEY = "login:code:";
    public static final Long LOGIN_CODE_TTL = 3L;
    public static final String LOGIN_USER_KEY = "login:token:";
    public static final Long LOGIN_USER_TTL = 30L;

实现效果

 手机号验证登入

 

 Redis 存储到验证码

 Redis 存储用户信息


http://www.kler.cn/a/406605.html

相关文章:

  • 【Linux】-学习笔记04
  • CKA认证 | Day2 K8s内部监控与日志
  • BugJson因为json格式问题OOM怎么办
  • C#元组详解:创建、访问与解构
  • Kafka 分区分配及再平衡策略深度解析与消费者事务和数据积压的简单介绍
  • 理解设计模式与 UML 类图:构建稳健软件架构的基石
  • Android开发实战班 - 现代 UI 开发之 Modifier 全面应用
  • HarmonyOS笔记5:ArkUI框架的Navigation导航组件
  • 第 21 章 - Go lang反射机制
  • (python)unittest框架
  • 《线性代数的本质》
  • 拥抱极简主义前端开发:NoCss.js 引领无 CSS 编程潮流
  • 基于Springboot+Vue动漫推荐平台管理系统(源码+lw+讲解部署+PPT)
  • [NewStarCTF 2023]Include--详细解析
  • 设计模式之 观察者模式
  • 卷积神经网络(CNN)中的池化层(Pooling Layer)
  • oracle排查长时间没提交的事务造成的阻塞案例
  • SPA 单页面深入解读:优劣势剖析及实现方法
  • Qt自定义表格TableWidget实现整行单列按键逐行切换及跳转首尾
  • 【工控】线扫相机小结 第四篇
  • 2024内科学综合类科技核心期刊汇总
  • Pytorch使用手册-快速开始(专题一)
  • ArcGIS 10.2软件安装包下载及安装教程!
  • 美团面试:有哪些情况会产生死锁
  • Linux下Intel编译器oneAPI安装和链接MKL库编译
  • Android——连接MySQL(Java版)