【用户登录与注销】你真的懂登录吗?

我记得我刚刚接触数据库 MySQL 的时候,老师最常让我们做的作业就是学生管理系统的注册和登录。当时我还没学过MyBatis,也不知道有 Mybatis-Plus ,只会一点 JDBC ,连代码都不记得,需要网上搜寻一遍。那时候,我以为的注册登录自然就是将用户数据写入数据库,然后将查询数据和输入的数据进行比对实现登录。

不过,我越学到后面越发现注册登录并没有我想的这么简单!

举个例子:用户从一个网站登录,我们首先需要校验他是否注册过;其次,我们他输入登陆密码,我们是不是还需要保证用户的个人隐私不被泄露,所以我们之前直接把密码存入数据库是行不通的,这样非常的不安全(我本人的数据库就被入侵过,害得我重建了十几张表);还有我们是不是还需要判断用户是否为管理员,不可能把一些网站的信息毫无保留地展现给所有人看·······

现在我来讲讲,用户注册登录的一些相关逻辑。

因为这个是我复盘总结出来的,新建了一个项目对原本的项目进行了功能的单独实现,你会看到有些代码实体类是 User2,有些是 Boot01.其实都是一样的,就是换了个类名而已。

简单的类我这里就不展示了

一、序列化ID

使用MyBatisX 自动生成sql和代码,实体类中会出现这个字段serialVersionUID

serialVersionUID 是 Java 中用于序列化和反序列化对象的唯一标识符。它是一个长整型的常量字段,用于识别不同版本的类是否兼容,能够在反序列化过程中确保类的版本一致。

serialVersionUID 的作用如下:

  1. 版本控制:当一个对象被序列化后,如果在反序列化过程中发现对象的 serialVersionUID 与当前类的 serialVersionUID 不一致,会抛出 InvalidClassException 异常。因此,通过显式地指定 serialVersionUID,可以控制类的版本,确保反序列化时与序列化时的类版本一致。

  2. 兼容性检查:如果类的结构发生了一些改变,比如添加、删除或修改了字段或方法,那么自动生成的 serialVersionUID 将会发生变化。通过手动指定 serialVersionUID,可以避免由于类结构变化而导致的反序列化失败的问题,确保兼容性。

  3. 安全性保证:在进行对象的序列化和反序列化时,如果没有指定 serialVersionUID,Java 编译器将根据类的结构等信息自动计算一个 serialVersionUID。这样的计算方式容易受到类的内容和结构改变的影响,可能导致安全漏洞。因此,显式指定 serialVersionUID 可以提高代码的安全性。

二、加密工具MD5

在线加密解密工具:在线加密/解密,对称加密/非对称加密 (sojson.com)

在项目中对用户密码采用了MD5加密算法进行加密,当然还有一些其他的加密算法,这里不作演示

 final String SALT="abcd";
 String encryptPassword = DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());

这段代码的主要目的是将用户密码加盐,并使用 MD5 哈希算法对加盐后的密码进行加密。

具体解释如下:

  1. final String SALT="yupi";:在代码中定义了一个名为 SALT 的常量,它的值为 "yupi"。这个常量在加密过程中作为“盐”(salt)的一部分,用于增加密码的复杂度和安全性。

  2. (SALT + userPassword).getBytes():将用户输入的密码与盐值进行拼接,并将拼接后的字符串转换为字节数组。这样可以将用户密码与盐值进行合并。

  3. DigestUtils.md5DigestAsHex(...):使用 Apache Commons Codec 库的 DigestUtils.md5DigestAsHex(...) 方法对拼接后的字节数组进行 MD5 哈希运算,并返回哈希结果的十六进制字符串表示。

  4. String encryptPassword = ...:将哈希计算得到的十六进制字符串赋值给名为 encryptPassword 的字符串变量。这个变量将保存加盐后并经过 MD5 加密的密码。

serviceImpl实现类

