前言

        最近在做一个AI智慧社区的后端项目,用的是Spring Boot + MyBatis + Redis + JWT这套组合。作为一个也在学习中的开发者,我发现很多教程讲技术原理讲得很好,但很少有人讲"拿到一份API接口文档后,怎么一步步把它变成能跑的代码"。

        这篇文章就以验证码、登录、Token校验这三个接口为例,记录一下我从读文档到写完代码的完整过程,踩过的坑也一并分享。希望对同样在学后端的同学有点帮助。

项目技术栈:Spring Boot 2.2.6 / MyBatis-Plus / Redis / JWT / easy-captcha

一、技术栈一览

技术 用途
Spring Boot 2.2.6 项目骨架,自动配置、内嵌Tomcat
MyBatis (mybatis-plus) ORM,用注解SQL查数据库
Redis 存验证码,天然支持过期时间,比Session更适合前后端分离
JWT (jjwt 0.9.1) 生成无状态token,前端每次请求带上就能识别身份
easy-captcha 一行代码生成图形验证码的Base64
SHA-256 密码单向哈希,数据库不存明文
Lombok 省掉getter/setter的样板代码

二、核心原理:三个接口串起来的认证流程

先看全局,这三个接口是怎么配合工作的:

这三个接口是整个系统的"门",后面所有业务接口都依赖token来识别"你是谁"。

三、怎么对照API文档写代码(方法论)

1.四步走:

  • 看URL和请求方式 → 决定用 @GetMapping 还是 @PostMapping,路径写什么
  • 看参数 → 决定用 @RequestBody 接JSON,还是 HttpServletRequest 取Header,还是无参
  • 看返回结构 → 对着JSON的key一个一个构造,用Map或VO封装
  • 想中间逻辑 → 这一步文档不会告诉你,需要自己根据业务推断

2.举例

举个具体例子,拿登录接口来说,文档写的是:

URL GET   /captcha
参数
返回 {
    "msg": "操作成功",
    "code": 200,
    "data": {
      "uuid": "b71fafb1a91b4961afb27372bd3af77c",
      "captcha": "data:image/png;base64,iVBORw0KGgoAAAA",
      "code": "nrew"
    }
}

3.翻译过程:

POST @PostMapping("/login")
4个参数是JSON 建一个 LoginForm 类,用 @RequestBody 接
返回有token和expire 建一个 LoginVO 类

        中间逻辑?文档没写,但常识告诉我:要验证验证码、查用户、比密码、生成token这就是"读文档→写代码"的完整思维链。

四、逐个接口讲解

1.接口一:生成验证码 GET /captcha

思路:生成一张随机字符的图片,转成Base64给前端展示。同时生成一个uuid作为这次验证码的"身份证",把正确答案存到Redis里,登录时用uuid去Redis取出来比对。

        为什么用Redis而不是Session?因为前后端分离架构下,前端和后端可能不在同一个域,Session不好使。Redis天然支持设置过期时间,验证码过期自动删除,干净利落。

@GetMapping("/captcha")
public Result getCaptcha(){
    // 1. 用easy-captcha生成验证码图片,130x48像素,4个字符
    SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);
    specCaptcha.setCharType(Captcha.TYPE_DEFAULT);

    // 2. 拿到验证码文本(转小写,方便后面不区分大小写比对)
    String code = specCaptcha.text().toLowerCase();

    // 3. 图片转Base64字符串,前端直接放到<img>的src里就能显示
    String imageBase64 = specCaptcha.toBase64();

    // 4. 生成uuid,作为这次验证码的唯一标识
    String uuid = UUID.randomUUID().toString().replace("-", "");

    // 5. 存Redis,key带前缀方便管理,20分钟过期
    String redisKey = "captcha:verify:" + uuid;
    redisTemplate.opsForValue().set(redisKey, code, 20, TimeUnit.MINUTES);

    // 6. 按文档要求的格式返回
    Map<String, Object> data = new HashMap<>();
    data.put("uuid", uuid);
    data.put("captcha", imageBase64);
    data.put("code", code);  // 注意:生产环境不应该返回code,这里是开发方便调试
    return Result.ok().put("data", data);
}

