Spring Boot+JWT 实战:登录鉴权全流程与安全最佳实践

内容分享7天前发布
0 1 0

Spring Boot+JWT 实战:登录鉴权全流程与安全最佳实践

Spring Boot+JWT 实战:登录鉴权全流程与安全最佳实践

一、技术铺垫:为何选择 Spring Boot+JWT?

在前后端分离、微服务架构成为主流的今天,传统 Session 认证暴露出明显短板:分布式环境下需额外实现 Session 共享,跨端(Web / 移动端)对接复杂,扩展性差。而Spring Boot+JWT的组合恰好解决这些痛点,成为当前主流的鉴权方案之一。

Spring Boot 的自动配置能力可大幅简化项目搭建,减少冗余配置;JWT(JSON Web Token)则是一种无状态认证机制,用户登录后服务端仅需签发一个加密 Token,后续请求无需在服务端存储状态 —— 只需验证 Token 有效性即可完成鉴权。这种组合的核心优势的在于:

  1. 无状态扩展:微服务集群无需同步认证状态,直接通过 Token 验证,降低部署复杂度;
  2. 跨端兼容:Token 可通过请求头、URL 参数等方式传递,适配 Web、APP、小程序等多端场景;
  3. 开发高效:Spring Boot 的 Starter 生态(如 spring-boot-starter-web、mybatis-plus-boot-starter)可快速集成依赖,JWT 工具类封装后可直接复用。

其典型应用场景包括:前后端分离项目的登录注册、微服务 API 的身份校验、第三方系统对接的临时授权等,几乎覆盖所有需要 “身份确认” 的后端场景。

二、完整实战流程:从环境搭建到鉴权落地

2.1 环境搭建与依赖配置(Maven)

第一创建 Spring Boot 项目(推荐版本 2.7.x,兼容性更优),在pom.xml中引入核心依赖,涵盖 Web、ORM、JWT、安全加密、数据校验等功能:

xml

<dependencies>
    <!-- Spring Web核心 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- MyBatis-Plus:简化数据库操作 -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.3.1</version>
    </dependency>
    <!-- JWT核心依赖 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
    </dependency>
    <!-- MySQL驱动 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    <!-- Spring Security:密码加密、权限控制 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <!-- 数据校验:注册参数验证 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <!-- Lombok:简化实体类代码 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

同时在application.yml中配置数据库连接、JWT 核心参数(避免硬编码):

yaml

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/jwt_demo?useSSL=false&serverTimezone=UTC
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:
  mapper-locations: classpath:mapper/**/*.xml
  type-aliases-package: com.example.jwt.entity
# JWT自定义配置
jwt:
  secret-key: 6f4a8d1e9b2c7e3a5d8f2b4e6a9d3c8b7e5f1a3d9b4c6e2a8f1d3b5e7a9c4f2d # 提议生产环境用配置中心管理
  access-expire: 7200000 # Access Token有效期:2小时(毫秒)
  refresh-expire: 604800000 # Refresh Token有效期:7天(毫秒)

2.2 核心实体与数据库设计

2.2.1 User 实体类

使用 Lombok 简化 Getter/Setter,添加 JSR380 数据校验注解(用于注册参数验证):

java

运行

package com.example.jwt.entity;

import lombok.Data;
import org.hibernate.validator.constraints.Length;

import javax.persistence.Id;
import javax.validation.constraints.NotBlank;
import java.time.LocalDateTime;

@Data
public class User {
    @Id
    private Long id;

    // 用户名:非空、长度2-20
    @NotBlank(message = "用户名不能为空")
    @Length(min = 2, max = 20, message = "用户名长度需在2-20字符之间")
    private String username;

    // 密码:存储加密后的密文
    @NotBlank(message = "密码不能为空")
    @Length(min = 6, message = "密码长度不能少于6字符")
    private String password;

    // 昵称
    private String nickname;

    // 角色:如ADMIN/USER,用于权限控制
    private String role;