@Resource
private Boot01Mapper boot01Mapper;
String SALK="yupi123";
@Override
public long AddDate(String name, String password, int age) {
    //添加数据——加密
    String encryptPassword = DigestUtils.md5DigestAsHex((SALK+password).getBytes());
    //String encryptPassword = DigestUtils.md5DigestAsHex(password.getBytes());
    Boot01 boot01=new Boot01();
    boot01.setName(name);
    boot01.setPassword(encryptPassword);
    boot01.setAge(age);
    return boot01Mapper.insert(boot01);
}

 测试类

@Test
    public void register(){
        String name="moon";
        String password="12345";
        int age=12;
        long result = boot01Service.AddDate(name,password,age);
        System.out.println(result);
    }

测试结果:

看起来加盐和没加,密码的复杂度其实也没啥太大变化

三、注册校验逻辑

1、特殊字符校验

校验账户中是否含有特殊字符,这个是符合特殊字符的正则表达式(不需要记住,可以直接复制)

String validPattern = "[`~!@#$%^&*()+=|{}':;',\\\\[\\\\].<>/?~!@#¥%……&*()——+|{}【】‘;:”“’。,、?]";
 Matcher matcher = Pattern.compile(validPattern).matcher(name);
 if (matcher.find()) {
     return -1;
 }

2、校验账户是否存在

这段代码的含义是使用 MyBatis-Plus 查询构造器 QueryWrapper 来执行条件查询。它的主要作用是构建查询条件,以便在数据库中进行筛选。

  • 创建一个 QueryWrapper 对象,指定实体类型为 Boot01,即待查询的实体类型。

  • 使用 eq 方法设置查询条件,该条件是通过等于操作符 (=) 在数据库中匹配 name 字段的值与指定的 name 变量值。

  • 通过 selectCount 方法执行查询,并返回满足查询条件的实体数量。selectCount 是 MyBatis-Plus 提供的方法,用于统计满足指定条件的记录数量。

QueryWrapper<Boot01> queryWrapper=new QueryWrapper<>();
queryWrapper.eq("name",name);
Long count = boot01Mapper.selectCount(queryWrapper);
//cont>0 表示账户已存在
if (count > 0) {
    return -1;
}

 这段代码的作用是统计满足条件 name 字段等于指定值的 Boot01 实体对象在数据库中的数量,并将结果保存在 count 变量中

四、登录逻辑校验

service 层

1、登录逻辑

将用户传入的密码进行加密,与查询数据库中的账户和密码进行比对

public Boot01 Login(String name, String password, HttpServletRequest request) {
        String encryptPassword = DigestUtils.md5DigestAsHex((SALK+password).getBytes());
        //查询用户是否存在
        QueryWrapper<Boot01> query = new QueryWrapper<>();
        query.eq("name", name);
        query.eq("password", encryptPassword);
        Boot01 boot01 = boot01Mapper.selectOne(query);
        //用户不存在
        if (boot01 == null) {
            log.info("yong");
            return null;
        }
        return boot01;
    }

2、记录用户登录态

service 接口中添加一个参数 request

User2 userLogin(String userAccount, String userPassword, HttpServletRequest request);

记录用户的登录状态

我们使用 request ,用getsession 拿到session ,用setAttribute 往session中设置一些值

(比如用户信息)

setAttribute 它是一个map,可以存储键值对,我们需要设置一个键值对,给用户的登陆状态分配一个键

//用户登录态键
private static final String USER_LOGIN_STATE = "userLoginState";

往 setAttribute 中传入这个键,放入user值

//3、记录用户的登录状态
    request.getSession().setAttribute(USER_LOGIN_STATE, user);

3、用户脱敏

如果我们用户信息不进行脱敏的话,前端可以看到用户从数据库中查询出来的所有信息

用户脱敏:就是对某些敏感信息进行数据的变形,实现对用户隐私信息的保护

创建一个新的安全用户对象 safetyUser,用于存储脱敏后的用户信息。我们需要返回给前端什么,就设置什么

这里用户密码就不需要返回