关键点:

  • SpecCaptcha 是 easy-captcha 提供的,开箱即用,不用自己画图
  • Redis的key设计 captcha:verify:{uuid} 带前缀是好习惯,避免和其他业务key冲突
  • 文档返回里有 code 字段(验证码明文),这在开发阶段方便前端调试,上线前应该去掉

2.接口二:登录 POST /login

思路:这是整个认证流程最核心的接口。接收用户名、密码、验证码、uuid,经过层层校验后,生成JWT token返回给前端。

(1)先看参数封装类 LoginForm

@Data
public class LoginForm {
    private String username;
    private String password;
    private String captcha;
    private String uuid;
}

(2)返回值封装类 LoginVO

@Data
public class LoginVO {
    private String token;
    private Integer expire;
}

这两个类完全是对着文档的参数和返回值建的,文档有什么字段,类里就有什么字段。

(3)Controller层很薄,只负责接参数和返回结果:

@PostMapping("/login")
public Result login(@RequestBody LoginForm form) {
    LoginVO loginVO = loginService.login(form);
    return Result.ok("登录成功!").put("data", loginVO);
}

(4)真正的业务逻辑在Service层

下面是登录接口的核心,也是需要重点理解的部分:

Service层完整代码:

@Service
public class LoginServiceImpl implements LoginService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private UserMapper userMapper;

    @Override
    public LoginVO login(LoginForm form) {
        // ===== 第1步:验证验证码 =====
        String key = "captcha:verify:" + form.getUuid();
        String captchaInRedis = redisTemplate.opsForValue().get(key);

        if (captchaInRedis == null) {
            throw new RuntimeException("验证码已过期");
        }
        if (!captchaInRedis.equalsIgnoreCase(form.getCaptcha())) {
            throw new RuntimeException("验证码错误");
        }
        // 验证通过后立即删除,防止同一个验证码被重复使用
        redisTemplate.delete(key);

        // ===== 第2步:查用户 =====
        User user = userMapper.selectByUsername(form.getUsername());
        if (user == null) {
            throw new RuntimeException("用户名或密码错误");
        }

        // ===== 第3步:验证密码 =====
        // 数据库存的是SHA-256哈希值,把用户输入的密码也做一次SHA-256再比对
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(form.getPassword().getBytes(StandardCharsets.UTF_8));
            StringBuilder hexString = new StringBuilder();
            for (byte b : hash) {
                String hex = Integer.toHexString(0xff & b);
                if (hex.length() == 1) hexString.append('0');
                hexString.append(hex);
            }
            if (!user.getPassword().equals(hexString.toString())) {
                throw new RuntimeException("用户名或密码错误");
            }
        } catch (Exception e) {
            throw new RuntimeException("密码验证失败", e);
        }

        // ===== 第4步:检查账号状态 =====
        if (user.getStatus() != 1) {
            throw new RuntimeException("账号已被禁用");
        }

        // ===== 第5步:生成JWT token =====
        String userId = String.valueOf(user.getUserId());
        Integer expireSeconds = 1296000; // 15天
        String token = JwtUtil.createToken(userId, expireSeconds);

        // ===== 第6步:封装返回 =====
        LoginVO vo = new LoginVO();
        vo.setToken(token);
        vo.setExpire(expireSeconds);
        return vo;
    }
}

(5)JWT工具类:

public class JwtUtil {
    private static final String SECRET = "your-secret-key-here-make-it-long-and-secure";

    public static String createToken(String userId, Integer expireSeconds) {
        Date now = new Date();
        Date expireDate = new Date(now.getTime() + expireSeconds * 1000L);
        return Jwts.builder()
                .setSubject(userId)       // 把userId放进token
                .setIssuedAt(now)          // 签发时间
                .setExpiration(expireDate)  // 过期时间
                .signWith(SignatureAlgorithm.HS512, SECRET) // HS512签名
                .compact();
    }
}

关键点:

  • 验证码用完即删(redisTemplate.delete(key)),这是安全基本操作
  • 密码错误时提示"用户名或密码错误"而不是"密码错误",不给攻击者确认用户名是否存在的机会
  • SHA-256是单向哈希,不可逆,数据库泄露了也拿不到明文密码
  • JWT的subject里只放userId,不放敏感信息,因为JWT的payload是Base64编码(不是加密),谁都能解码看到