    // 创建时间
    private LocalDateTime createTime;
}

2.2.2 数据库表 SQL

sql

CREATE TABLE `user` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `username` varchar(20) NOT NULL COMMENT '用户名',
  `password` varchar(100) NOT NULL COMMENT '加密密码',
  `nickname` varchar(50) DEFAULT NULL COMMENT '昵称',
  `role` varchar(20) NOT NULL DEFAULT 'USER' COMMENT '角色',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_username` (`username`) COMMENT '用户名唯一'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

2.3 JWT 工具类封装(核心)

封装 Token 生成、解析、验证逻辑,解决硬编码问题,支持携带用户角色信息(为权限控制做铺垫):

java

运行

package com.example.jwt.util;

import io.jsonwebtoken.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Component
public class JwtUtil {
    // 从配置文件读取密钥
    @Value("${jwt.secret-key}")
    private String secretKey;

    // Access Token有效期
    @Value("${jwt.access-expire}")
    private Long accessExpire;

    // Refresh Token有效期
    @Value("${jwt.refresh-expire}")
    private Long refreshExpire;

    /**
     * 生成Access Token(携带用户名、角色)
     */
    public String generateAccessToken(String username, String role) {
        return generateToken(username, role, accessExpire);
    }

    /**
     * 生成Refresh Token(仅携带用户名,用于刷新Access Token)
     */
    public String generateRefreshToken(String username) {
        return generateToken(username, null, refreshExpire);
    }

    /**
     * 通用Token生成方法
     */
    private String generateToken(String username, String role, Long expireTime) {
        // 1. 设置Token负载(Payload):不存敏感信息(如密码)
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", username);
        if (role != null) {
            claims.put("role", role);
        }

        // 2. 构建JWT:Header.Payload.Signature
        return Jwts.builder()
                .setClaims(claims) // 负载信息
                .setExpiration(new Date(System.currentTimeMillis() + expireTime)) // 过期时间
                .signWith(SignatureAlgorithm.HS512, secretKey) // 签名算法+密钥
                .compact();
    }

    /**
     * 解析Token,获取负载信息
     */
    public Claims parseToken(String token) {
        try {
            return Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token)
                    .getBody();
        } catch (ExpiredJwtException e) {
            log.error("Token已过期:{}", e.getMessage());
            throw new RuntimeException("Token已过期");
        } catch (SignatureException e) {
            log.error("Token签名无效:{}", e.getMessage());
            throw new RuntimeException("Token签名无效");
        } catch (IllegalArgumentException | MalformedJwtException e) {
            log.error("Token格式错误:{}", e.getMessage());
            throw new RuntimeException("Token格式错误");
        }
    }

    /**
     * 验证Token有效性(是否过期、签名是否正确)
     */
    public boolean validateToken(String token) {
        try {
            parseToken(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 从Token中获取用户名
     */
    public String getUsernameFromToken(String token) {
        Claims claims = parseToken(token);
        return claims.get("username", String.class);
    }

    /**
     * 从Token中获取角色
     */
    public String getRoleFromToken(String token) {
        Claims claims = parseToken(token);
        return claims.get("role", String.class);
    }
}

2.4 登录与注册功能实现

2.4.1 Service 层(含密码加密)

使用 Spring Security 的BCryptPasswordEncoder加密密码,避免明文存储:

java

运行

package com.example.jwt.service;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.jwt.entity.User;
import com.example.jwt.mapper.UserMapper;
import com.example.jwt.util.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class UserService extends ServiceImpl<UserMapper, User> {
    @Autowired
    private JwtUtil jwtUtil;

    // 密码加密器
    private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    /**
     * 用户注册:密码加密后保存
     */
    public boolean register(User user) {
        // 1. 检查用户名是否已存在
        User existUser = getOne(new QueryWrapper<User>().eq("username", user.getUsername()));
        if (existUser != null) {
            throw new RuntimeException("用户名已存在");
        }

        // 2. 密码加密(BCrypt自动生成盐值,无需手动处理)
        String encryptedPwd = passwordEncoder.encode(user.getPassword());
        user.setPassword(encryptedPwd);

        // 3. 默认角色为USER
        user.setRole("USER");

        // 4. 保存用户
        return save(user);
    }

    /**
     * 用户登录:验证密码,返回Token对(Access+Refresh)
     */
    public Map<String, String> login(String username, String password) {
        // 1. 查询用户
        User user = getOne(new QueryWrapper<User>().eq("username", username));
        if (user == null) {
            throw new RuntimeException("用户名不存在");
        }

        // 2. 验证密码(BCrypt匹配密文与明文)
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new RuntimeException("密码错误");
        }

        // 3. 生成Token对
        String accessToken = jwtUtil.generateAccessToken(user.getUsername(), user.getRole());
        String refreshToken = jwtUtil.generateRefreshToken(user.getUsername());

        // 4. 返回结果
        Map<String, String> tokenMap = new HashMap<>();
        tokenMap.put("accessToken", accessToken);
        tokenMap.put("refreshToken", refreshToken);
        return tokenMap;
    }

    /**
     * 刷新Access Token:通过Refresh Token获取新的Access Token
     */
    public String refreshAccessToken(String refreshToken) {
        // 1. 验证Refresh Token有效性
        if (!jwtUtil.validateToken(refreshToken)) {
            throw new RuntimeException("Refresh Token无效");
        }

        // 2. 从Refresh Token中获取用户名,查询角色
        String username = jwtUtil.getUsernameFromToken(refreshToken);
        User user = getOne(new QueryWrapper<User>().eq("username", username));
        if (user == null) {
            throw new RuntimeException("用户不存在");
        }

        // 3. 生成新的Access Token
        return jwtUtil.generateAccessToken(username, user.getRole());
    }
}

2.4.2 Controller 层(接口暴露)

处理 HTTP 请求,实现注册、登录、Token 刷新接口,添加参数校验:

java

运行

package com.example.jwt.controller;

import com.example.jwt.entity.User;
import com.example.jwt.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;
import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/auth")
public class AuthController {
    @Autowired
    private UserService userService;

    /**
     * 用户注册接口
     */
    @PostMapping("/register")
    public ResponseEntity<Map<String, Object>> register(
            @Valid @RequestBody User user, 
            BindingResult bindingResult
    ) {
        // 1. 处理参数校验错误
        if (bindingResult.hasErrors()) {
            Map<String, Object> errorMap = new HashMap<>();
            bindingResult.getFieldErrors().forEach(error -> 
                errorMap.put(error.getField(), error.getDefaultMessage())
            );
            return new ResponseEntity<>(errorMap, HttpStatus.BAD_REQUEST);
        }

        // 2. 调用Service注册
        boolean success = userService.register(user);
        Map<String, Object> resultMap = new HashMap<>();
        resultMap.put("success", success);
        resultMap.put("msg", success ? "注册成功" : "注册失败");
        return new ResponseEntity<>(resultMap, HttpStatus.OK);
    }

    /**
     * 用户登录接口
     */
    @PostMapping("/login")
    public ResponseEntity<Map<String, Object>> login(@RequestBody Map<String, String> loginParam) {
        // 1. 获取请求参数
        String username = loginParam.get("username");
        String password = loginParam.get("password");

        // 2. 调用Service登录,获取Token
        Map<String, String> tokenMap = userService.login(username, password);

        // 3. 构造返回结果
        Map<String, Object> resultMap = new HashMap<>();
        resultMap.put("success", true);
        resultMap.put("msg", "登录成功");
        resultMap.put("data", tokenMap);
        return new ResponseEntity<>(resultMap, HttpStatus.OK);
    }

    /**
     * 刷新Access Token接口
     */
    @PostMapping("/refresh-token")
    public ResponseEntity<Map<String, Object>> refreshToken(@RequestBody Map<String, String> param) {
        String refreshToken = param.get("refreshToken");
        String newAccessToken = userService.refreshAccessToken(refreshToken);

        Map<String, Object> resultMap = new HashMap<>();
        resultMap.put("success", true);
        resultMap.put("data", newAccessToken);
        return new ResponseEntity<>(resultMap, HttpStatus.OK);
    }
}

2.5 Token 拦截器与全局配置

通过拦截器统一校验 Token,排除登录、注册等公开接口:

2.5.1 JWT 拦截器

java

运行

package com.example.jwt.interceptor;

import com.example.jwt.util.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Collections;

@Component
public class JwtInterceptor implements HandlerInterceptor {
    @Autowired
    private JwtUtil jwtUtil;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 设置跨域支持(避免OPTIONS请求拦截问题)
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
        response.setHeader("Access-Control-Allow-Headers", "Authorization,Content-Type");

        // 2. 跳过OPTIONS预检请求
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            return true;
        }

        // 3. 获取Authorization请求头(格式:Bearer <token>)
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("请携带有效的Token");
            return false;
        }

        // 4. 提取Token(去掉"Bearer "前缀)
        String token = authHeader.substring(7);

        // 5. 验证Token有效性
        if (!jwtUtil.validateToken(token)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Token无效或已过期");
            return false;
        }

        // 6. 将用户信息存入SecurityContext(用于后续权限控制)
        String username = jwtUtil.getUsernameFromToken(token);
        String role = jwtUtil.getRoleFromToken(token);
        UserDetails userDetails = User.withUsername(username)
                .password("") // 密码无需存储(Token已验证身份)
                .roles(role)
                .build();
        SecurityContextHolder.getContext().setAuthentication(
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities())
        );

        return true;
    }
}

2.5.2 注册拦截器(WebConfig)

java

运行

package com.example.jwt.config;

import com.example.jwt.interceptor.JwtInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private JwtInterceptor jwtInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtInterceptor)
                .addPathPatterns("/**") // 拦截所有请求
                .excludePathPatterns( // 排除公开接口
                        "/auth/login",
                        "/auth/register",
                        "/auth/refresh-token",
                        "/swagger-ui/**", // 排除Swagger文档(如果使用)
                        "/v3/api-docs/**"
                );
    }
}

2.6 权限控制与异常处理

2.6.1 权限控制示例

通过@PreAuthorize注解实现方法级权限控制,需在启动类添加@
EnableGlobalMethodSecurity(prePostEnabled = true)开启功能:

java

运行

package com.example.jwt.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class TestController {
    // 所有登录用户可访问
    @GetMapping("/public")
    public String publicApi() {
        return "所有登录用户可访问的接口";
    }

    // 仅ADMIN角色可访问
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin")
    public String adminApi() {
        return "仅管理员可访问的接口";
    }
}

2.6.2 全局异常处理

统一异常响应格式,提升用户体验:

java

运行

package com.example.jwt.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {
    // 处理业务异常(如用户名已存在、密码错误)
    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<Map<String, Object>> handleRuntimeException(RuntimeException e) {
        Map<String, Object> resultMap = new HashMap<>();
        resultMap.put("success", false);
        resultMap.put("msg", e.getMessage());
        return new ResponseEntity<>(resultMap, HttpStatus.BAD_REQUEST);
    }

    // 处理参数校验异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidException(MethodArgumentNotValidException e) {
        Map<String, Object> errorMap = new HashMap<>();
        e.getBindingResult().getFieldErrors().forEach(error -> 
            errorMap.put(error.getField(), error.getDefaultMessage())
        );
        Map<String, Object> resultMap = new HashMap<>();
        resultMap.put("success", false);
        resultMap.put("msg", "参数校验失败");
        resultMap.put("errors", errorMap);
        return new ResponseEntity<>(resultMap, HttpStatus.BAD_REQUEST);
    }

    // 处理通用异常
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> handleException(Exception e) {
        Map<String, Object> resultMap = new HashMap<>();
        resultMap.put("success", false);
        resultMap.put("msg", "服务器内部错误");
        resultMap.put("error", e.getMessage());
        return new ResponseEntity<>(resultMap, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

三、JWT 安全最佳实践(生产环境必看)

  1. 采用 “双 Token” 刷新机制
  2. 生产环境避免单独使用长期 Token:短期Access Token(10-30 分钟)用于接口访问,长期Refresh Token(7-30 天)用于刷新 Access Token。当 Access Token 过期时,前端用 Refresh Token 静默获取新 Token,无需用户重新登录,兼顾安全性与用户体验。
  3. 密码加密与敏感信息防护
  4. 绝对禁止明文存储密码,推荐使用BCrypt(自动加盐)或Argon2加密算法;JWT 的 Payload 部分可被 Base64 解码,因此严禁存储密码、手机号、身份证号等敏感信息,仅保留用户名、角色等非敏感标识。
  5. 密钥安全管理
  6. 避免将 JWT 密钥硬编码在代码中,生产环境需通过配置中心(如 Nacos、Apollo)或环境变量注入;推荐使用非对称加密算法(如 RSA)替代 HS256—— 用私钥签名 Token,公钥验证 Token,降低密钥泄露风险。
  7. Token 存储与 XSS 防护
  8. 前端存储 Token 时,优先选择HttpOnly + Secure Cookie:HttpOnly 可防止 JS 脚本读取 Token(防 XSS 攻击),Secure 可确保 Token 仅通过 HTTPS 传输(防中间人攻击);避免使用localStorage或sessionStorage,二者易受 XSS 攻击窃取 Token。
  9. Token 过期与黑名单机制
  10. 若需实现 “用户登出即失效 Token”(JWT 无状态的短板),可维护一个 Token 黑名单:用户登出时将 Token 加入黑名单,拦截器验证 Token 时先检查是否在黑名单中。黑名单需设置过期时间(与 Token 有效期一致),避免内存溢出。

四、常见问题排查与优化

4.1 Token 失效问题

  • 现象 1:Token 已过期
  • 排查:检查 JWT 工具类中expireTime配置是否过短,或前端未及时刷新 Token。
  • 解决方案:调整 Access Token 有效期至合理范围(10-30 分钟),前端监听 Token 过期状态,通过 Refresh Token 自动刷新。
  • 现象 2:Token 签名无效
  • 排查:服务端密钥与生成 Token 时的密钥不一致(如环境配置错误),或 Token 被篡改。
  • 解决方案:统一环境密钥配置,生产环境使用非对称加密,增加签名校验日志。

4.2 跨域认证问题

  • 现象:前端跨域请求时 Token 未携带,或 OPTIONS 预检请求被拦截
  • 排查:跨域配置未允许Authorization请求头,或拦截器未跳过 OPTIONS 请求。
  • 解决方案:在拦截器中添加跨域响应头(Access-Control-Allow-Origin、Access-Control-Allow-Headers),并跳过 OPTIONS 预检请求。

4.3 性能优化

  • 拦截器性能瓶颈
  • 若系统 QPS 较高,JWT 验证(尤其是 RSA 算法)可能成为瓶颈,可通过 Redis 缓存已验证的 Token(缓存 key 为 Token,value 为用户信息,过期时间与 Token 一致),减少重复解析 Token 的开销。
  • 数据库查询优化
  • 用户登录、刷新 Token 时需查询数据库获取角色信息,可将用户角色缓存至 Redis(key 为用户名,value 为角色),有效期设置为 1 小时,降低数据库访问压力。

#Java 实战# #SpringBoot 安全# #JWT 鉴权# #前后端分离开发# #后端安全最佳实践#


感谢关注【AI码力】,获取更多Java秘籍!

© 版权声明

相关文章

1 条评论

您必须登录才能参与评论!
立即登录
  • 头像
    小小堇 读者

    收藏了,感谢分享

    无记录