@Override
public User2 getSafetyUser(User2 originUser) {
    if (originUser == null) {
        return null;
    }
    User2 safetyUser = new User2();
    //选中user2,按shift+F6批量修改相同变量
    safetyUser.setId(originUser.getId());
    safetyUser.setUsername(originUser.getUsername());
    safetyUser.setUserAccount(originUser.getUserAccount());
    safetyUser.setAvatarUrl(originUser.getAvatarUrl());
    safetyUser.setGender(originUser.getGender());
    safetyUser.setPhone(originUser.getPhone());
    safetyUser.setEmail(originUser.getEmail());
    safetyUser.setUserStatus(originUser.getUserStatus());
    safetyUser.setCreateTime(originUser.getCreateTime());
    safetyUser.setUserRole(originUser.getUserRole());
    safetyUser.setPlanetCode(originUser.getPlanetCode());
    return safetyUser;
}

调用用户脱敏方法,只需要一行代码

User2 safetyUser = getSafetyUser(user2);

把 user 改成 safetyUser 要记录脱敏后的用户信息

//3、记录用户的登录状态
    request.getSession().setAttribute(USER_LOGIN_STATE, safetyUser);

4、逻辑删除

这里用户登录的代码是有问题的,如果这个用户的已经被删除,那我们就无法查询

MP提供了一个逻辑删除,可以帮助我们保留被删除的用户信息,默认会帮助我们只查询出没有被删除的用户

1)配置文件

可以直接去官方文档查询配置

application.yml

mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: isDelete # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
      logic-delete-value: 1 # 逻辑已删除值(默认为 1)
      logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)

2)实体类

在实体类中添加注解

@TableLogic
    private Integer isDelete;

五、控制层代码

1、接收请求参数

这里我们先封装一个对象,专门用来接收请求参数。

在实体类包下新增request 包

创建一个对象 UserRegisterRequest 来记录所有的请求参数

让前端也传递 JSON 参数

定义前端需要接收的参数

@Data
public class UserRegisterRequest implements Serializable {
    /*防止序列化过程出现一些冲突*/
    private static final long serialVersionUID = 3191241716373120793L;
    private String userAccount;

    private String userPassword;

    private String checkPassword;

    private String planetCode;

}

注册方法传参

@PostMapping("/register")
    public Long userRegister(@RequestBody UserRegisterRequest userRegisterRequest) {
        return user2Service.userRegister(userAccount, userPassword, checkPassword, planetCode);
    }

六、用户注销

用户登录需要设置一个登陆状态,那我们如果想要用户注销的话,不就是要移除这个登陆状态就可以了。把登录状态的一个标识移除。

service接口创建方法 userLogout

int userLogout();

用户注销需要接收参数,看看我们用户登录的时候,填了什么参数

当用户登录成功,我们要把用户的登录状态存到服务器的一个 session中,我们取这

session的时候,从request 对象里面取。

我们在这个请求体的这个 session 中,这个请求对应的 session 中保存了用户的登录状态

我们判断用户是否登录,

只需要看看这个session里面有没有这个标识。

回到 Service 接口

这里接收的参数应该是一个请求对象,因为session 是和请求相关联的,session 要从request 对象中取,就需要把这个对象作为参数

int userLogout(HttpServletRequest request);

然后在serviceImpl 实现类中实现接口方法 userLogout

从 request 中取 session ,之前我们把用户登录状态的标识设为常量 USER_LOGIN——STATE

移除属性就把这个常量移除就可以了,注销成功返回1

@Override
public int userLogout(HttpServletRequest request) {
    // 移除登录态
    request.getSession().removeAttribute(USER_LOGIN_STATE);
    return 1;
}

控制层

@PostMapping("/logout")
public Integer userLogout(HttpServletRequest request) {
    if (request == null) {
        return null;
    }
    int result = user2Service.userLogout(request);
    return result;
}

七、用户登录/注销的延申

1、请求参数 request/session

1)请求参数 request

在用户中心项目中我们可以看到在注册Login方法和注销userLogout方法中都有这个参数HttpServletRequest request,那这个参数的作用是什么?

HttpServletRequest request 参数在用户登录中起着很重要的作用。它代表了客户端发出的HTTP请求,包含了客户端的请求信息和数据。通过这个参数,你可以获取用户在登录过程中提交的数据以及其他与请求相关的信息。