3.接口三:Token校验 GET /checkToken

思路:前端每次路由跳转或刷新页面时,调这个接口确认token还有没有效。后端解析token,成功就返回用户信息,失败就告诉前端token无效。

@GetMapping("/checkToken")
public Result checkToken(HttpServletRequest request) {
    // 1. 从请求头取token
    String token = request.getHeader("token");
    if (token == null || token.isEmpty()) {
        return Result.error("token为空");
    }
    try {
        // 2. 解析token,如果token被篡改或过期,这里会直接抛异常
        Claims claims = Jwts.parser()
                .setSigningKey(SECRET)
                .parseClaimsJws(token)
                .getBody();
        String userId = claims.getSubject();

        // 3. 用userId查数据库拿用户信息
        User user = userMapper.selectById(Integer.parseInt(userId));

        // 4. 按文档格式组装返回数据
        Map<String, Object> data = new HashMap<>();
        data.put("userId", userId);
        data.put("userName", user != null ? user.getUsername() : "admin");
        data.put("roles", Collections.singletonList("admin"));
        data.put("permissions", Collections.singletonList("*:*:*"));
        return Result.ok().put("data", data);
    } catch (Exception e) {
        return Result.error("token无效或已过期");
    }
}

关键点:

  • token放在Header里而不是参数里,这是业界惯例,更安全
  • Jwts.parser().parseClaimsJws() 会自动验证签名和过期时间,不需要手动判断
  • 这里的roles和permissions暂时写死了,后续接入角色权限模块后应该从数据库查

五、配套设施

1.统一返回格式 Result

        继承HashMap是个取巧的做法——可以随意 .put("data", xxx) 往里塞任何字段,灵活适配文档里各种不同的返回结构。链式调用让代码写起来很顺手。

public class Result extends HashMap<String, Object> {
    public static Result ok() {
        Result result = new Result();
        result.put("code", 200);
        result.put("msg", "操作成功");
        return result;
    }

    public static Result error(String msg) {
        Result result = new Result();
        result.put("code", 500);
        result.put("msg", msg);
        return result;
    }

    @Override
    public Result put(String key, Object value) {
        super.put(key, value);
        return this;  // 返回this实现链式调用
    }
}

2.全局异常处理 GlobalExceptionHandler

        有了这个,Service层直接 throw new RuntimeException("验证码错误") 就行,不用在Controller里写一堆try-catch。异常会被自动捕获,转成统一的JSON格式返回给前端。

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(RuntimeException.class)
    public Result handleRuntimeException(RuntimeException e) {
        return Result.error(e.getMessage());
    }
}

3.数据库访问 UserMapper

        用MyBatis的注解SQL,简单查询不用写XML。配合 application.yml 里的 map-underscore-to-camel-case: true,自动把数据库的 user_id 映射到Java的 userId

@Mapper
public interface UserMapper {
    @Select("SELECT * FROM user WHERE username = #{username}")
    User selectByUsername(String username);

    @Select("SELECT * FROM user WHERE user_id = #{id}")
    User selectById(int id);
}

六、项目结构

com.qcby.smartcommunity
├── config
│   └── GlobalExceptionHandler.java    // 全局异常 → 统一错误返回
├── controller
│   └── LoginController.java           // 三个接口都在这里
├── entity
│   └── User.java                      // 数据库实体
├── form
│   └── LoginForm.java                 // 登录请求参数
├── mapper
│   └── UserMapper.java                // 数据库查询
├── service
│   ├── LoginService.java              // 接口
│   └── impl/LoginServiceImpl.java     // 登录核心逻辑
├── util
│   ├── JwtUtil.java                   // JWT生成
│   └── Result.java                    // 统一返回格式
└── vo
    └── LoginVO.java                   // 登录返回值

        每个类都有明确的职责:Controller只管接参数和返回,Service处理业务逻辑,Mapper负责数据库,Util放工具方法。这种分层不是为了好看,是为了改代码的时候知道去哪改。

七、避坑指南

1.JWT的SECRET散落在两个地方(JwtUtil和LoginController),这是个隐患。

        当时赶进度没注意,后来发现如果改了一边忘了另一边,token就解析不了。应该统一放到配置文件或者常量类里。这种"能跑但不对"的代码,越早改越好。

2.SHA-256那段手写的哈希代码看着丑,但它是对的。

        后来我知道可以用Hutool的 SecureUtil.sha256() 一行搞定,但手写一遍让我真正理解了哈希的过程——把字节数组转成十六进制字符串,每个字节补零对齐。这种"笨功夫"对新手来说是值得的。

3.全局异常处理是我加的第二个类(第一个是Controller),但它应该是第一个。

        没有它的时候,Service抛异常,前端收到的是Spring默认的500错误页面,根本不是JSON格式,前端解析直接报错。加上之后,所有异常都变成了 {"code": 500, "msg": "xxx"} 的统一格式,前后端联调顺畅了很多。

4.Redis连不上的时候别慌。

        我第一次跑验证码接口直接报错,以为代码写错了,debug了半小时才发现是Redis服务没启动。后来养成习惯,跑项目前先 redis-cli ping 确认一下。环境问题和代码问题要分开排查,不然会浪费大量时间在错误的方向上。

Result继承HashMap这个设计,一开始我觉得很奇怪。

        为什么不用一个标准的泛型类 Result<T> ?后来联调的时候才体会到好处——文档里每个接口返回的data结构都不一样,有的是对象,有的是列表,有的还带分页。用HashMap可以随意 .put() 任何字段,不用为每个接口单独定义返回类型。灵活是真灵活,代价是没有编译期类型检查,写错字段名只有运行时才能发现。

八、心得体会

说几点做这三个接口过程中真正踩到的坑和感悟:

1.文档不是圣经,但它是我们的锚。

        刚开始我也会纠结"这个字段到底要不要"、"返回格式能不能改"。后来想明白了——前端已经按文档写好了,你返回的JSON哪怕少一个字段、多一层嵌套,前端就炸了。所以先严格按文档来,跑通了再优化。

2.验证码那个code字段让我犹豫了一下。 

        文档返回里带了验证码明文,第一反应是"这不对吧,验证码答案怎么能直接告诉前端?"后来想明白了,这是开发阶段的便利设计——前端同学不用真的去看图片识别验证码,直接拿code字段填进去就能测登录流程。但这个字段上线前必须删掉,否则等于验证码形同虚设,任何人抓个包就能拿到答案。我在代码里加了个注释提醒自己,也建议你这么做,免得上线前忘了。

3.不要试图一次写完所有代码再测试。

        我的节奏是写一个接口、用Postman测一个、通了再写下一个。验证码接口通了,才有信心写登录;登录通了,checkToken就是顺手的事。小步快跑,每一步都有正反馈,比憋大招强太多。

4.密码错误提示"用户名或密码错误"而不是"密码错误",这个细节是我从别人代码里学到的。

        如果你提示"密码错误",攻击者就知道这个用户名是存在的,接下来只需要暴力破解密码就行。模糊提示虽然对用户不太友好,但安全性高很多。安全和体验的平衡,是后端开发绕不开的课题。

5.接口文档是前后端的契约。

        写后端不是自己闷头写,而是在履行一份约定。参数名拼错一个字母、返回多嵌套一层、状态码用错,前端都会出问题。养成写完一个接口就对照文档自查一遍的习惯,比写完所有接口再联调效率高得多。

结尾

        以上就是验证码、登录、Token校验这三个接口从文档到代码的完整过程。回头看其实逻辑不复杂,难的是第一次做的时候不知道从哪下手。总结一下我自己摸索出来的节奏:先读文档定好入参和出参的结构,再理清中间的业务逻辑,最后一个接口一个接口地写和测。不要想着一口气全写完,小步验证才是最快的路。

        这三个接口只是系统的入口,后面还有角色权限、业务CRUD、文件上传这些模块要做。但认证这块搞明白了,后面的接口无非就是在token的保护下做数据的增删改查,套路是一样的。如果你也在学后端开发,希望这篇文章能给你一个"原来是这么回事"的感觉。有问题欢迎评论区交流,一起进步。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