作为用户登录的一部分,通常会使用表单提交用户提供的用户名和密码。通过 HttpServletRequest request 参数可以访问用户提交的表单数据,如用户名和密码。你可以使用 request 对象中的方法,例如 getParameter(),来获取表单字段的值。

例如:

String username = request.getParameter("username");
String password = request.getParameter("password");

除了获取表单数据,HttpServletRequest request 还提供了一些其他方法来获取请求的属性和信息,如:

  • 获取请求的 URL、HTTP 方法等:request.getRequestURL(), request.getMethod()

  • 获取请求的头部信息:request.getHeader()

  • 获取请求的客户端 IP 地址:request.getRemoteAddr()

  • 获取请求的会话对象:request.getSession()

通过这些方法,你可以在用户登录过程中获取请求的各种信息,并根据需要进行处理,比如验证用户输入的凭证、记录日志等。请注意,在处理敏感信息时要采取适当的安全措施,如密码加密存储、输入验证和防范常见的安全漏洞。

1️⃣封装请求参数

用户登录时需要传入用户名和密码,这些参数必须加上,所以这里我们将请求参数进行封装

@Data
public class LoginRequest implements Serializable {
    private String name;
    private String password;
}
2️⃣service层
//登录
@Override
public Boot01 login01(String name, String password, HttpServletRequest request) {
    String encryptPassword = DigestUtils
        .md5DigestAsHex((SALK+password).getBytes());
    //查询用户是否存在
    QueryWrapper<Boot01> query = new QueryWrapper<>();
    query.eq("name", name);
    query.eq("password", encryptPassword);
    Boot01 boot01 = boot01Mapper.selectOne(query);
    //用户不存在
    if (boot01 == null) {
        log.info("用户登录失败");
        return null;
    }
    request.getSession().setAttribute(USER_LOGIN_STATE,boot01);
    return boot01;
}
//注销
@Override
public int userLogout01(HttpServletRequest request) {
    // 移除登录态
    request.getSession().removeAttribute(USER_LOGIN_STATE);
    return 1;
}
3️⃣controller 层

特别注意:请求参数里面不要加 @RequestBody 之类的注解,这里按理来说是需要加这个注解的,主要是出现了报错。原因我还没弄清楚,欢迎知道的小伙伴解答

//登录
@PostMapping("/login01")
public Boot01 Login01( LoginRequest loginRequest, HttpServletRequest request){
    if (request==null) {
        return null;
    }
    String name = loginRequest.getName();
    String password = loginRequest.getPassword();
    Boot01 login = boot01Service.login01(name,password, request);
    return login;
}
//注销
@PostMapping("/logout01")
public int userLogout01(HttpServletRequest request) {
    // 移除登录态
    boot01Service.userLogout01(request);
    return 1;
}
4️⃣结果
localhost/boot01/login01?name=moon&password=123457777
//返回响应数据
{
    "id": 1,
    "name": "moon",
    "password": "571d054fa1dab896102a8828e6c7b1ec",
    "age": 12
}

注销

localhost/boot01/logout01
返回:1

前端传入用户名和密码在 service 层中进行校验,记录用户的登录状态(使用一个特殊标识),将用户登录信息存入request请求中,注销时我们就只需要移除登录状态就可以了。

这里返回类型为一个实体对象,输出的信息为对象信息

而注销方法,返回的是一个int值

2)请求参数 session

HttpSession session 参数在用户登录中起着很重要的作用。它代表了用户的会话,用于在服务器端保存用户的状态信息。

在用户登录过程中,通常会在验证用户凭证成功后,创建一个会话,并将用户的登录状态和其他相关信息存储到该会话中。

通过 HttpSession session 参数,你可以在用户登录后,将用户的登录信息保存到会话中以便后续使用。例如,你可以将用户对象、用户ID等信息存储到会话中:

session.setAttribute("user", user);

在后续的请求中,可以通过 session 参数来获取并处理用户的登录状态和其他信息:

User user = (User) session.getAttribute("user");

使用 HttpSession session,你可以在整个会话期间持久化和访问用户的登录状态和其他信息。这对于用户的身份认证、权限验证和用户会话管理非常有用。

此外,HttpSession 还提供了其他方法来管理会话,如使会话失效、获取会话ID、获取会话创建时间等。你可以使用这些方法来满足你的具体需求,比如注销用户、强制用户重新登录等场景。

需要注意的是,使用 HttpSession 时要确保在登录验证和用户状态管理中采取适当的安全措施,以防止会话劫持和其他安全问题。

1️⃣返回结果集
@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);
    }
}
1️⃣service 层

这里以 Result作为返回数据类型,输出的结果为设置好的 json 数据

@Override
public Result login02(String name, String password, HttpSession session) {
    String encryptPassword = DigestUtils.md5DigestAsHex((SALK+password).getBytes());
    //查询用户是否存在
    QueryWrapper<Boot01> query = new QueryWrapper<>();
    query.eq("name", name);
    query.eq("password", encryptPassword);
    Boot01 boot01 = boot01Mapper.selectOne(query);
    //用户不存在
    if (boot01 == null) {
        log.info("user login failed");
        return null;
    }
    //报存用户信息到session中
    session.setAttribute("boot01",boot01);
    return Result.ok("用户登录成功!!");
}

@Override
public Result userlogout02(HttpSession session) {
    if (session == null) {
        return null;
    }
    // 移除登录态
    session.removeAttribute("boot01");
    return Result.ok("用户注销成功!!");
}
2️⃣controller 层
@PostMapping("/login02")
public Result Login02( LoginRequest loginRequest, HttpSession session){
    if (session==null) {
        return null;
    }
    String name = loginRequest.getName();
    String password = loginRequest.getPassword();
    Result result = boot01Service.login02(name, password, session);
    System.out.println(result);
    return Result.ok("登录成功");
}

@PostMapping("/logout02")
public Result logout02(HttpSession session){
    if (session==null) {
        return null;
    }
    Result userlogout = boot01Service.userlogout02(session);
    System.out.println(userlogout);
    return Result.ok("注销成功!");
}
3️⃣结果
{
    "success": true,
    "errorMsg": null,
    "data": "登录成功",
    "total": null
}

{
    "success": true,
    "errorMsg": null,
    "data": "注销成功",
    "total": null
}

在service和controller层中都设置了返回值

controller层返回数据给了前端,而service层则返回数据给了后端

3)两者的不同

仔细发现两种传参的参数不同,记录用户的登录态和移除用户登录态的代码不同

HttpServletRequest request 和 HttpSession session 在用户登录中有一些区别。下面是它们的主要区别:

  1. HttpServletRequest request 参数代表了单个HTTP请求,并包含了客户端发出的请求信息和数据。它主要用于获取用户在登录过程中提交的表单数据和其他请求信息。

  2. HttpSession session 参数代表了用户的会话,用于在服务器端保存用户的状态信息,并为用户的多个请求提供共享的数据。它主要用于保存用户的登录状态和其他相关信息。

  3. request 对象和 session 对象的生命周期不同。每次客户端发送一个 HTTP 请求,都会创建一个新的 HttpServletRequest 对象,该对象在请求处理完成后就会被销毁。而 HttpSession 对象在用户第一次访问服务器创建会话后,会一直保持,并在会话失效、过期或手动销毁时被销毁。

  4. request 对象的作用局限于当前请求,只能在当前请求上下文中使用。而 session 对象的作用跨越多个请求,可以在不同的请求中使用同一个 session 对象来获取和更新用户的状态信息。

综上所述,HttpServletRequest request 参数主要用于获取请求中的数据和信息,而 HttpSession session 参数主要用于保存和管理用户的会话状态和信息。通常情况下,我们会将用户的登录信息保存到 session 中,以便在会话期间持久化和使用。

使用 HttpServletRequest request 参数获取用户信息时,数据是保存在请求对象中的,而请求对象是在客户端和服务器之间进行传输的。因此,请求对象中的数据可能会受到各种安全威胁,比如网络攻击、中间人攻击等。此外,如果应用程序没有实现恰当的数据验证和过滤机制,用户可以修改请求参数来伪造数据,从而导致安全漏洞。

相比之下,HttpSession session 对象的数据是保存在服务器端的,而不直接暴露在客户端。客户端只有一个 session ID,该 ID 是一个随机的、无法推测的字符串,用于标识用户会话。通过这个 session ID,服务器可以在后续的请求中找到对应的 session 对象来获取相关数据。因此,即使会话 ID 被截获,其他人也无法修改或访问 session 数据。

2、返回类型优化

用户登录和注销他们方法的返回值我们前面设置过返回一个实体对象,也有利用结果集返回一组信息,不过,这样都不够完美,我们可以将返回的数据类型用结果集进行封装,这样可以得到我们想要的用户信息又可以获得用户登录是否成功的信息

1)修改结果集

封装范型对象

为类加上范型,将data的数据类型设置为T,这样就可以返回范型中的数据信息

public class Result<T> implements Serializable {
    private Boolean success;
    private String errorMsg;
    private T data;
    private Long total;
}    

2)service层

将login02 登录方法和userlogout02 方法的数据类型由 Result 改成实体对象Boot01int,返回值Result.ok()修改成对应的值

注销成功返回1

3)controller 层

 

 修改 Login02 方法,再加一段校验逻辑

@PostMapping("/login02")
public Result<Boot01> Login02( LoginRequest loginRequest, HttpSession session){
    if (session==null) {
        return Result.fail("登录失败,请求参数异常");
    }
    String name = loginRequest.getName();
    String password = loginRequest.getPassword();
    Boot01 boot01 = boot01Service.login02(name, password, session);
    System.out.println(boot01);
    if (boot01==null) {
        return Result.fail("登录失败,请求参数异常");
    }
    return Result.ok(boot01);
}

4)结果

1️⃣登录成功

service层将用户登录的对象信息传给controller层,而controller层将对象信息存储到了范型中,而范型T是data的数据类型,所以就将对象信息传给了data

同时service层将用户信息打印到了控制台

{
    "success": true,
    "errorMsg": null,
    "data": {
        "id": 1,
        "name": "moon",
        "password": "571d054fa1dab896102a8828e6c7b1ec",
        "age": 12
    },
    "total": null
}
//注销
{
    "success": true,
    "errorMsg": null,
    "data": 1,
    "total": null
}

控制台

2️⃣登录失败
{
    "success": false,
    "errorMsg": "登录失败,请求参数异常",
    "data": null,
    "total": null
}

我的分享到此结束啦,欢迎各位小伙伴点赞、留言、关注!!!

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,关于用户登录注销,可以通过 session 实现。在用户登录时,将用户的信息存储在 session 中;在用户注销时,清除 session 中的用户信息即可。 以下是一个简单的示例代码: ```python from flask import Flask, session, redirect, url_for, request app = Flask(__name__) app.secret_key = 'your_secret_key' # 用户登录 @app.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': # 验证用户信息 username = request.form['username'] password = request.form['password'] if username == 'admin' and password == 'password': # 用户信息存储在 session 中 session['username'] = username return redirect(url_for('index')) else: return 'Invalid username/password' else: return ''' <form method="post"> <p><input type="text" name="username"> <p><input type="password" name="password"> <p><input type="submit" value="Login"> </form> ''' # 用户注销 @app.route('/logout') def logout(): # 清除 session 中的用户信息 session.pop('username', None) return redirect(url_for('index')) # 首页 @app.route('/') def index(): if 'username' in session: return 'Logged in as {}'.format(session['username']) else: return 'You are not logged in' if __name__ == '__main__': app.run() ``` 在上面的示例中,首先需要设置应用程序的 secret_key,这是为了安全起见。然后定义了一个登录页面,用户在该页面输入用户名和密码,如果用户名和密码正确,则将用户存储在 session 中,并重定向到首页。在首页中,如果 session 中存在用户名,则表示用户登录,否则表示用户登录。最后,定义了一个注销页面,当用户点击注销时,清除 session 中的用户名,并重定向到首页。 需要注意的是,session 中存储的数据量应该尽量少,以免影响性能。在本例中,只存储用户名,如果需要存储更多的信息,可以考虑将其存储数据库中,然后在 session 中存储一个标识符,用于查询数据库中的用户信息。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值